commit 13585eb54208c5be7bb16c20b2db55c3fefbf8e1 Author: DeFiDude <59237470+DeFiDude@users.noreply.github.com> Date: Fri Mar 13 02:22:44 2026 -0600 LRGP v0.2.0 — Lightweight Reticulum Gaming Protocol diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2188dd7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,46 @@ +# Changelog + +## 0.2.0 — 2025-03-12 + +### Breaking — Renamed to LRGP + +RLAP (Reticulum LXMF App Protocol) has been renamed and re-purposed to **LRGP** (Lightweight Reticulum Gaming Protocol). The protocol now focuses specifically on multiplayer gaming over Reticulum mesh networks. + +#### Wire Protocol +- Protocol marker: `rlap.v1` → `lrgp.v1` +- Legacy `rlap.v1` and `ratspeak.game` messages still recognized on inbound +- All outbound messages use `lrgp.v1` + +#### API Renames +- `RlapApp` trait → `GameApp` +- `AppManifest` → `GameManifest` +- `RlapRouter` → `LrgpRouter` +- `RlapStore` → `LrgpStore` +- `RlapError` → `LrgpError` + +#### New Features +- `GameManifest` adds `min_players`, `genre`, and `turn_timeout` fields +- New game session types: `round_based`, `single_round` +- `LEGACY_TYPES` array for multi-marker backward compatibility + +#### Database +- `app_sessions` table → `game_sessions` +- `app_actions` table → `game_actions` + +#### Fallback Text +- Format changed from `[RLAP ...]` to `[LRGP ...]` + +--- + +## 0.1.0 — 2025-02-28 + +### Initial Release + +- Envelope packing/unpacking with msgpack serialization +- Session state machine (pending → active → completed/expired/declined) +- `RlapApp` trait for pluggable applications +- `RlapRouter` for app registration and message dispatch +- `RlapStore` with SQLite persistence (WAL mode, parameterized queries) +- Transport bridge (LXMF field ↔ RLAP envelope) +- TicTacToe reference app with both-side validation +- Cross-compatible binary test vectors (`ttt_challenge.bin`, `ttt_move.bin`, `ttt_move_win.bin`) diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0fd352f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "lrgp" +version = "0.2.0" +edition = "2024" +license = "MIT" +rust-version = "1.85" +description = "Lightweight Reticulum Gaming Protocol — multiplayer games over LXMF mesh networks" +repository = "https://github.com/ratspeak/lrgp-rs" +keywords = ["reticulum", "lxmf", "gaming", "mesh", "off-grid"] +categories = ["network-programming", "encoding", "game-development"] + +[dependencies] +rmp-serde = "1" +rmpv = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +rusqlite = { version = "0.32", features = ["bundled"] } +thiserror = "2" +tracing = "0.1" +rand = "0.8" +hex = "0.4" + +[dev-dependencies] +tracing-subscriber = "0.3" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8835d1e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 RLAP Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b662e2f --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# LRGP-rs + +Rust implementation of the **Lightweight Reticulum Gaming Protocol (LRGP)** — a compact, session-based protocol for multiplayer games over [LXMF](https://github.com/markqvist/LXMF) / [Reticulum](https://github.com/markqvist/Reticulum) mesh networks. + +LRGP enables turn-based and real-time multiplayer games to run over LoRa radios, WiFi, TCP, and any other medium Reticulum supports. Game moves are encoded as tiny msgpack envelopes that fit in a single encrypted packet — no link setup needed. + +## Features + +- **Compact wire format** — msgpack with single-character keys, ~60 bytes per game move +- **Game session state machine** — challenge → accept → play → win/draw/resign lifecycle +- **`GameApp` trait** — implement this trait to create any game +- **`LrgpRouter`** — register games, dispatch moves, manage manifests +- **`LrgpStore`** — SQLite persistence for game sessions and move history +- **Transport bridge** — zero-copy conversion between LRGP envelopes and LXMF fields +- **Backward compatible** — recognizes legacy `rlap.v1` messages on inbound + +## Quick Start + +```rust +use lrgp::apps::tictactoe::TicTacToeApp; +use lrgp::router::LrgpRouter; + +let router = LrgpRouter::new(); +router.register(Box::new(TicTacToeApp::new())); + +// List available games +for game in router.list_apps() { + println!("{} v{} — {}", game.app_id, game.version, game.display_name); +} +``` + +## Architecture + +``` +src/ + constants.rs # Protocol constants, game session types, wire keys + errors.rs # LrgpError hierarchy + envelope.rs # Pack/unpack/validate LRGP envelopes (msgpack) + session.rs # Game session state machine and lifecycle + app_base.rs # GameApp trait + GameManifest + router.rs # Game registry and move dispatch + store.rs # SQLite persistence (game_sessions, game_actions) + transport.rs # LXMF ↔ LRGP bridge (pure data, no I/O) + apps/ + tictactoe.rs # Built-in Tic-Tac-Toe game +``` + +## Building a Game + +Implement the `GameApp` trait: + +```rust +use lrgp::app_base::{GameApp, GameManifest, IncomingResult, OutgoingResult}; + +struct MyGame; + +impl GameApp for MyGame { + fn app_id(&self) -> &str { "mygame" } + fn version(&self) -> u32 { 1 } + fn manifest(&self) -> GameManifest { /* ... */ } + fn handle_incoming(&self, /* ... */) -> IncomingResult { /* ... */ } + fn handle_outgoing(&self, /* ... */) -> OutgoingResult { /* ... */ } + fn validate_action(&self, /* ... */) -> (bool, Option) { /* ... */ } + fn get_session_state(&self, /* ... */) -> HashMap { /* ... */ } + fn render_fallback(&self, /* ... */) -> String { /* ... */ } +} +``` + +## Wire Format + +Every game move fits in a single LXMF OPPORTUNISTIC packet (≤295 bytes total): + +``` +fields[0xFB] = "lrgp.v1" # protocol marker +fields[0xFD] = { # envelope (≤200 bytes) + "a": "ttt.1", # game_id.version + "c": "move", # command + "s": "a1b2c3d4e5f6g7h8", # session_id + "p": {"i": 4, "b": "____X____", ...}, # payload +} +``` + +Non-LRGP clients see human-readable fallback text (e.g., `"[LRGP TTT] Move 3"`). + +## Protocol Spec + +See [SPEC.md](SPEC.md) for the full protocol specification. + +## See Also + +- [lrgp-py](../lrgp-py) — Python implementation (wire-compatible) + +## License + +AGPL-3.0 — see [LICENSE](LICENSE). diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..f35e446 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,331 @@ +# LRGP Specification v0.2 + +**Lightweight Reticulum Gaming Protocol** + +This document is the normative reference for LRGP. It is implementable without seeing the Rust or Python reference code. + +--- + +## 1. Overview + +LRGP defines how multiplayer game sessions are encoded as LXMF messages over Reticulum. Clients that don't understand LRGP see human-readable fallback text in the standard LXMF content field. + +LRGP v1 is **2-player only**. All sessions have exactly one initiator and one responder. + +--- + +## 2. LXMF Field Allocation + +LRGP uses two LXMF custom extension fields: + +| Field | ID | Value | +|-------|----|-------| +| `FIELD_CUSTOM_TYPE` | `0xFB` (251) | `"lrgp.v1"` | +| `FIELD_CUSTOM_META` | `0xFD` (253) | Envelope dict (see Section 3) | + +All fields are serialized via **msgpack** (not JSON). + +### Legacy Markers + +Implementations MUST also recognize the following legacy markers on inbound messages: +- `"rlap.v1"` — prior protocol version +- `"ratspeak.game"` — legacy v0 + +All outbound messages MUST use `"lrgp.v1"`. + +--- + +## 3. Envelope Schema + +The envelope is a msgpack dict stored in `fields[0xFD]`: + +``` +{ + "a": ".", # e.g. "ttt.1" + "c": "", # e.g. "move" + "s": "", # 16-char hex (8 random bytes) + "p": { } # game-specific, short keys +} +``` + +All keys are single characters to minimize wire size. The `game_id` and `version` are combined into a single string to save one key-value pair. + +### Required Fields + +All four keys (`a`, `c`, `s`, `p`) MUST be present in every envelope. + +### Session ID + +Session IDs are 8 random bytes encoded as 16 hexadecimal characters. The challenger generates the session ID. + +--- + +## 4. Size Constraints + +| Limit | Value | Source | +|-------|-------|--------| +| Envelope (packed) | max **200 bytes** | LRGP budget rule | +| OPPORTUNISTIC content | max **295 bytes** | `LXMessage.ENCRYPTED_PACKET_MAX_CONTENT` | +| DIRECT packet content | max **319 bytes** | `LXMessage.LINK_PACKET_MAX_CONTENT` | +| LXMF overhead | **112 bytes** | 16B dest + 16B src + 64B sig + 8B ts + 8B structure | + +LXMF content is packed as `[timestamp, title, content, fields_dict]`. + +If content exceeds 295 bytes, LXMF silently escalates from OPPORTUNISTIC to DIRECT delivery, which requires a full Reticulum link handshake. LRGP envelopes MUST be designed to fit within OPPORTUNISTIC limits. + +--- + +## 5. Fallback Text + +The LXMF `content` field IS the fallback text. There is no separate fallback key in the envelope. + +Format: `[LRGP ] ` + +Examples: +- `[LRGP TTT] Sent a challenge!` +- `[LRGP TTT] Move 3` +- `[LRGP TTT] X wins!` + +Non-LRGP clients display this as a regular message. + +--- + +## 6. Session Lifecycle + +### State Machine + +``` +challenge --> accept --> action* --> end + | | + +-> decline +-> resign + | +-> draw_offer --> draw_accept + +-> expire (local) | +-> draw_decline + +-> error (receiver -> sender) +``` + +### Commands + +| Command | Description | +|---------|-------------| +| `challenge` | Initiate a new game session | +| `accept` | Accept a challenge | +| `decline` | Decline a challenge | +| `move` | Game-specific action (e.g., place a piece) | +| `resign` | Voluntary forfeit | +| `draw_offer` | Propose a draw | +| `draw_accept` | Accept a draw proposal | +| `draw_decline` | Decline a draw proposal | +| `error` | Reject an invalid action | + +### Status Transitions + +| From | Command | To | +|------|---------|-----| +| `pending` | `accept` | `active` | +| `pending` | `decline` | `declined` | +| `active` | `move` (terminal) | `completed` | +| `active` | `resign` | `completed` | +| `active` | `draw_accept` | `completed` | +| `active` | `move` (normal) | `active` | +| `active` | `draw_offer` | `active` | +| `active` | `draw_decline` | `active` | +| `active` | `error` | `active` | + +--- + +## 7. Game Session Types + +| Type | Description | +|------|-------------| +| `turn_based` | Players alternate turns (e.g., Tic-Tac-Toe, Chess) | +| `real_time` | Both players can act at any time | +| `round_based` | Multiple rounds with scoring between rounds | +| `single_round` | Single round per session (e.g., coin flip, rock-paper-scissors) | + +--- + +## 8. Validation Models + +| Model | Description | Error Behavior | +|-------|-------------|----------------| +| `sender` | Sender validates before sending; receiver trusts | No error actions sent | +| `receiver` | Receiver validates on receipt; rejects invalid | Sends `error` action | +| `both` | Both sides validate independently | Receiver sends `error` if validation disagrees | + +--- + +## 9. Error Actions + +When a receiver rejects an action: + +``` +{ + "a": ".", + "c": "error", + "s": "", + "p": { + "code": "", + "msg": "", + "ref": "" + } +} +``` + +### Standard Error Codes + +| Code | Meaning | +|------|---------| +| `unsupported_app` | Receiver doesn't have this game | +| `invalid_move` | Move failed validation | +| `not_your_turn` | Out-of-turn action | +| `session_expired` | Session timed out on receiver | +| `protocol_error` | Malformed envelope or unknown command | + +Error actions are best-effort. If the error itself fails to deliver, the sender sees no response. + +--- + +## 10. Session Expiry + +| Status | Default TTL | Meaning | +|--------|-------------|---------| +| `pending` | 24 hours | Unanswered challenges expire | +| `active` | 7 days | Inactive sessions expire | +| `completed` | N/A | Preserved indefinitely | + +Enforcement is **local-only**: each peer expires sessions independently based on its own clock. No LXMF message is sent on expiry. + +A 1-hour grace period is applied to account for clock skew between peers. + +Games MAY override default TTLs via their manifest. + +--- + +## 11. Delivery Method Guidelines + +Games declare preferred delivery per command. LXMF auto-escalates if content exceeds limits, so these are preferences, not guarantees. + +| Action | Preferred | Rationale | +|--------|-----------|-----------| +| `challenge` | OPPORTUNISTIC | Small, fire-and-forget | +| `accept` | OPPORTUNISTIC | Small, includes initial state | +| `decline` | OPPORTUNISTIC | Minimal payload | +| `move` | OPPORTUNISTIC | Must fit in 295B | +| `resign` | DIRECT | Delivery confirmation important | +| `draw_offer` | OPPORTUNISTIC | Small | +| `draw_accept` / `draw_decline` | DIRECT | State-changing | +| `error` | OPPORTUNISTIC | Informational | + +--- + +## 12. Game Manifest + +Each game declares a manifest: + +``` +{ + "app_id": "", + "version": , + "display_name": "", + "icon": "", + "session_type": "turn_based" | "real_time" | "round_based" | "single_round", + "max_players": 2, + "min_players": 2, + "validation": "sender" | "receiver" | "both", + "actions": [], + "preferred_delivery": {}, + "ttl": {"pending": , "active": }, + "genre": "", + "turn_timeout": +} +``` + +--- + +## 13. Large Payloads + +Most LRGP actions fit in a single packet. For larger data: + +**Strategy A**: LXMF Resource auto-escalation. If DIRECT content exceeds 319 bytes, LXMF transfers as a Resource over the link (up to ~3.2 MB). Transparent to the game layer. + +**Strategy B**: `FIELD_FILE_ATTACHMENTS` (`0x05`). For explicit bulk data, use the standard LXMF file attachment field alongside the LRGP envelope. + +--- + +## 14. Backward Compatibility + +Messages with `fields[0xFB] = "rlap.v1"` or `"ratspeak.game"` are legacy. Implementations MUST recognize them on inbound and process normally. + +All outbound messages MUST use `"lrgp.v1"`. + +--- + +## 15. Cross-Client Adoption Levels + +| Level | Description | +|-------|-------------| +| **None** | Client ignores LRGP fields; shows fallback text | +| **Basic** | Client recognizes LRGP fields; shows enhanced notification | +| **Full** | Client renders interactive game UI | + +Any LXMF client achieves "None" level by default — fallback text appears as a regular message. + +--- + +## 16. Serialization + +All LRGP data MUST be serialized with msgpack. JSON is NOT supported on the wire. This is a hard constraint — every byte matters on LoRa links. + +--- + +## 17. Session Storage Schema + +### game_sessions + +| Column | Type | Description | +|--------|------|-------------| +| `session_id` | TEXT | 16-char hex, part of composite PK | +| `identity_id` | TEXT | Local identity, part of composite PK | +| `app_id` | TEXT | Game identifier | +| `app_version` | INTEGER | Protocol version | +| `contact_hash` | TEXT | Remote peer's identity hash | +| `initiator` | TEXT | Who sent the challenge | +| `status` | TEXT | pending/active/completed/expired/declined | +| `metadata` | TEXT (JSON) | Game-specific state blob | +| `unread` | INTEGER | 0 or 1 | +| `created_at` | REAL | Unix timestamp | +| `updated_at` | REAL | Unix timestamp | +| `last_action_at` | REAL | Unix timestamp (used for TTL) | + +Primary key: `(session_id, identity_id)` + +### game_actions (optional) + +| Column | Type | Description | +|--------|------|-------------| +| `session_id` | TEXT | Session reference | +| `identity_id` | TEXT | Local identity | +| `action_num` | INTEGER | Sequence number | +| `command` | TEXT | LRGP command | +| `payload_json` | TEXT | Serialized payload | +| `sender` | TEXT | Who sent this action | +| `timestamp` | REAL | Unix timestamp | + +Unique constraint: `(session_id, identity_id, action_num)` + +--- + +## A. TicTacToe Reference Game + +TicTacToe (`ttt.1`) is the built-in reference game demonstrating LRGP. + +### Payload Schema + +| Key | Type | Used In | Description | +|-----|------|---------|-------------| +| `i` | int | move | Cell index (0–8) | +| `b` | str | move, accept | Board state (9 chars: `_`, `X`, `O`) | +| `n` | int | move | Move number (1-based) | +| `t` | str | move, accept | Hash of player whose turn it is next | +| `x` | str | move | Terminal status: `""`, `"win"`, `"draw"` | +| `w` | str | move | Winner's hash (only when `x == "win"`) | diff --git a/examples/basic_envelope.rs b/examples/basic_envelope.rs new file mode 100644 index 0000000..4d37e6a --- /dev/null +++ b/examples/basic_envelope.rs @@ -0,0 +1,47 @@ +//! Demonstrates LRGP envelope packing, unpacking, and validation. + +use std::collections::HashMap; + +use lrgp::constants::*; +use lrgp::envelope::*; + +fn main() { + // Pack a challenge envelope + let env = pack_envelope("ttt", 1, "challenge", "a1b2c3d4e5f6g7h8", None); + println!("Challenge envelope: {env:?}"); + + // Validate size fits OPPORTUNISTIC delivery + let size = validate_envelope_size(&env).unwrap(); + println!("Packed size: {size} bytes (max {ENVELOPE_MAX_PACKED})"); + + // Serialize to msgpack bytes + let bytes = pack_to_bytes(&env).unwrap(); + println!("Wire bytes ({} bytes): {}", bytes.len(), hex::encode(&bytes)); + + // Deserialize back + let recovered = unpack_from_bytes(&bytes).unwrap(); + let app = value_as_str(recovered.get(KEY_APP).unwrap()).unwrap(); + let cmd = value_as_str(recovered.get(KEY_COMMAND).unwrap()).unwrap(); + let sid = value_as_str(recovered.get(KEY_SESSION).unwrap()).unwrap(); + println!("Recovered: app={app}, command={cmd}, session={sid}"); + + // Pack a move with payload + let mut payload = HashMap::new(); + payload.insert("i".to_string(), rmpv::Value::Integer(4.into())); + payload.insert("b".to_string(), rmpv::Value::String("____X____".into())); + payload.insert("n".to_string(), rmpv::Value::Integer(1.into())); + + let move_env = pack_envelope("ttt", 1, "move", "a1b2c3d4e5f6g7h8", Some(payload)); + let move_size = validate_envelope_size(&move_env).unwrap(); + println!("\nMove envelope size: {move_size} bytes"); + + // Pack into LXMF fields + let lxmf_fields = pack_lxmf_fields(&move_env); + println!("LXMF fields: type=0x{FIELD_CUSTOM_TYPE:02X}, meta=0x{FIELD_CUSTOM_META:02X}"); + + // Extract back from LXMF fields + let extracted = unpack_envelope(&lxmf_fields).unwrap().unwrap(); + println!("Extracted command: {}", value_as_str(extracted.get(KEY_COMMAND).unwrap()).unwrap()); + + println!("\nAll operations successful."); +} diff --git a/examples/session_lifecycle.rs b/examples/session_lifecycle.rs new file mode 100644 index 0000000..28c73e1 --- /dev/null +++ b/examples/session_lifecycle.rs @@ -0,0 +1,52 @@ +//! Demonstrates the LRGP game session state machine. + +use lrgp::constants::*; +use lrgp::session::{Session, SessionStateMachine}; + +fn main() { + // Create a new session (starts in "pending" state) + let mut session = Session::new("demo-session-001"); + session.app_id = "ttt".to_string(); + session.contact_hash = "abcdef0123456789".to_string(); + session.initiator = "abcdef0123456789".to_string(); + println!("Created session: status={}", session.status); + + // Challenge on a pending session stays pending + let status = SessionStateMachine::apply_command(&mut session, CMD_CHALLENGE, false).unwrap(); + println!("After challenge: status={status}"); + + // Accept transitions pending -> active + let status = SessionStateMachine::apply_command(&mut session, CMD_ACCEPT, false).unwrap(); + println!("After accept: status={status}"); + + // Move keeps session active (non-terminal) + let status = SessionStateMachine::apply_command(&mut session, CMD_MOVE, false).unwrap(); + println!("After move (non-terminal): status={status}"); + + // Another move, still active + let status = SessionStateMachine::apply_command(&mut session, CMD_MOVE, false).unwrap(); + println!("After move (non-terminal): status={status}"); + + // Terminal move completes the session + let status = SessionStateMachine::apply_command(&mut session, CMD_MOVE, true).unwrap(); + println!("After move (terminal): status={status}"); + + // Trying to move on a completed session fails + let result = SessionStateMachine::apply_command(&mut session, CMD_MOVE, false); + println!("Move on completed session: {result:?}"); + + // Demonstrate expiry + println!("\n--- Expiry demo ---"); + let mut pending = Session::new("expiry-demo"); + pending.last_action_at = 1000.0; // far in the past + let expired = SessionStateMachine::check_expiry(&mut pending, None, Some(1_000_000.0)); + println!("Pending session expired: {expired} (status={})", pending.status); + + // Demonstrate decline + println!("\n--- Decline demo ---"); + let mut challenged = Session::new("decline-demo"); + let status = SessionStateMachine::apply_command(&mut challenged, CMD_DECLINE, false).unwrap(); + println!("After decline: status={status}"); + + println!("\nAll lifecycle transitions demonstrated."); +} diff --git a/examples/tictactoe_game.rs b/examples/tictactoe_game.rs new file mode 100644 index 0000000..891dfa8 --- /dev/null +++ b/examples/tictactoe_game.rs @@ -0,0 +1,93 @@ +//! Simulates a full TicTacToe game through the LrgpRouter. + +use std::collections::HashMap; + +use lrgp::apps::tictactoe::TicTacToeApp; +use lrgp::envelope::value_as_str; +use lrgp::router::LrgpRouter; + +fn main() { + let router = LrgpRouter::new(); + router.register(Box::new(TicTacToeApp::new())); + + let player_a = "aaaa1111bbbb2222"; + let player_b = "cccc3333dddd4444"; + + // Player A sends a challenge + println!("=== Player A challenges Player B ==="); + let (env, fallback) = router + .dispatch_outgoing("ttt", 1, "challenge", "", &HashMap::new(), player_a) + .unwrap(); + let session_id = value_as_str(env.get("s").unwrap()).unwrap().to_string(); + println!("Fallback: {fallback}"); + println!("Session ID: {session_id}"); + + // Player B receives the challenge + println!("\n=== Player B receives challenge ==="); + let result = router.dispatch_incoming(&env, player_a, player_b).unwrap(); + if let Some(emit) = &result.emit { + println!("Event: {:?}", emit.get("type")); + } + + // Player B accepts + println!("\n=== Player B accepts ==="); + let (accept_env, fallback) = router + .dispatch_outgoing("ttt", 1, "accept", &session_id, &HashMap::new(), player_b) + .unwrap(); + println!("Fallback: {fallback}"); + + // Player A receives accept + let result = router.dispatch_incoming(&accept_env, player_b, player_a).unwrap(); + if let Some(emit) = &result.emit { + println!("Event: {:?}", emit.get("type")); + } + + // Play some moves + let moves = [ + (player_a, 4), + (player_b, 0), + (player_a, 2), + (player_b, 6), + (player_a, 8), + ]; + + for (i, (player, cell)) in moves.iter().enumerate() { + let move_num = i + 1; + println!( + "\n=== Move {move_num}: Player {} plays cell {cell} ===", + if *player == player_a { "A" } else { "B" } + ); + + let mut payload = HashMap::new(); + payload.insert("i".to_string(), rmpv::Value::Integer((*cell as i64).into())); + + let (move_env, fallback) = router + .dispatch_outgoing("ttt", 1, "move", &session_id, &payload, player) + .unwrap(); + println!("Fallback: {fallback}"); + + // Other player receives + let other = if *player == player_a { player_b } else { player_a }; + let result = router.dispatch_incoming(&move_env, player, other).unwrap(); + + if let Some(emit) = &result.emit { + if let Some(payload_val) = emit.get("payload") { + if let Some(board_val) = payload_val.get("b") { + println!("Board: {}", board_val.as_str().unwrap_or("?")); + } + } + } + } + + // List registered games + println!("\n=== Registered Games ==="); + for manifest in router.list_apps() { + println!( + " {}.{} — {} ({}) genre={:?}", + manifest.app_id, manifest.version, manifest.display_name, + manifest.session_type, manifest.genre + ); + } + + println!("\nGame simulation complete."); +} diff --git a/src/app_base.rs b/src/app_base.rs new file mode 100644 index 0000000..a77377f --- /dev/null +++ b/src/app_base.rs @@ -0,0 +1,102 @@ +/// LRGP GameApp trait — the interface all LRGP games must implement. + +use std::collections::HashMap; + +use serde_json::Value as JsonValue; + +/// Result returned by `handle_incoming`. +#[derive(Debug, Clone)] +pub struct IncomingResult { + /// Updated session dict, or None. + pub session: Option>, + /// Event to emit to the UI, or None. + pub emit: Option>, + /// Error info, or None. + pub error: Option>, +} + +/// Result returned by `handle_outgoing`. +#[derive(Debug, Clone)] +pub struct OutgoingResult { + /// Enriched payload to pack into the envelope. + pub payload: HashMap, + /// Human-readable fallback text for non-LRGP clients. + pub fallback_text: String, +} + +/// Game manifest describing an LRGP game's capabilities. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct GameManifest { + pub app_id: String, + pub version: u32, + pub display_name: String, + pub icon: String, + pub session_type: String, + pub max_players: u32, + pub min_players: u32, + pub validation: String, + pub actions: Vec, + pub preferred_delivery: HashMap, + pub ttl: HashMap, + /// Optional genre tag for game categorization (e.g., "strategy", "puzzle", "card"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub genre: Option, + /// Optional per-turn time limit in seconds. `None` means no limit. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_timeout: Option, +} + +/// The trait all LRGP games must implement. +pub trait GameApp: Send + Sync { + fn app_id(&self) -> &str; + fn version(&self) -> u32; + fn manifest(&self) -> GameManifest; + + /// Process an incoming LRGP game action. + fn handle_incoming( + &self, + session_id: &str, + command: &str, + payload: &HashMap, + sender_hash: &str, + identity_id: &str, + ) -> IncomingResult; + + /// Prepare an outgoing LRGP game action. + fn handle_outgoing( + &self, + session_id: &str, + command: &str, + payload: &HashMap, + identity_id: &str, + ) -> OutgoingResult; + + /// Validate an action. Returns (valid, error_message). + fn validate_action( + &self, + session_id: &str, + command: &str, + payload: &HashMap, + sender_hash: &str, + ) -> (bool, Option); + + /// Return current session state for rendering. + fn get_session_state( + &self, + session_id: &str, + identity_id: &str, + ) -> HashMap; + + /// Generate human-readable fallback text for LXMF content field. + fn render_fallback( + &self, + command: &str, + payload: &HashMap, + ) -> String; + + /// Return preferred delivery method for this command. + fn get_delivery_method(&self, command: &str) -> String { + let _ = command; + "opportunistic".to_string() + } +} diff --git a/src/apps/mod.rs b/src/apps/mod.rs new file mode 100644 index 0000000..9c8d308 --- /dev/null +++ b/src/apps/mod.rs @@ -0,0 +1 @@ +pub mod tictactoe; diff --git a/src/apps/tictactoe.rs b/src/apps/tictactoe.rs new file mode 100644 index 0000000..8dc41df --- /dev/null +++ b/src/apps/tictactoe.rs @@ -0,0 +1,1156 @@ +/// LRGP TicTacToe — built-in turn-based game with both-side validation. + +use std::collections::HashMap; +use std::sync::Mutex; + +use serde_json::Value as JsonValue; + +use crate::app_base::{GameManifest, IncomingResult, OutgoingResult, GameApp}; +use crate::constants::*; +use crate::envelope::{value_as_str, value_as_u64}; +use crate::session::{Session, SessionStateMachine}; + +const EMPTY_BOARD: &str = "_________"; + +const WIN_LINES: [(usize, usize, usize); 8] = [ + (0, 1, 2), (3, 4, 5), (6, 7, 8), // rows + (0, 3, 6), (1, 4, 7), (2, 5, 8), // columns + (0, 4, 8), (2, 4, 6), // diagonals +]; + +fn check_winner(board: &str) -> Option { + let b: Vec = board.chars().collect(); + for &(a, bi, c) in &WIN_LINES { + if b[a] != '_' && b[a] == b[bi] && b[bi] == b[c] { + return Some(b[a]); + } + } + None +} + +fn check_draw(board: &str) -> bool { + !board.contains('_') && check_winner(board).is_none() +} + +fn marker_for_move(move_num: u64) -> char { + if move_num % 2 == 1 { 'X' } else { 'O' } +} + +fn gen_session_id() -> String { + use rand::RngCore; + let mut buf = [0u8; 8]; + rand::thread_rng().fill_bytes(&mut buf); + hex::encode(buf) +} + +fn error_result(code: &str, msg: &str) -> IncomingResult { + let mut err = HashMap::new(); + err.insert("code".into(), JsonValue::String(code.into())); + err.insert("msg".into(), JsonValue::String(msg.into())); + IncomingResult { + session: None, + emit: None, + error: Some(err), + } +} + +fn emit_event(event_type: &str, session_id: &str, app_id: &str, from: &str) -> HashMap { + let mut m = HashMap::new(); + m.insert("type".into(), JsonValue::String(event_type.into())); + m.insert("session_id".into(), JsonValue::String(session_id.into())); + m.insert("app_id".into(), JsonValue::String(app_id.into())); + m.insert("from".into(), JsonValue::String(from.into())); + m +} + +/// Helper to get a string from metadata. +fn meta_str(meta: &HashMap, key: &str) -> String { + meta.get(key) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() +} + +fn meta_i64(meta: &HashMap, key: &str) -> i64 { + meta.get(key).and_then(|v| v.as_i64()).unwrap_or(0) +} + +fn meta_bool(meta: &HashMap, key: &str) -> bool { + meta.get(key).and_then(|v| v.as_bool()).unwrap_or(false) +} + +/// The Tic-Tac-Toe LRGP game. +pub struct TicTacToeApp { + sessions: Mutex>, +} + +impl TicTacToeApp { + pub fn new() -> Self { + Self { + sessions: Mutex::new(HashMap::new()), + } + } + + fn get_session(&self, session_id: &str, identity_id: &str) -> Option { + let sessions = self.sessions.lock().unwrap(); + sessions + .get(&(session_id.to_string(), identity_id.to_string())) + .cloned() + } + + fn save_session(&self, session: &Session) { + let mut sessions = self.sessions.lock().unwrap(); + sessions.insert( + (session.session_id.clone(), session.identity_id.clone()), + session.clone(), + ); + } + + fn default_metadata(my_marker: &str, first_turn: &str) -> HashMap { + let mut m = HashMap::new(); + m.insert("board".into(), JsonValue::String(EMPTY_BOARD.into())); + m.insert("turn".into(), JsonValue::String("".into())); + m.insert("first_turn".into(), JsonValue::String(first_turn.into())); + m.insert("my_marker".into(), JsonValue::String(my_marker.into())); + m.insert("move_count".into(), JsonValue::Number(0.into())); + m.insert("winner".into(), JsonValue::String("".into())); + m.insert("terminal".into(), JsonValue::String("".into())); + m.insert("draw_offered".into(), JsonValue::Bool(false)); + m + } + + // --- Incoming handlers --- + + fn handle_challenge_in( + &self, + session_id: &str, + _payload: &HashMap, + sender_hash: &str, + identity_id: &str, + ) -> IncomingResult { + let mut session = Session::new(session_id); + session.identity_id = identity_id.to_string(); + session.app_id = "ttt".to_string(); + session.app_version = 1; + session.contact_hash = sender_hash.to_string(); + session.initiator = sender_hash.to_string(); + session.status = STATUS_PENDING.to_string(); + session.metadata = Self::default_metadata("O", sender_hash); + session.unread = 1; + self.save_session(&session); + + IncomingResult { + session: Some(session_to_json(&session)), + emit: Some(emit_event("challenge", session_id, "ttt", sender_hash)), + error: None, + } + } + + fn handle_accept_in( + &self, + session_id: &str, + payload: &HashMap, + sender_hash: &str, + identity_id: &str, + ) -> IncomingResult { + let mut session = match self.get_session(session_id, identity_id) { + Some(s) => s, + None => return error_result(ERR_PROTOCOL_ERROR, "Unknown session"), + }; + + if let Err(e) = SessionStateMachine::apply_command(&mut session, CMD_ACCEPT, false) { + return error_result(ERR_PROTOCOL_ERROR, &e.to_string()); + } + + let board = payload + .get("b") + .and_then(|v| value_as_str(v)) + .unwrap_or(EMPTY_BOARD); + let first_turn = meta_str(&session.metadata, "first_turn"); + let turn = payload + .get("t") + .and_then(|v| value_as_str(v)) + .unwrap_or(&first_turn); + + session.metadata.insert("board".into(), JsonValue::String(board.to_string())); + session.metadata.insert("turn".into(), JsonValue::String(turn.to_string())); + session.unread = 1; + self.save_session(&session); + + IncomingResult { + session: Some(session_to_json(&session)), + emit: Some(emit_event("accept", session_id, "ttt", sender_hash)), + error: None, + } + } + + fn handle_decline_in( + &self, + session_id: &str, + sender_hash: &str, + identity_id: &str, + ) -> IncomingResult { + let mut session = match self.get_session(session_id, identity_id) { + Some(s) => s, + None => return error_result(ERR_PROTOCOL_ERROR, "Unknown session"), + }; + + if let Err(e) = SessionStateMachine::apply_command(&mut session, CMD_DECLINE, false) { + return error_result(ERR_PROTOCOL_ERROR, &e.to_string()); + } + + session.unread = 1; + self.save_session(&session); + + IncomingResult { + session: Some(session_to_json(&session)), + emit: Some(emit_event("decline", session_id, "ttt", sender_hash)), + error: None, + } + } + + fn handle_move_in( + &self, + session_id: &str, + payload: &HashMap, + sender_hash: &str, + identity_id: &str, + ) -> IncomingResult { + let mut session = match self.get_session(session_id, identity_id) { + Some(s) => s, + None => return error_result(ERR_PROTOCOL_ERROR, "Unknown session"), + }; + + let (valid, err_msg) = self.validate_move(&session, payload, sender_hash); + if !valid { + return IncomingResult { + session: Some(session_to_json(&session)), + emit: None, + error: Some({ + let mut m = HashMap::new(); + m.insert("code".into(), JsonValue::String(ERR_INVALID_MOVE.into())); + m.insert("msg".into(), JsonValue::String(err_msg.unwrap_or_default().into())); + m.insert("ref".into(), JsonValue::String(CMD_MOVE.into())); + m + }), + }; + } + + let board = payload.get("b").and_then(|v| value_as_str(v)).unwrap_or(""); + let move_num = payload.get("n").and_then(|v| value_as_u64(v)).unwrap_or(0); + let turn = payload.get("t").and_then(|v| value_as_str(v)).unwrap_or(""); + let terminal = payload.get("x").and_then(|v| value_as_str(v)).unwrap_or(""); + let winner = payload.get("w").and_then(|v| value_as_str(v)).unwrap_or(""); + + session.metadata.insert("board".into(), JsonValue::String(board.to_string())); + session.metadata.insert("move_count".into(), JsonValue::Number((move_num as i64).into())); + session.metadata.insert("turn".into(), JsonValue::String(turn.to_string())); + session.metadata.insert("terminal".into(), JsonValue::String(terminal.to_string())); + session.metadata.insert("winner".into(), JsonValue::String(winner.to_string())); + session.metadata.insert("draw_offered".into(), JsonValue::Bool(false)); + + let _ = SessionStateMachine::apply_command(&mut session, CMD_MOVE, !terminal.is_empty()); + session.unread = 1; + self.save_session(&session); + + let mut emit = emit_event("move", session_id, "ttt", sender_hash); + // Include payload in emit for moves + let payload_json: HashMap = payload + .iter() + .map(|(k, v)| (k.clone(), rmpv_to_json(v))) + .collect(); + emit.insert("payload".into(), JsonValue::Object(payload_json.into_iter().collect())); + + IncomingResult { + session: Some(session_to_json(&session)), + emit: Some(emit), + error: None, + } + } + + fn handle_resign_in( + &self, + session_id: &str, + sender_hash: &str, + identity_id: &str, + ) -> IncomingResult { + let mut session = match self.get_session(session_id, identity_id) { + Some(s) => s, + None => return error_result(ERR_PROTOCOL_ERROR, "Unknown session"), + }; + + let _ = SessionStateMachine::apply_command(&mut session, CMD_RESIGN, false); + session.metadata.insert("terminal".into(), JsonValue::String("resign".into())); + let first_turn = meta_str(&session.metadata, "first_turn"); + let winner = if sender_hash == first_turn { + identity_id.to_string() + } else { + first_turn + }; + session.metadata.insert("winner".into(), JsonValue::String(winner)); + session.unread = 1; + self.save_session(&session); + + IncomingResult { + session: Some(session_to_json(&session)), + emit: Some(emit_event("resign", session_id, "ttt", sender_hash)), + error: None, + } + } + + fn handle_draw_offer_in( + &self, + session_id: &str, + sender_hash: &str, + identity_id: &str, + ) -> IncomingResult { + let mut session = match self.get_session(session_id, identity_id) { + Some(s) => s, + None => return error_result(ERR_PROTOCOL_ERROR, "Unknown session"), + }; + + session.metadata.insert("draw_offered".into(), JsonValue::Bool(true)); + session.unread = 1; + self.save_session(&session); + + IncomingResult { + session: Some(session_to_json(&session)), + emit: Some(emit_event("draw_offer", session_id, "ttt", sender_hash)), + error: None, + } + } + + fn handle_draw_accept_in( + &self, + session_id: &str, + sender_hash: &str, + identity_id: &str, + ) -> IncomingResult { + let mut session = match self.get_session(session_id, identity_id) { + Some(s) => s, + None => return error_result(ERR_PROTOCOL_ERROR, "Unknown session"), + }; + + let _ = SessionStateMachine::apply_command(&mut session, CMD_DRAW_ACCEPT, false); + session.metadata.insert("terminal".into(), JsonValue::String("draw".into())); + session.metadata.insert("draw_offered".into(), JsonValue::Bool(false)); + session.unread = 1; + self.save_session(&session); + + IncomingResult { + session: Some(session_to_json(&session)), + emit: Some(emit_event("draw_accept", session_id, "ttt", sender_hash)), + error: None, + } + } + + fn handle_draw_decline_in( + &self, + session_id: &str, + sender_hash: &str, + identity_id: &str, + ) -> IncomingResult { + let mut session = match self.get_session(session_id, identity_id) { + Some(s) => s, + None => return error_result(ERR_PROTOCOL_ERROR, "Unknown session"), + }; + + session.metadata.insert("draw_offered".into(), JsonValue::Bool(false)); + session.unread = 1; + self.save_session(&session); + + IncomingResult { + session: Some(session_to_json(&session)), + emit: Some(emit_event("draw_decline", session_id, "ttt", sender_hash)), + error: None, + } + } + + // --- Outgoing handlers --- + + fn handle_challenge_out(&self, session_id: &str, identity_id: &str) -> OutgoingResult { + let sid = if session_id.is_empty() { + gen_session_id() + } else { + session_id.to_string() + }; + + let mut session = Session::new(&sid); + session.identity_id = identity_id.to_string(); + session.app_id = "ttt".to_string(); + session.app_version = 1; + session.initiator = identity_id.to_string(); + session.status = STATUS_PENDING.to_string(); + session.metadata = Self::default_metadata("X", identity_id); + self.save_session(&session); + + OutgoingResult { + payload: HashMap::new(), + fallback_text: "[LRGP TTT] Sent a challenge!".into(), + } + } + + fn handle_accept_out(&self, session_id: &str, identity_id: &str) -> OutgoingResult { + let mut session = match self.get_session(session_id, identity_id) { + Some(s) => s, + None => { + return OutgoingResult { + payload: HashMap::new(), + fallback_text: "[LRGP TTT] Challenge accepted".into(), + } + } + }; + + let _ = SessionStateMachine::apply_command(&mut session, CMD_ACCEPT, false); + let first_turn = meta_str(&session.metadata, "first_turn"); + let first = if first_turn.is_empty() { + session.initiator.clone() + } else { + first_turn + }; + session.metadata.insert("board".into(), JsonValue::String(EMPTY_BOARD.into())); + session.metadata.insert("turn".into(), JsonValue::String(first.clone())); + self.save_session(&session); + + let mut payload = HashMap::new(); + payload.insert("b".to_string(), rmpv::Value::String(EMPTY_BOARD.into())); + payload.insert("t".to_string(), rmpv::Value::String(first.into())); + + OutgoingResult { + payload, + fallback_text: "[LRGP TTT] Challenge accepted".into(), + } + } + + fn handle_decline_out(&self, session_id: &str, identity_id: &str) -> OutgoingResult { + if let Some(mut session) = self.get_session(session_id, identity_id) { + let _ = SessionStateMachine::apply_command(&mut session, CMD_DECLINE, false); + self.save_session(&session); + } + OutgoingResult { + payload: HashMap::new(), + fallback_text: "[LRGP TTT] Challenge declined".into(), + } + } + + fn handle_move_out( + &self, + session_id: &str, + payload: &HashMap, + identity_id: &str, + ) -> OutgoingResult { + let mut session = match self.get_session(session_id, identity_id) { + Some(s) => s, + None => { + return OutgoingResult { + payload: payload.clone(), + fallback_text: self.render_fallback_inner(CMD_MOVE, payload), + } + } + }; + + let meta = &session.metadata; + let old_board = meta_str(meta, "board"); + let index = payload.get("i").and_then(|v| value_as_u64(v)).unwrap_or(0) as usize; + let move_num = (meta_i64(meta, "move_count") + 1) as u64; + let marker = marker_for_move(move_num); + + let mut board_chars: Vec = old_board.chars().collect(); + if index < board_chars.len() { + board_chars[index] = marker; + } + let new_board: String = board_chars.into_iter().collect(); + + let winner = check_winner(&new_board); + let is_draw = check_draw(&new_board); + + let (terminal, winner_hash, next_turn) = if winner.is_some() { + ("win".to_string(), identity_id.to_string(), String::new()) + } else if is_draw { + ("draw".to_string(), String::new(), String::new()) + } else { + let first_turn = meta_str(meta, "first_turn"); + let mut nt = if marker == 'O' { + first_turn + } else { + session.contact_hash.clone() + }; + if nt == identity_id { + nt = session.contact_hash.clone(); + } + (String::new(), String::new(), nt) + }; + + let mut enriched = HashMap::new(); + enriched.insert("i".to_string(), rmpv::Value::Integer((index as i64).into())); + enriched.insert("b".to_string(), rmpv::Value::String(new_board.clone().into())); + enriched.insert("n".to_string(), rmpv::Value::Integer((move_num as i64).into())); + enriched.insert("t".to_string(), rmpv::Value::String(next_turn.clone().into())); + enriched.insert("x".to_string(), rmpv::Value::String(terminal.clone().into())); + if terminal == "win" { + enriched.insert("w".to_string(), rmpv::Value::String(winner_hash.clone().into())); + } + + // Update local session + session.metadata.insert("board".into(), JsonValue::String(new_board)); + session.metadata.insert("move_count".into(), JsonValue::Number((move_num as i64).into())); + session.metadata.insert("turn".into(), JsonValue::String(next_turn)); + session.metadata.insert("terminal".into(), JsonValue::String(terminal.clone())); + session.metadata.insert( + "winner".into(), + JsonValue::String(if terminal == "win" { winner_hash } else { String::new() }), + ); + session.metadata.insert("draw_offered".into(), JsonValue::Bool(false)); + let _ = SessionStateMachine::apply_command(&mut session, CMD_MOVE, !terminal.is_empty()); + self.save_session(&session); + + let fallback = self.render_fallback_inner(CMD_MOVE, &enriched); + OutgoingResult { + payload: enriched, + fallback_text: fallback, + } + } + + fn handle_resign_out(&self, session_id: &str, identity_id: &str) -> OutgoingResult { + if let Some(mut session) = self.get_session(session_id, identity_id) { + let _ = SessionStateMachine::apply_command(&mut session, CMD_RESIGN, false); + session.metadata.insert("terminal".into(), JsonValue::String("resign".into())); + session.metadata.insert("winner".into(), JsonValue::String(session.contact_hash.clone())); + self.save_session(&session); + } + OutgoingResult { + payload: HashMap::new(), + fallback_text: "[LRGP TTT] Resigned.".into(), + } + } + + fn handle_draw_accept_out(&self, session_id: &str, identity_id: &str) -> OutgoingResult { + if let Some(mut session) = self.get_session(session_id, identity_id) { + let _ = SessionStateMachine::apply_command(&mut session, CMD_DRAW_ACCEPT, false); + session.metadata.insert("terminal".into(), JsonValue::String("draw".into())); + session.metadata.insert("draw_offered".into(), JsonValue::Bool(false)); + self.save_session(&session); + } + OutgoingResult { + payload: HashMap::new(), + fallback_text: "[LRGP TTT] Draw accepted".into(), + } + } + + // --- Validation --- + + fn validate_move( + &self, + session: &Session, + payload: &HashMap, + sender_hash: &str, + ) -> (bool, Option) { + let meta = &session.metadata; + + // 1. Session must be active + if session.status != STATUS_ACTIVE { + return ( + false, + Some(format!("Session is not active (status={})", session.status)), + ); + } + + // 2. Must be sender's turn + let turn = meta_str(meta, "turn"); + if !turn.is_empty() && turn != sender_hash { + return (false, Some("Not your turn".into())); + } + + let index = match payload.get("i").and_then(|v| value_as_u64(v)) { + Some(i) if i <= 8 => i as usize, + _ => return (false, Some(format!("Invalid cell index"))), + }; + let board_str = payload.get("b").and_then(|v| value_as_str(v)).unwrap_or(""); + let move_num = payload.get("n").and_then(|v| value_as_u64(v)).unwrap_or(0); + let terminal = payload.get("x").and_then(|v| value_as_str(v)).unwrap_or(""); + + // 3. Cell must be empty + let old_board = meta_str(meta, "board"); + let old_chars: Vec = old_board.chars().collect(); + if index >= old_chars.len() || old_chars[index] != '_' { + return (false, Some(format!("Cell {index} is already occupied"))); + } + + // 4. Board must match expected + let marker = marker_for_move(move_num); + let expected: String = old_chars + .iter() + .enumerate() + .map(|(i, &c)| if i == index { marker } else { c }) + .collect(); + if board_str != expected { + return ( + false, + Some(format!("Board mismatch: expected {expected}, got {board_str}")), + ); + } + + // 5. Move number must be sequential + let expected_num = (meta_i64(meta, "move_count") + 1) as u64; + if move_num != expected_num { + return ( + false, + Some(format!( + "Move number mismatch: expected {expected_num}, got {move_num}" + )), + ); + } + + // 6. Terminal status must match computed result + let winner = check_winner(board_str); + let is_draw = check_draw(board_str); + + if winner.is_some() && terminal != "win" { + return ( + false, + Some(format!("Board shows a win but terminal='{terminal}'")), + ); + } + if is_draw && terminal != "draw" { + return ( + false, + Some(format!("Board is full (draw) but terminal='{terminal}'")), + ); + } + if winner.is_none() && !is_draw && !terminal.is_empty() { + return ( + false, + Some(format!("No win/draw but terminal='{terminal}'")), + ); + } + + // 7. Turn must be opponent (or empty if terminal) + let next_turn = payload.get("t").and_then(|v| value_as_str(v)).unwrap_or(""); + if !terminal.is_empty() { + if !next_turn.is_empty() { + return (false, Some("Turn should be empty on terminal move".into())); + } + } else if next_turn == sender_hash { + return ( + false, + Some("Turn cannot be the sender after their own move".into()), + ); + } + + (true, None) + } + + fn render_fallback_inner(&self, command: &str, payload: &HashMap) -> String { + match command { + CMD_CHALLENGE => "[LRGP TTT] Sent a challenge!".into(), + CMD_ACCEPT => "[LRGP TTT] Challenge accepted".into(), + CMD_DECLINE => "[LRGP TTT] Challenge declined".into(), + CMD_MOVE => { + let terminal = payload.get("x").and_then(|v| value_as_str(v)).unwrap_or(""); + if terminal == "win" { + let n = payload.get("n").and_then(|v| value_as_u64(v)).unwrap_or(0); + let marker = marker_for_move(n); + format!("[LRGP TTT] {marker} wins!") + } else if terminal == "draw" { + "[LRGP TTT] Game drawn!".into() + } else { + let n = payload.get("n").and_then(|v| value_as_u64(v)); + match n { + Some(n) => format!("[LRGP TTT] Move {n}"), + None => "[LRGP TTT] Move ?".into(), + } + } + } + CMD_RESIGN => "[LRGP TTT] Resigned.".into(), + CMD_DRAW_OFFER => "[LRGP TTT] Offered a draw".into(), + CMD_DRAW_ACCEPT => "[LRGP TTT] Draw accepted".into(), + CMD_DRAW_DECLINE => "[LRGP TTT] Draw declined".into(), + CMD_ERROR => { + let msg = payload.get("msg").and_then(|v| value_as_str(v)).unwrap_or("Unknown"); + format!("[LRGP TTT] Error: {msg}") + } + other => format!("[LRGP TTT] {other}"), + } + } +} + +impl Default for TicTacToeApp { + fn default() -> Self { + Self::new() + } +} + +impl GameApp for TicTacToeApp { + fn app_id(&self) -> &str { + "ttt" + } + + fn version(&self) -> u32 { + 1 + } + + fn manifest(&self) -> GameManifest { + let mut preferred_delivery = HashMap::new(); + preferred_delivery.insert(CMD_CHALLENGE.into(), "opportunistic".into()); + preferred_delivery.insert(CMD_ACCEPT.into(), "opportunistic".into()); + preferred_delivery.insert(CMD_DECLINE.into(), "opportunistic".into()); + preferred_delivery.insert(CMD_MOVE.into(), "opportunistic".into()); + preferred_delivery.insert(CMD_RESIGN.into(), "direct".into()); + preferred_delivery.insert(CMD_DRAW_OFFER.into(), "opportunistic".into()); + preferred_delivery.insert(CMD_DRAW_ACCEPT.into(), "direct".into()); + preferred_delivery.insert(CMD_DRAW_DECLINE.into(), "direct".into()); + + let mut ttl = HashMap::new(); + ttl.insert(STATUS_PENDING.into(), 86400.0); + ttl.insert(STATUS_ACTIVE.into(), 86400.0); + + GameManifest { + app_id: "ttt".into(), + version: 1, + display_name: "Tic-Tac-Toe".into(), + icon: "ttt".into(), + session_type: SESSION_TURN_BASED.into(), + max_players: 2, + min_players: 2, + validation: VALIDATION_BOTH.into(), + actions: vec![ + CMD_CHALLENGE.into(), + CMD_ACCEPT.into(), + CMD_DECLINE.into(), + CMD_MOVE.into(), + CMD_RESIGN.into(), + CMD_DRAW_OFFER.into(), + CMD_DRAW_ACCEPT.into(), + CMD_DRAW_DECLINE.into(), + ], + preferred_delivery, + ttl, + genre: Some("strategy".into()), + turn_timeout: None, + } + } + + fn handle_incoming( + &self, + session_id: &str, + command: &str, + payload: &HashMap, + sender_hash: &str, + identity_id: &str, + ) -> IncomingResult { + match command { + CMD_CHALLENGE => self.handle_challenge_in(session_id, payload, sender_hash, identity_id), + CMD_ACCEPT => self.handle_accept_in(session_id, payload, sender_hash, identity_id), + CMD_DECLINE => self.handle_decline_in(session_id, sender_hash, identity_id), + CMD_MOVE => self.handle_move_in(session_id, payload, sender_hash, identity_id), + CMD_RESIGN => self.handle_resign_in(session_id, sender_hash, identity_id), + CMD_DRAW_OFFER => self.handle_draw_offer_in(session_id, sender_hash, identity_id), + CMD_DRAW_ACCEPT => self.handle_draw_accept_in(session_id, sender_hash, identity_id), + CMD_DRAW_DECLINE => self.handle_draw_decline_in(session_id, sender_hash, identity_id), + CMD_ERROR => IncomingResult { + session: None, + emit: None, + error: Some( + payload + .iter() + .map(|(k, v)| (k.clone(), rmpv_to_json(v))) + .collect(), + ), + }, + other => error_result(ERR_PROTOCOL_ERROR, &format!("Unknown command: {other}")), + } + } + + fn handle_outgoing( + &self, + session_id: &str, + command: &str, + payload: &HashMap, + identity_id: &str, + ) -> OutgoingResult { + match command { + CMD_CHALLENGE => self.handle_challenge_out(session_id, identity_id), + CMD_ACCEPT => self.handle_accept_out(session_id, identity_id), + CMD_DECLINE => self.handle_decline_out(session_id, identity_id), + CMD_MOVE => self.handle_move_out(session_id, payload, identity_id), + CMD_RESIGN => self.handle_resign_out(session_id, identity_id), + CMD_DRAW_OFFER => OutgoingResult { + payload: HashMap::new(), + fallback_text: "[LRGP TTT] Offered a draw".into(), + }, + CMD_DRAW_ACCEPT => self.handle_draw_accept_out(session_id, identity_id), + CMD_DRAW_DECLINE => OutgoingResult { + payload: HashMap::new(), + fallback_text: "[LRGP TTT] Declined draw offer".into(), + }, + _ => OutgoingResult { + payload: payload.clone(), + fallback_text: format!("[LRGP TTT] {command}"), + }, + } + } + + fn validate_action( + &self, + session_id: &str, + command: &str, + payload: &HashMap, + sender_hash: &str, + ) -> (bool, Option) { + let session = match self.get_session(session_id, "") { + Some(s) => s, + None => { + return if command == CMD_CHALLENGE { + (true, None) + } else { + (false, Some("Session not found".into())) + } + } + }; + + let ttl = { + let mut m = HashMap::new(); + m.insert(STATUS_PENDING.to_string(), 86400.0); + m.insert(STATUS_ACTIVE.to_string(), 86400.0); + m + }; + let mut session = session; + if SessionStateMachine::check_expiry(&mut session, Some(&ttl), None) { + self.save_session(&session); + return (false, Some("Session expired".into())); + } + + if command == CMD_MOVE { + return self.validate_move(&session, payload, sender_hash); + } + + (true, None) + } + + fn get_session_state(&self, session_id: &str, identity_id: &str) -> HashMap { + match self.get_session(session_id, identity_id) { + Some(s) => session_to_json(&s), + None => HashMap::new(), + } + } + + fn render_fallback(&self, command: &str, payload: &HashMap) -> String { + self.render_fallback_inner(command, payload) + } + + fn get_delivery_method(&self, command: &str) -> String { + match command { + CMD_RESIGN | CMD_DRAW_ACCEPT | CMD_DRAW_DECLINE => "direct".into(), + _ => "opportunistic".into(), + } + } +} + +fn session_to_json(session: &Session) -> HashMap { + let mut m = HashMap::new(); + m.insert("session_id".into(), JsonValue::String(session.session_id.clone())); + m.insert("identity_id".into(), JsonValue::String(session.identity_id.clone())); + m.insert("app_id".into(), JsonValue::String(session.app_id.clone())); + m.insert("app_version".into(), JsonValue::Number((session.app_version as i64).into())); + m.insert("contact_hash".into(), JsonValue::String(session.contact_hash.clone())); + m.insert("initiator".into(), JsonValue::String(session.initiator.clone())); + m.insert("status".into(), JsonValue::String(session.status.clone())); + m.insert("metadata".into(), JsonValue::Object(session.metadata.clone().into_iter().collect())); + m.insert("unread".into(), JsonValue::Number(session.unread.into())); + m.insert("created_at".into(), serde_json::json!(session.created_at)); + m.insert("updated_at".into(), serde_json::json!(session.updated_at)); + m.insert("last_action_at".into(), serde_json::json!(session.last_action_at)); + m +} + +fn rmpv_to_json(v: &rmpv::Value) -> JsonValue { + match v { + rmpv::Value::Nil => JsonValue::Null, + rmpv::Value::Boolean(b) => JsonValue::Bool(*b), + rmpv::Value::Integer(i) => { + if let Some(u) = i.as_u64() { + JsonValue::Number(u.into()) + } else if let Some(s) = i.as_i64() { + JsonValue::Number(s.into()) + } else { + JsonValue::Null + } + } + rmpv::Value::F32(f) => serde_json::json!(*f), + rmpv::Value::F64(f) => serde_json::json!(*f), + rmpv::Value::String(s) => JsonValue::String(s.as_str().unwrap_or("").to_string()), + rmpv::Value::Binary(b) => JsonValue::String(hex::encode(b)), + rmpv::Value::Array(arr) => JsonValue::Array(arr.iter().map(rmpv_to_json).collect()), + rmpv::Value::Map(pairs) => { + let obj: serde_json::Map = pairs + .iter() + .filter_map(|(k, v)| { + let key = match k { + rmpv::Value::String(s) => s.as_str()?.to_string(), + _ => return None, + }; + Some((key, rmpv_to_json(v))) + }) + .collect(); + JsonValue::Object(obj) + } + rmpv::Value::Ext(_, _) => JsonValue::Null, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn _setup_game() -> (TicTacToeApp, String) { + let app = TicTacToeApp::new(); + let challenger = "challenger_hash"; + let responder = "responder_hash"; + + // Challenger sends challenge (outgoing) + let out = app.handle_outgoing("sess1", CMD_CHALLENGE, &HashMap::new(), challenger); + assert!(out.fallback_text.contains("challenge")); + + // Set contact_hash on the challenger's session + { + let mut sessions = app.sessions.lock().unwrap(); + if let Some(s) = sessions.get_mut(&("sess1".into(), challenger.into())) { + s.contact_hash = responder.to_string(); + } + } + + // Responder receives challenge (incoming) + let result = app.handle_incoming("sess1", CMD_CHALLENGE, &HashMap::new(), challenger, responder); + assert!(result.error.is_none()); + + (app, "sess1".to_string()) + } + + #[test] + fn test_check_winner() { + assert_eq!(check_winner("XXX______"), Some('X')); + assert_eq!(check_winner("___OOO___"), Some('O')); + assert_eq!(check_winner("X___X___X"), Some('X')); + assert_eq!(check_winner("__X_X_X__"), Some('X')); + assert_eq!(check_winner("_________"), None); + assert_eq!(check_winner("XOXOXOOXO"), None); // draw board, no winner + } + + #[test] + fn test_check_draw() { + assert!(check_draw("XOXOOXXXO")); + assert!(!check_draw("XOXOOXX_O")); + assert!(!check_draw("XXXOO____")); // has winner + } + + #[test] + fn test_marker_for_move() { + assert_eq!(marker_for_move(1), 'X'); + assert_eq!(marker_for_move(2), 'O'); + assert_eq!(marker_for_move(3), 'X'); + } + + #[test] + fn test_challenge_flow() { + let app = TicTacToeApp::new(); + + // Outgoing challenge + let out = app.handle_outgoing("s1", CMD_CHALLENGE, &HashMap::new(), "alice"); + assert_eq!(out.fallback_text, "[LRGP TTT] Sent a challenge!"); + + let sess = app.get_session("s1", "alice").unwrap(); + assert_eq!(sess.status, STATUS_PENDING); + assert_eq!(sess.metadata["my_marker"], "X"); + + // Incoming challenge on other side + let result = app.handle_incoming("s1", CMD_CHALLENGE, &HashMap::new(), "alice", "bob"); + assert!(result.error.is_none()); + + let sess = app.get_session("s1", "bob").unwrap(); + assert_eq!(sess.status, STATUS_PENDING); + assert_eq!(sess.metadata["my_marker"], "O"); + } + + #[test] + fn test_accept_flow() { + let app = TicTacToeApp::new(); + + // Setup: challenge + app.handle_outgoing("s1", CMD_CHALLENGE, &HashMap::new(), "alice"); + app.handle_incoming("s1", CMD_CHALLENGE, &HashMap::new(), "alice", "bob"); + + // Bob accepts (outgoing) + let out = app.handle_outgoing("s1", CMD_ACCEPT, &HashMap::new(), "bob"); + assert_eq!(out.fallback_text, "[LRGP TTT] Challenge accepted"); + + let sess = app.get_session("s1", "bob").unwrap(); + assert_eq!(sess.status, STATUS_ACTIVE); + + // Alice receives accept (incoming) + let result = app.handle_incoming("s1", CMD_ACCEPT, &out.payload, "bob", "alice"); + assert!(result.error.is_none()); + + let sess = app.get_session("s1", "alice").unwrap(); + assert_eq!(sess.status, STATUS_ACTIVE); + } + + #[test] + fn test_decline_flow() { + let app = TicTacToeApp::new(); + + app.handle_outgoing("s1", CMD_CHALLENGE, &HashMap::new(), "alice"); + app.handle_incoming("s1", CMD_CHALLENGE, &HashMap::new(), "alice", "bob"); + + let out = app.handle_outgoing("s1", CMD_DECLINE, &HashMap::new(), "bob"); + assert_eq!(out.fallback_text, "[LRGP TTT] Challenge declined"); + + let sess = app.get_session("s1", "bob").unwrap(); + assert_eq!(sess.status, STATUS_DECLINED); + } + + #[test] + fn test_full_game_x_wins() { + let app = TicTacToeApp::new(); + let x = "x_player"; + let o = "o_player"; + + // Challenge + accept + app.handle_outgoing("g1", CMD_CHALLENGE, &HashMap::new(), x); + { + let mut sessions = app.sessions.lock().unwrap(); + sessions.get_mut(&("g1".into(), x.into())).unwrap().contact_hash = o.to_string(); + } + app.handle_incoming("g1", CMD_CHALLENGE, &HashMap::new(), x, o); + let accept_out = app.handle_outgoing("g1", CMD_ACCEPT, &HashMap::new(), o); + app.handle_incoming("g1", CMD_ACCEPT, &accept_out.payload, o, x); + + // Move 1: X plays center (4) + let mut p = HashMap::new(); + p.insert("i".into(), rmpv::Value::Integer(4.into())); + let m1 = app.handle_outgoing("g1", CMD_MOVE, &p, x); + assert!(value_as_str(m1.payload.get("x").unwrap()).unwrap().is_empty()); + app.handle_incoming("g1", CMD_MOVE, &m1.payload, x, o); + + // Move 2: O plays top-left (0) + let mut p = HashMap::new(); + p.insert("i".into(), rmpv::Value::Integer(0.into())); + let m2 = app.handle_outgoing("g1", CMD_MOVE, &p, o); + app.handle_incoming("g1", CMD_MOVE, &m2.payload, o, x); + + // Move 3: X plays top-right (2) + let mut p = HashMap::new(); + p.insert("i".into(), rmpv::Value::Integer(2.into())); + let m3 = app.handle_outgoing("g1", CMD_MOVE, &p, x); + app.handle_incoming("g1", CMD_MOVE, &m3.payload, x, o); + + // Move 4: O plays bottom-left (6) + let mut p = HashMap::new(); + p.insert("i".into(), rmpv::Value::Integer(6.into())); + let m4 = app.handle_outgoing("g1", CMD_MOVE, &p, o); + app.handle_incoming("g1", CMD_MOVE, &m4.payload, o, x); + + // Move 5: X plays (5) + let mut p = HashMap::new(); + p.insert("i".into(), rmpv::Value::Integer(5.into())); + let m5 = app.handle_outgoing("g1", CMD_MOVE, &p, x); + app.handle_incoming("g1", CMD_MOVE, &m5.payload, x, o); + + // Move 6: O plays (1) + let mut p = HashMap::new(); + p.insert("i".into(), rmpv::Value::Integer(1.into())); + let m6 = app.handle_outgoing("g1", CMD_MOVE, &p, o); + app.handle_incoming("g1", CMD_MOVE, &m6.payload, o, x); + + // Move 7: X plays (3) → row 3,4,5 = X,X,X → WIN! + let mut p = HashMap::new(); + p.insert("i".into(), rmpv::Value::Integer(3.into())); + let m7 = app.handle_outgoing("g1", CMD_MOVE, &p, x); + assert_eq!(value_as_str(m7.payload.get("x").unwrap()).unwrap(), "win"); + assert!(m7.fallback_text.contains("wins")); + + let sess = app.get_session("g1", x).unwrap(); + assert_eq!(sess.status, STATUS_COMPLETED); + } + + #[test] + fn test_resign() { + let app = TicTacToeApp::new(); + + // Setup active game + app.handle_outgoing("g1", CMD_CHALLENGE, &HashMap::new(), "alice"); + { + let mut sessions = app.sessions.lock().unwrap(); + sessions.get_mut(&("g1".into(), "alice".into())).unwrap().contact_hash = "bob".to_string(); + } + app.handle_incoming("g1", CMD_CHALLENGE, &HashMap::new(), "alice", "bob"); + let accept = app.handle_outgoing("g1", CMD_ACCEPT, &HashMap::new(), "bob"); + app.handle_incoming("g1", CMD_ACCEPT, &accept.payload, "bob", "alice"); + + // Alice resigns + let out = app.handle_outgoing("g1", CMD_RESIGN, &HashMap::new(), "alice"); + assert_eq!(out.fallback_text, "[LRGP TTT] Resigned."); + + let sess = app.get_session("g1", "alice").unwrap(); + assert_eq!(sess.status, STATUS_COMPLETED); + assert_eq!(sess.metadata["terminal"], "resign"); + assert_eq!(sess.metadata["winner"], "bob"); // opponent wins + } + + #[test] + fn test_draw_negotiation() { + let app = TicTacToeApp::new(); + + app.handle_outgoing("g1", CMD_CHALLENGE, &HashMap::new(), "alice"); + app.handle_incoming("g1", CMD_CHALLENGE, &HashMap::new(), "alice", "bob"); + let accept = app.handle_outgoing("g1", CMD_ACCEPT, &HashMap::new(), "bob"); + app.handle_incoming("g1", CMD_ACCEPT, &accept.payload, "bob", "alice"); + + // Bob offers draw + let result = app.handle_incoming("g1", CMD_DRAW_OFFER, &HashMap::new(), "bob", "alice"); + assert!(result.error.is_none()); + let sess = app.get_session("g1", "alice").unwrap(); + assert_eq!(sess.metadata["draw_offered"], true); + + // Alice accepts draw + let out = app.handle_outgoing("g1", CMD_DRAW_ACCEPT, &HashMap::new(), "alice"); + assert_eq!(out.fallback_text, "[LRGP TTT] Draw accepted"); + let sess = app.get_session("g1", "alice").unwrap(); + assert_eq!(sess.status, STATUS_COMPLETED); + assert_eq!(sess.metadata["terminal"], "draw"); + } + + #[test] + fn test_render_fallback() { + let app = TicTacToeApp::new(); + + assert_eq!( + app.render_fallback(CMD_CHALLENGE, &HashMap::new()), + "[LRGP TTT] Sent a challenge!" + ); + assert_eq!( + app.render_fallback(CMD_RESIGN, &HashMap::new()), + "[LRGP TTT] Resigned." + ); + + let mut p = HashMap::new(); + p.insert("n".to_string(), rmpv::Value::Integer(3.into())); + p.insert("x".to_string(), rmpv::Value::String("".into())); + assert_eq!(app.render_fallback(CMD_MOVE, &p), "[LRGP TTT] Move 3"); + + let mut p = HashMap::new(); + p.insert("n".to_string(), rmpv::Value::Integer(5.into())); + p.insert("x".to_string(), rmpv::Value::String("win".into())); + assert_eq!(app.render_fallback(CMD_MOVE, &p), "[LRGP TTT] X wins!"); + } + + #[test] + fn test_validate_action_no_session() { + let app = TicTacToeApp::new(); + let (valid, _) = app.validate_action("nope", CMD_CHALLENGE, &HashMap::new(), "x"); + assert!(valid); + + let (valid, msg) = app.validate_action("nope", CMD_MOVE, &HashMap::new(), "x"); + assert!(!valid); + assert!(msg.unwrap().contains("not found")); + } +} diff --git a/src/constants.rs b/src/constants.rs new file mode 100644 index 0000000..51988cf --- /dev/null +++ b/src/constants.rs @@ -0,0 +1,68 @@ +/// LXMF field IDs. +pub const FIELD_CUSTOM_TYPE: u8 = 0xFB; // 251 +pub const FIELD_CUSTOM_META: u8 = 0xFD; // 253 +pub const FIELD_FILE_ATTACHMENTS: u8 = 0x05; + +/// Protocol marker stored in FIELD_CUSTOM_TYPE. +pub const PROTOCOL_TYPE: &str = "lrgp.v1"; +/// Legacy markers recognized on inbound messages. +pub const LEGACY_TYPES: &[&str] = &["rlap.v1", "ratspeak.game"]; + +/// Size limits (bytes). +pub const ENVELOPE_MAX_PACKED: usize = 200; +pub const OPPORTUNISTIC_MAX_CONTENT: usize = 295; +pub const LINK_PACKET_MAX_CONTENT: usize = 319; +/// 16B dest + 16B src + 64B sig + 8B ts + 8B structure. +pub const LXMF_OVERHEAD: usize = 112; + +/// Session statuses. +pub const STATUS_PENDING: &str = "pending"; +pub const STATUS_ACTIVE: &str = "active"; +pub const STATUS_COMPLETED: &str = "completed"; +pub const STATUS_EXPIRED: &str = "expired"; +pub const STATUS_DECLINED: &str = "declined"; + +/// Game session types. +pub const SESSION_TURN_BASED: &str = "turn_based"; +pub const SESSION_REAL_TIME: &str = "real_time"; +pub const SESSION_ROUND_BASED: &str = "round_based"; +pub const SESSION_SINGLE_ROUND: &str = "single_round"; + +/// Validation models. +pub const VALIDATION_SENDER: &str = "sender"; +pub const VALIDATION_RECEIVER: &str = "receiver"; +pub const VALIDATION_BOTH: &str = "both"; + +/// Standard commands. +pub const CMD_CHALLENGE: &str = "challenge"; +pub const CMD_ACCEPT: &str = "accept"; +pub const CMD_DECLINE: &str = "decline"; +pub const CMD_MOVE: &str = "move"; +pub const CMD_RESIGN: &str = "resign"; +pub const CMD_DRAW_OFFER: &str = "draw_offer"; +pub const CMD_DRAW_ACCEPT: &str = "draw_accept"; +pub const CMD_DRAW_DECLINE: &str = "draw_decline"; +pub const CMD_ERROR: &str = "error"; + +/// Standard error codes. +pub const ERR_UNSUPPORTED_APP: &str = "unsupported_app"; +pub const ERR_INVALID_MOVE: &str = "invalid_move"; +pub const ERR_NOT_YOUR_TURN: &str = "not_your_turn"; +pub const ERR_SESSION_EXPIRED: &str = "session_expired"; +pub const ERR_PROTOCOL_ERROR: &str = "protocol_error"; + +/// Session TTL defaults (seconds). +pub const TTL_PENDING: f64 = 86400.0; // 24 hours +pub const TTL_ACTIVE: f64 = 604800.0; // 7 days +pub const TTL_GRACE_PERIOD: f64 = 3600.0; // 1 hour clock-skew tolerance + +/// Envelope keys (single-char for wire efficiency). +pub const KEY_APP: &str = "a"; +pub const KEY_COMMAND: &str = "c"; +pub const KEY_SESSION: &str = "s"; +pub const KEY_PAYLOAD: &str = "p"; + +/// Error payload keys. +pub const KEY_ERR_CODE: &str = "code"; +pub const KEY_ERR_MSG: &str = "msg"; +pub const KEY_ERR_REF: &str = "ref"; diff --git a/src/envelope.rs b/src/envelope.rs new file mode 100644 index 0000000..317e171 --- /dev/null +++ b/src/envelope.rs @@ -0,0 +1,350 @@ +/// LRGP envelope packing, unpacking, and validation. + +use std::collections::HashMap; + +use crate::constants::*; +use crate::errors::LrgpError; + +/// An LRGP envelope — the top-level dict stored in LXMF field 0xFD. +pub type Envelope = HashMap; + +/// Convenience re-export of rmpv::Value for payload manipulation. +pub use rmpv::Value; + +/// Build an LRGP envelope dict. +pub fn pack_envelope( + app_id: &str, + version: u32, + command: &str, + session_id: &str, + payload: Option>, +) -> Envelope { + let mut env = Envelope::new(); + env.insert(KEY_APP.into(), rmpv::Value::String(format!("{app_id}.{version}").into())); + env.insert(KEY_COMMAND.into(), rmpv::Value::String(command.into())); + env.insert(KEY_SESSION.into(), rmpv::Value::String(session_id.into())); + env.insert( + KEY_PAYLOAD.into(), + match payload { + Some(p) => value_from_map(p), + None => rmpv::Value::Map(vec![]), + }, + ); + env +} + +/// Validate that the packed envelope fits within ENVELOPE_MAX_PACKED. +/// Returns the packed size in bytes. +pub fn validate_envelope_size(envelope: &Envelope) -> Result { + let packed = pack_to_bytes(envelope)?; + let size = packed.len(); + if size > ENVELOPE_MAX_PACKED { + return Err(LrgpError::EnvelopeTooLarge(size, ENVELOPE_MAX_PACKED)); + } + Ok(size) +} + +/// Return LXMF fields dict ready for inclusion in an LxMessage. +/// Returns `{0xFB: "lrgp.v1", 0xFD: envelope}` as a HashMap. +pub fn pack_lxmf_fields(envelope: &Envelope) -> HashMap { + let mut fields = HashMap::new(); + fields.insert( + FIELD_CUSTOM_TYPE, + rmpv::Value::String(PROTOCOL_TYPE.into()), + ); + fields.insert(FIELD_CUSTOM_META, value_from_map(envelope.clone())); + fields +} + +/// Extract and validate an LRGP envelope from LXMF fields. +/// Returns `None` if this is not an LRGP (or legacy RLAP) message. +pub fn unpack_envelope(fields: &HashMap) -> Result, LrgpError> { + let custom_type = fields.get(&FIELD_CUSTOM_TYPE); + let is_lrgp = match custom_type { + Some(rmpv::Value::String(s)) => { + let marker = s.as_str().unwrap_or(""); + marker == PROTOCOL_TYPE || LEGACY_TYPES.contains(&marker) + } + _ => false, + }; + if !is_lrgp { + return Ok(None); + } + + let meta = fields + .get(&FIELD_CUSTOM_META) + .ok_or_else(|| LrgpError::InvalidEnvelope("FIELD_CUSTOM_META is missing".into()))?; + + let envelope = map_from_value(meta) + .ok_or_else(|| LrgpError::InvalidEnvelope("FIELD_CUSTOM_META is not a map".into()))?; + + // Check required keys + for key in &[KEY_APP, KEY_COMMAND, KEY_SESSION, KEY_PAYLOAD] { + if !envelope.contains_key(*key) { + return Err(LrgpError::InvalidEnvelope(format!( + "Missing envelope key: {key}" + ))); + } + } + + // Validate app.version format + let app_ver = envelope + .get(KEY_APP) + .and_then(|v| match v { + rmpv::Value::String(s) => s.as_str().map(|s| s.to_string()), + _ => None, + }) + .ok_or_else(|| LrgpError::InvalidEnvelope("KEY_APP is not a string".into()))?; + + if !app_ver.contains('.') { + return Err(LrgpError::InvalidEnvelope(format!( + "Invalid app.version format: {app_ver:?}" + ))); + } + + Ok(Some(envelope)) +} + +/// Split "app_id.version" into (app_id, version). +pub fn parse_app_version(app_ver_string: &str) -> Option<(&str, u32)> { + let dot = app_ver_string.rfind('.')?; + let app_id = &app_ver_string[..dot]; + let version: u32 = app_ver_string[dot + 1..].parse().ok()?; + Some((app_id, version)) +} + +// --- Helpers for rmpv::Value ↔ HashMap conversion --- + +/// Convert a HashMap into an rmpv::Value::Map. +pub fn value_from_map(map: HashMap) -> rmpv::Value { + let pairs: Vec<(rmpv::Value, rmpv::Value)> = map + .into_iter() + .map(|(k, v)| (rmpv::Value::String(k.into()), v)) + .collect(); + rmpv::Value::Map(pairs) +} + +/// Try to convert an rmpv::Value::Map into a HashMap. +pub fn map_from_value(value: &rmpv::Value) -> Option> { + match value { + rmpv::Value::Map(pairs) => { + let mut map = HashMap::new(); + for (k, v) in pairs { + let key = match k { + rmpv::Value::String(s) => s.as_str()?.to_string(), + _ => return None, + }; + map.insert(key, v.clone()); + } + Some(map) + } + _ => None, + } +} + +/// Serialize an Envelope to msgpack bytes using rmpv. +pub fn pack_to_bytes(envelope: &Envelope) -> Result, LrgpError> { + let value = value_from_map(envelope.clone()); + let mut buf = Vec::new(); + rmpv::encode::write_value(&mut buf, &value) + .map_err(|e| LrgpError::InvalidEnvelope(format!("msgpack encode error: {e}")))?; + Ok(buf) +} + +/// Deserialize msgpack bytes into an Envelope. +pub fn unpack_from_bytes(data: &[u8]) -> Result { + let mut cursor = std::io::Cursor::new(data); + let value = rmpv::decode::read_value(&mut cursor) + .map_err(|e| LrgpError::InvalidEnvelope(format!("msgpack decode error: {e}")))?; + map_from_value(&value) + .ok_or_else(|| LrgpError::InvalidEnvelope("top-level value is not a map".into())) +} + +/// Helper: get a string from an rmpv::Value. +pub fn value_as_str(v: &rmpv::Value) -> Option<&str> { + match v { + rmpv::Value::String(s) => s.as_str(), + _ => None, + } +} + +/// Helper: get a u64 from an rmpv::Value. +pub fn value_as_u64(v: &rmpv::Value) -> Option { + match v { + rmpv::Value::Integer(i) => i.as_u64(), + _ => None, + } +} + +/// Helper: get an i64 from an rmpv::Value. +pub fn value_as_i64(v: &rmpv::Value) -> Option { + match v { + rmpv::Value::Integer(i) => i.as_i64(), + _ => None, + } +} + +/// Helper: get a bool from an rmpv::Value. +pub fn value_as_bool(v: &rmpv::Value) -> Option { + match v { + rmpv::Value::Boolean(b) => Some(*b), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pack_unpack_roundtrip() { + let mut payload = HashMap::new(); + payload.insert("i".to_string(), rmpv::Value::Integer(4.into())); + payload.insert( + "b".to_string(), + rmpv::Value::String("____X____".into()), + ); + + let env = pack_envelope("ttt", 1, "move", "a1b2c3d4e5f6g7h8", Some(payload)); + + let bytes = pack_to_bytes(&env).unwrap(); + let recovered = unpack_from_bytes(&bytes).unwrap(); + + assert_eq!( + value_as_str(recovered.get(KEY_APP).unwrap()).unwrap(), + "ttt.1" + ); + assert_eq!( + value_as_str(recovered.get(KEY_COMMAND).unwrap()).unwrap(), + "move" + ); + assert_eq!( + value_as_str(recovered.get(KEY_SESSION).unwrap()).unwrap(), + "a1b2c3d4e5f6g7h8" + ); + } + + #[test] + fn test_validate_envelope_size_ok() { + let env = pack_envelope("ttt", 1, "challenge", "a1b2c3d4e5f6g7h8", None); + let size = validate_envelope_size(&env).unwrap(); + assert!(size <= ENVELOPE_MAX_PACKED); + } + + #[test] + fn test_validate_envelope_size_too_large() { + let mut payload = HashMap::new(); + // Create a huge payload to exceed the limit + let big_string = "x".repeat(300); + payload.insert("data".to_string(), rmpv::Value::String(big_string.into())); + + let env = pack_envelope("ttt", 1, "move", "a1b2c3d4e5f6g7h8", Some(payload)); + assert!(matches!( + validate_envelope_size(&env), + Err(LrgpError::EnvelopeTooLarge(_, _)) + )); + } + + #[test] + fn test_parse_app_version() { + let (app, ver) = parse_app_version("ttt.1").unwrap(); + assert_eq!(app, "ttt"); + assert_eq!(ver, 1); + + let (app, ver) = parse_app_version("chess.game.2").unwrap(); + assert_eq!(app, "chess.game"); + assert_eq!(ver, 2); + } + + #[test] + fn test_unpack_envelope_not_lrgp() { + let fields = HashMap::new(); + assert!(unpack_envelope(&fields).unwrap().is_none()); + } + + #[test] + fn test_unpack_envelope_valid() { + let env = pack_envelope("ttt", 1, "challenge", "abc123", None); + let lxmf_fields = pack_lxmf_fields(&env); + let result = unpack_envelope(&lxmf_fields).unwrap().unwrap(); + assert_eq!( + value_as_str(result.get(KEY_COMMAND).unwrap()).unwrap(), + "challenge" + ); + } + + #[test] + fn test_unpack_envelope_legacy_rlap() { + // Simulate a legacy rlap.v1 message — should still be recognized + let mut lxmf = HashMap::new(); + lxmf.insert( + FIELD_CUSTOM_TYPE, + rmpv::Value::String("rlap.v1".into()), + ); + let env = pack_envelope("ttt", 1, "challenge", "abc123", None); + lxmf.insert(FIELD_CUSTOM_META, value_from_map(env)); + let result = unpack_envelope(&lxmf).unwrap(); + assert!(result.is_some()); + } + + #[test] + fn test_unpack_envelope_missing_key() { + let mut lxmf = HashMap::new(); + lxmf.insert( + FIELD_CUSTOM_TYPE, + rmpv::Value::String(PROTOCOL_TYPE.into()), + ); + // FIELD_CUSTOM_META has a map missing required keys + let bad_map = rmpv::Value::Map(vec![( + rmpv::Value::String("a".into()), + rmpv::Value::String("ttt.1".into()), + )]); + lxmf.insert(FIELD_CUSTOM_META, bad_map); + assert!(unpack_envelope(&lxmf).is_err()); + } + + #[test] + fn test_vector_challenge() { + let data = include_bytes!("../tests/ttt_challenge.bin"); + let env = unpack_from_bytes(data).unwrap(); + assert_eq!(value_as_str(env.get("a").unwrap()).unwrap(), "ttt.1"); + assert_eq!(value_as_str(env.get("c").unwrap()).unwrap(), "challenge"); + assert_eq!( + value_as_str(env.get("s").unwrap()).unwrap(), + "a1b2c3d4e5f6g7h8" + ); + } + + #[test] + fn test_vector_move() { + let data = include_bytes!("../tests/ttt_move.bin"); + let env = unpack_from_bytes(data).unwrap(); + assert_eq!(value_as_str(env.get("c").unwrap()).unwrap(), "move"); + let payload = map_from_value(env.get("p").unwrap()).unwrap(); + assert_eq!(value_as_u64(payload.get("i").unwrap()).unwrap(), 4); + assert_eq!( + value_as_str(payload.get("b").unwrap()).unwrap(), + "____X____" + ); + assert_eq!(value_as_u64(payload.get("n").unwrap()).unwrap(), 1); + } + + #[test] + fn test_vector_move_win() { + let data = include_bytes!("../tests/ttt_move_win.bin"); + let env = unpack_from_bytes(data).unwrap(); + assert_eq!(value_as_str(env.get("c").unwrap()).unwrap(), "move"); + let payload = map_from_value(env.get("p").unwrap()).unwrap(); + assert_eq!(value_as_u64(payload.get("i").unwrap()).unwrap(), 2); + assert_eq!( + value_as_str(payload.get("b").unwrap()).unwrap(), + "XXX_OO___" + ); + assert_eq!(value_as_u64(payload.get("n").unwrap()).unwrap(), 5); + assert_eq!(value_as_str(payload.get("x").unwrap()).unwrap(), "win"); + assert_eq!( + value_as_str(payload.get("w").unwrap()).unwrap(), + "abcdef0123456789" + ); + } +} diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..083c137 --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,21 @@ +/// LRGP error hierarchy. +#[derive(Debug, thiserror::Error)] +pub enum LrgpError { + #[error("envelope too large: {0} bytes (max {1})")] + EnvelopeTooLarge(usize, usize), + + #[error("invalid envelope: {0}")] + InvalidEnvelope(String), + + #[error("illegal transition: cannot apply '{command}' to session in '{status}' state")] + IllegalTransition { command: String, status: String }, + + #[error("unknown game: {0}")] + UnknownApp(String), + + #[error("validation error [{code}]: {message}")] + Validation { code: String, message: String }, + + #[error("store error: {0}")] + Store(String), +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..b586706 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,9 @@ +pub mod constants; +pub mod errors; +pub mod envelope; +pub mod session; +pub mod app_base; +pub mod router; +pub mod store; +pub mod transport; +pub mod apps; diff --git a/src/router.rs b/src/router.rs new file mode 100644 index 0000000..f3b222c --- /dev/null +++ b/src/router.rs @@ -0,0 +1,248 @@ +/// LRGP game router — registry for game implementations and dispatch of +/// incoming/outgoing game messages. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use crate::app_base::{GameApp, GameManifest, IncomingResult, OutgoingResult}; +use crate::constants::*; +use crate::envelope::{self, Envelope}; +use crate::errors::LrgpError; + +/// Thread-safe registry of LRGP game implementations. +pub struct LrgpRouter { + apps: Mutex>>, +} + +impl LrgpRouter { + pub fn new() -> Self { + Self { + apps: Mutex::new(HashMap::new()), + } + } + + /// Register a game implementation. + pub fn register(&self, app: Box) { + let id = app.app_id().to_string(); + let arc: Arc = Arc::from(app); + self.apps.lock().unwrap().insert(id, arc); + } + + /// List manifests for all registered games. + pub fn list_apps(&self) -> Vec { + let apps = self.apps.lock().unwrap(); + apps.values().map(|a| a.manifest()).collect() + } + + /// Execute a callback on a registered game by app_id. + pub fn with_app(&self, app_id: &str, f: F) -> Option + where + F: FnOnce(&dyn GameApp) -> R, + { + let apps = self.apps.lock().unwrap(); + apps.get(app_id).map(|app| f(app.as_ref())) + } + + /// Dispatch an incoming LRGP envelope to the appropriate game. + pub fn dispatch_incoming( + &self, + envelope: &Envelope, + sender_hash: &str, + identity_id: &str, + ) -> Result { + let app_ver = envelope + .get(KEY_APP) + .and_then(|v| envelope::value_as_str(v)) + .ok_or_else(|| LrgpError::InvalidEnvelope("missing 'a' key".into()))?; + + let (app_id, _version) = envelope::parse_app_version(app_ver) + .ok_or_else(|| LrgpError::InvalidEnvelope("invalid app.version format".into()))?; + + let command = envelope + .get(KEY_COMMAND) + .and_then(|v| envelope::value_as_str(v)) + .ok_or_else(|| LrgpError::InvalidEnvelope("missing 'c' key".into()))?; + + let session_id = envelope + .get(KEY_SESSION) + .and_then(|v| envelope::value_as_str(v)) + .ok_or_else(|| LrgpError::InvalidEnvelope("missing 's' key".into()))?; + + let payload: HashMap = envelope + .get(KEY_PAYLOAD) + .and_then(envelope::map_from_value) + .unwrap_or_default(); + + let apps = self.apps.lock().unwrap(); + let app = apps + .get(app_id) + .ok_or_else(|| LrgpError::UnknownApp(app_id.to_string()))?; + + Ok(app.handle_incoming(session_id, command, &payload, sender_hash, identity_id)) + } + + /// Dispatch an outgoing action: build envelope + payload for sending. + pub fn dispatch_outgoing( + &self, + app_id: &str, + version: u32, + command: &str, + session_id: &str, + payload: &HashMap, + identity_id: &str, + ) -> Result<(Envelope, String), LrgpError> { + let apps = self.apps.lock().unwrap(); + let app = apps + .get(app_id) + .ok_or_else(|| LrgpError::UnknownApp(app_id.to_string()))?; + + let result: OutgoingResult = + app.handle_outgoing(session_id, command, payload, identity_id); + + let env = envelope::pack_envelope(app_id, version, command, session_id, Some(result.payload)); + Ok((env, result.fallback_text)) + } +} + +impl Default for LrgpRouter { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_base::*; + use serde_json::Value as JsonValue; + + /// Minimal mock game for testing the router. + struct MockGame; + impl GameApp for MockGame { + fn app_id(&self) -> &str { + "mock" + } + fn version(&self) -> u32 { + 1 + } + fn manifest(&self) -> GameManifest { + GameManifest { + app_id: "mock".into(), + version: 1, + display_name: "Mock Game".into(), + icon: "mock".into(), + session_type: SESSION_TURN_BASED.into(), + max_players: 2, + min_players: 2, + validation: VALIDATION_BOTH.into(), + actions: vec![CMD_CHALLENGE.into(), CMD_MOVE.into()], + preferred_delivery: HashMap::new(), + ttl: HashMap::new(), + genre: Some("test".into()), + turn_timeout: None, + } + } + fn handle_incoming( + &self, + _session_id: &str, + command: &str, + _payload: &HashMap, + _sender_hash: &str, + _identity_id: &str, + ) -> IncomingResult { + IncomingResult { + session: None, + emit: Some({ + let mut m = HashMap::new(); + m.insert("type".into(), JsonValue::String(command.into())); + m + }), + error: None, + } + } + fn handle_outgoing( + &self, + _session_id: &str, + command: &str, + _payload: &HashMap, + _identity_id: &str, + ) -> OutgoingResult { + OutgoingResult { + payload: HashMap::new(), + fallback_text: format!("[LRGP Mock] {command}"), + } + } + fn validate_action( + &self, + _session_id: &str, + _command: &str, + _payload: &HashMap, + _sender_hash: &str, + ) -> (bool, Option) { + (true, None) + } + fn get_session_state( + &self, + _session_id: &str, + _identity_id: &str, + ) -> HashMap { + HashMap::new() + } + fn render_fallback( + &self, + command: &str, + _payload: &HashMap, + ) -> String { + format!("[LRGP Mock] {command}") + } + } + + #[test] + fn test_register_and_list() { + let router = LrgpRouter::new(); + router.register(Box::new(MockGame)); + let apps = router.list_apps(); + assert_eq!(apps.len(), 1); + assert_eq!(apps[0].app_id, "mock"); + assert_eq!(apps[0].genre, Some("test".into())); + } + + #[test] + fn test_dispatch_incoming() { + let router = LrgpRouter::new(); + router.register(Box::new(MockGame)); + + let env = envelope::pack_envelope("mock", 1, "challenge", "sess1", None); + let result = router.dispatch_incoming(&env, "sender", "local").unwrap(); + assert!(result.error.is_none()); + assert!(result.emit.is_some()); + } + + #[test] + fn test_dispatch_incoming_unknown_app() { + let router = LrgpRouter::new(); + let env = envelope::pack_envelope("unknown", 1, "challenge", "sess1", None); + let result = router.dispatch_incoming(&env, "sender", "local"); + assert!(matches!(result, Err(LrgpError::UnknownApp(_)))); + } + + #[test] + fn test_dispatch_outgoing() { + let router = LrgpRouter::new(); + router.register(Box::new(MockGame)); + + let (env, fallback) = router + .dispatch_outgoing("mock", 1, "challenge", "sess1", &HashMap::new(), "local") + .unwrap(); + assert!(env.contains_key(KEY_APP)); + assert_eq!(fallback, "[LRGP Mock] challenge"); + } + + #[test] + fn test_with_app() { + let router = LrgpRouter::new(); + router.register(Box::new(MockGame)); + let result = router.with_app("mock", |app| app.manifest().display_name); + assert_eq!(result, Some("Mock Game".to_string())); + } +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..7980685 --- /dev/null +++ b/src/session.rs @@ -0,0 +1,260 @@ +/// LRGP game session state machine and lifecycle. + +use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::constants::*; +use crate::errors::LrgpError; + +fn now() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64() +} + +/// An LRGP game session record. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Session { + pub session_id: String, + pub identity_id: String, + pub app_id: String, + pub app_version: u32, + pub contact_hash: String, + pub initiator: String, + pub status: String, + pub metadata: HashMap, + pub unread: i64, + pub created_at: f64, + pub updated_at: f64, + pub last_action_at: f64, +} + +impl Session { + pub fn new(session_id: impl Into) -> Self { + let now = now(); + Self { + session_id: session_id.into(), + identity_id: String::new(), + app_id: String::new(), + app_version: 1, + contact_hash: String::new(), + initiator: String::new(), + status: STATUS_PENDING.to_string(), + metadata: HashMap::new(), + unread: 0, + created_at: now, + updated_at: now, + last_action_at: now, + } + } +} + +/// Enforces legal game session state transitions. +pub struct SessionStateMachine; + +impl SessionStateMachine { + /// Apply a command to a session, updating its status if appropriate. + /// If `terminal` is true, the action ends the session (e.g., winning move). + pub fn apply_command( + session: &mut Session, + command: &str, + terminal: bool, + ) -> Result { + let current = session.status.as_str(); + let t = now(); + + // Check for explicit transitions + if let Some(new_status) = Self::get_transition(current, command) { + session.status = new_status.to_string(); + session.updated_at = t; + session.last_action_at = t; + return Ok(session.status.clone()); + } + + // Check for same-status commands + if Self::is_same_status_command(current, command) { + if terminal { + session.status = STATUS_COMPLETED.to_string(); + } + session.updated_at = t; + session.last_action_at = t; + return Ok(session.status.clone()); + } + + // Challenge creates a new session (pending) + if command == CMD_CHALLENGE && current == STATUS_PENDING { + session.updated_at = t; + session.last_action_at = t; + return Ok(session.status.clone()); + } + + Err(LrgpError::IllegalTransition { + command: command.to_string(), + status: current.to_string(), + }) + } + + /// Check if a session has expired based on its TTL. + /// Returns `true` if the session expired (and updates session.status). + pub fn check_expiry( + session: &mut Session, + ttl: Option<&HashMap>, + now_override: Option, + ) -> bool { + let status = session.status.as_str(); + if matches!( + status, + STATUS_COMPLETED | STATUS_EXPIRED | STATUS_DECLINED + ) { + return false; + } + + let t = now_override.unwrap_or_else(now); + + let limit = match status { + STATUS_PENDING => ttl + .and_then(|m| m.get(STATUS_PENDING).copied()) + .unwrap_or(TTL_PENDING), + STATUS_ACTIVE => ttl + .and_then(|m| m.get(STATUS_ACTIVE).copied()) + .unwrap_or(TTL_ACTIVE), + _ => return false, + }; + + let deadline = session.last_action_at + limit + TTL_GRACE_PERIOD; + if t > deadline { + session.status = STATUS_EXPIRED.to_string(); + session.updated_at = t; + return true; + } + + false + } + + fn get_transition(current: &str, command: &str) -> Option<&'static str> { + match (current, command) { + (STATUS_PENDING, CMD_ACCEPT) => Some(STATUS_ACTIVE), + (STATUS_PENDING, CMD_DECLINE) => Some(STATUS_DECLINED), + (STATUS_ACTIVE, CMD_RESIGN) => Some(STATUS_COMPLETED), + (STATUS_ACTIVE, CMD_DRAW_ACCEPT) => Some(STATUS_COMPLETED), + _ => None, + } + } + + fn is_same_status_command(current: &str, command: &str) -> bool { + if current == STATUS_ACTIVE { + matches!(command, CMD_MOVE | CMD_DRAW_OFFER | CMD_DRAW_DECLINE | CMD_ERROR) + } else { + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_session(status: &str) -> Session { + let mut s = Session::new("test-session"); + s.status = status.to_string(); + s.last_action_at = now(); + s + } + + #[test] + fn test_pending_accept_to_active() { + let mut s = make_session(STATUS_PENDING); + let result = SessionStateMachine::apply_command(&mut s, CMD_ACCEPT, false).unwrap(); + assert_eq!(result, STATUS_ACTIVE); + } + + #[test] + fn test_pending_decline_to_declined() { + let mut s = make_session(STATUS_PENDING); + let result = SessionStateMachine::apply_command(&mut s, CMD_DECLINE, false).unwrap(); + assert_eq!(result, STATUS_DECLINED); + } + + #[test] + fn test_active_resign_to_completed() { + let mut s = make_session(STATUS_ACTIVE); + let result = SessionStateMachine::apply_command(&mut s, CMD_RESIGN, false).unwrap(); + assert_eq!(result, STATUS_COMPLETED); + } + + #[test] + fn test_active_draw_accept_to_completed() { + let mut s = make_session(STATUS_ACTIVE); + let result = SessionStateMachine::apply_command(&mut s, CMD_DRAW_ACCEPT, false).unwrap(); + assert_eq!(result, STATUS_COMPLETED); + } + + #[test] + fn test_active_move_stays_active() { + let mut s = make_session(STATUS_ACTIVE); + let result = SessionStateMachine::apply_command(&mut s, CMD_MOVE, false).unwrap(); + assert_eq!(result, STATUS_ACTIVE); + } + + #[test] + fn test_active_move_terminal_completes() { + let mut s = make_session(STATUS_ACTIVE); + let result = SessionStateMachine::apply_command(&mut s, CMD_MOVE, true).unwrap(); + assert_eq!(result, STATUS_COMPLETED); + } + + #[test] + fn test_challenge_on_pending_stays_pending() { + let mut s = make_session(STATUS_PENDING); + let result = SessionStateMachine::apply_command(&mut s, CMD_CHALLENGE, false).unwrap(); + assert_eq!(result, STATUS_PENDING); + } + + #[test] + fn test_illegal_transition() { + let mut s = make_session(STATUS_COMPLETED); + let result = SessionStateMachine::apply_command(&mut s, CMD_MOVE, false); + assert!(result.is_err()); + } + + #[test] + fn test_check_expiry_pending() { + let mut s = make_session(STATUS_PENDING); + s.last_action_at = 1000.0; // Far in the past + let expired = SessionStateMachine::check_expiry(&mut s, None, Some(1_000_000.0)); + assert!(expired); + assert_eq!(s.status, STATUS_EXPIRED); + } + + #[test] + fn test_check_expiry_active_not_expired() { + let mut s = make_session(STATUS_ACTIVE); + let t = now(); + s.last_action_at = t; + let expired = SessionStateMachine::check_expiry(&mut s, None, Some(t + 100.0)); + assert!(!expired); + assert_eq!(s.status, STATUS_ACTIVE); + } + + #[test] + fn test_check_expiry_completed_ignored() { + let mut s = make_session(STATUS_COMPLETED); + s.last_action_at = 0.0; + let expired = SessionStateMachine::check_expiry(&mut s, None, Some(1_000_000.0)); + assert!(!expired); + } + + #[test] + fn test_check_expiry_custom_ttl() { + let mut s = make_session(STATUS_PENDING); + s.last_action_at = 1000.0; + let mut ttl = HashMap::new(); + ttl.insert(STATUS_PENDING.to_string(), 100.0); + // now = 1000 + 100 + 3600(grace) + 1 = 4701 + let expired = SessionStateMachine::check_expiry(&mut s, Some(&ttl), Some(4701.0)); + assert!(expired); + } +} diff --git a/src/store.rs b/src/store.rs new file mode 100644 index 0000000..1f12bbf --- /dev/null +++ b/src/store.rs @@ -0,0 +1,601 @@ +/// LRGP game store — SQLite persistence for game sessions and action history. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::Mutex; + +use serde_json::Value as JsonValue; + +use crate::errors::LrgpError; + +/// Allowed columns for session updates (prevents SQL injection). +const ALLOWED_COLUMNS: &[&str] = &[ + "status", + "metadata", + "unread", + "updated_at", + "last_action_at", + "contact_hash", + "initiator", +]; + +/// A stored game action. +#[derive(Debug, Clone)] +pub struct Action { + pub session_id: String, + pub identity_id: String, + pub action_num: i64, + pub command: String, + pub payload_json: String, + pub sender: String, + pub timestamp: f64, +} + +/// LRGP game store backed by SQLite. +pub struct LrgpStore { + conn: Mutex, +} + +impl LrgpStore { + /// Open (or create) a game store at the given path. + pub fn open(path: impl AsRef) -> Result { + let conn = rusqlite::Connection::open(path) + .map_err(|e| LrgpError::Store(format!("open error: {e}")))?; + + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;") + .map_err(|e| LrgpError::Store(format!("pragma error: {e}")))?; + + let store = Self { + conn: Mutex::new(conn), + }; + store.init_tables()?; + Ok(store) + } + + /// Open an in-memory store (mainly for testing). + pub fn open_memory() -> Result { + let conn = rusqlite::Connection::open_in_memory() + .map_err(|e| LrgpError::Store(format!("open_in_memory error: {e}")))?; + + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;") + .map_err(|e| LrgpError::Store(format!("pragma error: {e}")))?; + + let store = Self { + conn: Mutex::new(conn), + }; + store.init_tables()?; + Ok(store) + } + + fn init_tables(&self) -> Result<(), LrgpError> { + let conn = self.conn.lock().unwrap(); + conn.execute_batch( + " + CREATE TABLE IF NOT EXISTS game_sessions ( + session_id TEXT NOT NULL, + identity_id TEXT NOT NULL, + app_id TEXT NOT NULL, + app_version INTEGER NOT NULL DEFAULT 1, + contact_hash TEXT NOT NULL DEFAULT '', + initiator TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + metadata TEXT NOT NULL DEFAULT '{}', + unread INTEGER NOT NULL DEFAULT 0, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + last_action_at REAL NOT NULL, + PRIMARY KEY (session_id, identity_id) + ); + + CREATE TABLE IF NOT EXISTS game_actions ( + session_id TEXT NOT NULL, + identity_id TEXT NOT NULL, + action_num INTEGER NOT NULL, + command TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + sender TEXT NOT NULL DEFAULT '', + timestamp REAL NOT NULL, + UNIQUE(session_id, identity_id, action_num) + ); + ", + ) + .map_err(|e| LrgpError::Store(format!("init_tables error: {e}")))?; + Ok(()) + } + + // ──── Sessions ──── + + /// Save a new session. + pub fn save_session( + &self, + session_id: &str, + identity_id: &str, + app_id: &str, + app_version: u32, + contact_hash: &str, + initiator: &str, + status: &str, + metadata: &HashMap, + unread: i64, + created_at: f64, + updated_at: f64, + last_action_at: f64, + ) -> Result<(), LrgpError> { + let conn = self.conn.lock().unwrap(); + let meta_json = serde_json::to_string(metadata) + .map_err(|e| LrgpError::Store(format!("metadata serialization error: {e}")))?; + + conn.execute( + "INSERT OR REPLACE INTO game_sessions + (session_id, identity_id, app_id, app_version, contact_hash, initiator, + status, metadata, unread, created_at, updated_at, last_action_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + rusqlite::params![ + session_id, + identity_id, + app_id, + app_version, + contact_hash, + initiator, + status, + meta_json, + unread, + created_at, + updated_at, + last_action_at, + ], + ) + .map_err(|e| LrgpError::Store(format!("save_session error: {e}")))?; + + Ok(()) + } + + /// Update specific columns of a session (allowlist-validated). + pub fn update_session( + &self, + session_id: &str, + identity_id: &str, + updates: &HashMap, + ) -> Result<(), LrgpError> { + if updates.is_empty() { + return Ok(()); + } + + // Validate all keys against allowlist + for key in updates.keys() { + if !ALLOWED_COLUMNS.contains(&key.as_str()) { + return Err(LrgpError::Store(format!("invalid column: {key}"))); + } + } + + let conn = self.conn.lock().unwrap(); + + let set_clause: Vec = updates + .keys() + .enumerate() + .map(|(i, k)| format!("{k} = ?{}", i + 3)) + .collect(); + let sql = format!( + "UPDATE game_sessions SET {} WHERE session_id = ?1 AND identity_id = ?2", + set_clause.join(", ") + ); + + let mut params: Vec> = Vec::new(); + params.push(Box::new(session_id.to_string())); + params.push(Box::new(identity_id.to_string())); + for key in updates.keys() { + params.push(Box::new(updates[key].clone())); + } + + let params_ref: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + conn.execute(&sql, params_ref.as_slice()) + .map_err(|e| LrgpError::Store(format!("update_session error: {e}")))?; + + Ok(()) + } + + /// Retrieve a session by primary key. + pub fn get_session( + &self, + session_id: &str, + identity_id: &str, + ) -> Result, LrgpError> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare( + "SELECT session_id, identity_id, app_id, app_version, contact_hash, + initiator, status, metadata, unread, created_at, updated_at, + last_action_at + FROM game_sessions WHERE session_id = ?1 AND identity_id = ?2", + ) + .map_err(|e| LrgpError::Store(format!("get_session prepare error: {e}")))?; + + let result = stmt + .query_row(rusqlite::params![session_id, identity_id], |row| { + Ok(session_from_row(row)) + }) + .optional() + .map_err(|e| LrgpError::Store(format!("get_session query error: {e}")))?; + + Ok(result) + } + + /// List sessions, optionally filtered by status and/or identity. + pub fn list_sessions( + &self, + identity_id: Option<&str>, + status: Option<&str>, + app_id: Option<&str>, + ) -> Result, LrgpError> { + let conn = self.conn.lock().unwrap(); + let mut conditions = Vec::new(); + let mut params: Vec> = Vec::new(); + + if let Some(id) = identity_id { + params.push(Box::new(id.to_string())); + conditions.push(format!("identity_id = ?{}", params.len())); + } + if let Some(st) = status { + params.push(Box::new(st.to_string())); + conditions.push(format!("status = ?{}", params.len())); + } + if let Some(ai) = app_id { + params.push(Box::new(ai.to_string())); + conditions.push(format!("app_id = ?{}", params.len())); + } + + let where_clause = if conditions.is_empty() { + String::new() + } else { + format!(" WHERE {}", conditions.join(" AND ")) + }; + + let sql = format!( + "SELECT session_id, identity_id, app_id, app_version, contact_hash, + initiator, status, metadata, unread, created_at, updated_at, + last_action_at + FROM game_sessions{} ORDER BY updated_at DESC", + where_clause + ); + + let mut stmt = conn + .prepare(&sql) + .map_err(|e| LrgpError::Store(format!("list_sessions prepare error: {e}")))?; + + let params_ref: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let rows = stmt + .query_map(params_ref.as_slice(), |row| Ok(session_from_row(row))) + .map_err(|e| LrgpError::Store(format!("list_sessions query error: {e}")))?; + + let mut sessions = Vec::new(); + for row in rows { + sessions.push( + row.map_err(|e| LrgpError::Store(format!("list_sessions row error: {e}")))?, + ); + } + Ok(sessions) + } + + /// Delete a session and its actions. + pub fn delete_session( + &self, + session_id: &str, + identity_id: &str, + ) -> Result<(), LrgpError> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "DELETE FROM game_actions WHERE session_id = ?1 AND identity_id = ?2", + rusqlite::params![session_id, identity_id], + ) + .map_err(|e| LrgpError::Store(format!("delete actions error: {e}")))?; + + conn.execute( + "DELETE FROM game_sessions WHERE session_id = ?1 AND identity_id = ?2", + rusqlite::params![session_id, identity_id], + ) + .map_err(|e| LrgpError::Store(format!("delete session error: {e}")))?; + + Ok(()) + } + + // ──── Actions ──── + + /// Save a game action. + pub fn save_action(&self, action: &Action) -> Result<(), LrgpError> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT OR REPLACE INTO game_actions + (session_id, identity_id, action_num, command, payload_json, sender, timestamp) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + action.session_id, + action.identity_id, + action.action_num, + action.command, + action.payload_json, + action.sender, + action.timestamp, + ], + ) + .map_err(|e| LrgpError::Store(format!("save_action error: {e}")))?; + Ok(()) + } + + /// List all actions for a session, ordered by action_num. + pub fn list_actions( + &self, + session_id: &str, + identity_id: &str, + ) -> Result, LrgpError> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare( + "SELECT session_id, identity_id, action_num, command, + payload_json, sender, timestamp + FROM game_actions + WHERE session_id = ?1 AND identity_id = ?2 + ORDER BY action_num ASC", + ) + .map_err(|e| LrgpError::Store(format!("list_actions prepare error: {e}")))?; + + let rows = stmt + .query_map(rusqlite::params![session_id, identity_id], |row| { + Ok(Action { + session_id: row.get(0)?, + identity_id: row.get(1)?, + action_num: row.get(2)?, + command: row.get(3)?, + payload_json: row.get(4)?, + sender: row.get(5)?, + timestamp: row.get(6)?, + }) + }) + .map_err(|e| LrgpError::Store(format!("list_actions query error: {e}")))?; + + let mut actions = Vec::new(); + for row in rows { + actions + .push(row.map_err(|e| LrgpError::Store(format!("list_actions row error: {e}")))?); + } + Ok(actions) + } + + /// Get the next action number for a session. + pub fn next_action_num( + &self, + session_id: &str, + identity_id: &str, + ) -> Result { + let conn = self.conn.lock().unwrap(); + let max: Option = conn + .query_row( + "SELECT MAX(action_num) FROM game_actions + WHERE session_id = ?1 AND identity_id = ?2", + rusqlite::params![session_id, identity_id], + |row| row.get(0), + ) + .map_err(|e| LrgpError::Store(format!("next_action_num error: {e}")))?; + + Ok(max.unwrap_or(0) + 1) + } + + /// Delete all actions for a session. + pub fn delete_actions( + &self, + session_id: &str, + identity_id: &str, + ) -> Result<(), LrgpError> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "DELETE FROM game_actions WHERE session_id = ?1 AND identity_id = ?2", + rusqlite::params![session_id, identity_id], + ) + .map_err(|e| LrgpError::Store(format!("delete_actions error: {e}")))?; + Ok(()) + } +} + +use rusqlite::OptionalExtension; + +fn session_from_row(row: &rusqlite::Row) -> crate::session::Session { + let metadata_str: String = row.get::<_, String>(7).unwrap_or_else(|_| "{}".into()); + let metadata: HashMap = + serde_json::from_str(&metadata_str).unwrap_or_default(); + + crate::session::Session { + session_id: row.get(0).unwrap_or_default(), + identity_id: row.get(1).unwrap_or_default(), + app_id: row.get(2).unwrap_or_default(), + app_version: row.get::<_, u32>(3).unwrap_or(1), + contact_hash: row.get(4).unwrap_or_default(), + initiator: row.get(5).unwrap_or_default(), + status: row.get(6).unwrap_or_default(), + metadata, + unread: row.get(8).unwrap_or(0), + created_at: row.get(9).unwrap_or(0.0), + updated_at: row.get(10).unwrap_or(0.0), + last_action_at: row.get(11).unwrap_or(0.0), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_store() -> LrgpStore { + LrgpStore::open_memory().unwrap() + } + + #[test] + fn test_save_and_get_session() { + let store = test_store(); + let mut meta = HashMap::new(); + meta.insert("board".into(), JsonValue::String("_________".into())); + + store + .save_session( + "s1", "id1", "ttt", 1, "remote", "id1", "pending", &meta, 0, 1.0, 1.0, 1.0, + ) + .unwrap(); + + let session = store.get_session("s1", "id1").unwrap().unwrap(); + assert_eq!(session.session_id, "s1"); + assert_eq!(session.app_id, "ttt"); + assert_eq!(session.status, "pending"); + assert_eq!( + session.metadata.get("board").unwrap().as_str().unwrap(), + "_________" + ); + } + + #[test] + fn test_update_session() { + let store = test_store(); + store + .save_session( + "s1", + "id1", + "ttt", + 1, + "remote", + "id1", + "pending", + &HashMap::new(), + 0, + 1.0, + 1.0, + 1.0, + ) + .unwrap(); + + let mut updates = HashMap::new(); + updates.insert("status".into(), "active".into()); + updates.insert("unread".into(), "1".into()); + store.update_session("s1", "id1", &updates).unwrap(); + + let session = store.get_session("s1", "id1").unwrap().unwrap(); + assert_eq!(session.status, "active"); + } + + #[test] + fn test_update_session_rejects_invalid_column() { + let store = test_store(); + store + .save_session( + "s1", + "id1", + "ttt", + 1, + "remote", + "id1", + "pending", + &HashMap::new(), + 0, + 1.0, + 1.0, + 1.0, + ) + .unwrap(); + + let mut updates = HashMap::new(); + updates.insert("evil_column; DROP TABLE--".into(), "hack".into()); + let result = store.update_session("s1", "id1", &updates); + assert!(result.is_err()); + } + + #[test] + fn test_list_sessions() { + let store = test_store(); + for i in 0..3 { + store + .save_session( + &format!("s{i}"), + "id1", + "ttt", + 1, + "remote", + "id1", + if i == 2 { "active" } else { "pending" }, + &HashMap::new(), + 0, + 1.0, + 1.0, + 1.0, + ) + .unwrap(); + } + + let all = store.list_sessions(Some("id1"), None, None).unwrap(); + assert_eq!(all.len(), 3); + + let pending = store + .list_sessions(Some("id1"), Some("pending"), None) + .unwrap(); + assert_eq!(pending.len(), 2); + } + + #[test] + fn test_delete_session() { + let store = test_store(); + store + .save_session( + "s1", + "id1", + "ttt", + 1, + "remote", + "id1", + "pending", + &HashMap::new(), + 0, + 1.0, + 1.0, + 1.0, + ) + .unwrap(); + store.delete_session("s1", "id1").unwrap(); + assert!(store.get_session("s1", "id1").unwrap().is_none()); + } + + #[test] + fn test_save_and_list_actions() { + let store = test_store(); + for i in 1..=3 { + store + .save_action(&Action { + session_id: "s1".into(), + identity_id: "id1".into(), + action_num: i, + command: "move".into(), + payload_json: format!("{{\"n\":{i}}}"), + sender: "player1".into(), + timestamp: i as f64, + }) + .unwrap(); + } + + let actions = store.list_actions("s1", "id1").unwrap(); + assert_eq!(actions.len(), 3); + assert_eq!(actions[0].action_num, 1); + assert_eq!(actions[2].action_num, 3); + } + + #[test] + fn test_next_action_num() { + let store = test_store(); + assert_eq!(store.next_action_num("s1", "id1").unwrap(), 1); + + store + .save_action(&Action { + session_id: "s1".into(), + identity_id: "id1".into(), + action_num: 1, + command: "move".into(), + payload_json: "{}".into(), + sender: "p1".into(), + timestamp: 1.0, + }) + .unwrap(); + assert_eq!(store.next_action_num("s1", "id1").unwrap(), 2); + } +} diff --git a/src/transport.rs b/src/transport.rs new file mode 100644 index 0000000..1f6081a --- /dev/null +++ b/src/transport.rs @@ -0,0 +1,208 @@ +/// LRGP transport bridge — converts between LRGP envelopes and LXMF field bytes. +/// +/// This module handles the raw byte-level conversion needed to embed LRGP +/// game envelopes inside LXMF messages and extract them on receipt. +/// It is pure data transformation — no I/O. + +use std::collections::HashMap; + +use crate::constants::*; +use crate::envelope::{self, Envelope}; +use crate::errors::LrgpError; + +/// Check whether an LXMF fields dict contains an LRGP game message. +/// Recognizes both `lrgp.v1` and legacy `rlap.v1`/`ratspeak.game` markers. +pub fn is_lrgp_message(fields: &HashMap>) -> bool { + match fields.get(&FIELD_CUSTOM_TYPE) { + Some(data) => { + // Try to decode the msgpack-encoded string + if let Ok(val) = rmpv::decode::read_value(&mut &data[..]) { + if let Some(s) = envelope::value_as_str(&val) { + return s == PROTOCOL_TYPE || LEGACY_TYPES.contains(&s); + } + } + false + } + None => false, + } +} + +/// Extract an LRGP envelope from raw LXMF field bytes. +/// +/// Steps: +/// 1. Check `fields[0xFB]` for the LRGP (or legacy) protocol marker. +/// 2. Decode `fields[0xFD]` from msgpack bytes into an rmpv::Value. +/// 3. Convert that value into a `HashMap` envelope. +/// +/// Returns `Ok(None)` if the message is not an LRGP message. +pub fn extract_envelope(fields: &HashMap>) -> Result, LrgpError> { + // 1. Check type marker + let type_data = match fields.get(&FIELD_CUSTOM_TYPE) { + Some(d) => d, + None => return Ok(None), + }; + let type_val = rmpv::decode::read_value(&mut &type_data[..]) + .map_err(|e| LrgpError::InvalidEnvelope(format!("type field decode error: {e}")))?; + let marker = envelope::value_as_str(&type_val).unwrap_or(""); + if marker != PROTOCOL_TYPE && !LEGACY_TYPES.contains(&marker) { + return Ok(None); + } + + // 2. Decode meta field + let meta_data = fields + .get(&FIELD_CUSTOM_META) + .ok_or_else(|| LrgpError::InvalidEnvelope("FIELD_CUSTOM_META (0xFD) missing".into()))?; + + let meta_val = rmpv::decode::read_value(&mut &meta_data[..]) + .map_err(|e| LrgpError::InvalidEnvelope(format!("meta field decode error: {e}")))?; + + // 3. Convert to HashMap envelope + let env = envelope::map_from_value(&meta_val) + .ok_or_else(|| LrgpError::InvalidEnvelope("meta field is not a map".into()))?; + + // Validate required keys + for key in &[KEY_APP, KEY_COMMAND, KEY_SESSION, KEY_PAYLOAD] { + if !env.contains_key(*key) { + return Err(LrgpError::InvalidEnvelope(format!( + "Missing required key: {key}" + ))); + } + } + + Ok(Some(env)) +} + +/// Pack an LRGP envelope into raw LXMF field bytes. +/// +/// Returns `HashMap>` ready to pass to lxmf message construction: +/// - `0xFB` → msgpack("lrgp.v1") +/// - `0xFD` → msgpack(envelope dict) +/// +/// Always uses the current protocol marker (`lrgp.v1`) for outbound messages. +pub fn pack_into_fields(envelope: &Envelope) -> Result>, LrgpError> { + let mut fields = HashMap::new(); + + // Type marker → always lrgp.v1 + let type_val = rmpv::Value::String(PROTOCOL_TYPE.into()); + let mut type_buf = Vec::new(); + rmpv::encode::write_value(&mut type_buf, &type_val) + .map_err(|e| LrgpError::InvalidEnvelope(format!("type encode error: {e}")))?; + fields.insert(FIELD_CUSTOM_TYPE, type_buf); + + // Envelope dict + let env_val = envelope::value_from_map(envelope.clone()); + let mut env_buf = Vec::new(); + rmpv::encode::write_value(&mut env_buf, &env_val) + .map_err(|e| LrgpError::InvalidEnvelope(format!("envelope encode error: {e}")))?; + fields.insert(FIELD_CUSTOM_META, env_buf); + + Ok(fields) +} + +/// Convert raw LXMF field bytes into typed rmpv values (for use with envelope::unpack_envelope). +pub fn fields_bytes_to_rmpv( + fields: &HashMap>, +) -> Result, LrgpError> { + let mut result = HashMap::new(); + for (&key, data) in fields { + let val = rmpv::decode::read_value(&mut &data[..]) + .map_err(|e| LrgpError::InvalidEnvelope(format!("field {key:#x} decode error: {e}")))?; + result.insert(key, val); + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pack_and_extract_roundtrip() { + let env = envelope::pack_envelope("ttt", 1, "challenge", "abcdef0123456789", None); + let raw_fields = pack_into_fields(&env).unwrap(); + let recovered = extract_envelope(&raw_fields).unwrap().unwrap(); + + assert_eq!( + envelope::value_as_str(recovered.get(KEY_APP).unwrap()).unwrap(), + "ttt.1" + ); + assert_eq!( + envelope::value_as_str(recovered.get(KEY_COMMAND).unwrap()).unwrap(), + "challenge" + ); + } + + #[test] + fn test_is_lrgp_message_true() { + let env = envelope::pack_envelope("ttt", 1, "move", "abc", None); + let raw_fields = pack_into_fields(&env).unwrap(); + assert!(is_lrgp_message(&raw_fields)); + } + + #[test] + fn test_is_lrgp_message_false() { + let fields: HashMap> = HashMap::new(); + assert!(!is_lrgp_message(&fields)); + } + + #[test] + fn test_is_lrgp_message_legacy_rlap() { + // Simulate legacy rlap.v1 marker + let type_val = rmpv::Value::String("rlap.v1".into()); + let mut type_buf = Vec::new(); + rmpv::encode::write_value(&mut type_buf, &type_val).unwrap(); + + let mut fields = HashMap::new(); + fields.insert(FIELD_CUSTOM_TYPE, type_buf); + assert!(is_lrgp_message(&fields)); + } + + #[test] + fn test_is_lrgp_message_legacy_ratspeak() { + let type_val = rmpv::Value::String("ratspeak.game".into()); + let mut type_buf = Vec::new(); + rmpv::encode::write_value(&mut type_buf, &type_val).unwrap(); + + let mut fields = HashMap::new(); + fields.insert(FIELD_CUSTOM_TYPE, type_buf); + assert!(is_lrgp_message(&fields)); + } + + #[test] + fn test_extract_envelope_not_lrgp() { + let fields: HashMap> = HashMap::new(); + assert!(extract_envelope(&fields).unwrap().is_none()); + } + + #[test] + fn test_extract_envelope_legacy_rlap() { + // Build an rlap.v1-marked message + let type_val = rmpv::Value::String("rlap.v1".into()); + let mut type_buf = Vec::new(); + rmpv::encode::write_value(&mut type_buf, &type_val).unwrap(); + + let env = envelope::pack_envelope("ttt", 1, "challenge", "abc", None); + let env_val = envelope::value_from_map(env); + let mut env_buf = Vec::new(); + rmpv::encode::write_value(&mut env_buf, &env_val).unwrap(); + + let mut fields = HashMap::new(); + fields.insert(FIELD_CUSTOM_TYPE, type_buf); + fields.insert(FIELD_CUSTOM_META, env_buf); + + let result = extract_envelope(&fields).unwrap().unwrap(); + assert_eq!( + envelope::value_as_str(result.get(KEY_COMMAND).unwrap()).unwrap(), + "challenge" + ); + } + + #[test] + fn test_fields_bytes_to_rmpv() { + let env = envelope::pack_envelope("ttt", 1, "move", "abc", None); + let raw = pack_into_fields(&env).unwrap(); + let rmpv_fields = fields_bytes_to_rmpv(&raw).unwrap(); + assert!(rmpv_fields.contains_key(&FIELD_CUSTOM_TYPE)); + assert!(rmpv_fields.contains_key(&FIELD_CUSTOM_META)); + } +} diff --git a/tests/ttt_challenge.bin b/tests/ttt_challenge.bin new file mode 100644 index 0000000..dca81ca --- /dev/null +++ b/tests/ttt_challenge.bin @@ -0,0 +1 @@ +„¡a¥ttt.1¡c©challenge¡s°a1b2c3d4e5f6g7h8¡p€ \ No newline at end of file diff --git a/tests/ttt_move.bin b/tests/ttt_move.bin new file mode 100644 index 0000000..fc9fb10 --- /dev/null +++ b/tests/ttt_move.bin @@ -0,0 +1 @@ +„¡a¥ttt.1¡c¤move¡s°a1b2c3d4e5f6g7h8¡p…¡i¡b©____X____¡n¡t°abcdef0123456789¡x  \ No newline at end of file diff --git a/tests/ttt_move_win.bin b/tests/ttt_move_win.bin new file mode 100644 index 0000000..e25de3c --- /dev/null +++ b/tests/ttt_move_win.bin @@ -0,0 +1 @@ +„¡a¥ttt.1¡c¤move¡s°a1b2c3d4e5f6g7h8¡p†¡i¡b©XXX_OO___¡n¡t ¡x£win¡w°abcdef0123456789 \ No newline at end of file