LRGP v0.2.0 — Lightweight Reticulum Gaming Protocol

This commit is contained in:
DeFiDude 2026-03-13 02:22:44 -06:00
commit 13585eb542
23 changed files with 3738 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
Cargo.lock

46
CHANGELOG.md Normal file
View file

@ -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`)

24
Cargo.toml Normal file
View file

@ -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"

21
LICENSE Normal file
View file

@ -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.

95
README.md Normal file
View file

@ -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<String>) { /* ... */ }
fn get_session_state(&self, /* ... */) -> HashMap<String, JsonValue> { /* ... */ }
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).

331
SPEC.md Normal file
View file

@ -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": "<game_id>.<version>", # e.g. "ttt.1"
"c": "<command>", # e.g. "move"
"s": "<session_id>", # 16-char hex (8 random bytes)
"p": { <payload> } # 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 <GameName>] <description>`
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": "<game_id>.<version>",
"c": "error",
"s": "<session_id>",
"p": {
"code": "<error_code>",
"msg": "<human-readable message>",
"ref": "<command that caused the error>"
}
}
```
### 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": "<string>",
"version": <int>,
"display_name": "<string>",
"icon": "<string>",
"session_type": "turn_based" | "real_time" | "round_based" | "single_round",
"max_players": 2,
"min_players": 2,
"validation": "sender" | "receiver" | "both",
"actions": [<list of command strings>],
"preferred_delivery": {<command: method>},
"ttl": {"pending": <seconds>, "active": <seconds>},
"genre": "<optional string>",
"turn_timeout": <optional seconds>
}
```
---
## 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 (08) |
| `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"`) |

View file

@ -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.");
}

View file

@ -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.");
}

View file

@ -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.");
}

102
src/app_base.rs Normal file
View file

@ -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<HashMap<String, JsonValue>>,
/// Event to emit to the UI, or None.
pub emit: Option<HashMap<String, JsonValue>>,
/// Error info, or None.
pub error: Option<HashMap<String, JsonValue>>,
}
/// Result returned by `handle_outgoing`.
#[derive(Debug, Clone)]
pub struct OutgoingResult {
/// Enriched payload to pack into the envelope.
pub payload: HashMap<String, rmpv::Value>,
/// 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<String>,
pub preferred_delivery: HashMap<String, String>,
pub ttl: HashMap<String, f64>,
/// Optional genre tag for game categorization (e.g., "strategy", "puzzle", "card").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub genre: Option<String>,
/// Optional per-turn time limit in seconds. `None` means no limit.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turn_timeout: Option<f64>,
}
/// 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<String, rmpv::Value>,
sender_hash: &str,
identity_id: &str,
) -> IncomingResult;
/// Prepare an outgoing LRGP game action.
fn handle_outgoing(
&self,
session_id: &str,
command: &str,
payload: &HashMap<String, rmpv::Value>,
identity_id: &str,
) -> OutgoingResult;
/// Validate an action. Returns (valid, error_message).
fn validate_action(
&self,
session_id: &str,
command: &str,
payload: &HashMap<String, rmpv::Value>,
sender_hash: &str,
) -> (bool, Option<String>);
/// Return current session state for rendering.
fn get_session_state(
&self,
session_id: &str,
identity_id: &str,
) -> HashMap<String, JsonValue>;
/// Generate human-readable fallback text for LXMF content field.
fn render_fallback(
&self,
command: &str,
payload: &HashMap<String, rmpv::Value>,
) -> String;
/// Return preferred delivery method for this command.
fn get_delivery_method(&self, command: &str) -> String {
let _ = command;
"opportunistic".to_string()
}
}

1
src/apps/mod.rs Normal file
View file

@ -0,0 +1 @@
pub mod tictactoe;

1156
src/apps/tictactoe.rs Normal file

File diff suppressed because it is too large Load diff

68
src/constants.rs Normal file
View file

@ -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";

350
src/envelope.rs Normal file
View file

