diff --git a/ROADMAP.md b/ROADMAP.md index 712d189..a75a7a9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,318 +2,247 @@ ## Vision -Two players load `MPBTWIN.EXE`, connect to this server, pick their mechs, drop into a Solaris arena, and fight a real-time BattleMech duel β€” served entirely from this open-source emulator with no Kesmai infrastructure. +Build a server emulator that lets the original retail **Multiplayer BattleTech: Solaris v1.29 client** (`MPBTWIN.EXE`, released June 24, 1999) connect, create or resume a character, move through Solaris, select mechs, enter sanctioned and unsanctioned arena fights, complete real-time multiplayer BattleMech combat, and persist SCentEx-style results without any Kesmai infrastructure. -This is a long-haul reverse-engineering project. Every milestone below is blocked by RE work or builds directly on a prior milestone. Progress is honest: where the protocol is unknown, we say so. +This roadmap is intentionally scoped to the **retail v1.29 client**. Earlier v1.23 work remains valuable as reverse-engineering history, but completion is measured against the 1999 retail client behavior. ---- - -## How to Read This +## Roadmap Rules | Icon | Meaning | -|------|---------| -| βœ… | Complete and tested against the real client | -| πŸ”¬ | Blocked on Ghidra RE β€” protocol unknown | -| πŸ”§ | Engineering work β€” protocol understood, implementation needed | +|---|---| +| βœ… | Complete and tested against a real retail client | +| 🟑 | Implemented or mostly understood, but needs v1.29 hardening or broader coverage | +| πŸ”¬ | Reverse-engineering blocker | | ❌ | Not started | ---- +## Commit-History Read -## Current State +The project moved from first lobby compatibility to playable arena prototypes quickly: -| Feature | Status | -|---|---| -| ARIES 12-byte transport framing | βœ… | -| TCP stream reassembly (fragmentation) | βœ… | -| `LOGIN_REQUEST` β†’ `LOGIN` handshake | βœ… | -| `SYNC` ack + welcome escape sequence | βœ… | -| Inner game frame (seq + cmd + CRC) | βœ… | -| Base-85 encode/decode | βœ… | -| 19-bit LFSR CRC (lobby init `0x0A5C25`) | βœ… | -| Cmd 26 β€” mech list window | βœ… | -| Cmd 7 β€” menu dialog (select + confirm) | βœ… | -| Cmd 0x1D β€” cancel/ESC re-sends mech list | βœ… | -| Cmd 20 β€” examine mech text-dialog response | βœ… | -| `REDIRECT` packet (type `0x03`) | βœ… | -| Post-redirect game world (world login, navigation, mech bay, combat handoff) | βœ… | -| Two-human sanctioned duel playtest | βœ… | - -The emulator now goes well beyond the old redirect-only frontier: the client reaches the world, travels, selects mechs in-world, enters Solaris combat, and can complete supervised two-human sanctioned duel playtests. The remaining work is broader fidelity, richer multi-client arena behavior, and fuller late-1990s-faithful world/combat coverage. - ---- - -## Reference Materials - -These files are gitignored β€” place them in `research/` for local use. - -| File | Contents | Project use | -|---|---|---| -| `BT-MAN.decrypted.txt` | Full game manual: world navigation, chat channels, combat controls, mech stat tables | Design reference for M4–M9; source for `src/data/mech-stats.ts` | -| `SOLARIS.MAP` | Solaris city venue locations, 189 KB. Leading room table is now reproducibly parsed: u16 record count, then room ID / flags / coordinates / aux fields / NUL-included name+description strings. Local file count is 32 records: Solaris rooms 146–171 plus sector rows 1–6; trailing non-room sections remain undecoded | M5 world map reconstruction | -| `IS.MAP` | Inner Sphere / global location table, 40 KB. Same leading room-table format; local file count is 271 records, covering room IDs 1–271, including Solaris entries duplicated in the global namespace | M5 world map; full-sector navigation | -| `Gnum*.txt / Gnum*.md` | Firsthand gameplay observations: 4v4 lances, fixed spawns, travel times, team/all-chat | Sanity-check for RE findings | +- 2026-04-05 to 2026-04-07: lobby, `.MEC` roster, command 20, character/world entry, v1.23 protocol RE. +- 2026-04-08 to 2026-04-11: M4/M5 world travel, map parsing, combat entry, movement/control frames. +- 2026-04-10 to 2026-04-17: concentrated M6/M7 combat work: jump jets, firing gates, match-end scenes, ready rooms, sanctioned duel flow. +- 2026-04-18 to 2026-04-22: retail combat fidelity work: fall/recovery, gait, ammo, radar distance, bot behavior, arena regressions. +- 2026-04-23 to 2026-04-27: v1.29 migration: world command repurposing, Solaris live testing, travel fallback, bot team bootstrap fidelity. ---- +Practical implication: the remaining work is not a wholesale protocol rewrite. It is v1.29-specific completion: safer Solaris UI routing, accurate arena staging, multi-client combat fidelity, sanctioned-battle lifecycle, SCentEx persistence, and long-running operational hardening. Client-visible mech-management screens exist in v1.29, but firsthand GameStorm behavior indicates buy-ammo/repair/name-mech style flows were not part of live 1999 gameplay, so they are post-completion research rather than launch-blocking server scope. -## Milestones +## Current Baseline ---- +| Area | Status | Notes | +|---|---|---| +| ARIES transport, login, keepalive, redirect | βœ… | `COMMEG32.DLL` and `INITAR.DLL` are byte-identical between the local v1.23 and v1.29 installs; no new transport contract is expected just because of v1.29. | +| Lobby and world login | βœ… | Returning players can skip character creation and enter world; first-login character creation is implemented. | +| Room presence, room chat, ComStar DM | βœ… | Room roster, arrivals/departures, booths, online/offline messages, reply flow, and personnel-record basics exist. | +| Solaris travel | 🟑 | v1.29 `Cmd40`/`Cmd43` browser family and `Cmd49` overlays are understood enough for current travel; authentic topology and all facility flows need completion. | +| In-world mech picker | βœ… | Class/chassis/variant picker feeds combat bootstrap. Retail completion requires selection/loadout fidelity, not dormant repair or ammo-purchase economics. | +| Arena ready rooms | 🟑 | `MECH` / `SIDE` / `STATUS`, same-room staging, readiness, and 2..8 pilot launch are partly implemented. Full sanctioned lifecycle is not complete. | +| Single-client combat | 🟑 | Movement, jump jets, selected mech bootstrap, bot opponent, firing, damage feedback, and result scenes exist. Retail heat/damage/fall fidelity still needs closure. | +| Multi-client combat | 🟑 | Two-human sanctioned duel playtests are possible. Broader 4v4/team behavior, synchronized damage/state, and settlement hardening remain. | +| v1.29 world UI migration | 🟑 | Major repurposed commands are known (`Cmd39`, `Cmd44`, `Cmd46`, `Cmd57`, `Cmd45`/`Cmd58`), but some server surfaces still use conservative compatibility routes. | +| Rankings / SCentEx | 🟑 | Emulator ranking pages and duel persistence exist. Exact retail formula, result routing, and Team Sanctioned Battle reporting are not complete. | + +## Completion Definition + +The emulator is complete enough for a first public retail-v1.29 server when all of the following are true: + +- A clean retail v1.29 install can connect through the normal launcher flow with no binary patches other than optional local windowing helpers. +- A new account can create a character, select allegiance, enter Solaris, travel, use ComStar, and persist state. +- Returning accounts restore world room, selected mech, messages, ranking state, and pending settlement notices. +- Players can use Solaris facilities needed for routine 1999 play: travel, ComStar, mech selection, arena ready rooms, rankings/results, and personnel records. +- At least one sanctioned 1v1 duel path is retail-stable end to end: staging, entry, combat, result scene, disconnect/restore, C-bill transfer, ranking update. +- Multi-player arena fights support the retail eight-side model and at least 4v4 lance-scale play without state desync. +- Combat uses real `.MEC` data for movement, weapons, ammo, armor/internal state, heat, jump capability, and critical effects as far as the retail client exposes them. +- Long-running sessions survive keepalives, disconnect/reconnect, duplicate-login replacement, combat result timing, and common client focus/window quirks. +- All remaining deviations from known retail behavior are documented as intentional compatibility choices. + +## Milestones -### M1 β€” Lobby Completeness +### M0 β€” Preserve the Retail v1.29 Contract -**Goal:** The lobby experience is fully correct before moving to the game world. +**Goal:** Keep the project aligned to the actual June 1999 client rather than drifting around older v1.23 assumptions. | Task | Status | Notes | |---|---|---| -| Parse real `.MEC` files β†’ `src/data/mechs.ts` | βœ… | `loadMechs()` scans `mechdata/*.MEC`, assigns correct `mech_id` from MPBT.MSG variant table; `mechType` field hardcoded to 0 pending M2 binary RE; `variant`/`name` empty β†’ client falls back to its own MPBT.MSG lookup | -| Cmd 20 β€” mech examine/stats response | βœ… | Single mode=2 packet with direct stats text built by `buildMechExamineText()` from `MECH_STATS`; `0x5C` (`\`) is the line separator (`FUN_00433310` NULs it before rendering); `#NNN` shortcode is NOT used β€” our MPBT.MSG has incomplete/stale stats data | -| Cmd 20 β€” hierarchical class/mech/variant examine (issue #21) | ⏸ | **Deferred to M8** β€” the single-variant examine (`mode 2`) is complete. The 3-level hierarchy (class β†’ mech list β†’ variant details) is enhancement scope; will be revisited alongside `.MEC` file integration in M8. | -| Cmd `0x1D` β€” cancel/ESC in menu dialogs | βœ… | Resolved β€” server re-sends mech list; sending nothing freezes client | -| ACK reply for seq > 42 | πŸ”¬ | Trigger condition documented in RESEARCH.md Β§9; v1.23 RE confirms `FUN_0040eb40` is a no-op stub β€” no ACK is sent by the combat client in v1.23. Server must not require combat ACKs. | +| Document v1.29 binary baseline | βœ… | `RESEARCH.md` records v1.29 file version, size, and SHA-256 plus byte-identical `COMMEG32.DLL` / `INITAR.DLL`. | +| Keep ARIES transport unchanged | βœ… | v1.29 does not require a new login, redirect, keepalive, or launcher contract. | +| Track repurposed v1.29 world opcodes | 🟑 | Known: old v1.23 meanings for `Cmd39`, `Cmd44`, and `Cmd46` are unsafe for v1.29. | +| Maintain v1.29 regression tests/smokes | 🟑 | Existing socket and GUI-driven probes should be kept; add explicit v1.29 fixtures for every fixed compatibility issue. | -**Verification:** Connect real `MPBTWIN.EXE`; press `X` on a mech (stats appear), press `ESC` in dialog (no disconnect), browse the first 20 mechs without crash. +**Exit criteria:** Every new server-visible feature states whether it targets v1.29 directly or is a v1.23-era compatibility fallback. ---- +### M1 β€” Stable Retail Login, Account, and Character Flow -### M2 β€” RE: Game World Protocol +**Goal:** A retail v1.29 player can reliably get from launcher to world. -**Goal:** Understand the protocol spoken on the second TCP connection (post-`REDIRECT`). +| Task | Status | Notes | +|---|---|---| +| ARIES login and redirect | βœ… | Transport, `LOGIN_REQUEST` / `LOGIN`, `SYNC`, welcome, and `REDIRECT` are implemented. | +| Account auth and first-login character creation | βœ… | PostgreSQL accounts/characters and House allegiance selection exist. | +| Returning-player direct world entry | βœ… | Existing character skips creation and redirects to world. | +| Duplicate-login/session replacement | 🟑 | Replacement behavior exists; broaden coverage for lobby, world, ready room, combat, and post-result windows. | +| Launcher/test tooling | 🟑 | `play.pcgi` generation exists; improve reproducibility for fresh per-launch retail-client validation. | -This milestone is pure Ghidra work. No code is written here β€” findings go into `RESEARCH.md`. +**Exit criteria:** Ten clean v1.29 launcher runs in a row can create or resume characters without manual recovery. -| RE Target | Binary | Status | Notes | -|---|---|---|---| -| `Aries_RecvHandler` case 0 & REDIRECT | `COMMEG32.DLL` | βœ… | Β§17: REDIRECT handler confirmed; case 0 sends WM_0x7f0 to game window | -| World command dispatch table | `MPBTWIN.EXE` | βœ… | Β§18: two tables β€” RPS (0x00470198, cmd 0–76) and Combat (0x00470408, cmd 0–79); full address table | -| Initial world handshake | `COMMEG32.DLL` + `MPBTWIN.EXE` | βœ… | Β§18: LOGIN_REQUESTβ†’LOGINβ†’`"\x1b?MMW Copyright Kesmai Corp. 1991"`β†’cmd-3; same sequence as lobby | -| `g_aries_GameWorldConn` (`DAT_1001a080`) | `COMMEG32.DLL` | βœ… | Β§17: created by `Aries_Connect`; secondary connection object | -| Combat CRC crossover point | `MPBTWIN.EXE` | βœ… | Β§18: `Frame_VerifyCRC` uses `g_combatMode` to select seed; RPS=`0x0a5c25`, Combat=`0x0a5c45` | -| First 10+ world commands | `MPBTWIN.EXE` | βœ… | Β§18: first 13 RPS cmds decompiled β€” ping/ack (1–2), text broadcast (3), scene init (4), cursor (5–6), menu (7), session data (8), room list (9), text feed (10), player events (11–13) | -| World frame format | `MPBTWIN.EXE` | βœ… | Β§18: identical to lobby β€” ESC-delimited, 19-bit LFSR CRC, same base-85 encoding | +### M2 β€” Solaris World Core -**Deliverable:** RESEARCH.md Β§17 (COMMEG32.DLL RE) and Β§18 (world protocol RE) β€” COMPLETE. +**Goal:** The world layer is stable enough for ordinary social play. ---- +| Task | Status | Notes | +|---|---|---| +| Room presence and chat | βœ… | Same-room roster, arrival/departure, booth privacy, and room-local text relay work. | +| ComStar direct messages | βœ… | Online and offline message delivery works. | +| All-roster inquiry and personnel record | 🟑 | `Cmd48` / `Cmd14` path works, but personnel-record page semantics and target header limitations need cleanup. | +| Facility entry model | 🟑 | Travel and arena entry exist. Bar terminal, global ComStar access, and facility-specific action menus need retail-safe coverage. | +| World reconnect restore | 🟑 | Room/mech/deferred settlement restore exists; expand to all world UI states that matter. | -### M3 β€” Game World Connection +**Exit criteria:** Two retail clients can spend 30 minutes in world using travel, room chat, ComStar, roster/personnel lookup, and reconnects without stale presence or broken UI state. -**Goal:** The client successfully connects to the game world server and enters a stable state without crashing. +### M3 β€” v1.29 Solaris Browser and Menu Surfaces -*Depends on M2.* +**Goal:** Replace unsafe v1.23-shaped UI assumptions with v1.29-correct command families. | Task | Status | Notes | |---|---|---| -| `src/server-world.ts` β€” second TCP listener | βœ… | Port 2001; same `PacketParser` (ARIES); RPS CRC seed 0x0A5C25 | -| `src/protocol/world.ts` β€” world command builders | βœ… | Cmd3 TextBroadcast, Cmd4 SceneInit, Cmd5/6 cursor, Cmd9 character-creation prompt notes | -| `src/state/launch.ts` β€” mech launch registry | βœ… | Bridges lobbyβ†’world: records selected mech before REDIRECT, consumed on world LOGIN | -| `ClientSession` β€” add `'world'` phase | βœ… | Extended `src/state/players.ts`; `selectedMechId?` / `selectedMechSlot?` added | -| Initial world handshake | βœ… | LOGIN_REQUEST β†’ LOGIN β†’ SYNC ack β†’ MMW welcome β†’ cmd-3 β†’ Cmd6+Cmd4+Cmd10+Cmd3+Cmd5 | -| Fix REDIRECT target to WORLD_PORT | βœ… | Lobby now redirects to port 2001; launch record stored before REDIRECT sends | -| `gen-pcgi.ts` β€” separate lobby/world ports | N/A | `play.pcgi` always points to lobby (2000); REDIRECT carries the world address. Combat server is a separate dynamic spin-up (M6/M7). | - -**M3 additions β€” Persistence, Character Creation, Direct World Entry (#25 / #26 / #27):** +| `Cmd40` / `Cmd43` location browser | 🟑 | v1.29 browser family is mapped; server should consistently use the v1.29-safe path for travel and grouped Solaris browsing. | +| `Cmd49` map connector overlay | 🟑 | Handler is identified; use it where retail map links are known. | +| `Cmd45` / `Cmd58` scroll-list shell | 🟑 | Accepted body syntax for rankings/results is emulator-proven against v1.29; keep it as the safe paged-list surface. | +| `Cmd57` hotkey selection menu | πŸ”¬ | Strong v1.29 chooser candidate, but preset/control-strip details are still risky. Do not replace working compatibility menus until a safe builder is proven. | +| Remove unsafe old `Cmd44`/`Cmd46` usage | 🟑 | v1.29 repurposes them; keep explicit builders only for the new meanings. | -| Task | Status | Notes | -|---|---|---| -| PostgreSQL persistence layer | βœ… | `pg` + `bcryptjs`; `src/db/{client,schema.sql,accounts,characters,migrate}.ts`; `docker-compose.yml` | -| `accounts` table + bcrypt password auth | βœ… | Auto-register on first login; verify password on subsequent logins; rejects wrong passwords | -| `characters` table + allegiance enum | βœ… | One character per account; `display_name UNIQUE`; allegiance CHECK constraint `Davion\|Steiner\|Liao\|Marik\|Kurita` | -| `npm run db:migrate` β€” idempotent schema apply | βœ… | Reads `src/db/schema.sql`; safe to re-run | -| `ClientSession` β€” add `accountId`, `displayName`, `allegiance` | βœ… | Set from DB after login; `'char-creation'` phase added | -| Character creation flow (first login) | βœ… | cmd-3 β†’ no character in DB β†’ send `Cmd9` callsign + House prompt β†’ persist typed display name and allegiance β†’ seed launch context β†’ REDIRECT | -| Post-login direct world entry (returning player) | βœ… | cmd-3 β†’ character found β†’ REDIRECT to port 2001 immediately; no mech-select shown | -| World server uses `displayName` as Cmd4 callsign | βœ… | Falls back to `username` if character data unavailable (e.g. test direct-connect) | -| Display name entry (name selection dialog) | βœ… | Implemented with server `Cmd9`, the likely authentic first-login prompt: it opens `MPBT.MSG[5]` (`"Enter your character's name"`), then a numbered selector titled `MPBT.MSG[6]` (`"Choose your allegiance:"`), and submits outbound `cmd 9, subcmd 1, , `. This supersedes the earlier `Cmd36`/`Cmd37` hypothesis; `Cmd36` is the read/reply viewer, `Cmd37` opens the ComStar compose editor, and the live `Cmd37(0)` probe is only a compatibility bridge. Live GUI probe confirmed the wire path; socket smoke now confirms persistence, launch-context seeding, and returning-account world entry with the typed callsign in `Cmd4`. | +**Exit criteria:** Travel, ranking choosers, mech-selection surfaces, and result pages use v1.29-safe surfaces with no accidental v1.23 opcode semantics. -**Known M3 limitations / M4 work:** -- Initial room-sync uses `Cmd10`; the earlier `Cmd9(count=0)` placeholder was removed, and `Cmd9` is now tied to the first-login name + allegiance prompt rather than room presence. -- `Cmd8` (session binary data / mech loadout) not yet sent; client mech stats display may be absent. -- Arena navigation and movement not yet implemented (M5). -- World server does not yet bounce a second REDIRECT to a combat server (M6/M7). +### M4 β€” Map, Room, and Facility Model -**Verification:** -- *New player:* connect, select House allegiance, enter world β€” Cmd4 callsign shows username; allegiance persisted to DB. -- *Returning player:* connect, skip character creation, enter world directly β€” no mech-select screen shown. -- *Wrong password:* second login with wrong credentials β†’ connection closed. -- *Mech select (M6 path):* cmd-26 visible only when explicitly triggered; pre-combat flow unaffected. -- *First-login `Cmd9` implementation:* socket smoke confirmed `Cmd9` prompt β†’ typed callsign + House reply β†’ persisted character β†’ REDIRECT β†’ world init `6,4,10,3,5`, with `Cmd4` containing the typed callsign on both first-login and returning-account paths. The older `Cmd37(0)` probe remains a compatibility bridge, not the authentic original name-entry UI. +**Goal:** Solaris feels like a coherent world, not a hardcoded room stub. ---- +| Task | Status | Notes | +|---|---|---| +| Parse `SOLARIS.MAP` and `IS.MAP` leading room tables | βœ… | Leading room records are decoded and loaded. | +| Use real room names/descriptions/icons | 🟑 | Room descriptions are wired; complete icon/facility metadata and fallback behavior. | +| Authentic travel topology | πŸ”¬ | Leading map tables do not contain exits. Need either RE of server-side topology clues, manual reconstruction, or documented approximation. | +| Room/facility classification | 🟑 | Arena vs. non-arena is enough for current flow; bar/terminal/bank/hub/street semantics need completion. | +| Tram/T.O.F.S. behavior | βœ… | Same travel flow as ordinary Solaris map travel; no separate command needed. | -### M4 β€” Chat and Presence +**Exit criteria:** A player can navigate all known Solaris sectors/facilities exposed by the v1.29 client, with documented topology choices. -**Goal:** Players see each other and communicate across the full world β€” not just within a room. +### M5 β€” Asset-Backed Mech Selection and Combat State -*Depends on M3.* +**Goal:** Server-owned mech selection and combat state match the retail data the client actually used during 1999 play. | Task | Status | Notes | |---|---|---| -| ComStar DM β€” store and deliver | βœ… | `Cmd36` delivers to online recipients immediately. Offline messages are persisted to a `messages` DB table (`src/db/messages.ts`: `storeMessage` / `claimUndeliveredMessages`). The message body is stored pre-formatted and delivered atomically on the recipient's next world login (cmd-3 trigger). Offline detection: `10_000_000 + accountId` rangeβ†’`recipientAccountId` derived from ComStar ID. `Reply` flow: `Cmd37(targetId)` opens the compose editor pre-addressed to the target. | -| Room broadcast | βœ… | Same-room presence seeds the roster with `Cmd10`, then uses `Cmd13` arrival and `Cmd11(status=0)` departure for incremental updates. World `cmd-4` free-text relay is implemented as room-local chat fan-out via `Cmd3`. Booth privacy implemented: booth occupants only hear each other; standing players only hear other standing players. Validated with two-GUI sandbox (2026-04-07): real Client B receives `Cmd10 RoomPresenceSync (2 entries)`, both clients exchange chat, and arrival/departure notifications fire correctly. | -| Player join / leave events | βœ… | Same-room `Cmd10` / `Cmd13` / `Cmd11(status=0)` path fully operational. Social-room status: `Cmd7(listId=3)` `selection=0` grabs a booth, `selection=2` stands, `selection>=3` joins booth `selection-2`, with `Cmd11(status=5..12)` updating the roster table. Two-GUI sandbox confirmed Client B world-init, `RoomPresenceSync`, and arrival/departure events with Client A connected. | -| F7 β€” team / lance channel | ❌ | Arena-only; requires `Cmd8` team assignment β€” moved to M7. v1.23 RE (Β§19.4) confirms F7 does NOT emit a network packet β€” it only toggles the local chat-channel UI indicator. Channel selection is implicit via the mode command (`FUN_0043d920`). | -| F8 β€” all-comm / chat-window toggle | ❌ | Arena-only; v1.23 RE (Β§19.4) confirms F8 does NOT emit a network packet β€” purely local UI state toggle (same `FUN_0042dc30` visual handler as F7). Moved to M7. | -| All-roster query + inquiry submenu | βœ… | KP5 β†’ `Cmd7(listId=3, selection=1)` sends `Cmd48_KeyedTripleStringList` (`0x51`) with live sessions. Row-picks open the inquiry submenu (`INQUIRY_MENU_ID=0x3F3`; 0x3E8 is client-reserved and must not be used). `selection=1` sends `Cmd37(targetId)` to open ComStar compose; `selection=2` sends `Cmd14` personnel record. Both selections work even if the target disconnects after the submenu opens. Personnel record: `Handle` header fixed by sending a single-entry `Cmd10` (target only) before `Cmd14` β€” seeds `entry[0]` of the all-roster table; `ID` header fixed by shifting `comstarId` into the `FUN_00405ea0` valid range (`10_000_000 + accountId` β†’ 5-char base-36). | +| Load all `.MEC` variants | βœ… | Roster and mech IDs come from real assets and `MPBT.MSG`. | +| Use `.MEC` movement fields | βœ… | Walk/run split, speedMag, and jump-jet presence are integrated. | +| Use `.MEC` weapons/ammo/heat/armor/internal fields | 🟑 | Weapon ranges, ammo bootstrap, heat sinks, and key armor/internal values are partly integrated; complete all combat-critical fields. | +| Selected-mech persistence | 🟑 | Preserve the selected mech across world, ready-room, combat, reconnect, and result-restore flows. | +| Deliberately defer inactive mech-management economics | βœ… | `Cmd30`, `Cmd31`, and repurposed `Cmd39` are useful RE findings, but GameStorm-era play did not expose functional buy-ammo/repair/name-mech loops. Keep them out of the completion path unless new retail evidence proves otherwise. | -**Verification:** Two-GUI sandbox: KP5 β†’ select target β†’ "Send ComStar message" opens compose editor pre-addressed to correct player; "Access personnel data" shows correct Handle, ID, Rank, House, Sector, Location, Status. Offline stub shown when target disconnects between menu open and pick. +**Exit criteria:** A player can select a retail `.MEC` variant, enter world/ready-room/combat with that selection intact, fight with asset-backed movement/weapons/ammo/heat/armor data, and return to world without loadout or result-state corruption. ---- +### M6 β€” Single-Client Combat Fidelity -### M5 β€” World Navigation - -**Goal:** A single player can move around the game world (Solaris sectors / arenas) from the server's perspective. - -*Depends on M4.* - -The world uses two distinct room types: **bar** (social spaces, Tier Ranking terminals, ComStar facilities) and **arena** (combat venues). Source topology: `SOLARIS.MAP` (rooms 146+, partly decoded) and `IS.MAP` (rooms 1–145) β€” both gitignored; see Reference Materials above. +**Goal:** One retail v1.29 client can fight a server bot with retail-shaped movement, weapons, damage, and result flow. | Task | Status | Notes | |---|---|---| -| `SOLARIS.MAP` / `IS.MAP` binary format RE | βœ… | **DECODED** (RESEARCH.md Β§19.7): 2-byte LE record_count header; each record = 18-byte fixed prefix (room_id, faction, raw_x, raw_y, 4Γ—flags) + uint8 name_len + name chars + uint8 desc_len + desc chars. IS.MAP display: `x/3+380`, `y/βˆ’3+248`; SOLARIS.MAP: identity. Parser via `npm run map:dump -- --rooms`. Ghidra confirms `Map_LoadFile` passes trailing bytes to the picture/resource loader; exits not stored in trailing blob. Needs implementation in room-loader. | -| RE movement protocol | πŸ”§ | **DECODED** (RESEARCH.md Β§19.2): clientβ†’server timer-based (100 ms). Cmd 8 (coasting): X(3w)+Y(3w)+heading(2w)+adj_vel(1w)+rotation(1w). Cmd 9 (moving): X(3w)+Y(3w)+heading(2w)+turn(1w)+0xe1c(1w)+throttle(1w)+leg(1w)+rotation(1w). Bias constant=0xe1c (3612), divisor=0xb6 (182). Travel-reply: server cmd 40/43 opens IS/Solaris map UI; client replies `cmd 10` (`type1 contextId` + `type4 selectedRoomId+1`). Real GUI validated `Travel β†’ Cmd43 β†’ cmd 10(selection=148) β†’ Ishiyama Arena`. Serverβ†’client position packets (Cmd65) still πŸ”¬. | -| Tram / monorail RE | βœ… | **RESOLVED** (RESEARCH.md Β§19.10): T.O.F.S. (The Tram) uses the **identical** `cmd5 actionType 4 β†’ Cmd43 (context 0xc6) β†’ cmd10` travel flow as regular Solaris map travel. `World_HandleMapOpenSolarisPacket_v123` has no tram-specific context branch; no separate tram command exists in the v1.23 dispatch table. No new server implementation needed. Closes issue #70. | -| Room model from map files | πŸ”§ | `parseMapFile()` implemented in `src/data/maps.ts`; `SOLARIS_SCENE_ROOMS` (32 rooms: 146–171 Solaris + sectors 1–6) is a hardcoded stub with provisional linear exits in `getSolarisRoomExits()`. `Cmd23` location-icon clicks handled via `handleLocationAction`; `Cmd43`β†’`cmd10` travel reply handled via `handleMapTravelReply`. Next: load rooms, types (bar / arena), and exits from `IS.MAP` / `SOLARIS.MAP` parsed data; replace hardcoded stub; authentic exit graph still πŸ”¬. | -| RE world scene-action family | βœ… | **RESOLVED** (RESEARCH.md Β§19.6.0a): subtype `1/2` in-game noun confirmed as **Agreement** (C-bill contract between Successor State parties β€” `MSG[0x19e]` = `"Details of Agreement between"`). Full field-label MSG string table (`MSG[0x19e]`–`MSG[0x1b4]`) now documented. Subtype model: `1/2` = Agreement offer/review, `3` = duel, `4` = membership bid, `5/6/7` = subcontract offer/review/terms. Live capture for `cmd5 actionId β†’ subtype` mapping deferred (not required for M5 verification). | -| Server-side position tracking | βœ… | `worldX/Y/Z` + `worldMapRoomId` on `ClientSession`; populated atomically via `setSessionRoomPosition()` in `world-data.ts` from SOLARIS.MAP `centreX/centreY` at every room transition. | -| Position sync to client | βœ… | World-mode scene position conveyed via Cmd4 `playerScoreSlot` (= room sceneIndex) β€” already working. Room type communicated via arena-only "Fight" button (`actionType 5`) in `buildSceneInitForSession`; Cmd65-equivalent serverβ†’client coord push in travel-world mode remains πŸ”¬. | - -**Verification:** Single client can navigate between areas; room type (bar vs. arena) is correctly identified by the server. - ---- +| Combat bootstrap (`Cmd72`, `Cmd64`, `Cmd65`, `Cmd62`) | βœ… | Local and remote actor setup works. | +| Movement and radar distance | βœ… | Recent history aligned movement/speed and radar distance with the retail client. | +| Jump jets including low-jet v1.27+ behavior | 🟑 | v1.29 confirms non-zero jump capability, not a four-jet minimum. Finish fuel, altitude, landing, and damage edge cases. | +| Weapon fire and projectile/effect updates | 🟑 | `cmd10` shot geometry and `Cmd68`/`Cmd69` effects are usable; finish ordinary vs. TIC volley semantics. | +| Damage, heat, criticals, ammo | 🟑 | Current model is playable but not complete retail fidelity. Heat/system degradation and all critical sections need closure. | +| Fall/recovery | 🟑 | v1.29 confirms `Cmd70` remains the main fall/landing/collapse driver; 60 FPS cap solved the visible slow-fall symptom. Finish recovery/stand-up and damaged-leg edge cases. | +| Bot opponent | 🟑 | Bot AI is increasingly retail-like; keep it deterministic enough for tests and configurable enough for play. | -### M6 β€” Single-Client Combat Loop +**Exit criteria:** A single player can complete repeated bot fights with no stuck combat gate, no bogus speed/altitude state, correct result scene, and documented remaining combat deviations. -**Goal:** One player in an arena can engage with the combat system (even against a scripted dummy opponent). +### M7 β€” Multi-Client Arena Combat -*Depends on M5.* +**Goal:** Multiple retail v1.29 clients can fight each other in real time with coherent state. | Task | Status | Notes | |---|---|---| -| RE serverβ†’client combat bootstrap / position sync | βœ… | Full bootstrap sequence documented in RESEARCH.md Β§19.9: Cmd72 (local mech init) β†’ Cmd64 (remote actors) β†’ Cmd65 (initial positions) β†’ Cmd62 (combat-start, clears SPACEBAR block). SpeedMag Cmd65 echo implemented and confirmed on HUD gauge (Β§19.10). | -| Combat movement + speedMag physics | βœ… | Cmd8 (coasting) and Cmd9 (moving) clientβ†’server are parsed and handled with the corrected `.MEC` speed split from RESEARCH.md Β§24: `walkSpeedMag = mec_speed Γ— 300`, `maxSpeedMag = round(mec_speed Γ— 1.5) Γ— 300`, and full-forward Cmd9 scaling now uses `THROTTLE_RUN_SCALE = 20` instead of the old `45`. TAP-mode Cmd8 echo suppression remains required so local throttle ownership is not overwritten, and current live-combat server policy also walk-caps reverse drift / echoed reverse `speedMag` for range pressure parity. HUD speed gauge confirmed working. | -| In-world 3-step mech picker (Mech / Mech Bay) | βœ… | Class β†’ chassis β†’ variant flow implemented. Arena scenes now label action type `6` as `Mech`; non-arena rooms still show `Mech Bay`. Safe listIds: `0x20` (class/variant), `0x3e` (chassis). Cursor-freeze fix: `Cmd5 CURSOR_NORMAL` sent after every `Cmd26` and after post-selection `Cmd3`. Selected mech slot stored in `session.selectedMechSlot`. See RESEARCH.md Β§23. | -| RE weapon fire packets | πŸ”§ | v1.23 client β†’ server fire request decoding in RESEARCH.md Β§19.3 is now tighter: `Combat_SendCmd12Action_v123` emits `cmd 12`, but fresh 2026-04-20 caller audit shows its `action 0` caller is the downed recovery branch in `Combat_InputActionDispatch_v123`, while the other two live callers are jump start (`4`) and landing (`6`). That weakens the old "action0 is ordinary fire" interpretation further; current server combat still relies on `cmd10` shot geometry for actual weapon fire acceptance, with TIC volleys already proven to arrive as direct bundled `cmd10`. Server `Cmd68` is projectile/effect spawn; `Cmd66`/`Cmd67` now carry damage code/value updates. | -| RE TIC system | βœ… N/A | Three Targeting Interlock Circuits (A/B/C): v1.23 RE **confirms TIC is entirely client-local**. Toggle membership stored in local arrays (`DAT_004f2128`, `DAT_004f2150`, `DAT_004f2178`); TIC group fire calls a local effect path only. No separate network sender exists. No server-side player TIC protocol implementation is needed. Dynamic capture still needed to clarify whether `cmd 12/action 0` targets the selected weapon, selected TIC group, or all queued weapons. Current server note: bot AI may still derive TIC-style volley presets locally from weapon/heat data without changing this protocol conclusion. | -| RE damage model | πŸ”§ | v1.23 damage-result path is partially decoded in RESEARCH.md Β§19.6.1: `Cmd66` applies actor damage code/value pairs, `Cmd67` applies local-actor pairs, and the shared classifier partitions codes into critical/system, armor-like, internal-like, weapon, and ammo-bin ranges. `.MEC` offset correction: `0x3c` is a signed critical/equipment range bound and weapon ids start at `0x3e`. Exact section labels, kill semantics, and heat/system-degradation mapping still need live capture. | -| RE non-death fall / recovery fidelity | πŸ”¬ | The server can now prove multiple left-leg loss probes on the wire (`Cmd70/8`, `1->8`, `4->8->6`, `1->4->8->6`, and local recovery `1->8->0`), and all meaningful variants tried so far are GUI-validated as visually insufficient: the retail client stays upright. Ghidra now narrows the recovery side: local slot `0` ignores inbound `Cmd70/4` and `Cmd70/6`, inbound `Cmd70/0` is the strongest current local recovery-ack candidate, and F12 stand-up should emit wire `cmd12/action0` only when the client is truly down. The latest live callback proof closes that local gate (`+0xdc bit 0x10` must clear before F12 sends), and fresh caller audit shows `cmd12/action0` comes from the recovery branch rather than ordinary weapon fire. Based on that, the server-side stateful `action0 -> Cmd70/0` path has now been promoted beyond the old env-gated experiment; the remaining blocker is full retail-visible fall/recovery fidelity, not the old timer heuristic. | -| RE jump jets | πŸ”§ | Fire command **decoded** (Β§19.3): client sends ESC+'!'+0x2D+0x25+CRC (cmd=12, action=4) via `Combat_SendCmd12Action_v123('\x04')`; landing/touchdown sends `cmd 12/action 6`. The server now matches several confirmed client guards instead of the older loose prototype: jump fuel uses the client's `0x78`/`120` cap, start requires fuel `> 0x32`/`50`, duplicate airborne start is rejected, and grounded recharge follows a single timer path closer to the client's main-loop regen instead of the old per-frame + passive combo. Remaining πŸ”¬: exact airborne drain breakdown by thrust/turn/velocity flags, authoritative altitude/landing semantics for `action 6`, and no-jump chassis validation against broader `.MEC` data. | -| Implement `src/protocol/combat.ts` | βœ… | All combat packet builders: Cmd64–Cmd73 implemented; combat entry wired in server-world.ts via `/fight` text command; MMC welcome + Cmd72 bootstrap sent on trigger | -| Selected mech β†’ combat bootstrap propagation | βœ… | World mech selection now feeds live combat bootstrap state. `tools\\duel-selected-mech-smoke.mjs` proves the shared duel path sends each pilot's chosen mech ID through `Cmd72` (local) and `Cmd64` (remote) on both clients. | -| RE torso/leg independence | πŸ”¬ | Legs = heading (KP4/6/2/8); torso = facing (WASD); server must track both; compass shows both simultaneously | -| RE turn timer / match end | βœ… | **RE complete (issue #79, Β§23):** No server-to-client match-end packet exists. Win = client local sim kills enemy β†’ results loop β†’ exit key β†’ TCP close. Loss = Cmd67 IS damage β†’ actor-0 IS=0 β†’ disconnect timer β†’ TCP close. Server stops Cmd67 when `playerHealth ≀ 0`. | -| RE physical combat | πŸ”¬ | Death-from-above (DFA) and alpha strike β€” dedicated commands or derived from positional data? | -| RE v1.23 RPSβ†’combat state handoff | πŸ”¬ | `MMW` welcome enters `"Solaris RPS"`; later `MMC` welcome enters `"Solaris COMBAT"` only after RPS is established. `"Transition to combat - even"` is an internal music state, not a server payload. | -| Scripted dummy opponent | πŸ”§ | The old static dummy has been replaced by a much more retail-like combat bot. It now maintains persistent remote position/facing, maneuvers to matchup-aware preferred range, uses jump jets tactically, strafes/jinks under threat, applies movement-aware to-hit rolls in both directions, derives TIC-style volley presets from heat/weapon data, and now keeps its retreat / reverse behavior inside the same walk-capped reverse envelope the live player path uses. Bot range holding and jump-fit planning are also ammo-aware, so spent long-range bins no longer keep it kiting for bands it cannot actually threaten. Remaining work is live tuning and any deeper retail fidelity gaps that only show up in manual duels. | +| Arena ready-room identity | 🟑 | Ready rooms, `MECH`, `SIDE`, `STATUS`, and room capacity exist; harden for all room counts and reconnects. | +| Team/side bootstrap | 🟑 | Recent upstream work improved bot team bootstrap fidelity; generalize to all human/bot combinations. | +| Remote movement/gait sync | 🟑 | Remote gait and position have received fixes; build stronger long-running multi-client tests. | +| Synchronized damage and death state | 🟑 | Ensure all clients see the same armor/internal, fall, destruction, and result state. | +| Arena chat channels | πŸ”¬ | F7/F8 are local UI toggles; server-side fan-out depends on team/all-comm mode inference and team assignment. | +| Match orchestration | 🟑 | Shared combat can start from ready rooms; finish timers, disconnect policy, result settlement, and cleanup. | -**Verification:** Player can enter an arena, fire weapons, receive damage feedback, and reach a win/lose screen. +**Exit criteria:** Four or more clients can enter the same arena, split into sides, fight, see consistent remote state, and return to world cleanly. ---- +### M8 β€” Sanctioned Battles and SCentEx -### M7 β€” Multi-Client Combat - -**Goal:** Two human players can fight each other in real time. - -*Depends on M6.* - -Manual-backed arena staging model: the ready room exposes `MECH`, `SIDE`, and `STATUS`; `SIDE` offers eight sides, and players on the same side are teammates. Current implementation assumption: cap an arena ready room at **8 participants**, matching the eight-side model, unless stronger contrary evidence appears. **Sanctioned matches** use only arenas #1 and #2 per sector β€” results feed SCentEx (M9). The primary full-match use case is a **4v4 lance (8 total players)**. +**Goal:** Sanctioned play affects persistent rankings and player history like the retail service. | Task | Status | Notes | |---|---|---| -| Room broadcast | ❌ | Sync combat state to all clients in the same arena | -| Player enter / leave events | βœ… | Generic same-room `Cmd13` arrival / `Cmd11(status=0)` departure already work for arena rooms, `tools\\arena-room-smoke.mjs` live-validates arena ready-room arrival/departure visibility, and the lone-pilot combat fallback now uses the same departure/restore announcement path. | -| Side assignment enforcement | πŸ”§ | Arena scenes now expose `SIDE`, and same-side duel staging is rejected once both pilots explicitly pick the same side. Broader multi-party side-cap enforcement is still open. | -| Arena ready-room roster / listing model | πŸ”§ | Manual proves `MECH` / `SIDE` / `STATUS` and eight sides; current server assumption is max 8 participants. Arena entry now opens a live ready-room chooser, ready rooms are tracked as `Ready Room N` under each arena, `STATUS` titles/presence text/reconnect restore preserve that room identity, and a full room is rejected on selection instead of silently overfilling. Custom room naming, an explicit room-size selector, and explicit FFA/team-play labels remain unproven. | -| Synchronized position | πŸ”¬ | Each client sees other mechs move in real time. Current local Ghidra lead: combat cmd `65` / wire `0x66` (`FUN_00401820`) parses player id, X/Y/Z, rotation-ish bytes, and speed/throttle-ish byte; constants differ from RazorWing/solaris. | -| Synchronized damage | ❌ | Damage dealt by one client is reflected in all clients' views | -| Match orchestration | πŸ”§ | Arena fights can now start as shared combat directly from `READY` when 2..8 pilots are in the same arena ready room. Remaining gaps are the full 15-minute match timer, end-of-match orchestration/settlement, and the sanctioned-match flag. | -| F7 β€” team / lance channel | πŸ”¬ | Scoped broadcast to your lance teammates; v1.23 RE confirms F7 is local-only (no network packet). The server-side team-channel fan-out mechanism (identifying which clients are on the same lance) remains πŸ”¬; wire format unknown. Requires `Cmd8` team assignment to be established. | -| F8 β€” all-comm channel | πŸ”¬ | Broadcast to all players in the current arena match; v1.23 RE confirms F8 is local-only (no network packet). The all-comm delivery mechanism and any associated serverβ†’client command remain πŸ”¬. | - -Live robustness coverage now also includes `tools\\duel-reconnect-restore-smoke.mjs`, which reconnects a participant during the post-duel restore window and confirms deferred settlement delivery plus selected-mech persistence on the replacement session. +| Sanctioned duel lifecycle | 🟑 | First duel progression and C-bill settlement exist; finish all arena/session edge cases. | +| Team Sanctioned Battles | πŸ”¬ | v1.27 introduced support; v1.29 still needs explicit server-visible lifecycle RE and implementation. | +| SCentEx formula | πŸ”¬ | Current ranking model is emulator-owned. Retail damage-inflicted/sustained and fame/rank math need RE or documented approximation. | +| Ranking/result display | 🟑 | `Cmd45`/`Cmd58` pages are accepted by v1.29; `Cmd41` score matrix is a likely results surface. Finish safe result/ranking routing. | +| Personnel and public history | 🟑 | Basic personnel records exist; finish battles-to-date, fame, rank, house, mech, and result history pages. | +| Durable result settlement | 🟑 | Duel results persist; make settlement idempotent across disconnect/reconnect/server restart. | -**Verification:** Two `MPBTWIN.EXE` instances connect, enter the same arena, see each other, and fight to completion. +**Exit criteria:** Two or more players complete sanctioned matches and can immediately see correct C-bill, fame/rank, personnel, and ranking/result updates from retail UI surfaces. ---- +### M9 β€” Operational Hardening -### M8 β€” Playable Game - -**Goal:** The emulator is complete enough for a real play session. - -*Depends on M7.* +**Goal:** The emulator can run unattended for real users. | Task | Status | Notes | |---|---|---| -| All 161 mechs loaded from real `.MEC` files | πŸ”§ | `loadMechs()` scans/parses `mechdata/*.MEC` in M1. Mech examine/status surfaces and the world mech picker now expose `.MEC`-derived tonnage, walk/run speed, and jump-jet presence across all variants; remaining work is actual gameplay integration (armor, weapons, heat, internal state). | -| Real Solaris arena layouts | ❌ | From M5 RE work | -| Correct mech stat handling (armor, weapons, heat) | πŸ”§ | `.MEC`-driven weapon/heat fidelity is no longer purely placeholder: weapon families now carry direct damage, cooldown, per-weapon S/M/L range caps, and mech heat-sink counts; combat uses the real long-range table from `screenshots/weapon-ranges.png` (including `AC/10 = 360m`) and bot range-band logic now uses explicit per-weapon S/M/L caps instead of generic `90/270` heuristics. Bot TIC/range planning also now follows the **usable** loadout instead of stale mounted range alone, so expected-damage and jump-fit decisions ignore ammo-depleted weapons. Remaining work is broader gameplay integration: armor/internal fidelity, heat/system-degradation behavior, and any additional retail-only stat surfaces still missing from RE. | -| Client launcher β€” `play.pcgi` generator | βœ… | `npm run gen-pcgi` already works | -| Basic observability (logs, session captures) | βœ… | Already implemented | -| Graceful disconnect / reconnect handling | πŸ”§ | ARIES type-`0x05` keepalive is now sent periodically by the server and echoed by the client, matching COMMEG32.DLL `FUN_100014e0` case `5`. `ARIES_KEEPALIVE_INTERVAL_MS` and `SOCKET_IDLE_TIMEOUT_MS` are configurable so long GUI validation sessions are not cut off by the old hardcoded 120-second idle timeout. Real two-GUI validation on 2026-04-07 confirmed both `MPBTWIN.EXE` sessions remained connected beyond 120 seconds and replied to repeated world keepalive pings. Lobbyβ†’world reconnect now restores the previous room, selected mech, and deferred duel-settlement notice; replacement-session settlement sync also covers disconnect/reconnect timing races. Longer mid-match recovery and broader world-session restoration are still incomplete. | - -**Verification:** Full play session β€” two humans, real mechs, real arena, fight to conclusion β€” with no manual intervention. - ---- +| Database migrations and backups | 🟑 | Schema/migrate tooling exists; add operational backup/restore guidance and migration checks. | +| Observability | 🟑 | Logs and captures exist; add structured event IDs for session, combat, settlement, and packet-failure analysis. | +| Long-running soak tests | ❌ | Add multi-client soak covering world idle, travel, ComStar, arena staging, combat, result restore, and reconnect. | +| Config and deployment | 🟑 | Docker and env config exist; harden for public server deployment. | +| Abuse/duplicate/session controls | 🟑 | Duplicate session handling exists; complete rate limits, stale locks, and admin recovery tools. | -### M9 β€” SCentEx / Persistence +**Exit criteria:** A public test weekend can run without manual database surgery or server restarts for routine failures. -**Goal:** Sanctioned matches produce persistent ranking results, matching original game behaviour. +### M10 β€” Completion Polish -*Depends on M8. Not optional β€” SCentEx existed in the original game.* +**Goal:** Close the difference between β€œplayable” and β€œcredible retail service.” | Task | Status | Notes | |---|---|---| -| SCentEx ranking model | ❌ | Damage inflicted vs. damage sustained determines rank change after each sanctioned match | -| Player fame stat | ❌ | Per-character fame tracked (BT-MAN p. 9) | -| Tier Ranking display | πŸ”§ | Tier/class ranking pages, match results, and v1.29-safe Cmd14 ranking detail are now served by the world layer. The submit path is already retail-shaped enough: inbound selection accepts both cmd-7 and world cmd-29 subtype 2, and later chooser surfaces still appear to submit through ordinary cmd-7. Remaining gap is the exact late-client outbound chooser preset/paging semantics: the counted Cmd57 row body is now largely understood, but the direct row callback only handles six visible row controls, so the 8-row tier chooser still needs the correct preset-strip state before it can safely replace the current Cmd7 compatibility menu. Live 2026-04-25 GUI + `cdb.exe` verification now also proves the current emulator still emits only the compatibility Cmd7 ranking choosers/results menus in real runs β€” no actual outbound `Cmd57` ranking packet exists in the running server yet. Preserved repo artifacts close the obvious archive fallback too: current captures/logs contain no ranking-side `Cmd57`, only later `CMD58_SCROLL_LIST_ID` + `CMD45_*RANKINGS` result-shell traffic. The active shared shell path also now correlates special control `0x131` with the literal stock `OPTN` glyph and surrounding `CSTR` / `EXIT` chrome, and the dynamic strip-slot table now resolves to stock `MW_MPICS.DAT` world/location art (`I101..I184`, including `ACADEMY`, `STEINER STADIUM`, `DAVION ARENA`, and HQ-style emblems), which makes guessing a ranking-safe preset even riskier. | -| Personnel record | πŸ”¬ | First page is now identified: `Cmd7(0x3f2, target_id + 1)` triggers world `Cmd14_PersonnelRecord` (`0x2f`), which displays the selected handle, ComStar ID, battles-to-date, and six server-formatted text lines. Follow-up trace on the built-in `Cmd7(0x95, 2)` `More` request did not reveal a distinct second-page command handler; strongest current inference is that later pages are delivered as additional `Cmd14` payloads. A minimal two-page server prototype is now implemented on the branch and passes a direct world-session socket smoke (`Cmd48 -> Cmd14 page 1 -> Cmd7(0x95, 2) -> Cmd14 page 2`). New client disassembly confirms the remaining header limitation too: `Cmd14` takes header `ID` from packet payload, but header `Handle` still comes from the client-local room-roster cursor, so the server cannot currently make both header values target-correct at once. Remaining unknowns: exact mapping of the six text lines and the meaning of two legacy/unused `type4` payload slots. | -| SCentEx result reporting protocol | πŸ”¬ | Duel results are now persisted and feed the emulator ranking pages, but the exact retail reporting/routing path into the later ranking UI is still unresolved | +| Retail comparison matrix | ❌ | For every known retail surface, list implemented / approximate / intentionally absent. | +| Manual-backed gameplay audit | ❌ | Re-read BT manual against implemented world, mech, combat, and SCentEx behavior. | +| Compatibility docs | ❌ | Document exact required client version, assets, launcher flow, optional windowing shim, and known client quirks. | +| Admin/player docs | ❌ | Account creation, server setup, backup, troubleshooting, and play guide. | +| Release candidate test plan | ❌ | Freeze feature surface and run full regression checklist against clean v1.29 installs. | -**Verification:** Two players complete a sanctioned match; both observe updated rankings at a bar terminal. - ---- +**Exit criteria:** A new operator can deploy the server and a new player can connect with a retail v1.29 install using only documented steps. ## RE Priority Queue -Work these in order when sitting down with Ghidra: - -1. ~~**`FUN_100014e0` case 0** (`COMMEG32.DLL`) β€” secondary connection handler.~~ βœ… Resolved (RESEARCH.md Β§17) -2. ~~**World command dispatch table** (`MPBTWIN.EXE`).~~ βœ… Resolved (RESEARCH.md Β§18) -3. ~~**Initial world handshake**.~~ βœ… Resolved (RESEARCH.md Β§18) -4. ~~**Cmd 20 server response** (`FUN_00401c90`).~~ βœ… Resolved (M1 complete) -5. ~~**Combat CRC crossover**.~~ βœ… Resolved β€” `g_combatMode` flag selects seed; RPS=`0x0a5c25`, Combat=`0x0a5c45` (RESEARCH.md Β§18) -6. ~~**`SOLARIS.MAP` / `IS.MAP` exit graph** β€” decode room-to-room connections.~~ βœ… Leading room tables fully decoded (RESEARCH.md Β§19.7); provisional exit tree implemented in server-world.ts; authentic exit graph from trailing section still needs RE. -7. **Non-death fall / recovery local state** β€” why does the retail client remain upright even after proven local `Cmd70` fall/collapse/recover probes, and what additional local state unlocks wire `cmd12/action0` stand-up? -8. **F7 / F8 chat channel wire format** β€” are team and all-comm differentiated by command code or a flag in the packet? (M7 prerequisite; both channels require `Cmd8` team assignment and are arena-phase only). -9. **Movement packets** β€” βœ… DECODED (RESEARCH.md Β§19.2): Cmd8 (coasting) and Cmd9 (moving) clientβ†’server formats fully mapped; bias/divisor constants confirmed. Serverβ†’client position (Cmd65) implemented and confirmed in live combat test. -10. **Weapon fire / damage packets** β€” βœ… DECODED (RESEARCH.md Β§19.3 / Β§19.9): weapon fire acceptance is now grounded primarily on `cmd10` shot geometry, while recovery-side RE shows `cmd12/action 0` is reused for stand-up when already down rather than being a clean dedicated ordinary-fire opcode. Server `Cmd62` (wire `0x5F`) unblocks the fire gate by clearing `DAT_0047ef60` bit `0x20`. Damage model (Cmd66/Cmd67) is partially decoded; round-trip hit confirmation is strong enough for current combat play, but deeper retail damage/heat fidelity still needs capture. | -11. **TIC circuit wire format** (M6 prerequisite). -12. **Jump jet / altitude state packets** (M6 prerequisite) β€” fire command decoded; fuel/regen/Z-altitude still πŸ”¬. -13. **Turn timer / sanctioned match lifecycle** (M6/M7 prerequisite) β€” local win/loss disconnect path is understood, but the shared 15-minute arena timer and broader sanctioned settlement/orchestration are still open. -14. **SCentEx result reporting** (M9 prerequisite). - ---- - -## Known Unknowns +Work these in order when doing Ghidra / live-client sessions: -These are gaps we know exist. They are not bugs β€” they are the RE frontier. +1. **Team Sanctioned Battles**: server-visible lifecycle, side/team assignment details, result aggregation, and whether any v1.27+ opcode family is involved. +2. **SCentEx formula and result routing**: rank/fame/C-bill math, `Cmd41` score matrix role, and persistent public history. +3. **Combat heat/damage/critical fidelity**: heat buildup/dissipation, shutdown, ammo explosions, weapon disablement, section labels, and death/cripple boundaries. +4. **Jump/altitude edge cases**: fuel drain, recharge, landing, jump-jet damage, DFA/physical attacks if present. +5. **Arena chat channels**: F7/F8 local UI state and the server-visible distinction for team/all-comm delivery. +6. **World topology and facility menus**: authentic room graph, bar terminals, ComStar global entry, bank/terminal/hub actions. +7. **`Cmd57` safe builder**: prove the chooser preset/control-strip contract before using it for production ranking or menu flows. +8. **v1.28+ anti-hack/guard surfaces**: confirm whether any runtime guard affects server-visible behavior in v1.29. +9. **Long-run retail-client timing**: keep validating 60 FPS cap, result timers, fall/recovery timing, and focus/window repaint quirks. +10. **Post-completion mech-management surfaces**: exact `Cmd26` β†’ `Cmd30` β†’ `Cmd31` / `Cmd39` row order and submit behavior for repair/reload/buy-extra-ammo/name-mech, kept as optional future work unless new evidence shows GameStorm used it. -- **`SOLARIS.MAP` / `IS.MAP` exit graph** β€” leading room tables are fully decoded (RESEARCH.md Β§19.7); the trailing binary section (picture/resource data) still needs a separate movement/topology RE pass to extract authentic room-to-room exit connections and room-type classifications. -- **F7 / F8 chat channel differentiation** β€” two distinct broadcast channels exist (team/lance and all-comm); both are arena-phase constructs gated on `Cmd8` team assignment; wire-format difference is unknown. Tracked in M7. -- **Bar booth terminal commands** β€” `KP5` β†’ `Cmd48` all-roster query and `Cmd7(0x3f2)` personnel record are implemented; Tier Ranking terminal activation format is still unknown. -- **Arena ready-room creation / listing UI** β€” manual evidence proves `MECH`, `SIDE`, and `STATUS` in the ready room plus an eight-side team model; current server work assumes up to 8 participants, but custom room naming, an explicit room-size selector, and explicit FFA/team-play labels remain unproven. -- **Tram / monorail command** β€” βœ… **RESOLVED** (RESEARCH.md Β§19.10): T.O.F.S. uses the same `cmd5 actionType 4 β†’ Cmd43 β†’ cmd10` path as regular Solaris travel; no separate tram command. Closes issue #70. -- **SCentEx result-reporting protocol** β€” how does the server communicate sanctioned match results? -- **Non-death fall / recovery local state** β€” retail still stays upright after every meaningful server-side `Cmd70` probe tried so far, including local `1->8->0`. Ghidra now says F12 stand-up should become wire `cmd12/action0` only when the client is truly down, but the latest live `legrecover` validation still produced no `cmd12/action0`, no `cmd10`, and no posture change. The missing state transition that makes the client consider itself recoverable is still unknown. -- **Serverβ†’client combat position sync (`Cmd65`)** β€” implemented and live-confirmed for bootstrap/movement echo, but fuller field semantics for remote multi-client sync still need stronger capture confirmation. -- **TIC group fire** β€” whether `cmd 12/action 0` means selected weapon, selected TIC group, or all queued fire needs dynamic capture to confirm. -- **Jump jet fuel / Z-altitude state** β€” fire (`cmd 12/action 4`) and landing (`cmd 12/action 6`) decoded; fuel depletion, regeneration rate, and serverβ†’client altitude feedback still unknown. -- **Turn timer / sanctioned match lifecycle** β€” the local win/loss disconnect path is now understood, but the shared 15-minute arena timer, mech-kill broadcast/settlement behavior, and sanctioned-match orchestration are still unconfirmed. +## Known Unknowns ---- +- Exact retail SCentEx ranking formula and Team Sanctioned Battle settlement. +- Authentic Solaris room topology beyond decoded map room records. +- Full mech bay repair/reload/extra-ammo/name-mech contracts in v1.29, treated as post-completion client-capability research rather than required 1999 GameStorm behavior. +- Whether `Cmd57` can be safely used for production choosers without drawing unrelated stock controls. +- Full heat, shutdown, critical, ammo explosion, and physical-combat behavior. +- Complete arena team/all-comm delivery semantics. +- Whether v1.28+ anti-hack strings have any server-visible consequence. ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow. -See [RESEARCH.md](RESEARCH.md) for all confirmed protocol details and RE methodology. +See [RESEARCH.md](RESEARCH.md) for confirmed protocol details, binary notes, and RE methodology. -If you have access to Ghidra and want to help, the RE Priority Queue above is where to start. Open a **Research Finding** issue with your findings before opening a PR. +For new RE findings, update `RESEARCH.md` first, then adjust this roadmap only after the finding changes implementation scope or completion criteria. diff --git a/package-lock.json b/package-lock.json index a1e17a0..80ccba9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,10 @@ "version": "0.1.61", "license": "AGPL-3.0-only", "dependencies": { + "@types/ws": "^8.18.1", "bcryptjs": "^3.0.3", - "pg": "^8.20.0" + "pg": "^8.20.0", + "ws": "^8.20.0" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", @@ -103,7 +105,6 @@ "version": "20.19.39", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -121,6 +122,15 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -386,7 +396,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/v8-compile-cache-lib": { @@ -396,6 +405,27 @@ "dev": true, "license": "MIT" }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 9331be0..926f9a0 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,9 @@ "typescript": "^5.4.0" }, "dependencies": { + "@types/ws": "^8.18.1", "bcryptjs": "^3.0.3", - "pg": "^8.20.0" + "pg": "^8.20.0", + "ws": "^8.20.0" } } diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..03137c1 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,558 @@ +/** + * MPBT REST API server β€” modern client adapter. + * + * Provides a lightweight HTTP server on API_PORT (default 3002) for the + * Godot 4 client. The ARIES TCP protocol (ports 2000/2001) is unaffected. + * + * Endpoints: + * GET /health β†’ { ok: true, version, name } + * GET /mechs β†’ { ok: true, mechs: MechApiEntry[] } + * GET /world/rooms β†’ { ok: true, rooms: WorldRoom[], source_available: boolean } + * POST /world/travel β†’ { ok: true, room: WorldRoom | null } + * Body: { roomId: number } + * Header: X-Username (authenticated display name) + * GET /world/presence β†’ { ok: true, rooms: Array<{ roomId, occupants: string[] }> } + * POST /world/chat β†’ { ok: true } + * Body: { roomId: number, text: string (max 200 chars) } + * Header: X-Username + * Broadcasts room_chat WebSocket event to all clients + * PATCH /world/mech/select β†’ { ok: true, mechId, typeString, slot } + * Body: { mechId: number } + * Header: X-Username + * GET /standings β†’ { ok: true, standings: SolarisStanding[] (top 50) } + * GET /world/character β†’ { ok: true, cbills, allegiance, mechId, mechSlot } + * Header: X-Username + * GET /arena/queue β†’ { ok: true, slots: ArenaSlot[], pendingMatch } + * POST /arena/queue β†’ { ok: true, slot: ArenaSlot } + * Header: X-Username + * Derives mech from character DB; clears any pendingMatch + * Broadcasts arena_queue_update + * DELETE /arena/queue β†’ { ok: true } + * Header: X-Username + * Broadcasts arena_queue_update + * PATCH /arena/ready β†’ { ok: true, launched?: boolean, arenaId?: string } + * Body: { ready: boolean } + * Header: X-Username + * Broadcasts arena_queue_update; if all ready: + * broadcasts arena_match_launch + records pendingMatch + * GET /comstar β†’ { ok: true, messages: ComstarApiMessage[], unreadCount } + * Header: X-Username + * GET /comstar/unread β†’ { ok: true, count: number } + * Header: X-Username + * POST /comstar β†’ { ok: true } + * Body: { to, subject (max 100), body (max 1000) } + * Header: X-Username + * Broadcasts comstar_new_message { to } to all WS clients + * PATCH /comstar/:id/read β†’ { ok: true } or 404 + * Header: X-Username + * DELETE /comstar/:id β†’ { ok: true } or 404 + * Header: X-Username + * WS /ws β†’ real-time push: presence_update, room_chat, + * arena_queue_update, arena_match_launch, + * comstar_new_message events + */ + +import * as http from 'http'; +import { readFileSync } from 'fs'; +import { Logger } from './util/logger.js'; +import { loadSolarisRooms } from './data/maps.js'; +import { WORLD_MECHS } from './world/world-data.js'; +import { MECH_STATS } from './data/mech-stats.js'; +import { findCharacterByDisplayName, updateCharacterMech, listCharacters } from './db/characters.js'; +import { listAllDuelResults } from './db/duel-results.js'; +import { computeSolarisStandings } from './world/solaris-rankings.js'; +import { presenceStore } from './world/presence.js'; +import { wsBroadcaster } from './world/ws_broadcaster.js'; +import { arenaQueue } from './world/arena-queue.js'; +import { combatWsManager } from './world/combat-ws.js'; +import { randomUUID } from 'crypto'; +import { + listInbox, + countUnread, + sendComstarModern, + markReadById, + softDelete, +} from './db/comstar_modern.js'; + +const _pkg = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +) as { version: string }; + +function setCors(res: http.ServerResponse): void { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Username'); +} + +function jsonOk(res: http.ServerResponse, body: object): void { + const payload = JSON.stringify(body); + setCors(res); + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + }); + res.end(payload); +} + +function jsonError(res: http.ServerResponse, status: number, message: string): void { + const payload = JSON.stringify({ ok: false, error: message }); + setCors(res); + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + }); + res.end(payload); +} + +function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +export function startApiServer(log: Logger, host: string, port: number): http.Server { + const apiLog = log.child('api'); + + const server = http.createServer(async (req, res) => { + const pathname = req.url?.split('?')[0] ?? '/'; + + if (req.method === 'OPTIONS') { + setCors(res); + res.writeHead(204); + res.end(); + return; + } + + if (req.method === 'GET' && pathname === '/health') { + jsonOk(res, { ok: true, version: _pkg.version, name: 'mpbt-server' }); + return; + } + + if (req.method === 'GET' && pathname === '/mechs') { + const mechs = WORLD_MECHS.map((entry) => { + const stats = MECH_STATS.get(entry.typeString) ?? null; + return { + id: entry.id, + slot: entry.slot, + typeString: entry.typeString, + name: stats?.name ?? '', + weightClass: stats?.weightClass ?? 'unknown', + tonnage: stats?.tonnage ?? entry.tonnage ?? null, + maxSpeedKph: stats?.maxSpeedKph ?? null, + armor: stats?.armor ?? null, + jumpMeters: stats?.jumpMeters ?? null, + armament: stats?.armament ?? [], + effectiveRange: stats?.effectiveRange ?? null, + disabled: stats?.disabled ?? true, + }; + }); + jsonOk(res, { ok: true, mechs }); + return; + } + + if (req.method === 'GET' && pathname === '/world/rooms') { + const rooms = loadSolarisRooms() ?? []; + jsonOk(res, { ok: true, rooms, source_available: rooms.length > 0 }); + return; + } + + if (req.method === 'POST' && pathname === '/world/travel') { + const username = (req.headers['x-username'] ?? '') as string; + if (!username) { + jsonError(res, 400, 'X-Username header required'); + return; + } + let body: string; + try { + body = await readBody(req); + } catch { + jsonError(res, 400, 'failed to read request body'); + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + jsonError(res, 400, 'invalid JSON body'); + return; + } + const roomId = + parsed !== null && + typeof parsed === 'object' && + 'roomId' in parsed && + typeof (parsed as Record).roomId === 'number' + ? ((parsed as Record).roomId as number) + : NaN; + if (!Number.isFinite(roomId)) { + jsonError(res, 400, 'roomId must be a number'); + return; + } + presenceStore.travel(username, roomId); + apiLog.info('%s traveled to room %d', username, roomId); + wsBroadcaster.broadcast('presence_update', { rooms: presenceStore.getAll() }); + const rooms = loadSolarisRooms() ?? []; + const room = rooms.find((r) => r.roomId === roomId) ?? null; + jsonOk(res, { ok: true, room }); + return; + } + + if (req.method === 'GET' && pathname === '/world/presence') { + jsonOk(res, { ok: true, rooms: presenceStore.getAll() }); + return; + } + + if (req.method === 'POST' && pathname === '/world/chat') { + const username = (req.headers['x-username'] ?? '') as string; + if (!username) { + jsonError(res, 400, 'X-Username header required'); + return; + } + let body: string; + try { + body = await readBody(req); + } catch { + jsonError(res, 400, 'failed to read request body'); + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + jsonError(res, 400, 'invalid JSON body'); + return; + } + const p = parsed as Record; + const roomId = + parsed !== null && typeof parsed === 'object' && typeof p.roomId === 'number' + ? (p.roomId as number) + : NaN; + const rawText = + parsed !== null && typeof parsed === 'object' && typeof p.text === 'string' + ? (p.text as string).trim() + : ''; + if (!Number.isFinite(roomId)) { + jsonError(res, 400, 'roomId must be a number'); + return; + } + if (!rawText) { + jsonError(res, 400, 'text must be a non-empty string'); + return; + } + if (rawText.length > 200) { + jsonError(res, 400, 'text must be 200 characters or fewer'); + return; + } + apiLog.info('chat room %d [%s]: %s', roomId, username, rawText.slice(0, 40)); + wsBroadcaster.broadcast('room_chat', { roomId, username, text: rawText }); + jsonOk(res, { ok: true }); + return; + } + + if (req.method === 'PATCH' && pathname === '/world/mech/select') { + const username = (req.headers['x-username'] ?? '') as string; + if (!username) { + jsonError(res, 400, 'X-Username header required'); + return; + } + let body: string; + try { + body = await readBody(req); + } catch { + jsonError(res, 400, 'failed to read request body'); + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + jsonError(res, 400, 'invalid JSON body'); + return; + } + const mechId = + parsed !== null && + typeof parsed === 'object' && + typeof (parsed as Record).mechId === 'number' + ? ((parsed as Record).mechId as number) + : NaN; + if (!Number.isFinite(mechId)) { + jsonError(res, 400, 'mechId must be a number'); + return; + } + const entry = WORLD_MECHS.find((m) => m.id === mechId); + if (!entry) { + jsonError(res, 404, `mech id ${mechId} not in roster`); + return; + } + const character = await findCharacterByDisplayName(username); + if (!character) { + jsonError(res, 404, 'character not found'); + return; + } + await updateCharacterMech(character.account_id, mechId, entry.slot); + apiLog.info('%s selected mech %s (id=%d slot=%d)', username, entry.typeString, mechId, entry.slot); + jsonOk(res, { ok: true, mechId, typeString: entry.typeString, slot: entry.slot }); + return; + } + + if (req.method === 'GET' && pathname === '/standings') { + const [results, chars] = await Promise.all([ + listAllDuelResults(), + listCharacters(), + ]); + const standings = computeSolarisStandings(results, chars).slice(0, 50); + jsonOk(res, { + ok: true, + standings: standings.map(s => ({ + overallRank: s.overallRank, + displayName: s.displayName, + allegiance: s.allegiance, + wins: s.wins, + losses: s.losses, + ratioText: s.ratioText, + tierLabel: s.tierLabel, + score: s.score, + })), + }); + return; + } + + if (req.method === 'GET' && pathname === '/world/character') { + const username = (req.headers['x-username'] ?? '') as string; + if (!username) { + jsonError(res, 400, 'X-Username header required'); + return; + } + const character = await findCharacterByDisplayName(username); + if (!character) { + jsonError(res, 404, 'character not found'); + return; + } + jsonOk(res, { + ok: true, + cbills: character.cbills, + allegiance: character.allegiance, + mechId: character.mech_id, + mechSlot: character.mech_slot, + }); + return; + } + + if (req.method === 'GET' && pathname === '/arena/queue') { + jsonOk(res, { + ok: true, + slots: arenaQueue.getAll(), + pendingMatch: arenaQueue.pendingMatch, + }); + return; + } + + if (req.method === 'POST' && pathname === '/arena/queue') { + const username = (req.headers['x-username'] ?? '') as string; + if (!username) { + jsonError(res, 400, 'X-Username header required'); + return; + } + const character = await findCharacterByDisplayName(username); + if (!character) { + jsonError(res, 404, 'character not found'); + return; + } + const mechId: number = character.mech_id ?? -1; + if (mechId < 0) { + jsonError(res, 400, 'No mech selected β€” visit the Mech Bay first'); + return; + } + const mechEntry = WORLD_MECHS.find((m) => m.id === mechId); + const typeString = mechEntry?.typeString ?? ''; + // Clear any stale pending match when a new queue cycle begins. + arenaQueue.clearPendingMatch(); + const slot = arenaQueue.join(username, mechId, typeString); + apiLog.info('%s joined arena queue (mech=%s id=%d)', username, typeString, mechId); + wsBroadcaster.broadcast('arena_queue_update', { slots: arenaQueue.getAll() }); + jsonOk(res, { ok: true, slot }); + return; + } + + if (req.method === 'DELETE' && pathname === '/arena/queue') { + const username = (req.headers['x-username'] ?? '') as string; + if (!username) { + jsonError(res, 400, 'X-Username header required'); + return; + } + const removed = arenaQueue.leave(username); + if (removed) { + apiLog.info('%s left arena queue', username); + wsBroadcaster.broadcast('arena_queue_update', { slots: arenaQueue.getAll() }); + } + jsonOk(res, { ok: true }); + return; + } + + if (req.method === 'PATCH' && pathname === '/arena/ready') { + const username = (req.headers['x-username'] ?? '') as string; + if (!username) { + jsonError(res, 400, 'X-Username header required'); + return; + } + let body: string; + try { + body = await readBody(req); + } catch { + jsonError(res, 400, 'failed to read request body'); + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + jsonError(res, 400, 'invalid JSON body'); + return; + } + const ready = + parsed !== null && + typeof parsed === 'object' && + typeof (parsed as Record).ready === 'boolean' + ? ((parsed as Record).ready as boolean) + : null; + if (ready === null) { + jsonError(res, 400, 'ready must be a boolean'); + return; + } + const slot = arenaQueue.setReady(username, ready); + if (!slot) { + jsonError(res, 404, 'not in arena queue β€” join first'); + return; + } + apiLog.info('%s arena ready=%s', username, ready); + wsBroadcaster.broadcast('arena_queue_update', { slots: arenaQueue.getAll() }); + + if (arenaQueue.isAllReady()) { + const arenaId = randomUUID(); + const match = arenaQueue.recordLaunch(arenaId); + apiLog.info('arena match launching: id=%s players=%d', arenaId, match.slots.length); + const mode = match.slots.length === 1 ? 'solo' : 'pvp'; + wsBroadcaster.broadcast('arena_match_launch', { + arenaId: match.arenaId, + slots: match.slots, + launchedAt: match.launchedAt, + mode, + }); + combatWsManager.startSession(match.arenaId, mode, match.slots); + jsonOk(res, { ok: true, launched: true, arenaId: match.arenaId }); + return; + } + + jsonOk(res, { ok: true, launched: false }); + return; + } + + // ── ComStar ────────────────────────────────────────────────────────────── + + if (req.method === 'GET' && pathname === '/comstar/unread') { + const username = (req.headers['x-username'] ?? '') as string; + if (!username) { jsonError(res, 400, 'X-Username header required'); return; } + const character = await findCharacterByDisplayName(username); + if (!character) { jsonError(res, 404, 'character not found'); return; } + const count = await countUnread(character.account_id); + jsonOk(res, { ok: true, count }); + return; + } + + if (req.method === 'GET' && pathname === '/comstar') { + const username = (req.headers['x-username'] ?? '') as string; + if (!username) { jsonError(res, 400, 'X-Username header required'); return; } + const character = await findCharacterByDisplayName(username); + if (!character) { jsonError(res, 404, 'character not found'); return; } + const rows = await listInbox(character.account_id); + const unreadCount = rows.filter((r) => r.read_at === null).length; + jsonOk(res, { + ok: true, + unreadCount, + messages: rows.map((r) => ({ + id: r.id, + from: r.from_name, + subject: r.subject, + body: r.body, + sentAt: r.sent_at.toISOString(), + readAt: r.read_at?.toISOString() ?? null, + })), + }); + return; + } + + if (req.method === 'POST' && pathname === '/comstar') { + const username = (req.headers['x-username'] ?? '') as string; + if (!username) { jsonError(res, 400, 'X-Username header required'); return; } + let rawBody: string; + try { rawBody = await readBody(req); } catch { jsonError(res, 400, 'failed to read body'); return; } + let parsed: unknown; + try { parsed = JSON.parse(rawBody); } catch { jsonError(res, 400, 'invalid JSON body'); return; } + const p = parsed as Record; + const to = typeof p?.to === 'string' ? (p.to as string).trim() : ''; + const subject = typeof p?.subject === 'string' ? (p.subject as string).trim() : ''; + const body = typeof p?.body === 'string' ? (p.body as string).trim() : ''; + if (!to) { jsonError(res, 400, 'to is required'); return; } + if (subject.length > 100) { jsonError(res, 400, 'subject must be 100 characters or fewer'); return; } + if (!body) { jsonError(res, 400, 'body is required'); return; } + if (body.length > 1000) { jsonError(res, 400, 'body must be 1000 characters or fewer'); return; } + if (to.toLowerCase() === username.toLowerCase()) { jsonError(res, 400, 'cannot send to yourself'); return; } + const sender = await findCharacterByDisplayName(username); + if (!sender) { jsonError(res, 404, 'sender character not found'); return; } + const recipient = await findCharacterByDisplayName(to); + if (!recipient) { jsonError(res, 404, 'recipient not found'); return; } + const msg = await sendComstarModern(sender.account_id, recipient.account_id, username, subject, body); + if (!msg) { jsonError(res, 500, 'failed to send message'); return; } + wsBroadcaster.broadcast('comstar_new_message', { to: recipient.display_name }); + apiLog.info('comstar: %s β†’ %s subject=%s', username, to, subject.slice(0, 40)); + jsonOk(res, { ok: true }); + return; + } + + { + const comstarReadMatch = /^\/comstar\/(\d+)\/read$/.exec(pathname); + if (req.method === 'PATCH' && comstarReadMatch) { + const username = (req.headers['x-username'] ?? '') as string; + if (!username) { jsonError(res, 400, 'X-Username header required'); return; } + const character = await findCharacterByDisplayName(username); + if (!character) { jsonError(res, 404, 'character not found'); return; } + const updated = await markReadById(Number(comstarReadMatch[1]), character.account_id); + if (!updated) { jsonError(res, 404, 'message not found'); return; } + jsonOk(res, { ok: true }); + return; + } + } + + { + const comstarDeleteMatch = /^\/comstar\/(\d+)$/.exec(pathname); + if (req.method === 'DELETE' && comstarDeleteMatch) { + const username = (req.headers['x-username'] ?? '') as string; + if (!username) { jsonError(res, 400, 'X-Username header required'); return; } + const character = await findCharacterByDisplayName(username); + if (!character) { jsonError(res, 404, 'character not found'); return; } + const deleted = await softDelete(Number(comstarDeleteMatch[1]), character.account_id); + if (!deleted) { jsonError(res, 404, 'message not found'); return; } + jsonOk(res, { ok: true }); + return; + } + } + + res.writeHead(404); + res.end(); + }); + + wsBroadcaster.attach(server); + combatWsManager.init(wsBroadcaster); + + server.on('error', (err: Error) => { + apiLog.error('HTTP server error: %s', err.message); + }); + + server.listen(port, host, () => { + apiLog.info('HTTP server listening on %s:%d', host, port); + }); + + return server; +} diff --git a/src/config.ts b/src/config.ts index 60d2fcb..c27a8cb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -73,3 +73,9 @@ export const MPBT_LOG_LEVEL = readLogLevelEnv('MPBT_LOG_LEVEL', 'info'); // Packet hex captures are useful during protocol work, but they add sustained disk // I/O on every send/receive. Keep them opt-in for regular local playtesting. export const MPBT_CAPTURE_ENABLED = readBooleanEnv('MPBT_CAPTURE', false); + +// REST API server for the modern Godot client. Listens on a separate port from +// the ARIES TCP server. Set API_HOST=0.0.0.0 to expose to LAN clients. +// Port 3002 keeps this separate from mpbt-web (3000) and mpbt-web/api (3001). +export const API_PORT = readNonNegativeIntEnv('API_PORT', 3002); +export const API_HOST = process.env['API_HOST'] ?? '127.0.0.1'; diff --git a/src/data/maps.ts b/src/data/maps.ts index 1242d19..bf2e305 100644 --- a/src/data/maps.ts +++ b/src/data/maps.ts @@ -240,7 +240,7 @@ export function loadSolarisRooms(filePath?: string): WorldRoom[] | null { * Room type tags used in world-map.json. * bar | arena | hub | terminal | bank | street | sector | path */ -export type RoomType = 'bar' | 'arena' | 'hub' | 'terminal' | 'bank' | 'street' | 'sector' | 'path' | 'tram'; +export type RoomType = 'bar' | 'arena' | 'hub' | 'terminal' | 'bank' | 'street' | 'sector' | 'path' | 'tram' | 'park' | 'stub'; /** One entry from world-map.json, representing navigation data for a single room. */ export interface WorldMapRoom { diff --git a/src/db/characters.ts b/src/db/characters.ts index 9af6e69..0b21c98 100644 --- a/src/db/characters.ts +++ b/src/db/characters.ts @@ -165,6 +165,18 @@ export async function settleDuelStakeTransfer( } } +/** Find the character for a given display name (case-insensitive), or null if none exists. */ +export async function findCharacterByDisplayName(displayName: string): Promise { + const res = await pool.query( + `SELECT id, account_id, display_name, allegiance, cbills, mech_id, mech_slot, created_at + FROM characters + WHERE lower(display_name) = lower($1) + LIMIT 1`, + [displayName], + ); + return res.rows[0] ?? null; +} + /** * Check whether a display name is already taken by another character. * Used to re-prompt when a chosen name is unavailable. diff --git a/src/db/comstar_modern.ts b/src/db/comstar_modern.ts new file mode 100644 index 0000000..9e80730 --- /dev/null +++ b/src/db/comstar_modern.ts @@ -0,0 +1,117 @@ +/** + * ComStar modern-client message persistence. + * + * Uses the separate `comstar_modern` table so there is zero overlap with the + * ARIES-oriented `messages` table consumed by the retail client adapter. + */ + +import { pool } from './client.js'; + +export interface ComstarModernRow { + id: number; + from_account_id: number; + to_account_id: number; + from_name: string; + subject: string; + body: string; + sent_at: Date; + read_at: Date | null; + deleted_at: Date | null; +} + +/** Maximum inbox depth returned per request. */ +export const COMSTAR_INBOX_LIMIT = 50; + +/** + * Insert a new message. Returns the inserted row or null when the sender or + * recipient account is invalid (FK constraint violation). + */ +export async function sendComstarModern( + fromAccountId: number, + toAccountId: number, + fromName: string, + subject: string, + body: string, +): Promise { + try { + const res = await pool.query( + `INSERT INTO comstar_modern + (from_account_id, to_account_id, from_name, subject, body) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, from_account_id, to_account_id, from_name, + subject, body, sent_at, read_at, deleted_at`, + [fromAccountId, toAccountId, fromName, subject, body], + ); + return res.rows[0] ?? null; + } catch { + return null; + } +} + +/** + * List the inbox for a recipient β€” non-deleted rows, newest-first, capped at + * COMSTAR_INBOX_LIMIT. + */ +export async function listInbox(toAccountId: number): Promise { + const res = await pool.query( + `SELECT id, from_account_id, to_account_id, from_name, + subject, body, sent_at, read_at, deleted_at + FROM comstar_modern + WHERE to_account_id = $1 AND deleted_at IS NULL + ORDER BY sent_at DESC, id DESC + LIMIT $2`, + [toAccountId, COMSTAR_INBOX_LIMIT], + ); + return res.rows; +} + +/** + * Count unread (non-deleted) messages for a recipient. + */ +export async function countUnread(toAccountId: number): Promise { + const res = await pool.query<{ count: string }>( + `SELECT COUNT(*)::text AS count + FROM comstar_modern + WHERE to_account_id = $1 + AND read_at IS NULL + AND deleted_at IS NULL`, + [toAccountId], + ); + return Number(res.rows[0]?.count ?? '0'); +} + +/** + * Mark a message as read. Only updates if the caller is the recipient. + * Returns true when a row was updated, false when not found / wrong owner. + */ +export async function markReadById( + messageId: number, + toAccountId: number, +): Promise { + const res = await pool.query( + `UPDATE comstar_modern + SET read_at = COALESCE(read_at, now()) + WHERE id = $1 AND to_account_id = $2 AND deleted_at IS NULL + RETURNING id`, + [messageId, toAccountId], + ); + return (res.rowCount ?? 0) > 0; +} + +/** + * Soft-delete a message. Only the recipient may delete. + * Returns true when a row was updated, false when not found / wrong owner. + */ +export async function softDelete( + messageId: number, + toAccountId: number, +): Promise { + const res = await pool.query( + `UPDATE comstar_modern + SET deleted_at = COALESCE(deleted_at, now()) + WHERE id = $1 AND to_account_id = $2 + RETURNING id`, + [messageId, toAccountId], + ); + return (res.rowCount ?? 0) > 0; +} diff --git a/src/db/schema.sql b/src/db/schema.sql index f252952..0812e87 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -142,3 +142,23 @@ CREATE INDEX IF NOT EXISTS duel_results_winner_account_idx CREATE INDEX IF NOT EXISTS duel_results_loser_account_idx ON duel_results (loser_account_id, completed_at DESC); + +-- comstar_modern: private messages sent between players via the modern REST API. +-- Separate from the legacy `messages` table (ARIES/retail client) to avoid any +-- interference with ARIES delivery semantics. +CREATE TABLE IF NOT EXISTS comstar_modern ( + id SERIAL PRIMARY KEY, + from_account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + to_account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + from_name VARCHAR(64) NOT NULL, -- display_name snapshot at send time + subject VARCHAR(100) NOT NULL DEFAULT '', + body TEXT NOT NULL, + sent_at TIMESTAMPTZ NOT NULL DEFAULT now(), + read_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ +); + +-- Fast inbox lookup (all non-deleted messages for a recipient, newest first). +CREATE INDEX IF NOT EXISTS comstar_modern_inbox_idx + ON comstar_modern (to_account_id, sent_at DESC) + WHERE deleted_at IS NULL; diff --git a/src/server.ts b/src/server.ts index b84b71b..7414867 100644 --- a/src/server.ts +++ b/src/server.ts @@ -42,6 +42,7 @@ import { PlayerRegistry, ClientSession } from './state/players.js'; import { launchRegistry } from './state/launch.js'; import { replaceSessionForReconnect } from './state/session-replacement.js'; import { startWorldServer } from './server-world.js'; +import { startApiServer } from './api.js'; import { Logger } from './util/logger.js'; import { CaptureLogger } from './util/capture.js'; import { verifyOrRegister } from './db/accounts.js'; @@ -51,6 +52,8 @@ import { SOCKET_IDLE_TIMEOUT_MS, MPBT_LOG_LEVEL, MPBT_CAPTURE_ENABLED, + API_PORT, + API_HOST, } from './config.js'; // ── Global state ────────────────────────────────────────────────────────────── @@ -715,6 +718,9 @@ server.on('error', (err: Error) => { // Shares the same player registry and logger as the lobby server. startWorldServer(log, players); +// Start the REST API server for the modern Godot client. +startApiServer(log, API_HOST, API_PORT); + server.listen(ARIES_PORT, '0.0.0.0', () => { const addr = server.address() as net.AddressInfo; log.info('═══════════════════════════════════════════════════════'); @@ -728,6 +734,7 @@ server.listen(ARIES_PORT, '0.0.0.0', () => { ' Packet captures: %s', MPBT_CAPTURE_ENABLED ? 'enabled β†’ captures/' : 'disabled (set MPBT_CAPTURE=1 to enable)', ); + log.info(' REST API: http://%s:%d/health', API_HOST, API_PORT); log.info('═══════════════════════════════════════════════════════'); }); diff --git a/src/world/arena-queue.ts b/src/world/arena-queue.ts new file mode 100644 index 0000000..11cd0b8 --- /dev/null +++ b/src/world/arena-queue.ts @@ -0,0 +1,122 @@ +/** + * In-memory arena staging queue. + * + * Tracks which players are waiting for an arena match, their selected mech, + * and their ready status. Not persisted to DB; clears on server restart. + * + * The queue uses the player's display_name (X-Username) as the identifier, + * consistent with the rest of the mpbt-server REST API. + * + * Launch semantics: + * - Any queue size β‰₯ 1 may launch when all slots are ready. + * - A single-player launch is a solo-vs-bot practice match. + * - A multi-player launch is a PvP match. + * - After launch, pendingMatch is set so late-joining WS clients can + * recover the launch event; the queue is cleared. + */ + +export interface ArenaSlot { + username: string; + mechId: number; + typeString: string; + joinedAt: number; + ready: boolean; +} + +export interface PendingMatch { + arenaId: string; + slots: ArenaSlot[]; + launchedAt: number; +} + +class ArenaQueue { + private _slots: ArenaSlot[] = []; + private _pendingMatch: PendingMatch | null = null; + + /** + * Join the queue. If the player is already in the queue, their mech is + * updated and ready is reset to false (mech change voids readiness). + */ + join(username: string, mechId: number, typeString: string): ArenaSlot { + const existing = this._slots.find((s) => s.username === username); + if (existing) { + if (existing.mechId !== mechId || existing.typeString !== typeString) { + existing.mechId = mechId; + existing.typeString = typeString; + existing.ready = false; + } + return existing; + } + const slot: ArenaSlot = { + username, + mechId, + typeString, + joinedAt: Date.now(), + ready: false, + }; + this._slots.push(slot); + return slot; + } + + /** Remove the player from the queue. Returns true if they were queued. */ + leave(username: string): boolean { + const idx = this._slots.findIndex((s) => s.username === username); + if (idx < 0) return false; + this._slots.splice(idx, 1); + return true; + } + + /** + * Set the ready state for a player. + * Returns the updated slot, or null if the player is not in the queue. + */ + setReady(username: string, ready: boolean): ArenaSlot | null { + const slot = this._slots.find((s) => s.username === username); + if (!slot) return null; + slot.ready = ready; + return slot; + } + + getAll(): ArenaSlot[] { + return [...this._slots]; + } + + getSlot(username: string): ArenaSlot | undefined { + return this._slots.find((s) => s.username === username); + } + + /** True when there is at least one queued player and all are ready. */ + isAllReady(): boolean { + return this._slots.length > 0 && this._slots.every((s) => s.ready); + } + + /** + * Record a pending match launch. Clears the queue so players can re-queue + * after the match; pendingMatch is retained for WS reconnect recovery. + */ + recordLaunch(arenaId: string): PendingMatch { + const match: PendingMatch = { + arenaId, + slots: [...this._slots], + launchedAt: Date.now(), + }; + this._pendingMatch = match; + this._slots = []; + return match; + } + + /** + * The most recently launched match, if any. Cleared when a new player + * joins (i.e. after the match ends and players return to queue). + */ + get pendingMatch(): PendingMatch | null { + return this._pendingMatch; + } + + /** Clear a stale pending match (e.g. when a new queue cycle begins). */ + clearPendingMatch(): void { + this._pendingMatch = null; + } +} + +export const arenaQueue = new ArenaQueue(); diff --git a/src/world/combat-ws.ts b/src/world/combat-ws.ts new file mode 100644 index 0000000..f234b71 --- /dev/null +++ b/src/world/combat-ws.ts @@ -0,0 +1,509 @@ +/** + * CombatWsManager β€” manages in-memory combat sessions for the Godot client's + * modern JSON-over-WebSocket combat protocol. + * + * Sessions are created when the arena queue fires all-ready (via api.ts) and + * cleaned up when the match ends, times out, or all players disconnect. + * + * Security: + * - combat_join validates the username is in the session's slot list. + * - First WS to claim a slot wins; subsequent claims with an open WS are + * rejected, preventing socket hijacking. + * - Server derives winner/loser from session state, never from the payload. + * + * Bot AI (solo mode): + * - Pursues the human player at BOT_SPEED_FRACTION of max speed. + * - Fires every BOT_FIRE_INTERVAL_MS. + * - Stops pursuing when either actor health ≀ 0. + * + * Fire validation (human player only): + * - Cooldown: min PLAYER_FIRE_COOLDOWN_MS between shots. + * - Heat gate: blocked when heat β‰₯ HEAT_OVERHEAT_THRESHOLD. + * - Range: target must be within MAX_FIRE_RANGE_M. + * - Arc: target must be within Β±45Β° of the attacker's heading. + * + * Coordinate convention (matches Godot 4 with default mech orientation): + * - Forward vector from heading h = (-sin(h), 0, -cos(h)). + * - Bot heading is set so that its forward vector aims at the player. + */ + +import { WebSocket } from 'ws'; +import { Logger } from '../util/logger.js'; +import type { WsBroadcaster } from './ws_broadcaster.js'; +import type { ArenaSlot } from './arena-queue.js'; +import { + findCharacterByDisplayName, + settleDuelStakeTransfer, +} from '../db/characters.js'; +import { createDuelResult } from '../db/duel-results.js'; + +const log = new Logger('combat-ws'); + +// ─── Tuning constants ───────────────────────────────────────────────────────── + +const TICK_MS = 100; +const BOT_FIRE_INTERVAL_MS = 3000; +const HEAT_REGEN_PER_TICK = 1.5; +const WEAPON_HEAT = 10; +const HEAT_OVERHEAT_THRESHOLD = 80; +const BOT_DAMAGE = 8; +const PLAYER_DAMAGE = 10; +const SPAWN_Z = 100; // Β±Z from origin (m) +const BOT_MAX_SPEED_KPH = 64.8; // Locust default +const BOT_SPEED_FRACTION = 0.5; +const BOT_MIN_ENGAGE_DIST = 20; // stop closing in within 20 m +const PLAYER_FIRE_COOLDOWN_MS = 1500; +const MAX_FIRE_RANGE_M = 500; +const FIRE_BEARING_COS = Math.cos(Math.PI / 4); // Β±45 Β° +const PRE_JOIN_TIMEOUT_MS = 30_000; +const SESSION_GC_DELAY_MS = 10_000; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface CombatActor { + username: string; + isBot: boolean; + ws: WebSocket | null; // null for bots; set on combat_join for humans + x: number; + z: number; + heading: number; // radians, Y-axis rotation + health: number; // 0–100 + heat: number; // 0–100 + maxSpeedKph: number; + typeString: string; + mechId: number; // 0 for BOT + lastFireTime: number; + claimed: boolean; // true once a WS has joined for this slot +} + +interface CombatSession { + arenaId: string; + mode: 'solo' | 'pvp'; + actors: Map; + tick: number; + state: 'waiting' | 'active' | 'ended'; + tickTimer: ReturnType | null; + preJoinTimer: ReturnType | null; + botLastFireTime: number; + humanJoinedCount: number; + expectedHumanCount: number; +} + +// ─── Manager ────────────────────────────────────────────────────────────────── + +export class CombatWsManager { + private readonly _sessions = new Map(); + + init(broadcaster: WsBroadcaster): void { + broadcaster.registerMessageHandler((ws, raw) => this._handleRaw(ws, raw)); + } + + /** + * Create a new combat session immediately after arena_match_launch. + * Slots contain all human players; the bot is added automatically for solo. + */ + startSession(arenaId: string, mode: 'solo' | 'pvp', slots: ArenaSlot[]): void { + if (this._sessions.has(arenaId)) { + log.warn('startSession called for existing arenaId=%s β€” ignored', arenaId); + return; + } + + const actors = new Map(); + + slots.forEach((slot, i) => { + actors.set(slot.username, { + username: slot.username, + isBot: false, + ws: null, + x: 0, + z: i === 0 ? -SPAWN_Z : SPAWN_Z, + heading: i === 0 ? 0 : Math.PI, // face each other + health: 100, + heat: 0, + maxSpeedKph: 64.8, // TODO: derive from mech stats + typeString: slot.typeString, + mechId: slot.mechId, + lastFireTime: 0, + claimed: false, + }); + }); + + if (mode === 'solo') { + actors.set('BOT', { + username: 'BOT', + isBot: true, + ws: null, + x: 0, + z: SPAWN_Z, + heading: Math.PI, // faces toward player spawn (z = -SPAWN_Z) + health: 100, + heat: 0, + maxSpeedKph: BOT_MAX_SPEED_KPH, + typeString: 'Locust', + mechId: 0, + lastFireTime: 0, + claimed: true, + }); + } + + const preJoinTimer = setTimeout(() => { + const session = this._sessions.get(arenaId); + if (!session || session.state !== 'waiting') return; + log.warn('arena %s: pre-join timeout β€” cancelling session', arenaId); + this._endSession(session, null, null); + }, PRE_JOIN_TIMEOUT_MS); + + const session: CombatSession = { + arenaId, + mode, + actors, + tick: 0, + state: 'waiting', + tickTimer: null, + preJoinTimer, + botLastFireTime: Date.now() + 2000, // 2 s grace before first bot shot + humanJoinedCount: 0, + expectedHumanCount: slots.length, + }; + + this._sessions.set(arenaId, session); + log.info('combat session created: arenaId=%s mode=%s players=%d', arenaId, mode, slots.length); + } + + // ─── Incoming message dispatch ───────────────────────────────────────────── + + private _handleRaw(ws: WebSocket, raw: string): void { + let msg: unknown; + try { + msg = JSON.parse(raw); + } catch { + return; + } + if (msg === null || typeof msg !== 'object') return; + + const m = msg as Record; + const type = typeof m['type'] === 'string' ? m['type'] : ''; + + if (!['combat_join', 'combat_input', 'combat_fire', 'combat_leave'].includes(type)) return; + + const arenaId = typeof m['arenaId'] === 'string' ? m['arenaId'] : ''; + if (!arenaId) return; + + // Username is validated against the slot list β€” not used for routing. + const username = typeof m['username'] === 'string' ? m['username'] : ''; + if (!username) return; + + switch (type) { + case 'combat_join': this._handleJoin(ws, arenaId, username); break; + case 'combat_input': this._handleInput(arenaId, username, m); break; + case 'combat_fire': this._handleFire(arenaId, username); break; + case 'combat_leave': this._handleLeave(arenaId, username); break; + } + } + + private _handleJoin(ws: WebSocket, arenaId: string, username: string): void { + const session = this._sessions.get(arenaId); + if (!session) { + log.warn('combat_join: unknown session arenaId=%s username=%s', arenaId, username); + return; + } + if (session.state === 'ended') return; + + const actor = session.actors.get(username); + if (!actor || actor.isBot) { + log.warn('combat_join: username=%s not a human slot in arenaId=%s', username, arenaId); + return; + } + + // Reject if another open connection already holds this slot. + if (actor.claimed && actor.ws && actor.ws.readyState === WebSocket.OPEN) { + log.warn('combat_join: slot already claimed username=%s arenaId=%s', username, arenaId); + return; + } + + actor.ws = ws; + actor.claimed = true; + + // Disconnect = surrender. + ws.once('close', () => { + log.info('WS closed during combat: username=%s arenaId=%s', username, arenaId); + this._handleLeave(arenaId, username); + }); + + session.humanJoinedCount++; + log.info('combat_join: username=%s arenaId=%s (%d/%d)', + username, arenaId, session.humanJoinedCount, session.expectedHumanCount); + + // Send immediate snapshot so client can initialise from server state. + this._sendToActor(actor, 'combat_snapshot', this._buildSnapshot(session)); + + // Start the tick loop when all expected humans have joined. + if (session.humanJoinedCount >= session.expectedHumanCount && session.state === 'waiting') { + if (session.preJoinTimer) { + clearTimeout(session.preJoinTimer); + session.preJoinTimer = null; + } + session.state = 'active'; + session.tickTimer = setInterval(() => this._tick(session), TICK_MS); + log.info('combat session active: arenaId=%s', arenaId); + } + } + + private _handleInput(arenaId: string, username: string, m: Record): void { + const session = this._sessions.get(arenaId); + if (!session || session.state !== 'active') return; + + const actor = session.actors.get(username); + if (!actor || actor.isBot) return; + + const x = typeof m['x'] === 'number' ? m['x'] : null; + const z = typeof m['z'] === 'number' ? m['z'] : null; + const heading = typeof m['heading'] === 'number' ? m['heading'] : null; + + if (x !== null) actor.x = x; + if (z !== null) actor.z = z; + if (heading !== null) actor.heading = heading; + } + + private _handleFire(arenaId: string, username: string): void { + const session = this._sessions.get(arenaId); + if (!session || session.state !== 'active') return; + + const attacker = session.actors.get(username); + if (!attacker || attacker.isBot) return; + + const now = Date.now(); + if (now - attacker.lastFireTime < PLAYER_FIRE_COOLDOWN_MS) return; + if (attacker.heat >= HEAT_OVERHEAT_THRESHOLD) return; + + // Any other actor is the target. + const target = Array.from(session.actors.values()).find(a => a.username !== username); + if (!target) return; + + const dx = target.x - attacker.x; + const dz = target.z - attacker.z; + const dist = Math.hypot(dx, dz); + + if (dist > MAX_FIRE_RANGE_M) return; + + // Forward vector from heading h = (-sin(h), 0, -cos(h)). + const fwdX = -Math.sin(attacker.heading); + const fwdZ = -Math.cos(attacker.heading); + const dot = (fwdX * dx + fwdZ * dz) / (dist || 1); + if (dot < FIRE_BEARING_COS) return; + + attacker.lastFireTime = now; + attacker.heat = Math.min(100, attacker.heat + WEAPON_HEAT); + target.health = Math.max(0, target.health - PLAYER_DAMAGE); + + this._broadcastToSession(session, 'combat_hit', { + arenaId, + attacker: username, + target: target.username, + damage: PLAYER_DAMAGE, + health: target.health, + }); + + if (target.health <= 0) { + this._endSession(session, username, target.username); + } + } + + private _handleLeave(arenaId: string, username: string): void { + const session = this._sessions.get(arenaId); + if (!session || session.state === 'ended') return; + + log.info('combat_leave/surrender: username=%s arenaId=%s', username, arenaId); + + // Leaving player loses; the other human (or BOT) wins. + const winner = Array.from(session.actors.values()) + .find(a => a.username !== username && !a.isBot)?.username ?? 'BOT'; + + this._endSession(session, winner, username); + } + + // ─── Tick loop ───────────────────────────────────────────────────────────── + + private _tick(session: CombatSession): void { + if (session.state !== 'active') return; + + const botActor = session.actors.get('BOT'); + const humanActors = Array.from(session.actors.values()).filter(a => !a.isBot); + const primaryHuman = humanActors[0]; + + if (botActor && primaryHuman) { + const dx = primaryHuman.x - botActor.x; + const dz = primaryHuman.z - botActor.z; + const dist = Math.hypot(dx, dz); + + if (dist > BOT_MIN_ENGAGE_DIST) { + const speed = (botActor.maxSpeedKph * BOT_SPEED_FRACTION / 3.6) * (TICK_MS / 1000); + // Set heading so that forward vector (-sin(h), 0, -cos(h)) = normalize(dx, dz). + botActor.heading = Math.atan2(-dx, -dz); + botActor.x += (dx / dist) * speed; + botActor.z += (dz / dist) * speed; + } + + const now = Date.now(); + if (now - session.botLastFireTime >= BOT_FIRE_INTERVAL_MS) { + session.botLastFireTime = now; + primaryHuman.health = Math.max(0, primaryHuman.health - BOT_DAMAGE); + + this._broadcastToSession(session, 'combat_hit', { + arenaId: session.arenaId, + attacker: 'BOT', + target: primaryHuman.username, + damage: BOT_DAMAGE, + health: primaryHuman.health, + }); + + if (primaryHuman.health <= 0) { + this._endSession(session, 'BOT', primaryHuman.username); + return; + } + } + } + + for (const actor of session.actors.values()) { + actor.heat = Math.max(0, actor.heat - HEAT_REGEN_PER_TICK); + } + + this._broadcastToSession(session, 'combat_snapshot', this._buildSnapshot(session)); + session.tick++; + } + + // ─── Helpers ─────────────────────────────────────────────────────────────── + + private _buildSnapshot(session: CombatSession): object { + return { + arenaId: session.arenaId, + tick: session.tick, + actors: Array.from(session.actors.values()).map(a => ({ + username: a.username, + x: a.x, + z: a.z, + heading: a.heading, + health: a.health, + heat: a.heat, + isBot: a.isBot, + typeString: a.typeString, + })), + }; + } + + private _endSession( + session: CombatSession, + winner: string | null, + loser: string | null, + ): void { + if (session.state === 'ended') return; + + const wasActive = session.state === 'active'; + session.state = 'ended'; + + if (session.tickTimer) { + clearInterval(session.tickTimer); + session.tickTimer = null; + } + if (session.preJoinTimer) { + clearTimeout(session.preJoinTimer); + session.preJoinTimer = null; + } + + log.info('combat ended: arenaId=%s winner=%s loser=%s', session.arenaId, winner, loser); + + this._broadcastToSession(session, 'combat_end', { + arenaId: session.arenaId, + winner, + loser, + mode: session.mode, + }); + + // Only persist ranked results for PvP matches that reached active state. + if (wasActive && session.mode === 'pvp' && winner && loser && winner !== 'BOT' && loser !== 'BOT') { + this._persistResult(session, winner, loser).catch(err => + log.error('failed to persist combat result arenaId=%s: %s', session.arenaId, err), + ); + } + + setTimeout(() => this._sessions.delete(session.arenaId), SESSION_GC_DELAY_MS); + } + + private async _persistResult( + session: CombatSession, + winner: string, + loser: string, + ): Promise { + const [winnerChar, loserChar] = await Promise.all([ + findCharacterByDisplayName(winner), + findCharacterByDisplayName(loser), + ]); + if (!winnerChar || !loserChar) { + log.warn('_persistResult: character not found for arenaId=%s winner=%s loser=%s', session.arenaId, winner, loser); + return; + } + + const winnerActor = session.actors.get(winner); + const loserActor = session.actors.get(loser); + if (!winnerActor || !loserActor) return; + + const STAKE_CB = 250; + const safeStake = Math.min(STAKE_CB, loserChar.cbills); + + // ON CONFLICT DO NOTHING makes the insert idempotent; only settle cbills + // when the row is freshly inserted to avoid double-transfers on replay. + const inserted = await createDuelResult({ + combatSessionId: session.arenaId, + worldMapRoomId: 0, + roomName: 'Arena', + winnerAccountId: winnerChar.account_id, + loserAccountId: loserChar.account_id, + winnerDisplayName: winner, + loserDisplayName: loser, + winnerComstarId: 100000 + winnerChar.account_id, + loserComstarId: 100000 + loserChar.account_id, + winnerMechId: winnerActor.mechId, + loserMechId: loserActor.mechId, + winnerStakeCb: safeStake, + loserStakeCb: safeStake, + winnerRemainingHealth: winnerActor.health, + winnerMaxHealth: 100, + loserRemainingHealth: 0, + loserMaxHealth: 100, + resultReason: 'combat_ws_defeat', + }); + + if (!inserted) { + // Row already existed β€” skip settlement to prevent double transfer. + log.warn('_persistResult: duplicate insert skipped for arenaId=%s', session.arenaId); + return; + } + + if (safeStake > 0) { + try { + await settleDuelStakeTransfer(winnerChar.account_id, loserChar.account_id, safeStake); + log.info('cbills settled: arenaId=%s winner=%s +%d loser=%s -%d', + session.arenaId, winner, safeStake, loser, safeStake); + } catch (err) { + log.error('cbills settlement failed arenaId=%s: %s', session.arenaId, err); + } + } + } + + private _broadcastToSession(session: CombatSession, type: string, data: object): void { + const msg = JSON.stringify({ type, ...data }); + for (const actor of session.actors.values()) { + if (!actor.isBot && actor.ws && actor.ws.readyState === WebSocket.OPEN) { + actor.ws.send(msg); + } + } + } + + private _sendToActor(actor: CombatActor, type: string, data: object): void { + if (actor.ws && actor.ws.readyState === WebSocket.OPEN) { + actor.ws.send(JSON.stringify({ type, ...data })); + } + } +} + +export const combatWsManager = new CombatWsManager(); diff --git a/src/world/presence.ts b/src/world/presence.ts new file mode 100644 index 0000000..05faeeb --- /dev/null +++ b/src/world/presence.ts @@ -0,0 +1,61 @@ +/** + * In-memory presence store β€” tracks which room each player is currently in. + * + * Not persisted to DB; cleared on server restart. Sufficient for M4 local + * play; WebSocket push updates replace polling in a later milestone. + */ + +export class PresenceStore { + /** roomId β†’ set of display-name strings currently in that room. */ + private readonly _map = new Map>(); + + /** + * Move `username` to `roomId`. Removes the player from their previous + * room automatically. + */ + travel(username: string, roomId: number): void { + this._removeUser(username); + let occupants = this._map.get(roomId); + if (!occupants) { + occupants = new Set(); + this._map.set(roomId, occupants); + } + occupants.add(username); + } + + /** Ordered list of usernames currently in a specific room. */ + getRoomOccupants(roomId: number): string[] { + return [...(this._map.get(roomId) ?? [])].sort(); + } + + /** + * Snapshot of all rooms that have at least one occupant. + * Rooms with no occupants are omitted. + */ + getAll(): Array<{ roomId: number; occupants: string[] }> { + const out: Array<{ roomId: number; occupants: string[] }> = []; + for (const [roomId, occupants] of this._map) { + if (occupants.size > 0) { + out.push({ roomId, occupants: [...occupants].sort() }); + } + } + return out; + } + + /** Remove a player from whichever room they are in (e.g. on disconnect). */ + remove(username: string): void { + this._removeUser(username); + } + + private _removeUser(username: string): void { + for (const [roomId, occupants] of this._map) { + if (occupants.has(username)) { + occupants.delete(username); + if (occupants.size === 0) this._map.delete(roomId); + return; + } + } + } +} + +export const presenceStore = new PresenceStore(); diff --git a/src/world/ws_broadcaster.ts b/src/world/ws_broadcaster.ts new file mode 100644 index 0000000..a785723 --- /dev/null +++ b/src/world/ws_broadcaster.ts @@ -0,0 +1,86 @@ +/** + * WsBroadcaster β€” attaches a WebSocket server to the existing HTTP server on + * port 3002. Handles the upgrade handshake at path `/ws` and broadcasts + * JSON events to all connected clients. + * + * On every new connection an initial `presence_update` snapshot is sent so + * late-joiners and re-connectors get current state without waiting for the + * next travel event. + */ + +import { WebSocketServer, WebSocket } from 'ws'; +import * as http from 'http'; +import { presenceStore } from './presence.js'; +import { arenaQueue } from './arena-queue.js'; + +export class WsBroadcaster { + private readonly _wss = new WebSocketServer({ noServer: true }); + private _attached = false; + private readonly _messageHandlers: Array<(ws: WebSocket, raw: string) => void> = []; + + /** + * Register a handler that is called for every incoming WebSocket message. + * Multiple handlers may be registered; each receives the same (ws, raw) pair. + * Handlers must not throw β€” errors are swallowed to protect the WS loop. + */ + registerMessageHandler(handler: (ws: WebSocket, raw: string) => void): void { + this._messageHandlers.push(handler); + } + + attach(server: http.Server): void { + if (this._attached) return; + this._attached = true; + + server.on('upgrade', (req, socket, head) => { + const pathname = (req.url ?? '').split('?')[0]; + if (pathname === '/ws') { + this._wss.handleUpgrade(req, socket, head, (ws) => { + this._wss.emit('connection', ws, req); + }); + } else { + socket.destroy(); + } + }); + + this._wss.on('connection', (ws) => { + // Send immediate snapshots so late-joiners/re-connectors get current state. + ws.send( + JSON.stringify({ type: 'presence_update', rooms: presenceStore.getAll() }), + ); + ws.send( + JSON.stringify({ type: 'arena_queue_update', slots: arenaQueue.getAll() }), + ); + const pm = arenaQueue.pendingMatch; + if (pm) { + ws.send( + JSON.stringify({ + type: 'arena_match_launch', + arenaId: pm.arenaId, + slots: pm.slots, + launchedAt: pm.launchedAt, + mode: pm.slots.length === 1 ? 'solo' : 'pvp', + }), + ); + } + + // Route incoming messages to registered handlers. + ws.on('message', (data) => { + const raw = Buffer.isBuffer(data) ? data.toString('utf8') : String(data); + for (const handler of this._messageHandlers) { + try { handler(ws, raw); } catch { /* handler errors must not crash the WS loop */ } + } + }); + }); + } + + broadcast(type: string, data: object): void { + const msg = JSON.stringify({ type, ...data }); + for (const client of this._wss.clients) { + if (client.readyState === WebSocket.OPEN) { + client.send(msg); + } + } + } +} + +export const wsBroadcaster = new WsBroadcaster(); diff --git a/world-map.json b/world-map.json index 8503e74..5063090 100644 --- a/world-map.json +++ b/world-map.json @@ -1,4 +1,7 @@ { + "_exitNotes": [ + "Any room whose exits.north, exits.south, exits.east, and exits.west are all null is effectively an island; sending a player there will leave them stuck unless some other non-exit travel path moves them out." + ], "rooms": [ { "roomId": 161,