mirror of
https://github.com/ratspeak/lrgp-rs
synced 2026-08-12 18:07:21 -04:00
LRGP v0.2.0 — Lightweight Reticulum Gaming Protocol
This commit is contained in:
commit
13585eb542
23 changed files with 3738 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/target
|
||||
Cargo.lock
|
||||
46
CHANGELOG.md
Normal file
46
CHANGELOG.md
Normal 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
24
Cargo.toml
Normal 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
21
LICENSE
Normal 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
95
README.md
Normal 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
331
SPEC.md
Normal 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 (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"`) |
|
||||
47
examples/basic_envelope.rs
Normal file
47
examples/basic_envelope.rs
Normal 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.");
|
||||
}
|
||||
52
examples/session_lifecycle.rs
Normal file
52
examples/session_lifecycle.rs
Normal 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.");
|
||||
}
|
||||
93
examples/tictactoe_game.rs
Normal file
93
examples/tictactoe_game.rs
Normal 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
102
src/app_base.rs
Normal 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
1
src/apps/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod tictactoe;
|
||||
1156
src/apps/tictactoe.rs
Normal file
1156
src/apps/tictactoe.rs
Normal file
File diff suppressed because it is too large
Load diff
68
src/constants.rs
Normal file
68
src/constants.rs
Normal 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
350
src/envelope.rs
Normal 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
21
src/errors.rs
Normal 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
9
src/lib.rs
Normal 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
248
src/router.rs
Normal 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
260
src/session.rs
Normal 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
601
src/store.rs
Normal 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
208
src/transport.rs
Normal 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
1
tests/ttt_challenge.bin
Normal file
|
|
@ -0,0 +1 @@
|
|||
„¡a¥ttt.1¡c©challenge¡s°a1b2c3d4e5f6g7h8¡p€
|
||||
1
tests/ttt_move.bin
Normal file
1
tests/ttt_move.bin
Normal 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
1
tests/ttt_move_win.bin
Normal file
|
|
@ -0,0 +1 @@
|
|||
„¡a¥ttt.1¡c¤move¡s°a1b2c3d4e5f6g7h8¡p†¡i¡b©XXX_OO___¡n¡t ¡x£win¡w°abcdef0123456789
|
||||
Loading…
Add table
Add a link
Reference in a new issue