@ -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<String, rmpv::Value>;
/// 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<HashMap<String, rmpv::Value>>,
) -> 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<usize, LrgpError> {
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<u8, ...>.
pub fn pack_lxmf_fields(envelope: &Envelope) -> HashMap<u8, rmpv::Value> {
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<u8, rmpv::Value>) -> Result<Option<Envelope>, 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<String, Value> into an rmpv::Value::Map.
pub fn value_from_map(map: HashMap<String, rmpv::Value>) -> 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<String, Value>.
pub fn map_from_value(value: &rmpv::Value) -> Option<HashMap<String, rmpv::Value>> {
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<Vec<u8>, 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<Envelope, LrgpError> {
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<u64> {
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<i64> {
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<bool> {
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"
);
}
}

21
src/errors.rs Normal file
View file

@ -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),
}

9
src/lib.rs Normal file
View file

@ -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;

248
src/router.rs Normal file
View file

@ -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<HashMap<String, Arc<dyn GameApp>>>,
}
impl LrgpRouter {
pub fn new() -> Self {
Self {
apps: Mutex::new(HashMap::new()),
}
}
/// Register a game implementation.
pub fn register(&self, app: Box<dyn GameApp>) {
let id = app.app_id().to_string();
let arc: Arc<dyn GameApp> = Arc::from(app);
self.apps.lock().unwrap().insert(id, arc);
}
/// List manifests for all registered games.
pub fn list_apps(&self) -> Vec<GameManifest> {
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<F, R>(&self, app_id: &str, f: F) -> Option<R>
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<IncomingResult, LrgpError> {
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<String, rmpv::Value> = 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<String, rmpv::Value>,
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<String, rmpv::Value>,
_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<String, rmpv::Value>,
_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<String, rmpv::Value>,
_sender_hash: &str,
) -> (bool, Option<String>) {
(true, None)
}
fn get_session_state(
&self,
_session_id: &str,
_identity_id: &str,
) -> HashMap<String, JsonValue> {
HashMap::new()
}
fn render_fallback(
&self,
command: &str,
_payload: &HashMap<String, rmpv::Value>,
) -> 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()));
}
}

260
src/session.rs Normal file
View file

@ -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<String, serde_json::Value>,
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<String>) -> 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<String, LrgpError> {
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<String, f64>>,
now_override: Option<f64>,
) -> 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);
}
}

601
src/store.rs Normal file
View file

@ -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<rusqlite::Connection>,
}
impl LrgpStore {
/// Open (or create) a game store at the given path.
pub fn open(path: impl AsRef<Path>) -> Result<Self, LrgpError> {
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<Self, LrgpError> {
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<String, JsonValue>,
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<String, String>,
) -> 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<String> = 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<Box<dyn rusqlite::types::ToSql>> = 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<Option<crate::session::Session>, 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<Vec<crate::session::Session>, LrgpError> {
let conn = self.conn.lock().unwrap();
let mut conditions = Vec::new();
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = 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<Vec<Action>, 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<i64, LrgpError> {
let conn = self.conn.lock().unwrap();
let max: Option<i64> = 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<String, JsonValue> =
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);
}
}

208
src/transport.rs Normal file
View file

@ -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<u8, Vec<u8>>) -> 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<String, Value>` envelope.
///
/// Returns `Ok(None)` if the message is not an LRGP message.
pub fn extract_envelope(fields: &HashMap<u8, Vec<u8>>) -> Result<Option<Envelope>, 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<u8, Vec<u8>>` 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<HashMap<u8, Vec<u8>>, 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<u8, Vec<u8>>,
) -> Result<HashMap<u8, rmpv::Value>, 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<u8, Vec<u8>> = 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<u8, Vec<u8>> = 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));
}
}

1
tests/ttt_challenge.bin Normal file
View file

@ -0,0 +1 @@
„¡a¥ttt.1¡c©challenge¡s°a1b2c3d4e5f6g7h8¡p€

1
tests/ttt_move.bin Normal file
View file

@ -0,0 +1 @@
„¡a¥ttt.1¡c¤move¡s°a1b2c3d4e5f6g7h8¡p…¡i¡b©____X____¡n¡t°abcdef0123456789¡x 

1
tests/ttt_move_win.bin Normal file
View file

@ -0,0 +1 @@
„¡a¥ttt.1¡c¤move¡s°a1b2c3d4e5f6g7h8¡p†¡i¡b©XXX_OO___¡n¡t ¡x£win¡w°abcdef0123456789