From 0b2b6265f3d6ee0a53972509e64b6dfd7e9f2cb5 Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Fri, 17 Apr 2026 17:09:21 -0500 Subject: [PATCH] Remove Redis backend support --- Architecture_Diagram.md | 173 ++----- Cargo.toml | 1 - README.md | 345 ++----------- .../extension/functions/fnc_extCall.sqf | 37 -- arma/server/config.example.toml | 49 +- arma/server/docs/README.md | 300 ++---------- arma/server/docs/api-reference.md | 449 ++--------------- arma/server/docs/usage-examples.md | 452 ++---------------- arma/server/extension/Cargo.toml | 2 - arma/server/extension/README.md | 418 ++-------------- arma/server/extension/config.example.toml | 53 +- arma/server/extension/src/actor.rs | 2 +- arma/server/extension/src/adapters/README.md | 270 ----------- arma/server/extension/src/adapters/mod.rs | 3 - .../extension/src/adapters/redis_client.rs | 208 -------- arma/server/extension/src/bank.rs | 2 +- arma/server/extension/src/config.rs | 79 +++ arma/server/extension/src/icom.rs | 24 +- arma/server/extension/src/lib.rs | 53 +- arma/server/extension/src/log.rs | 2 +- arma/server/extension/src/org.rs | 4 +- arma/server/extension/src/redis/README.md | 281 ----------- arma/server/extension/src/redis/client.rs | 48 -- arma/server/extension/src/redis/common.rs | 74 --- arma/server/extension/src/redis/config.rs | 215 --------- arma/server/extension/src/redis/hash.rs | 99 ---- arma/server/extension/src/redis/helpers.rs | 73 --- arma/server/extension/src/redis/list.rs | 167 ------- arma/server/extension/src/redis/macros.rs | 91 ---- arma/server/extension/src/redis/mod.rs | 138 ------ arma/server/extension/src/redis/set.rs | 87 ---- arma/server/extension/src/storage.rs | 6 +- arma/server/extension/src/storage/actor.rs | 13 +- arma/server/extension/src/storage/bank.rs | 13 +- arma/server/extension/src/storage/garage.rs | 27 +- arma/server/extension/src/storage/locker.rs | 27 +- arma/server/extension/src/storage/org.rs | 20 +- arma/server/extension/src/storage/phone.rs | 22 +- arma/server/extension/src/surreal.rs | 5 +- arma/server/extension/src/transport.rs | 8 +- bin/icom/src/config.rs | 10 +- docs/GARAGE_USAGE_GUIDE.md | 2 +- docs/LOCKER_USAGE_GUIDE.md | 2 +- lib/README.md | 278 +---------- lib/models/src/actor.rs | 26 +- lib/models/src/bank.rs | 2 +- lib/models/src/garage.rs | 6 + lib/models/src/v_garage.rs | 15 +- lib/models/src/v_locker.rs | 9 +- lib/repositories/Cargo.toml | 5 - lib/repositories/README.md | 213 +-------- lib/repositories/src/actor.rs | 126 +---- lib/repositories/src/bank.rs | 126 +---- lib/repositories/src/garage.rs | 68 +-- lib/repositories/src/lib.rs | 29 +- lib/repositories/src/locker.rs | 68 +-- lib/repositories/src/org.rs | 316 +----------- lib/repositories/src/phone.rs | 371 -------------- lib/repositories/src/v_garage.rs | 113 +---- lib/repositories/src/v_locker.rs | 111 +---- lib/services/README.md | 186 +------ lib/services/src/cad.rs | 57 ++- lib/services/src/garage.rs | 8 +- lib/services/src/org.rs | 9 +- lib/services/src/store.rs | 17 +- lib/shared/src/lib.rs | 2 - lib/shared/src/redis_client.rs | 70 --- 67 files changed, 475 insertions(+), 6110 deletions(-) delete mode 100644 arma/server/extension/src/adapters/README.md delete mode 100644 arma/server/extension/src/adapters/mod.rs delete mode 100644 arma/server/extension/src/adapters/redis_client.rs create mode 100644 arma/server/extension/src/config.rs delete mode 100644 arma/server/extension/src/redis/README.md delete mode 100644 arma/server/extension/src/redis/client.rs delete mode 100644 arma/server/extension/src/redis/common.rs delete mode 100644 arma/server/extension/src/redis/config.rs delete mode 100644 arma/server/extension/src/redis/hash.rs delete mode 100644 arma/server/extension/src/redis/helpers.rs delete mode 100644 arma/server/extension/src/redis/list.rs delete mode 100644 arma/server/extension/src/redis/macros.rs delete mode 100644 arma/server/extension/src/redis/mod.rs delete mode 100644 arma/server/extension/src/redis/set.rs delete mode 100644 lib/shared/src/redis_client.rs diff --git a/Architecture_Diagram.md b/Architecture_Diagram.md index 85521aa..329d53a 100644 --- a/Architecture_Diagram.md +++ b/Architecture_Diagram.md @@ -1,134 +1,49 @@ -# Forge Architecture & Data Flow Diagram +# Forge Architecture -## ๐Ÿ—๏ธ **System Architecture Overview** - -```mermaid -graph TD - subgraph ForgeSystem [FORGE SYSTEM] - subgraph Clients [Clients #40;Read-Only#41;] - ClientA[CLIENT A] - ClientB[CLIENT B] - ClientN[CLIENT N] - - subgraph OptimisticCache [Optimistic Cache] - ActorObj[Actor Object
- loadout
- position
- stats] - end - - ClientA --- OptimisticCache - ClientB --- OptimisticCache - ClientN --- OptimisticCache - end - - subgraph Server [ArmA 3 SERVER #40;Hot Cache#41;] - Registry["GVAR(Registry)
In-Memory HashMap
UID -> {loadout, position, stats...}"] - SessionMgmt[Session Management
- Token Generation
- UID Resolution
- Player State] - end - - subgraph Rust [EXTENSION #40;Cold Storage#41;] - ConnPool["Connection Pool
(bb8-redis)
2-10 connections"] - RedisOps[Redis Operations
- actor_get/set/update
- Async I/O] - end - - subgraph Redis [DATABASE #40;Saved to Disc#41;] - ActorDataStore[Actor Data Store
actor:UID -> JSON] - Modules[Additional Modules
garage, locker, bank, org] - end - - Clients -->|Event Driven
#40;CBA A3 Events#41;| Server - Server -->|Extension Calls
#40;Rust FFI#41;| Rust - Rust -->|Redis Protocol
#40;bb8-redis#41;| Redis - end -``` - -## ๐Ÿ”„ **Data Flow Sequence** - -### **1. Player Connection & Initial Data Load** - -```mermaid -sequenceDiagram - participant Client - participant Server as Server (Hot Cache) - participant Extension as Extension (Cold Storage) - participant Redis as Redis (Database) - - Note over Client, Redis: 1. Player Connection & Initial Data Load - - Client->>Server: 1. Connect - Client->>Server: 2. Request Actor Data - Server->>Server: 3. Check Cache (Cache Miss) - Server->>Extension: 4. Extension Call - Extension->>Redis: 5. Redis Query - Redis-->>Extension: 6. JSON Data - Extension-->>Server: 7. Actor Data - Server->>Server: 8. Store in Hot Cache - Server-->>Client: 9. Secure Response - Client->>Client: 10. Update Local Cache -``` - -### **2. Subsequent Data Access (Cache Hit)** - -```mermaid -sequenceDiagram - participant Client - participant Server as Server (Hot Cache) - participant Extension as Extension (Cold Storage) - participant Redis as Redis (Database) - - Note over Client, Redis: 2. Subsequent Data Access (Cache Hit) - - Client->>Server: 1. Request Actor Data - Server->>Server: 2. Check Cache (Cache Hit!) - Server-->>Client: 3. Instant Response - Client->>Client: 4. Update Local Cache -``` - -### **3. Data Update (Write-Through)** - -```mermaid -sequenceDiagram - participant Client - participant Server as Server (Hot Cache) - participant Extension as Extension (Cold Storage) - participant Redis as Redis (Database) - - Note over Client, Redis: 3. Data Update (Write-Through) - - Client->>Server: 1. Action (Move, etc) - Server->>Server: 2. Validate & Update Cache - Server->>Extension: 3. Persist to Database - Extension->>Redis: 4. Redis Update - Redis-->>Extension: 5. Confirmation - Extension-->>Server: 6. Success - Server-->>Client: 7. Sync to All Clients -``` - -## ๐Ÿš€ **Performance Characteristics** - -### **Access Times** - -- **Hot Cache (Server)**: `< 1ms` (HashMap lookup) -- **Cold Storage (Redis)**: `1-5ms` (Network + Redis) -- **Client Cache**: `< 0.1ms` (Local object access) - -### **Cache Hit Ratios** - -- **Hot Cache**: `~95%` (Active players) -- **Cold Storage**: `~5%` (New connections, cache misses) - -### **Memory Usage** - -- **Server Registry**: `~1KB per active player` -- **Client Cache**: `~500B per player object` -- **Redis**: `~2KB per player (persistent)` - -## ๐Ÿ”’ **Security & Session Management** +## Runtime Flow ```mermaid flowchart TD - subgraph SessionMgmt [SERVER-SIDE #40;Session MGT#41;] - Conn[Player Connection] --> Token[Session Token Generation
#40;Generated on server#41;] - Token --> UID[UID Resolution
#40;Steam UID mapping#41;] - UID --> State[Player State Tracking
#40;Tracked in Registry#41;] - State --> Access[Data Access Authorized
#40;Authorized via session#41;] - end + Client[Arma Client Addons] --> Server[Arma Server Addons] + Server --> Bridge[Extension Bridge] + Bridge --> Extension[Rust arma-rs Extension] + Extension --> Services[Service Layer] + Services --> Repositories[Repository Traits] + Repositories --> Surreal[(SurrealDB)] +``` + +## Persistence Startup + +```mermaid +sequenceDiagram + participant Arma as Arma Server + participant Ext as Forge Extension + participant Db as SurrealDB + + Arma->>Ext: init + Ext->>Db: connect + Ext->>Db: apply schema modules + Db-->>Ext: ready + Arma->>Ext: status + Ext-->>Arma: connected +``` + +## Data Access + +```mermaid +sequenceDiagram + participant SQF as SQF Addon + participant Ext as Extension Command + participant Service as Service + participant Repo as Repository + participant Db as SurrealDB + + SQF->>Ext: domain command + Ext->>Service: validate and execute + Service->>Repo: repository call + Repo->>Db: query/upsert/delete + Db-->>Repo: result + Repo-->>Service: domain model + Service-->>Ext: response + Ext-->>SQF: serialized result ``` diff --git a/Cargo.toml b/Cargo.toml index 080237f..d08f21f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,6 @@ resolver = "3" [workspace.dependencies] arma-rs = { version = "1.11.15", features = ["chrono", "serde_json", "uuid"] } chrono = "0.4.42" -redis = "1.0.0-rc.1" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" tokio = { version = "1.47.1", features = ["full"] } diff --git a/README.md b/README.md index c97d5c2..2902ee8 100644 --- a/README.md +++ b/README.md @@ -1,313 +1,54 @@ -# Forge Framework +# Forge -**Forge** is a high-performance, production-ready framework for Arma 3 persistent game servers, built with Rust and Redis for optimal performance and reliability. +Forge is a framework for Arma 3 persistent game servers. It combines SQF +addons, a Rust `arma-rs` extension, shared service crates, and web-based client +interfaces for player data, organizations, banking, garages, lockers, phones, +CAD, stores, and task workflows. -## Overview +## Storage -Forge provides a complete solution for managing persistent player data, organizations, and game state in Arma 3 multiplayer environments. It combines the performance of Rust with the flexibility of Redis to deliver sub-millisecond response times while maintaining data consistency across server restarts. - -### Key Features - -- **๐Ÿš€ High Performance**: Sub-millisecond data access through intelligent caching -- **๐Ÿ”’ Data Integrity**: Strict validation and type safety at every layer -- **๐Ÿ—๏ธ Clean Architecture**: Layered design following SOLID principles -- **๐Ÿ“ฆ Modular Design**: Easy to extend with new entities and features -- **๐Ÿ”„ Real-time Sync**: Automatic state synchronization across all clients -- **๐Ÿ’พ Persistent Storage**: Redis-backed storage with automatic failover -- **๐Ÿงช Testable**: Mock-friendly architecture for comprehensive testing - -## Architecture - -Forge follows a **layered architecture** pattern: - -```mermaid -graph TD - Extension[Extension Layer
ArmA 3 Interface <---> Rust] - Services[Services Layer
#40;Business Logic#41;] - Repositories[Repositories Layer
#40;Data Persistence#41;] - Models[Models Layer
#40;Data Structures & Validation#41;] - - Extension --> Services - Services --> Repositories - Repositories --> Models -``` - -**Communication Flow**: - -- **Clients** โ†’ Use events (`CBA_Events`) to communicate with server -- **Server** โ†’ Calls Rust extension via `callExtension` -- **Extension** โ†’ Manages Redis connection pool and data operations - -For detailed architecture information, see [Diagram](Architecture_Diagram.md). - -## Project Structure - -``` -forge/ -โ”œโ”€โ”€ arma/ -โ”‚ โ”œโ”€โ”€ client/ # Client-side SQF mod -โ”‚ โ”‚ โ”œโ”€โ”€ addons/ -โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ main/ # Core initialization & config -โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ common/ # Shared utilities & helpers -โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ actor/ # Actor/player UI, class & events -โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ org/ # Organization UI, class & events -โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ bank/ # Banking UI, class & events -โ”‚ โ”‚ โ”œโ”€โ”€ include/ # Header files -โ”‚ โ”‚ โ””โ”€โ”€ tools/ # Build tools -โ”‚ โ”œโ”€โ”€ server/ -โ”‚ โ”‚ โ”œโ”€โ”€ addons/ -โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ main/ # Core initialization & config -โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ common/ # Shared utilities & helpers -โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ actor/ # Actor/player Registry, Store & events -โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ org/ # Organization Registry, Store & events -โ”‚ โ”‚ โ”œโ”€โ”€ include/ # Header files -โ”‚ โ”‚ โ”œโ”€โ”€ tools/ # Build tools -โ”‚ โ”‚ โ””โ”€โ”€ extension/ # Rust extension (Arma 3 interface) -โ”‚ โ”‚ โ”œโ”€โ”€ src/ -โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ actor.rs # Actor/player commands -โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ org.rs # Organization commands -โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ redis/ # Redis operations module -โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ adapters/ # Repository adapters -โ”‚ โ”‚ โ””โ”€โ”€ README.md -โ”œโ”€โ”€ lib/ -โ”‚ โ”œโ”€โ”€ models/ # Data structures & validation -โ”‚ โ”œโ”€โ”€ repositories/ # Data persistence layer -โ”‚ โ”œโ”€โ”€ services/ # Business logic layer -โ”‚ โ”œโ”€โ”€ shared/ # Common utilities & traits -โ”‚ โ””โ”€โ”€ README.md -โ””โ”€โ”€ FORGE_Architecture_Diagram.md -``` - -## Quick Start - -### Prerequisites - -- Rust 1.70+ with `cargo` -- Redis 6.0+ -- HEMTT - -1. Clone the repository from Gitea -2. Install HEMTT - The latest version of HEMTT can be installed by running: - -```cmd -winget install hemtt -``` - -### Coding Guidelines - -This mod follows the same coding guidelines as the ACE3 mod, which can be found [here](https://ace3.acemod.org/wiki/development/coding-guidelines). - -### Building the Extension - -```bash -# Build for release -cargo build --release - -# The compiled extension will be at: -# target/release/forge_server.dll (Windows) -# target/release/forge_server.so (Linux) -``` - -### Configuration - -Create `@forge_server/config.toml`: +Durable persistence is backed by SurrealDB. The server extension loads schema +modules at startup and routes domain repositories through the SurrealDB client. ```toml -[redis] -host = "127.0.0.1" -port = 6379 -password = "" # Optional -max_connections = 10 -min_connections = 2 -idle_timeout = 300 +[surreal] +endpoint = "127.0.0.1:8000" +namespace = "forge" +database = "main" +username = "root" +password = "root" +connect_timeout_ms = 5000 ``` -### SQF Usage +## Workspace + +```text +arma/ + client/ Client-side addons and browser UIs + server/ Server-side addons and extension crate +bin/ + icom/ Interprocess communication helper +lib/ + models/ Shared domain models + repositories/ Repository traits and in-memory test stores + services/ Domain business logic + shared/ Cross-crate helpers +tools/ Web UI build tooling +``` + +## Common Commands + +```powershell +cargo test +npm run build:webui +.\build-arma.ps1 +``` + +## Extension Status ```sqf -// Create an actor -private _data = createHashMapFromArray [ - ["name", "John Doe"], - ["bank", 1000], - ["level", 1] -]; -private _result = "forge_server" callExtension ["actor:create", [getPlayerUID player, toJSON _data]]; - -// Get actor data -private _result = "forge_server" callExtension ["actor:get", [getPlayerUID player]]; -private _actorData = fromJSON (_result select 0); - -// Update actor -private _update = createHashMapFromArray [["bank", 1500]]; -"forge_server" callExtension ["actor:update", [getPlayerUID player, toJSON _update]]; +"forge_server" callExtension ["status", []]; +"forge_server" callExtension ["surreal:status", []]; ``` -## Core Modules - -### Models - -Defines strict data structures with built-in validation: - -- `Actor`: Player data (stats, inventory, position) -- `Org`: Organization/clan data (members, roles, metadata) - -[Documentation](lib/models/README.md) - -### Repositories - -Manages data persistence with Redis: - -- Hash-based storage for structured data -- Set-based storage for collections -- Generic over Redis client implementations - -[Documentation](lib/repositories/README.md) - -### Services - -Implements business logic and orchestration: - -- Get-or-create patterns -- Data validation and transformation -- Complex workflows - -[Documentation](lib/services/README.md) - -### Extension - -Arma 3 interface layer: - -- Command routing and parsing -- Session management -- Error handling and logging - -[Documentation](arma/server/extension/README.md) - -### Client Mod - -Client-side SQF addon that provides: - -- **UI Components**: Player interfaces for inventory, organizations, banking -- **Event Handlers**: CBA event listeners for server communication -- **Optimistic Caching**: Local data caching for instant UI updates -- **State Management**: Client-side state synchronization -- **Input Validation**: Client-side validation before server requests - -The client mod communicates with the server using **CBA Events**, ensuring: - -- No direct extension calls from clients (security) -- Event-driven architecture for scalability -- Automatic state synchronization across all clients -- Reduced server load through client-side caching - -## Available Commands - -### Actor Commands - -| Command | Description | -| -------------- | -------------------------- | -| `actor:get` | Retrieve actor data by UID | -| `actor:create` | Create a new actor | -| `actor:update` | Update actor fields | -| `actor:exists` | Check if actor exists | -| `actor:delete` | Delete actor data | - -### Organization Commands - -| Command | Description | -| ------------------- | ------------------------------- | -| `org:get` | Retrieve organization data | -| `org:create` | Create a new organization | -| `org:update` | Update organization fields | -| `org:exists` | Check if organization exists | -| `org:delete` | Delete organization | -| `org:add_member` | Add member to organization | -| `org:remove_member` | Remove member from organization | -| `org:get_members` | Get all organization members | - -### Redis Operations - -Direct Redis operations for advanced use cases: - -- **Common**: Key-value operations (set, get, incr, decr, del) -- **Hash**: Structured data (hset, hget, hgetall, hdel) -- **List**: Ordered collections (lpush, rpush, lrange, lpop, rpop) -- **Set**: Unique collections (sadd, smembers, srem, sismember) - -[Documentation](arma/server/extension/src/redis/README.md) - -## Performance - -- **Hot Cache (Server)**: < 1ms (HashMap lookup) -- **Cold Storage (Redis)**: 1-5ms (Network + Redis query) -- **Cache Hit Ratio**: ~95% for active players -- **Memory Usage**: ~1KB per active player (server), ~2KB per player (Redis) - -## Contributing - -We welcome contributions! Please see the contributing guides for each layer: - -- [Extension Contributing Guide](arma/server/extension/README.md#contributing) -- [Services Contributing Guide](lib/services/README.md#contributing) -- [Repositories Contributing Guide](lib/repositories/README.md#contributing) -- [Models Contributing Guide](lib/models/README.md#contributing) -- [Library Contributing Guide](lib/README.md#contributing) -- [Adapter Contributing Guide](arma/server/extension/src/adapters/#contributing) - -### Development Workflow - -1. **Define Model**: Create data structure with validation -2. **Create Repository**: Implement persistence layer -3. **Build Service**: Add business logic -4. **Expose in Extension**: Create SQF-callable commands -5. **Test**: Verify each layer independently - -## Error Handling - -All commands return consistent error messages: - -```sqf -private _result = "forge_server" callExtension ["actor:get", ["invalid_uid"]]; -private _response = _result select 0; - -if (_response find "Error:" == 0) then { - diag_log format ["Operation failed: %1", _response]; -} else { - private _data = fromJSON _response; - // Use data -}; -``` - -## Logging - -Logs are automatically created in `@forge_server/logs/`: - -- `actor.log` - Actor operations -- `org.log` - Organization operations -- `redis.log` - Redis connection and operations -- `debug.log` - General debug information - -## License - -View the License [here](LICENSE.md). - -## Support - -- **Issues**: [Gitea Issues](https://gitea.innovativedevsolutions.org/IDSolutions/forge/issues) -- **Documentation**: See individual module READMEs -- **Architecture**: [Diagram](Architecture_Diagram.md) - -## Roadmap - -- [ ] Admin system -- [ ] Arsenal system -- [ ] Banking system -- [ ] Economy system -- [ ] Garage system -- [ ] Locker system -- [ ] Mission template - ---- - -Built using **Rust**, **Redis**, and **Arma 3** +Both commands report the persistence connection state. diff --git a/arma/server/addons/extension/functions/fnc_extCall.sqf b/arma/server/addons/extension/functions/fnc_extCall.sqf index 7cd5f8d..40754af 100644 --- a/arma/server/addons/extension/functions/fnc_extCall.sqf +++ b/arma/server/addons/extension/functions/fnc_extCall.sqf @@ -61,10 +61,6 @@ private _transportResponseFunctions = [ "org:assets:get", "org:fleet:get" ]; -private _requiresRedis = !(_functionLower in ["status", "version"]) - && (_functionLower find "icom:" == 0) - && (_functionLower find "terrain:" == 0); - private _callExtensionCommand = { params [["_command", "", [""]], ["_commandArguments", [], [[]]]]; @@ -110,28 +106,6 @@ private _callExtensionCommand = { [_response, _responseSuccess] }; -private _checkRedisAvailability = { - ("forge_server" callExtension ["status", []]) params [ - "_redisStatus", - "_statusExtCode", - "_statusArmaCode" - ]; - - private _statusSuccess = (_statusExtCode == 0) && (_statusArmaCode == 0 || _statusArmaCode == 301); - - if (!_statusSuccess) exitWith { - ["WARNING", "Unable to determine Redis status before extension call", nil, nil] call EFUNC(common,log); - ["Error: Redis status check failed", false] - }; - - if (_redisStatus != "connected") exitWith { - ["WARNING", format ["Blocked extension call '%1' because Redis status is '%2'", _function, _redisStatus], nil, nil] call EFUNC(common,log); - [format ["Error: Redis is %1", _redisStatus], false] - }; - - ["", true] -}; - private _buildTransportArgumentsJson = { private _rawArguments = _this; if !(_rawArguments isEqualType []) then { @@ -156,17 +130,6 @@ private _buildTransportArgumentsJson = { format ["[%1]", _encodedArguments joinString ","] }; -if (_requiresRedis) exitWith { - [_function, _arguments] call _checkRedisAvailability params ["_redisResult", "_redisSuccess"]; - if (!_redisSuccess) exitWith { [_redisResult, false] }; - - if (_functionLower in ["status", "version"]) exitWith { - [_function, _arguments] call _callExtensionCommand - }; - - [_function, _arguments] call _callExtensionCommand -}; - if (_functionLower in ["status", "version"]) exitWith { [_function, _arguments] call _callExtensionCommand }; diff --git a/arma/server/config.example.toml b/arma/server/config.example.toml index cce0abb..e25d1a4 100644 --- a/arma/server/config.example.toml +++ b/arma/server/config.example.toml @@ -1,41 +1,10 @@ -# Crate Server Configuration -# Copy this file to config.toml and modify as needed -# Place this file in the same directory as your crate_server_x64.dll +# Forge Server Configuration +# Copy this file to config.toml and place it beside forge_server_x64.dll. -[redis] -# Redis server connection settings -host = "127.0.0.1" -port = 6379 -db = 0 # Redis database number (0-15) - -# Optional authentication -# username = "your_username" -# password = "your_password" - -# Optional connection pool settings -max_connections = 10 # Maximum number of connections in pool -min_connections = 2 # Minimum number of idle connections -idle_timeout = 60 # Idle connection timeout in seconds -connect_timeout_ms = 2000 # Pool connect timeout in milliseconds -pool_get_timeout_ms = 2000 # Pool checkout timeout in milliseconds -command_timeout_ms = 2000 # Redis command timeout in milliseconds - -# Example configurations for different environments: - -# Development (local Redis) -# host = "127.0.0.1" -# port = 6379 -# max_connections = 5 -# min_connections = 1 - -# Production (remote Redis with auth) -# host = "redis.example.com" -# port = 6379 -# username = "arma_server" -# password = "secure_password_here" -# max_connections = 20 -# min_connections = 5 -# idle_timeout = 30 -# connect_timeout_ms = 5000 -# pool_get_timeout_ms = 5000 -# command_timeout_ms = 5000 +[surreal] +endpoint = "127.0.0.1:8000" +namespace = "forge" +database = "main" +username = "root" +password = "root" +connect_timeout_ms = 5000 diff --git a/arma/server/docs/README.md b/arma/server/docs/README.md index 350a651..41a834e 100644 --- a/arma/server/docs/README.md +++ b/arma/server/docs/README.md @@ -1,285 +1,39 @@ -# Forge Server - Redis Client Module +# Forge Server Extension -A high-performance arma-rs extension for Arma 3, featuring a **low-level Redis data access layer** that provides raw Redis operations as a foundation for higher-level game modules. +Forge Server is an arma-rs extension for Arma 3 server-side persistence and +domain services. It exposes game-facing commands and stores durable state in +SurrealDB. -## ๐ŸŽฏ Overview +## Architecture -The Forge Server Redis module is designed as a **foundational data access layer** that: +SQF modules call `forge_server` through `fnc_extCall`. Small requests use the +direct `callExtension` path, while large payloads are staged through the +transport layer. -- **Returns raw Redis responses** for maximum performance and flexibility -- **Serves as the foundation** for higher-level game modules (actor, garage, locker, bank, etc.) -- **Provides connection pooling** and error handling for Redis operations -- **Enables persistent data storage** across server restarts -- **Supports cross-server data sharing** in multi-server environments - -## ๐Ÿ—๏ธ Layered Architecture - -``` -SQF Scripts - โ†“ (JSON responses) -Game Modules (actor, garage, locker, bank) - โ†“ (raw Redis responses) -Redis Client Module - โ†“ (Redis protocol) -Redis Server +```text +SQF module + -> extension bridge + -> domain command + -> service layer + -> repository + -> SurrealDB ``` -**This module handles the bottom layer** - raw Redis operations with connection pooling and error handling. +## Configuration -## ๐Ÿ—๏ธ Internal Architecture - -``` -forge_server_x64.dll Extension -โ”œโ”€โ”€ lib.rs # Core extension initialization & global runtime -โ”œโ”€โ”€ config.example.toml # Example configuration file -โ””โ”€โ”€ redis/ # Redis Client module - โ”œโ”€โ”€ mod.rs # Group definitions & module exports - โ”œโ”€โ”€ client.rs # Connection pool management - โ”œโ”€โ”€ config.rs # Configuration system - โ”œโ”€โ”€ macros.rs # redis_operation! macro for boilerplate elimination - โ”œโ”€โ”€ common.rs # String/key operations - โ”œโ”€โ”€ hash.rs # Hash operations (HSET, HGET, etc.) - โ”œโ”€โ”€ list.rs # List operations (LPUSH, LPOP, etc.) - โ””โ”€โ”€ set.rs # Set operations (SADD, SMEMBERS, etc.) -``` - -### Key Components - -- **lib.rs**: Manages global Redis pool and single Tokio runtime -- **macros.rs**: Provides `redis_operation!` macro to eliminate boilerplate -- **Operation modules**: Focus purely on Redis logic using the macro -- **Synchronous Interface**: All functions appear synchronous to Arma while using async Redis internally - -## ๐Ÿš€ Features - -### Raw Redis Operations - -- **String Operations**: SET, GET, INCR, DECR, DEL, KEYS -- **Hash Operations**: HSET, HGET, HMSET, HGETALL, HDEL, HKEYS, HVALS, HLEN -- **List Operations**: LSET, LGET, LLEN, LRANGE, LPUSH, RPUSH, LPOP, RPOP, LTRIM, LREM -- **Set Operations**: SADD, SMEMBERS, SCARD, SREM, SISMEMBER, SPOP, SRANDMEMBER - -### Performance Features - -- **Connection Pooling**: bb8-redis pool with configurable size and timeouts -- **Single Runtime**: One shared Tokio runtime for all async operations -- **Macro-Based**: `redis_operation!` macro eliminates boilerplate while maintaining performance -- **Synchronous Interface**: Functions block until completion, compatible with Arma's threading model -- **Raw Responses**: Returns native Redis values for maximum performance -- **Thread Safety**: Safe concurrent access from multiple Arma threads - -## โš™๏ธ Configuration System - -Forge Server uses a TOML-based configuration system for flexible Redis connection management. - -### Configuration File - -Create a `config.toml` file in your extension directory: +Copy `config.example.toml` to `config.toml` next to the extension DLL. ```toml -[redis] -host = "127.0.0.1" -port = 6379 -# db = 0 # Optional: Redis database number -# username = "user" # Optional: Redis username -# password = "password" # Optional: Redis password -# max_connections = 10 # Optional: Maximum connections in pool -# min_connections = 2 # Optional: Minimum idle connections in pool -# idle_timeout = 60 # Optional: Connection idle timeout (seconds) +[surreal] +endpoint = "127.0.0.1:8000" +namespace = "forge" +database = "main" +username = "root" +password = "root" +connect_timeout_ms = 5000 ``` -### Fallback Behavior +## References -The extension uses a robust fallback system: - -1. **Loads `config.toml`** if present in the extension directory -2. **Falls back to defaults** if configuration fails or file is missing -3. **Only fails** if both config and defaults cannot establish connection - -**Default Settings:** - -- **Host**: `127.0.0.1` -- **Port**: `6379` -- **Max Connections**: `10` -- **Min Connections**: `2` -- **Idle Timeout**: `60 seconds` - -### Common Configurations - -**Development (Local Redis)**: - -```toml -[redis] -host = "127.0.0.1" -port = 6379 -max_connections = 5 -min_connections = 1 -``` - -**Production (Remote Redis with Authentication)**: - -```toml -[redis] -host = "redis.example.com" -port = 6379 -username = "arma_server" -password = "secure_password" -max_connections = 20 -min_connections = 5 -idle_timeout = 60 -``` - -### Troubleshooting - -**Connection Issues:** - -- Verify Redis server is running: `redis-cli ping` -- Check host/port settings in `config.toml` -- Ensure firewall allows connection - -**Authentication Issues:** - -- Verify username/password in config -- Check Redis server auth settings - -**Config File Issues:** - -- Check TOML syntax with online validators -- Ensure quotes are properly closed -- Verify file permissions - -**Connection Pool Benefits:** - -- Pre-warmed connections for zero-latency operations -- Automatic connection recovery on network issues -- Resource-efficient connection sharing -- Configurable pool sizing for different deployment scenarios - -## ๐Ÿ”ง Installation - -1. **Prerequisites**: - - Redis server (local or remote) - - Arma 3 server with extension support - -2. **Extension Setup**: - - Build the extension: `cargo build --release` - - Copy the compiled `forge_server_x64.dll` to your Arma 3 server - - Copy `config.example.toml` to `config.toml` and configure as needed - - Load in server config or mission - -3. **Redis Server**: - - ```bash - # Start Redis server - redis-server - - # Verify connection - redis-cli ping - ``` - -## ๐Ÿ“ Documentation - -- **[API Reference](./api-reference.md)** - Complete Redis command reference -- **[Usage Examples](./usage-examples.md)** - Practical SQF integration examples - -## ๐Ÿ“Š Performance - -- **Connection Pool**: 2-10 persistent connections using bb8-redis -- **Single Runtime**: One shared Tokio runtime eliminates overhead from multiple runtimes -- **Macro Efficiency**: Zero-cost abstraction โ€“ macros expand to optimal code at compile time -- **Synchronous Blocking**: Functions use `block_on()` for Arma compatibility without sacrificing async I/O benefits -- **Response Format**: Raw Redis responses for minimal overhead -- **Thread Safety**: Multiple Arma threads can safely call operations concurrently -- **Memory Efficient**: Minimal resource usage per operation - -## ๐Ÿ”„ Response Format - -This module returns **raw Redis responses** as strings for maximum performance: - -### Success Responses - -- **String values**: `"John"` (raw string) -- **Numbers**: `"42"` (number as string) -- **Lists/Arrays**: `"item1,item2,item3"` (comma-separated) -- **Hashes**: `"key1,value1,key2,value2"` (comma-separated key-value pairs) -- **Boolean**: `"1"` or `"0"` (for exists checks) -- **Status**: `"OK"` (for successful SET operations) - -### Error Responses - -- **Format**: `"Error: "` -- **Pool errors**: `"Error: Redis pool not initialized"` -- **Connection errors**: `"Error: "` -- **Redis errors**: `"Error: "` - -### Higher-Level JSON Formatting - -Game modules (actor, garage, etc.) will wrap these raw responses in structured JSON for SQF consumption. - -## โš™๏ธ Macro-Based Implementation - -This extension uses a **macro-based architecture** to eliminate boilerplate while maintaining performance: - -### The `redis_operation!` Macro - -```rust -pub fn set_key(key: String, value: String) -> String { - redis_operation!(conn => { - match conn.set::<_, _, ()>(&key, &value).await { - Ok(()) => "OK".to_string(), - Err(e) => format!("Error: {}", e), - } - }) -} -``` - -### What the Macro Handles - -- **Pool Management**: Retrieves Redis connection pool -- **Error Handling**: Returns "Error: ..." for pool/connection failures -- **Async Bridging**: Uses shared Tokio runtime via `block_on()` -- **Connection Acquisition**: Gets connection from pool with error handling -- **Cleanup**: Automatic connection return to pool - -### Benefits - -- **Reduced Code**: 70% less boilerplate per function -- **Consistency**: Identical error handling across all operations -- **Maintainability**: Changes to connection logic in one place -- **Performance**: No runtime overhead from abstraction -- **Synchronous Interface**: Functions block until Redis operation completes - -## ๐Ÿ› ๏ธ Development - -- **Language**: Rust (Edition 2024) -- **Dependencies**: arma-rs, bb8-redis, redis, tokio -- **Architecture**: Macro-based design with single runtime and connection pool -- **Key Patterns**: - - Global state management in `lib.rs` - - Boilerplate elimination via `redis_operation!` macro - - Synchronous interfaces over async operations - - Raw Redis responses for minimal overhead -- **Testing**: Unit tests for core functionality - -## ๐Ÿšจ Error Handling - -The extension provides comprehensive error handling: - -- Connection failures -- Redis operation errors -- Invalid parameters -- Pool initialization errors - -All errors include descriptive messages for debugging. - -## ๐Ÿ” Monitoring - -Connection pool status and Redis operations can be monitored through: - -- Extension logs -- Redis server logs -- Connection pool metrics - ---- - -**Built with โค๏ธ for the Arma 3 community** +- [API Reference](./api-reference.md) +- [Usage Examples](./usage-examples.md) diff --git a/arma/server/docs/api-reference.md b/arma/server/docs/api-reference.md index 47a3f05..7ad1f62 100644 --- a/arma/server/docs/api-reference.md +++ b/arma/server/docs/api-reference.md @@ -1,426 +1,35 @@ -# Redis Client API Reference +# Forge Server API Reference -Complete reference for **raw Redis operations** available in the Forge Server extension. This module returns native Redis values without JSON formatting. +The Forge server extension exposes domain-oriented commands through +`callExtension`. Persistent data is stored through the configured SurrealDB +connection and schema modules. -> **Note**: This is a low-level data access layer. Higher-level game modules (actor, garage, etc.) will provide structured JSON responses for SQF consumption. - -## ๐Ÿ—๏ธ Implementation - -All Redis operations are implemented using the `redis_operation!` macro for: - -- **Consistent Error Handling**: All functions return identical error formats -- **Connection Management**: Automatic pool and connection handling -- **Synchronous Interface**: Functions block until Redis operations complete -- **Performance**: Zero-cost abstraction with compile-time optimization - -## ๐Ÿ”— Command Structure - -All redis client commands follow the pattern: `"forge_server" callExtension ["redis:command", [parameters]]` - -## ๐Ÿ“ Common Operations - -### SET - Store a key-value pair - -**Command**: `redis:common:set` -**Parameters**: `[key, value]` +## Core Commands ```sqf -"forge_server" callExtension ["redis:common:set", ["player_name", "John"]] +"forge_server" callExtension ["version", []]; +"forge_server" callExtension ["status", []]; +"forge_server" callExtension ["surreal:status", []]; ``` -**Raw Response**: `"OK"` - -### GET - Retrieve a value by key - -**Command**: `redis:common:get` -**Parameters**: `[key]` - -```sqf -"forge_server" callExtension ["redis:common:get", ["player_name"]] -``` - -**Raw Response**: `"John"` (the actual stored value) - -### INCR - Increment a numeric value - -**Command**: `redis:common:incr` -**Parameters**: `[key, increment_amount]` - -```sqf -"forge_server" callExtension ["redis:common:incr", ["player_score", 10]] -``` - -**Raw Response**: `"110"` (the new value as string) - -### DECR - Decrement a numeric value - -**Command**: `redis:common:decr` -**Parameters**: `[key, decrement_amount]` - -```sqf -"forge_server" callExtension ["redis:common:decr", ["player_lives", 1]] -``` - -**Raw Response**: `"2"` (the new value as string) - -### DEL - Delete a key - -**Command**: `redis:common:del` -**Parameters**: `[key]` - -```sqf -"forge_server" callExtension ["redis:common:del", ["temp_data"]] -``` - -**Raw Response**: `"1"` (number of keys deleted) - -### KEYS - List all keys matching pattern - -**Command**: `redis:common:keys` -**Parameters**: `[]` (currently returns all keys with "\*" pattern) - -```sqf -"forge_server" callExtension ["redis:common:keys", []] -``` - -**Raw Response**: `"player_name,player_score,mission_state"` (comma-separated list) - -## ๐Ÿ—‚๏ธ Hash Operations - -### HSET - Set hash field - -**Command**: `redis:hash:set` -**Parameters**: `[hash_key, field, value]` - -```sqf -"forge_server" callExtension ["redis:hash:set", ["player:123", "name", "John"]] -``` - -**Raw Response**: `"1"` (number of fields added) - -### HGET - Get hash field - -**Command**: `redis:hash:get` -**Parameters**: `[hash_key, field]` - -```sqf -"forge_server" callExtension ["redis:hash:get", ["player:123", "name"]] -``` - -**Raw Response**: `"John"` (the field value) - -### HMSET - Set multiple hash fields - -**Command**: `redis:hash:mset` -**Parameters**: `[hash_key, [[field1, value1], [field2, value2], ...]]` - -```sqf -"forge_server" callExtension ["redis:hash:mset", ["player:123", [["name", "John"], ["score", "100"]]]] -``` - -**Raw Response**: `"OK"` - -### HGETALL - Get all hash fields - -**Command**: `redis:hash:getall` -**Parameters**: `[hash_key]` - -```sqf -"forge_server" callExtension ["redis:hash:getall", ["player:123"]] -``` - -**Raw Response**: `"name,John,score,100,level,5"` (comma-separated key-value pairs) - -### HDEL - Delete hash field - -**Command**: `redis:hash:del` -**Parameters**: `[hash_key, field]` - -```sqf -"forge_server" callExtension ["redis:hash:del", ["player:123", "temp_field"]] -``` - -**Raw Response**: `"1"` (number of fields removed) - -### HKEYS - Get all hash field names - -**Command**: `redis:hash:keys` -**Parameters**: `[hash_key]` - -```sqf -"forge_server" callExtension ["redis:hash:keys", ["player:123"]] -``` - -**Raw Response**: `"name,score,level"` (comma-separated field names) - -### HVALS - Get all hash values - -**Command**: `redis:hash:vals` -**Parameters**: `[hash_key]` - -```sqf -"forge_server" callExtension ["redis:hash:vals", ["player:123"]] -``` - -**Raw Response**: `"John,100,5"` (comma-separated values) - -### HLEN - Get hash field count - -**Command**: `redis:hash:len` -**Parameters**: `[hash_key]` - -```sqf -"forge_server" callExtension ["redis:hash:len", ["player:123"]] -``` - -**Raw Response**: `"3"` (number of fields in hash) - -## ๐Ÿ“‹ List Operations - -### LSET - Set list element by index - -**Command**: `redis:list:set` -**Parameters**: `[list_key, index, value]` - -```sqf -"forge_server" callExtension ["redis:list:set", ["mission_queue", 0, "patrol_alpha"]] -``` - -**Raw Response**: `"OK"` - -### LGET - Get list element by index - -**Command**: `redis:list:get` -**Parameters**: `[list_key, index]` - -```sqf -"forge_server" callExtension ["redis:list:get", ["mission_queue", 0]] -``` - -**Raw Response**: `"patrol_alpha"` (the element value) - -### LLEN - Get list length - -**Command**: `redis:list:len` -**Parameters**: `[list_key]` - -```sqf -"forge_server" callExtension ["redis:list:len", ["mission_queue"]] -``` - -**Raw Response**: `"5"` (list length) - -### LRANGE - Get list elements in range - -**Command**: `redis:list:range` -**Parameters**: `[list_key, start_index, end_index]` - -```sqf -"forge_server" callExtension ["redis:list:range", ["mission_queue", 0, 2]] -``` - -**Raw Response**: `"patrol_alpha,escort_beta,defend_gamma"` (comma-separated values) - -### LPUSH - Add element to list head - -**Command**: `redis:list:lpush` -**Parameters**: `[list_key, value]` - -```sqf -"forge_server" callExtension ["redis:list:lpush", ["recent_actions", "player_joined"]] -``` - -**Raw Response**: `"6"` (new list length) - -### RPUSH - Add element to list tail - -**Command**: `redis:list:rpush` -**Parameters**: `[list_key, value]` - -```sqf -"forge_server" callExtension ["redis:list:rpush", ["mission_queue", "new_objective"]] -``` - -**Raw Response**: `"6"` (new list length) - -### LPOP - Remove and return element from list head - -**Command**: `redis:list:lpop` -**Parameters**: `[list_key, count]` - -```sqf -"forge_server" callExtension ["redis:list:lpop", ["recent_actions", 1]] -``` - -**Raw Response**: `"player_joined"` (removed element) or `"item1,item2"` (if count > 1) - -### RPOP - Remove and return element from list tail - -**Command**: `redis:list:rpop` -**Parameters**: `[list_key, count]` - -```sqf -"forge_server" callExtension ["redis:list:rpop", ["mission_queue", 1]] -``` - -**Raw Response**: `"new_objective"` (removed element) or `"item1,item2"` (if count > 1) - -### LTRIM - Trim list to specified range - -**Command**: `redis:list:trim` -**Parameters**: `[list_key, start_index, end_index]` - -```sqf -"forge_server" callExtension ["redis:list:trim", ["recent_actions", 0, 9]] // Keep only last 10 items -``` - -**Raw Response**: `"OK"` - -### LREM - Remove elements from list - -**Command**: `redis:list:del` -**Parameters**: `[list_key, count, value]` - -```sqf -"forge_server" callExtension ["redis:list:del", ["mission_queue", 1, "completed_mission"]] -``` - -**Raw Response**: `"1"` (number of elements removed) - -## ๐ŸŽฏ Set Operations - -### SADD - Add element to set - -**Command**: `redis:set:add` -**Parameters**: `[set_key, value]` - -```sqf -"forge_server" callExtension ["redis:set:add", ["online_players", "player_123"]] -``` - -**Raw Response**: `"1"` (1 if element was added, 0 if already existed) - -### SMEMBERS - Get all set members - -**Command**: `redis:set:members` -**Parameters**: `[set_key]` - -```sqf -"forge_server" callExtension ["redis:set:members", ["online_players"]] -``` - -**Raw Response**: `"player_123,player_456,player_789"` (comma-separated members) - -### SCARD - Get set size - -**Command**: `redis:set:card` -**Parameters**: `[set_key]` - -```sqf -"forge_server" callExtension ["redis:set:card", ["online_players"]] -``` - -**Raw Response**: `"3"` (number of elements in set) - -### SREM - Remove element from set - -**Command**: `redis:set:del` -**Parameters**: `[set_key, value]` - -```sqf -"forge_server" callExtension ["redis:set:del", ["online_players", "player_456"]] -``` - -**Raw Response**: `"1"` (1 if element was removed, 0 if didn't exist) - -### SISMEMBER - Check if element is in set - -**Command**: `redis:set:ismember` -**Parameters**: `[set_key, value]` - -```sqf -"forge_server" callExtension ["redis:set:ismember", ["online_players", "player_123"]] -``` - -**Raw Response**: `"1"` (1 if member exists, 0 if not) - -### SPOP - Remove and return random element - -**Command**: `redis:set:pop` -**Parameters**: `[set_key]` - -```sqf -"forge_server" callExtension ["redis:set:pop", ["available_missions"]] -``` - -**Raw Response**: `"mission_alpha"` (the removed element) - -### SRANDMEMBER - Get random element without removing - -**Command**: `redis:set:randmember` -**Parameters**: `[set_key]` - -```sqf -"forge_server" callExtension ["redis:set:randmember", ["available_missions"]] -``` - -**Raw Response**: `"mission_beta"` (a random element) - -### SRANDMEMBER - Get multiple random elements - -**Command**: `redis:set:randmembers` -**Parameters**: `[set_key, count]` - -```sqf -"forge_server" callExtension ["redis:set:randmembers", ["available_missions", 3]] -``` - -**Raw Response**: `"mission_alpha,mission_gamma,mission_delta"` (comma-separated random elements) - -## โš ๏ธ Error Responses - -All commands may return error responses in this format: - -**Raw Error Response**: `"Error: "` - -### Common Error Types - -- **Pool not initialized**: `"Error: Redis pool not initialized"` -- **Connection failed**: `"Error: Connection refused (os error 61)"` -- **Key not found**: `"Error: key not found"` (for operations on non-existent keys) -- **Invalid type**: `"Error: WRONGTYPE Operation against a key holding the wrong kind of value"` -- **Index out of range**: `"Error: index out of range"` (for list operations) - -### Error Handling in Game Modules - -Higher-level game modules should check if the response starts with `"Error: "` to distinguish between successful responses and errors. - -```json -{ - "status": "error", - "error": "Failed to connect to Redis server" -} -``` - -Common error types: - -- **Connection errors**: Redis server unavailable -- **Operation errors**: Invalid data type for operation -- **Parameter errors**: Missing or invalid parameters -- **Pool errors**: Connection pool exhausted - -## ๐Ÿ“Š Response Fields - -### Common Fields - -- `status`: Always present - "success" or "error" -- `key`: The Redis key being operated on -- `error`: Error message (only on error responses) - -### Success-Specific Fields - -- `data`: The retrieved data (for GET operations) -- `value`: The stored value (for SET operations) -- `was_new`: Boolean indicating if operation created new data -- `removed_count`: Number of elements removed -- `fields_set`: Number of fields set in hash operations +`status` and `surreal:status` return `initializing`, `connected`, or `failed`. + +## Domain Commands + +Game systems should call the domain APIs instead of raw database operations: + +- `actor:*` +- `bank:*` +- `garage:*` +- `locker:*` +- `org:*` +- `phone:*` +- `store:*` +- `task:*` +- `cad:*` +- `owned:garage:*` +- `owned:locker:*` +- `transport:*` + +Large request and response payloads are routed through the transport layer when +needed by `forge_server_addons_extension_fnc_extCall`. diff --git a/arma/server/docs/usage-examples.md b/arma/server/docs/usage-examples.md index 026450c..c766ea2 100644 --- a/arma/server/docs/usage-examples.md +++ b/arma/server/docs/usage-examples.md @@ -1,437 +1,47 @@ -# Redis Client Usage Examples +# Forge Server Usage Examples -Practical examples of using the **raw Redis client module** as a foundation for higher-level game modules. These examples show low-level Redis operations that would typically be wrapped by game-specific modules (actor, garage, locker, bank). +These examples use the domain command surface exposed by the extension. +Persistence is handled by the server through SurrealDB. -> **Note**: These examples show raw Redis responses. In practice, your game modules would wrap these calls and return structured JSON to SQF scripts. - -## ๐Ÿš€ Function Behavior - -All Redis functions are **synchronous from SQF's perspective**: - -- Functions **block** until Redis operation completes -- **No callbacks** or async handling needed in SQF -- **Direct return values** โ€“ either data or error strings -- **Thread-safe** โ€“ multiple scripts can call simultaneously - -The extension handles all async complexity internally using a macro-based architecture. - -## ๐ŸŽฎ Player Management - -### Player Join/Leave Tracking +## Status Check ```sqf -// When player joins -_playerUID = getPlayerUID player; -_playerName = name player; - -// Store player info in hash -"forge_server" callExtension ["redis:hash:set", [format ["player:%1", _playerUID], "name", _playerName]]; -"forge_server" callExtension ["redis:hash:set", [format ["player:%1", _playerUID], "join_time", str time]]; - -// Add to online players set -"forge_server" callExtension ["redis:set:add", ["online_players", _playerUID]]; - -// When player leaves -"forge_server" callExtension ["redis:set:del", ["online_players", _playerUID]]; -"forge_server" callExtension ["redis:hash:set", [format ["player:%1", _playerUID], "leave_time", str time]]; -``` - -### Player Statistics System - -```sqf -// Initialize player stats -fnc_initPlayerStats = { - params ["_playerUID"]; - - _playerKey = format ["stats:%1", _playerUID]; - "forge_server" callExtension ["redis:hash:mset", [_playerKey, [ - ["kills", "0"], - ["deaths", "0"], - ["score", "0"], - ["playtime", "0"] - ]]]; -}; - -// Update player kill -fnc_addPlayerKill = { - params ["_playerUID"]; - - _playerKey = format ["stats:%1", _playerUID]; - "forge_server" callExtension ["redis:hash:incr", [_playerKey, "kills", 1]]; - "forge_server" callExtension ["redis:hash:incr", [_playerKey, "score", 10]]; -}; - -// Get player stats (raw response) -fnc_getPlayerStats = { - params ["_playerUID"]; - - _playerKey = format ["stats:%1", _playerUID]; - _rawResult = "forge_server" callExtension ["redis:hash:getall", [_playerKey]]; - // _rawResult is now "kills,15,deaths,3,score,150,playtime,7200" - - // Game modules would parse this into structured data - // For now, return raw comma-separated response - _rawResult select 0; +["status", []] call forge_server_extension_fnc_extCall params ["_status", "_ok"]; +if (_ok && {_status isEqualTo "connected"}) then { + systemChat "Forge persistence is online."; }; ``` -## ๐Ÿ† Leaderboards and Rankings - -### Global Kill Leaderboard +## Actor Fetch ```sqf -// Add score to sorted leaderboard (using list for simplicity) -fnc_updateLeaderboard = { - params ["_playerName", "_kills"]; - - // Store individual score - "forge_server" callExtension ["redis:common:set", [format ["kills:%1", _playerName], str _kills]]; - - // Add to leaderboard tracking - "forge_server" callExtension ["redis:set:add", ["leaderboard_players", _playerName]]; -}; - -// Get top 10 players (raw response handling) -fnc_getTopPlayers = { - // Get all leaderboard players - returns comma-separated list - _playersResult = "forge_server" callExtension ["redis:set:members", ["leaderboard_players"]]; - _rawPlayers = _playersResult select 0; - - // Check for error - if (_rawPlayers find "Error:" == 0) exitWith { [] }; - - // Split comma-separated player list - _players = _rawPlayers splitString ","; - _scoreArray = []; - - // Get scores for all players - { - _killsResult = "forge_server" callExtension ["redis:common:get", [format ["kills:%1", _x]]]; - _rawKills = _killsResult select 0; - - // Check for valid response (not an error) - if (_rawKills find "Error:" != 0) then { - _scoreArray pushBack [_x, parseNumber _rawKills]; - }; - } forEach _players; - - // Sort by score (highest first) - _scoreArray sort false; - _scoreArray resize (10 min (count _scoreArray)); // Top 10 - - _scoreArray; +private _uid = getPlayerUID player; +["actor:get", [_uid]] call forge_server_extension_fnc_extCall params ["_payload", "_ok"]; +if (_ok) then { + private _actor = fromJSON _payload; + systemChat format ["Loaded actor %1", _actor getOrDefault ["uid", _uid]]; }; ``` -## ๐ŸŽฏ Mission State Management - -### Objective System +## Store Checkout ```sqf -// Set mission objectives -fnc_initMissionObjectives = { - "forge_server" callExtension ["redis:list:rpush", ["objectives", "Secure Alpha Base"]]; - "forge_server" callExtension ["redis:list:rpush", ["objectives", "Extract Intel"]]; - "forge_server" callExtension ["redis:list:rpush", ["objectives", "Eliminate HVT"]]; - - // Set current objective pointer - "forge_server" callExtension ["redis:common:set", ["current_objective", "0"]]; -}; - -// Complete current objective -fnc_completeObjective = { - // Get current objective index - returns raw string - _indexResult = "forge_server" callExtension ["redis:common:get", ["current_objective"]]; - _rawIndex = _indexResult select 0; - - // Check for error - if (_rawIndex find "Error:" == 0) exitWith {}; - - _currentIndex = parseNumber _rawIndex; - - // Get objective name - returns raw string - _objResult = "forge_server" callExtension ["redis:list:get", ["objectives", _currentIndex]]; - _objectiveName = _objResult select 0; - - // Check for valid response - if (_objectiveName find "Error:" != 0) then { - // Move to completed objectives - returns new list length - "forge_server" callExtension ["redis:list:rpush", ["completed_objectives", _objectiveName]]; - - // Move to next objective - returns "OK" - "forge_server" callExtension ["redis:common:set", ["current_objective", str (_currentIndex + 1)]]; - - // Broadcast completion - [format ["Objective Complete: %1", _objectiveName]] remoteExec ["hint"]; - }; -}; - -// Get mission progress - raw responses -fnc_getMissionProgress = { - _totalResult = "forge_server" callExtension ["redis:list:len", ["objectives"]]; - _completedResult = "forge_server" callExtension ["redis:list:len", ["completed_objectives"]]; - - _rawTotal = _totalResult select 0; - _rawCompleted = _completedResult select 0; - - // Check for errors - if (_rawTotal find "Error:" == 0 || _rawCompleted find "Error:" == 0) exitWith { - "Mission Progress: Unknown"; - }; - - _total = parseNumber _rawTotal; - _completed = parseNumber _rawCompleted; - - format ["Mission Progress: %1/%2 objectives completed", _completed, _total]; -}; -``` - -## ๐Ÿš Vehicle and Equipment Tracking - -### Vehicle Pool System - -```sqf -// Initialize vehicle pool -fnc_initVehiclePool = { - params ["_vehicleClass", "_count"]; - - for "_i" from 1 to _count do { - _vehicleId = format ["%1_%2", _vehicleClass, _i]; - "forge_server" callExtension ["redis:set:add", ["available_vehicles", _vehicleId]]; - "forge_server" callExtension ["redis:hash:mset", [format ["vehicle:%1", _vehicleId], [ - ["class", _vehicleClass], - ["status", "available"], - ["condition", "100"] - ]]]; - }; -}; - -// Request vehicle -fnc_requestVehicle = { - params ["_playerUID"]; - - // Get random available vehicle - _result = "forge_server" callExtension ["redis:set:pop", ["available_vehicles"]]; - _data = fromJSON (_result select 0); - - if ((_data select "status") == "success") then { - _vehicleId = _data select "data"; - - // Mark as in use - "forge_server" callExtension ["redis:hash:set", [format ["vehicle:%1", _vehicleId], "status", "in_use"]]; - "forge_server" callExtension ["redis:hash:set", [format ["vehicle:%1", _vehicleId], "user", _playerUID]]; - "forge_server" callExtension ["redis:set:add", ["used_vehicles", _vehicleId]]; - - _vehicleId; - } else { - ""; // No vehicles available - }; -}; - -// Return vehicle -fnc_returnVehicle = { - params ["_vehicleId", "_condition"]; - - // Update condition - "forge_server" callExtension ["redis:hash:set", [format ["vehicle:%1", _vehicleId], "condition", str _condition]]; - - // Return to pool if condition is good - if (_condition > 50) then { - "forge_server" callExtension ["redis:hash:set", [format ["vehicle:%1", _vehicleId], "status", "available"]]; - "forge_server" callExtension ["redis:hash:del", [format ["vehicle:%1", _vehicleId], "user"]]; - "forge_server" callExtension ["redis:set:del", ["used_vehicles", _vehicleId]]; - "forge_server" callExtension ["redis:set:add", ["available_vehicles", _vehicleId]]; - } else { - "forge_server" callExtension ["redis:hash:set", [format ["vehicle:%1", _vehicleId], "status", "maintenance"]]; - "forge_server" callExtension ["redis:set:add", ["maintenance_vehicles", _vehicleId]]; - }; -}; -``` - -## ๐Ÿ“Š Server Analytics - -### Player Session Tracking - -```sqf -// Track player session start -fnc_startPlayerSession = { - params ["_playerUID"]; - - _sessionId = format ["%1_%2", _playerUID, floor time]; - _sessionKey = format ["session:%1", _sessionId]; - - "forge_server" callExtension ["redis:hash:mset", [_sessionKey, [ - ["player_uid", _playerUID], - ["start_time", str time], - ["server_id", serverName], - ["player_count", str (count allPlayers)] - ]]]; - - // Store current session for player - "forge_server" callExtension ["redis:common:set", [format ["current_session:%1", _playerUID], _sessionId]]; - - _sessionId; -}; - -// End player session -fnc_endPlayerSession = { - params ["_playerUID", "_sessionStats"]; - - // Get current session - _result = "forge_server" callExtension ["redis:common:get", [format ["current_session:%1", _playerUID]]]; - _data = fromJSON (_result select 0); - - if ((_data select "status") == "success") then { - _sessionId = _data select "data"; - _sessionKey = format ["session:%1", _sessionId]; - - // Update session with end data - "forge_server" callExtension ["redis:hash:mset", [_sessionKey, [ - ["end_time", str time], - ["duration", str (_sessionStats select "duration")], - ["kills", str (_sessionStats select "kills")], - ["deaths", str (_sessionStats select "deaths")] - ]]]; - - // Clean up current session tracking - "forge_server" callExtension ["redis:common:del", [format ["current_session:%1", _playerUID]]]; - }; -}; -``` - -## ๐Ÿ”„ Cross-Server Communication - -### Message Queue System - -```sqf -// Send message to other servers -fnc_sendCrossServerMessage = { - params ["_targetServer", "_messageType", "_messageData"]; - - _message = createHashMap; - _message set ["from_server", serverName]; - _message set ["type", _messageType]; - _message set ["data", _messageData]; - _message set ["timestamp", str time]; - - _queueKey = format ["messages:%1", _targetServer]; - "forge_server" callExtension ["redis:list:rpush", [_queueKey, str _message]]; -}; - -// Check for incoming messages -fnc_checkMessages = { - _queueKey = format ["messages:%1", serverName]; - - // Get next message - _result = "forge_server" callExtension ["redis:list:lpop", [_queueKey, 1]]; - _data = fromJSON (_result select 0); - - if ((_data select "status") == "success") then { - _messages = _data select "data"; - if (count _messages > 0) then { - _messageStr = _messages select 0; - _message = fromJSON _messageStr; - - // Process message based on type - _type = _message select "type"; - _messageData = _message select "data"; - - switch (_type) do { - case "player_transfer": { - [_messageData] call fnc_handlePlayerTransfer; - }; - case "server_status": { - [_messageData] call fnc_handleServerStatus; - }; - case "admin_broadcast": { - [_messageData select "message"] remoteExec ["hint"]; - }; - }; - }; - }; -}; - -// Run message checker periodically -[] spawn { - while {true} do { - call fnc_checkMessages; - sleep 5; // Check every 5 seconds - }; -}; -``` - -## ๐Ÿ› ๏ธ Utility Functions - -### Redis Helper Functions - -```sqf -// Parse Redis response safely -fnc_parseRedisResponse = { - params ["_response"]; - - try { - _data = fromJSON (_response select 0); - if ((_data select "status") == "success") then { - _data select "data"; - } else { - diag_log format ["Redis Error: %1", _data select "error"]; - nil; - }; - } catch { - diag_log format ["JSON Parse Error: %1", _exception]; - nil; - }; -}; - -// Batch Redis operations -fnc_redisBatch = { - params ["_operations"]; - - _results = []; - { - _op = _x; - _result = "forge_server" callExtension [_op select 0, _op select 1]; - _results pushBack (fromJSON (_result select 0)); - } forEach _operations; - - _results; -}; - -// Example batch usage: -_batchOps = [ - ["redis:common:set", ["key1", "value1"]], - ["redis:common:set", ["key2", "value2"]], - ["redis:common:get", ["key1"]] +private _checkout = createHashMapFromArray [ + ["requesterUid", getPlayerUID player], + ["requesterName", name player], + ["orgId", "default"], + ["requesterIsDefaultOrgCeo", false], + ["paymentMethod", "bank"], + ["items", [ + createHashMapFromArray [ + ["classname", "FirstAidKit"], + ["category", "item"], + ["priceValue", 50], + ["quantity", 2] + ] + ]], + ["vehicles", []] ]; -_results = [_batchOps] call fnc_redisBatch; + +["store:checkout", [toJSON _checkout]] call forge_server_extension_fnc_extCall; ``` - -## ๐ŸŽฏ Best Practices - -### Error Handling Pattern - -```sqf -fnc_safeRedisCall = { - params ["_command", "_params", ["_defaultValue", nil]]; - - try { - _result = "forge_server" callExtension [_command, _params]; - _data = fromJSON (_result select 0); - - if ((_data select "status") == "success") then { - _data select "data"; - } else { - diag_log format ["Redis operation failed: %1 - %2", _command, _data select "error"]; - _defaultValue; - }; - } catch { - diag_log format ["Redis call exception: %1 - %2", _command, _exception]; - _defaultValue; - }; -}; - -// Usage: -_playerName = ["redis:common:get", ["player_name"], "Unknown"] call fnc_safeRedisCall; -``` - -These examples demonstrate real-world usage patterns for the Redis extension in Arma 3 environments, covering player management, mission state, analytics, and cross-server communication. diff --git a/arma/server/extension/Cargo.toml b/arma/server/extension/Cargo.toml index e4410d1..dd91c69 100644 --- a/arma/server/extension/Cargo.toml +++ b/arma/server/extension/Cargo.toml @@ -10,14 +10,12 @@ crate-type = ["cdylib"] [dependencies] arma-rs = { workspace = true } base64 = "0.22.1" -bb8-redis = "0.26.0" chrono = { workspace = true } forge-icom = { path = "../../../bin/icom" } forge-models = { path = "../../../lib/models", features = ["actor"] } forge-repositories = { path = "../../../lib/repositories" } forge-services = { path = "../../../lib/services" } forge-shared = { path = "../../../lib/shared" } -redis = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } surrealdb = { version = "2", default-features = false, features = ["protocol-http", "rustls"] } diff --git a/arma/server/extension/README.md b/arma/server/extension/README.md index 7f118a4..128af85 100644 --- a/arma/server/extension/README.md +++ b/arma/server/extension/README.md @@ -1,405 +1,41 @@ -# Forge Arma 3 Server Extension +# Forge Server Extension -This extension provides the core server-side functionality for the Forge framework, handling persistent data storage, actor management, and game state synchronization through a high-performance Rust backend. +The Forge server extension is the Rust backend for server-side game systems. +It exposes domain commands through `arma-rs`, runs a shared Tokio runtime, and +persists durable state through SurrealDB. -## Architecture +## Responsibilities -The extension follows a layered architecture designed for reliability, performance, and maintainability: +- Register extension command groups for actor, bank, garage, locker, org, + phone, store, task, CAD, terrain, and transport systems. +- Load extension configuration from `@forge_server/config.toml`. +- Connect to SurrealDB and apply schema modules on startup. +- Keep SQF-facing command handlers thin while service crates own domain rules. -- **Extension Layer**: Handles the raw Arma 3 `callExtension` interface, parameter parsing, and command routing. -- **Service Layer**: Implements business logic, validation, and orchestration of operations (e.g., `ActorService`). -- **Repository Layer**: Manages data persistence and retrieval using Redis (e.g., `RedisActorRepository`). -- **Model Layer**: Defines strict data structures and validation rules (e.g., `Actor` model). +## Configuration -This separation ensures that game logic is decoupled from data storage and that all data entering the system is validated before persistence. - -### Module Documentation - -For detailed information about specific modules, see: - -- **[Redis Operations](src/redis/README.md)**: Comprehensive guide to Redis commands (hash, list, set, common operations) -- **[Adapters](src/adapters/README.md)**: Adapter pattern implementation bridging repositories with Redis - -## Organization Management - -The Organization module handles guild/clan management, allowing players to form groups, manage members, and persist organizational data. It supports role management, automatic UID resolution, and robust error handling. - -### Available Commands - -| Command | Description | -| ------------------- | ------------------------------------------------------- | -| `org:get` | Retrieve organization data by key or ID. | -| `org:create` | Create a new organization with provided JSON data. | -| `org:update` | Update an existing organization with partial JSON data. | -| `org:delete` | Permanently remove an organization and its data. | -| `org:exists` | Check if an organization exists. | -| `org:get_members` | Retrieve a list of organization members. | -| `org:add_member` | Add a member to an organization. | -| `org:remove_member` | Remove a member from an organization. | - -### SQF Examples - -#### Retrieving an Organization - -```sqf -// Get organization by ID -private _result = "forge_server" callExtension ["org:get", ["elite_squad"]]; -private _orgData = fromJSON (_result select 0); - -// Access data -private _name = _orgData get "name"; -private _leader = _orgData get "leader"; +```toml +[surreal] +endpoint = "127.0.0.1:8000" +namespace = "forge" +database = "main" +username = "root" +password = "root" +connect_timeout_ms = 5000 ``` -#### Creating an Organization +## Status ```sqf -// Prepare data using HashMap -private _data = createHashMapFromArray [ - ["name", "Elite Squad"], - ["description", "Best players"], - ["leader", getPlayerUID player], - ["max_members", 50], - ["type", "military"] -]; - -// Create the organization -private _result = "forge_server" callExtension ["org:create", ["elite_squad", toJSON _data]]; - -if ((_result select 0) find "Error:" == 0) then { - diag_log format ["Failed to create org: %1", _result select 0]; -} else { - private _createdOrg = fromJSON (_result select 0); - systemChat format ["Created organization: %1", _createdOrg get "name"]; -}; +"forge_server" callExtension ["status", []]; +"forge_server" callExtension ["surreal:status", []]; ``` -#### Updating an Organization +Status values are `initializing`, `connected`, or `failed`. -```sqf -// Prepare partial update -private _update = createHashMapFromArray [ - ["description", "Updated description"], - ["max_members", 100] -]; +## Build -// Apply update -private _result = "forge_server" callExtension ["org:update", ["elite_squad", toJSON _update]]; +```powershell +cargo test -p forge-server +cargo build -p forge-server ``` - -#### Managing Members - -```sqf -// Get members -private _result = "forge_server" callExtension ["org:get_members", ["elite_squad"]]; -private _members = fromJSON (_result select 0); - -// Add a member -private _addResult = "forge_server" callExtension ["org:add_member", ["elite_squad", "76561198123456789"]]; - -// Remove a member -private _removeResult = "forge_server" callExtension ["org:remove_member", ["elite_squad", "76561198123456789"]]; -``` - -#### Checking Existence - -```sqf -private _exists = "forge_server" callExtension ["org:exists", ["elite_squad"]]; - -if ((_exists select 0) == "true") then { - systemChat "Organization exists."; -}; -``` - -#### Deleting an Organization - -```sqf -// Permanently delete organization -private _result = "forge_server" callExtension ["org:delete", ["elite_squad"]]; - -if ((_result select 0) == "OK") then { - systemChat "Organization deleted."; -}; -``` - -## Actor Management - -The Actor module handles all player-related operations, including data retrieval, creation, updates, and existence checks. It features automatic Steam UID resolution and robust error handling. - -### Available Commands - -| Command | Description | -| -------------- | ------------------------------------------------ | -| `actor:get` | Retrieve actor data by key or UID. | -| `actor:create` | Create a new actor with provided JSON data. | -| `actor:update` | Update an existing actor with partial JSON data. | -| `actor:exists` | Check if an actor exists in the database. | -| `actor:delete` | Permanently remove an actor and their data. | - -### SQF Examples - -The extension is designed to work seamlessly with modern Arma 3 SQF features like `HashMap` and `toJSON`/`fromJSON`. - -#### Retrieving an Actor - -```sqf -// Get actor by Steam UID -private _result = "forge_server" callExtension ["actor:get", ["76561198123456789"]]; -private _actorData = fromJSON (_result select 0); - -// Access data -private _name = _actorData get "name"; -private _bank = _actorData get "bank"; -``` - -#### Creating an Actor - -```sqf -// Prepare data using HashMap -private _data = createHashMapFromArray [ - ["name", "John Doe"], - ["bank", 1000], - ["cash", 100], - ["level", 1], - ["class", "civilian"] -]; - -// Create the actor -private _result = "forge_server" callExtension ["actor:create", ["player123", toJSON _data]]; - -if ((_result select 0) find "Error:" == 0) then { - diag_log format ["Failed to create actor: %1", _result select 0]; -} else { - private _createdActor = fromJSON (_result select 0); - systemChat format ["Welcome, %1!", _createdActor get "name"]; -}; -``` - -#### Updating an Actor - -```sqf -// Prepare partial update -private _update = createHashMapFromArray [ - ["bank", 1500], - ["level", 2] -]; - -// Apply update -private _result = "forge_server" callExtension ["actor:update", ["player123", toJSON _update]]; -``` - -#### Checking Existence - -```sqf -private _exists = "forge_server" callExtension ["actor:exists", ["player123"]]; - -if ((_exists select 0) == "true") then { - systemChat "Player profile found."; -} else { - systemChat "Player profile not found."; -}; -``` - -#### Deleting an Actor - -```sqf -// Permanently delete actor data -private _result = "forge_server" callExtension ["actor:delete", ["player123"]]; - -if ((_result select 0) == "OK") then { - systemChat "Actor deleted successfully."; -}; -``` - -## Error Handling - -The extension uses a consistent error reporting format. If an operation fails, the returned string will start with `Error: ` followed by a descriptive message. - -- **Consistent Responses**: All commands return JSON on success or an error message on failure. -- **No Fallbacks**: `actor:get` and `org:get` will return error messages if the requested entity cannot be found, rather than fallback objects with dummy data. -- **Validation**: All input data is validated against the strict schema defined in the models. Invalid data will result in an error message. - -### Example Error Handling - -```sqf -private _result = "forge_server" callExtension ["actor:get", ["76561198123456789"]]; -private _response = _result select 0; - -if (_response find "Error:" == 0) then { - diag_log format ["Failed to get actor: %1", _response]; -} else { - private _actorData = fromJSON _response; - systemChat format ["Welcome, %1!", _actorData get "name"]; -}; -``` - -## Performance - -- **Asynchronous Core**: Built on `tokio`, the extension performs heavy I/O operations (like database writes) without blocking the Arma 3 simulation thread. -- **Connection Pooling**: Uses a Redis connection pool to efficiently manage database connections. -- **Lazy Initialization**: Services are initialized only when first needed, reducing startup time. -- **Minimal Serialization**: Only necessary data is serialized and transferred between Rust and SQF to minimize overhead. - -## Contributing - -We welcome contributions to the Forge Extension! This guide will help you understand how to add new commands and maintain the existing codebase. - -### Adding a Command to an Existing Module - -To add a new command to an existing module (e.g., `actor:set_position`), follow these steps: - -1. **Register the Command**: In the module file (e.g., `src/actor.rs`), add the command to the `group()` function. - - ```rust - pub fn group() -> Group { - Group::new() - .command("get", get_actor) - .command("exists", exists_actor) - .command("create", create_actor) - .command("update", update_actor) - .command("delete", delete_actor) - .command("set_position", set_actor_position) // New command - } - ``` - -2. **Implement the Handler Function**: Create the function that handles the command logic. - - ```rust - use crate::log::log; - - /// Sets the position of an actor. - pub fn set_actor_position(call_context: CallContext, key: String, position: String) -> String { - log("actor", "DEBUG", &format!("Setting position for key: {}", key)); - - // 1. Resolve UID - let resolved_uid = match resolve_uid(&key, &call_context) { - Some(uid) => uid, - None => { - let error_msg = format!("Error: Failed to resolve UID for key: {}", key); - log("actor", "ERROR", &error_msg); - return error_msg; - } - }; - - // 2. Parse and validate input - let position_data: Vec = match serde_json::from_str(&position) { - Ok(data) => data, - Err(e) => { - let error_msg = format!("Error: Invalid position JSON: {}", e); - log("actor", "ERROR", &error_msg); - return error_msg; - } - }; - - // 3. Get the actor, update position, and save - match ACTOR_SERVICE.get_actor(resolved_uid.clone()) { - Ok(mut actor) => { - actor.set_position(position_data); - - match ACTOR_SERVICE.update_actor(actor.clone()) { - Ok(_) => { - log("actor", "INFO", &format!("Updated position for: {}", resolved_uid)); - match serde_json::to_string(&actor) { - Ok(json) => json, - Err(e) => format!("Error: Failed to serialize actor: {}", e), - } - } - Err(e) => format!("Error: {}", e), - } - } - Err(e) => format!("Error: {}", e), - } - } - ``` - -### Creating a New Module - -To create a new module (e.g., `vehicle`), follow these steps: - -1. **Create the Module File**: Add `src/vehicle.rs`. -2. **Create the Global Service Instance**: Define a lazily initialized singleton service. - - ```rust - use std::sync::LazyLock; - use forge_services::VehicleService; - use forge_repositories::RedisVehicleRepository; - use crate::adapters::ExtensionRedisClient; - - static VEHICLE_SERVICE: LazyLock>> = - LazyLock::new(|| { - let redis_client = ExtensionRedisClient::new(); - let repository = RedisVehicleRepository::new(redis_client); - VehicleService::new(repository) - }); - ``` - -3. **Register the Command**: In the module file, register the command in the `group()` function. - ```rust - pub fn group() -> Group { - Group::new() - .command("get", get_vehicle) - .command("create", create_vehicle) - // ... other commands - } - ``` -4. **Use Logging**: Import and use the generic `log` function in your handler functions. - - ```rust - use crate::log::log; - - pub fn get_vehicle(key: String) -> String { - log("vehicle", "DEBUG", &format!("Getting vehicle for key: {}", key)); - - // Call service layer - match VEHICLE_SERVICE.get_vehicle(key.clone()) { - Ok(vehicle) => { - log("vehicle", "INFO", &format!("Successfully retrieved vehicle: {}", key)); - match serde_json::to_string(&vehicle) { - Ok(json) => { - log("vehicle", "DEBUG", &format!("Serialized vehicle to JSON: {}", json)); - json - } - Err(e) => { - let error_msg = format!("Error: Failed to serialize vehicle: {}", e); - log("vehicle", "ERROR", &error_msg); - error_msg - } - } - } - Err(e) => { - let error_msg = format!("Error: {}", e); - log("vehicle", "ERROR", &format!("Failed to get vehicle '{}': {}", key, e)); - error_msg - } - } - } - ``` - - The `log` function takes three parameters: - - `category`: The log category (e.g., "vehicle", "actor", "org") - - `level`: The log level ("INFO", "DEBUG", "WARN", "ERROR") - - `message`: The message to log - - Log files are created automatically in `@forge_server/logs/{category}.log`. - -5. **Register the Module** (if new): If you created a new module, add it to `src/lib.rs`. - - ```rust - pub mod vehicle; - - // In the extension function, register the group - extension.group("vehicle", vehicle::group()); - ``` - -### Testing - -- **In-Game Testing**: Test your commands in Arma 3 to ensure they work correctly with SQF. -- **Error Cases**: Test error scenarios (invalid input, missing entities, etc.) to ensure proper error messages. - -### Best Practices - -- **Return Types**: Always return `String` (JSON on success, error message on failure). -- **Error Messages**: Prefix all error messages with `"Error: "` for consistency. -- **Logging**: Use the `log(category, level, message)` function to track operations. -- **Service Layer**: Delegate business logic to the service layer. The extension layer should only handle parameter parsing and response formatting. -- **Validation**: Validate inputs before calling the service layer to provide clear error messages. diff --git a/arma/server/extension/config.example.toml b/arma/server/extension/config.example.toml index f2df52b..bd1bc63 100644 --- a/arma/server/extension/config.example.toml +++ b/arma/server/extension/config.example.toml @@ -1,59 +1,14 @@ -# Crate Server Configuration -# Copy this file to config.toml and modify as needed -# Place this file in the same directory as your crate_server_x64.dll - -[storage] -# Redis remains the default while modules are migrated incrementally. -# Current SurrealDB-backed durable repositories: -# actor, bank, garage, locker, owned garage, owned locker, org, phone. -backend = "redis" # "redis" or "surreal" - -[redis] -# Redis server connection settings -host = "127.0.0.1" -port = 6379 -db = 0 # Redis database number (0-15) - -# Optional authentication -# username = "your_username" -# password = "your_password" - -# Optional connection pool settings -max_connections = 10 # Maximum number of connections in pool -min_connections = 2 # Minimum number of idle connections -idle_timeout = 60 # Idle connection timeout in seconds -connect_timeout_ms = 2000 # Pool connect timeout in milliseconds -pool_get_timeout_ms = 2000 # Pool checkout timeout in milliseconds -command_timeout_ms = 2000 # Redis command timeout in milliseconds +# Forge Server Configuration +# Copy this file to config.toml and place it beside forge_server_x64.dll. [surreal] -# SurrealDB HTTP endpoint. Use "127.0.0.1:8000" for a local SurrealDB server. +# SurrealDB HTTP endpoint. Use "127.0.0.1:8000" for a local server. endpoint = "127.0.0.1:8000" namespace = "forge" database = "main" -# Optional authentication +# Optional authentication. username = "root" password = "root" connect_timeout_ms = 5000 - -# Example configurations for different environments: - -# Development (local Redis) -# host = "127.0.0.1" -# port = 6379 -# max_connections = 5 -# min_connections = 1 - -# Production (remote Redis with auth) -# host = "redis.example.com" -# port = 6379 -# username = "arma_server" -# password = "secure_password_here" -# max_connections = 20 -# min_connections = 5 -# idle_timeout = 30 -# connect_timeout_ms = 5000 -# pool_get_timeout_ms = 5000 -# command_timeout_ms = 5000 diff --git a/arma/server/extension/src/actor.rs b/arma/server/extension/src/actor.rs index 7565c59..59672d0 100644 --- a/arma/server/extension/src/actor.rs +++ b/arma/server/extension/src/actor.rs @@ -15,7 +15,7 @@ use crate::storage::ActorStorageRepository; /// Global actor service instance. /// -/// Lazily initialized singleton combining Redis adapter, repository, and service layers. +/// Lazily initialized singleton combining repository and service layers. static ACTOR_SERVICE: LazyLock> = LazyLock::new(|| ActorService::new(ActorStorageRepository::configured())); static HOT_ACTOR_SERVICE: LazyLock< diff --git a/arma/server/extension/src/adapters/README.md b/arma/server/extension/src/adapters/README.md deleted file mode 100644 index 167b2bd..0000000 --- a/arma/server/extension/src/adapters/README.md +++ /dev/null @@ -1,270 +0,0 @@ -# Adapters Module - -This module provides adapter implementations that bridge the repository layer with the extension's Redis operations. Adapters translate between the generic `RedisClient` trait and the extension-specific Redis module. - -## Architecture - -The adapters module follows the **Adapter Pattern**, allowing the repository layer to remain decoupled from the specific Redis implementation: - -```mermaid -graph TD - Repo[Repository Layer
#40;forge-repositories#41;] - Trait[RedisClient Trait
#40;forge-shared#41;] - Adapter[ExtensionRedisClient
#40;adapter#41;] - Redis[Redis Module
#40;extension#41;] - - Repo --> Trait - Trait --> Adapter - Adapter --> Redis -``` - -This design enables: - -- **Testability**: Repositories can use mock adapters for testing -- **Flexibility**: Different Redis implementations can be swapped without changing repositories -- **Separation of Concerns**: Repository logic is independent of Redis connection details - -## ExtensionRedisClient - -The `ExtensionRedisClient` is the primary adapter that implements the `RedisClient` trait from `forge_shared`. - -### Responsibilities - -- **Translate Calls**: Convert trait method calls to Redis module function calls -- **Error Handling**: Parse Redis operation results and convert to `Result` types -- **Data Transformation**: Handle response parsing (e.g., JSON arrays for lists/sets) -- **Logging**: Log debug information for Redis operations - -### Implemented Operations - -#### Hash Operations - -| Method | Description | Returns | -| -------------- | ------------------------------ | ------------------------ | -| `hash_mset` | Set multiple fields atomically | `Result<(), String>` | -| `hash_get_all` | Get all fields and values | `Result` | -| `hash_get` | Get a single field value | `Result` | -| `hash_del` | Delete a field | `Result<(), String>` | - -#### List Operations - -| Method | Description | Returns | -| ------------ | --------------------- | ----------------------------- | -| `list_rpush` | Append to list | `Result<(), String>` | -| `list_range` | Get range of elements | `Result, String>` | -| `list_del` | Remove by value | `Result<(), String>` | - -#### Set Operations - -| Method | Description | Returns | -| ------------- | --------------- | ----------------------------- | -| `set_add` | Add member | `Result<(), String>` | -| `set_members` | Get all members | `Result, String>` | -| `set_del` | Remove member | `Result<(), String>` | - -#### Common Operations - -| Method | Description | Returns | -| ------------ | ------------------- | ---------------------- | -| `key_exists` | Check if key exists | `Result` | -| `delete_key` | Delete key | `Result<(), String>` | - -### Usage Example - -```rust -use crate::adapters::ExtensionRedisClient; -use forge_shared::RedisClient; - -// Create the adapter -let client = ExtensionRedisClient::new(); - -// Use it with the RedisClient trait -let fields = vec![ - ("name".to_string(), "John".to_string()), - ("age".to_string(), "30".to_string()), -]; -client.hash_mset("user:123".to_string(), fields)?; - -// Retrieve data -let data = client.hash_get_all("user:123".to_string())?; -``` - -## Error Handling - -The adapter translates Redis string responses to Rust `Result` types: - -- **Success**: Returns `Ok(value)` with the appropriate type -- **Error**: Returns `Err(message)` if the response starts with "Error:" - -```rust -// Redis module returns "OK" โ†’ Adapter returns Ok(()) -// Redis module returns "Error: Connection failed" โ†’ Adapter returns Err("Error: Connection failed") -``` - -### Response Parsing - -For operations that return collections, the adapter parses JSON responses: - -```rust -// list_range returns JSON: ["item1", "item2", "item3"] -let items = client.list_range("mylist".to_string(), 0, -1)?; -// items: Vec = vec!["item1", "item2", "item3"] -``` - -## Contributing - -We welcome contributions to the adapters module! Follow these guidelines to add new adapter methods or create new adapters. - -### Adding a New Method to ExtensionRedisClient - -To add a new method (e.g., `hash_exists`), follow these steps: - -1. **Check the Trait**: Ensure the method is defined in the `RedisClient` trait in `forge_shared`. - - ```rust - // In forge_shared/src/redis_client.rs - pub trait RedisClient: Send + Sync { - fn hash_exists(&self, key: String, field: String) -> Result; - } - ``` - -2. **Implement the Method**: Add the implementation to `ExtensionRedisClient`. - - ```rust - impl RedisClient for ExtensionRedisClient { - fn hash_exists(&self, key: String, field: String) -> Result { - // Call the Redis module function - let result = redis::hash::hash_exists(key, field); - - // Parse the response - match result.as_str() { - "1" => Ok(true), - "0" => Ok(false), - _ if result.starts_with("Error:") => Err(result), - _ => Err(format!("Unexpected response: {}", result)), - } - } - } - ``` - -3. **Add Logging** (if needed): For debugging, log the operation. - - ```rust - fn hash_exists(&self, key: String, field: String) -> Result { - let result = redis::hash::hash_exists(key, field); - log("debug", "DEBUG", &format!("hash_exists({}, {}): {}", key, field, result)); - - match result.as_str() { - "1" => Ok(true), - "0" => Ok(false), - _ if result.starts_with("Error:") => Err(result), - _ => Err(format!("Unexpected response: {}", result)), - } - } - ``` - -4. **Handle Response Types**: Match the return type to the trait signature. - - **Unit type** (`()`): Return `Ok(())` on success - - **Boolean**: Parse "1"/"0" to `true`/`false` - - **String**: Return the value directly - - **Vec**: Parse JSON array response - - **Number**: Parse string to number - -### Creating a New Adapter - -To create a new adapter (e.g., `MockRedisClient` for testing): - -1. **Create the Module File**: Add `src/adapters/mock_client.rs`. -2. **Define the Struct**: Create the adapter struct. - - ```rust - use forge_shared::RedisClient; - use std::collections::HashMap; - use std::sync::RwLock; - - /// Mock Redis client for testing. - /// - /// Uses RwLock to allow multiple concurrent readers while maintaining thread safety. - pub struct MockRedisClient { - data: RwLock>, - } - - impl MockRedisClient { - pub fn new() -> Self { - Self { - data: RwLock::new(HashMap::new()), - } - } - } - ``` - -3. **Implement the Trait**: Implement all `RedisClient` methods. - - ```rust - impl RedisClient for MockRedisClient { - fn hash_mset(&self, key: String, fields: Vec<(String, String)>) -> Result<(), String> { - // Acquire write lock only when modifying data - let mut data = self.data.write().unwrap(); - for (field, value) in fields { - let hash_key = format!("{}:{}", key, field); - data.insert(hash_key, value); - } - Ok(()) - } - - fn hash_get(&self, key: String, field: String) -> Result { - // Acquire read lock - multiple threads can read concurrently - let data = self.data.read().unwrap(); - let hash_key = format!("{}:{}", key, field); - Ok(data.get(&hash_key) - .map(|v| v.clone()) - .unwrap_or_default()) - } - - // ... implement other methods - } - ``` - -4. **Register the Module**: Add to `src/adapters/mod.rs`. - - ```rust - pub mod redis_client; - pub mod mock_client; - - pub use redis_client::ExtensionRedisClient; - pub use mock_client::MockRedisClient; - ``` - -### Concurrency Best Practices - -> [!IMPORTANT] -> Choose the right synchronization primitive based on your access patterns and performance requirements. - -**Recommended Synchronization Primitives:** - -| Primitive | Use Case | Performance | Dependency | -| --------------------- | ---------------------------------------- | ----------------------- | ---------------- | -| **`RwLock`** | Read-heavy workloads, concurrent readers | Good (multiple readers) | Standard library | -| **`Mutex`** | Write-heavy or exclusive access required | Fair (single lock) | Standard library | -| **`DashMap`** | Extreme high-frequency reads/writes | Excellent (lock-free) | External crate | - -**When to use each:** - -- **`RwLock`**: Best for most use cases. Allows multiple concurrent readers, only blocks on writes. Use this by default. -- **`Mutex`**: Only when you need exclusive access or operations are very lightweight (< 1ฮผs). -- **`DashMap`**: When profiling shows `RwLock` is a bottleneck and you need lock-free performance. - -**Why avoid `Mutex` for read-heavy workloads?** - -- Blocks all threads (readers and writers) on every access -- No concurrent reads possible -- Can cause performance bottlenecks in high-concurrency scenarios - -### Best Practices - -- **Error Consistency**: Always check for "Error:" prefix in Redis responses -- **Type Safety**: Ensure return types match the trait signature exactly -- **Logging**: Log operations at DEBUG level for troubleshooting -- **Response Parsing**: Handle all possible response formats (success, error, unexpected) -- **Documentation**: Document the purpose and behavior of each method -- **Testing**: Test adapters with both success and error scenarios diff --git a/arma/server/extension/src/adapters/mod.rs b/arma/server/extension/src/adapters/mod.rs deleted file mode 100644 index 6fa6c1c..0000000 --- a/arma/server/extension/src/adapters/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod redis_client; - -pub use redis_client::ExtensionRedisClient; diff --git a/arma/server/extension/src/adapters/redis_client.rs b/arma/server/extension/src/adapters/redis_client.rs deleted file mode 100644 index c9ccf4a..0000000 --- a/arma/server/extension/src/adapters/redis_client.rs +++ /dev/null @@ -1,208 +0,0 @@ -use crate::log::log; -use crate::redis; -use forge_shared::RedisClient; - -/// Redis client implementation that bridges the repository layer with the extension's Redis module. -pub struct ExtensionRedisClient; - -impl ExtensionRedisClient { - /// Creates a new instance of the Redis client adapter. - pub fn new() -> Self { - Self - } -} - -impl RedisClient for ExtensionRedisClient { - /// Sets multiple fields in a Redis hash. - fn hash_mset(&self, key: String, fields: Vec<(String, String)>) -> Result<(), String> { - let result = redis::hash::hash_mset(key, fields); - log("debug", "DEBUG", &result); - - if result == "OK" { Ok(()) } else { Err(result) } - } - - /// Retrieves all fields and values from a Redis hash. - fn hash_get_all(&self, key: String) -> Result { - let result = redis::hash::hash_get_all(key); - log("debug", "DEBUG", &result); - - if result.starts_with("Error:") { - Err(result) - } else { - Ok(result) - } - } - - /// Retrieves a single field value from a Redis hash. - fn hash_get(&self, key: String, field: String) -> Result { - let result = redis::hash::hash_get(key, field); - log("debug", "DEBUG", &result); - - if result.starts_with("Error:") { - Err(result) - } else { - Ok(result) - } - } - - /// Deletes a specific field from a Redis hash. - fn hash_del(&self, key: String, field: String) -> Result<(), String> { - let result = redis::hash::hash_del(key, field); - log("debug", "DEBUG", &result); - - if result.starts_with("Error:") { - Err(result) - } else { - Ok(()) - } - } - - /// Appends a value to the end of a Redis list. - fn list_rpush(&self, key: String, value: String) -> Result<(), String> { - let result = redis::list::list_rpush(key, value); - log("debug", "DEBUG", &result); - - if result.starts_with("Error:") { - Err(result) - } else { - Ok(()) - } - } - - /// Retrieves a range of elements from a Redis list. - fn list_range(&self, key: String, start: isize, end: isize) -> Result, String> { - let result = redis::list::list_range(key, start, end); - log("debug", "DEBUG", &result); - - if result.starts_with("Error:") { - Err(result) - } else { - // Parse the JSON array response - match serde_json::from_str::>(&result) { - Ok(values) => Ok(values), - Err(e) => Err(format!("Failed to parse list response: {}", e)), - } - } - } - - /// Removes elements from a Redis list by value. - fn list_del(&self, key: String, count: isize, value: String) -> Result<(), String> { - let result = redis::list::list_del(key, count, value); - log("debug", "DEBUG", &result); - - if result.starts_with("Error:") { - Err(result) - } else { - Ok(()) - } - } - - /// # Set operations - - /// Adds a member to a Redis set. - fn set_add(&self, key: String, member: String) -> Result<(), String> { - let result = redis::set::set_add(key, member); - log("debug", "DEBUG", &result); - - if result.starts_with("Error:") { - Err(result) - } else { - Ok(()) - } - } - - /// Retrieves all members from a Redis set. - fn set_members(&self, key: String) -> Result, String> { - let result = redis::set::set_members(key); - log("debug", "DEBUG", &result); - - if result.starts_with("Error:") { - Err(result) - } else if result.trim().is_empty() { - Ok(Vec::new()) - } else { - serde_json::from_str::>(&result).or_else(|_| { - Ok(result - .split(',') - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) - .collect()) - }) - } - } - - /// Removes a member from a Redis set. - fn set_del(&self, key: String, member: String) -> Result<(), String> { - let result = redis::set::set_del(key, member); - log("debug", "DEBUG", &result); - - if result.starts_with("Error:") { - Err(result) - } else { - Ok(()) - } - } - - /// Checks if a Redis key exists. - fn key_exists(&self, key: String) -> Result { - let result = redis::common::key_exists(key); - log("debug", "DEBUG", &result); - - match result.as_str() { - "1" => Ok(true), - "0" => Ok(false), - _ => Err(format!("Unexpected Redis response: {}", result)), - } - } - - /// Retrieves the value of a Redis key. - fn get_key(&self, key: String) -> Result { - let result = redis::common::get_key(key); - log("debug", "DEBUG", &result); - - if result.starts_with("Error:") { - Err(result) - } else { - Ok(result) - } - } - - /// Sets a value in a Redis key. - fn set_key(&self, key: String, value: String) -> Result<(), String> { - let result = redis::common::set_key(key, value); - log("debug", "DEBUG", &result); - - if result.starts_with("Error:") { - Err(result) - } else { - Ok(()) - } - } - - /// Increments a numeric Redis key. - fn incr_key(&self, key: String, count: usize) -> Result { - let result = redis::common::incr_key(key, count); - log("debug", "DEBUG", &result); - - if result.starts_with("Error:") { - Err(result) - } else { - result - .parse::() - .map_err(|error| format!("Failed to parse increment response: {}", error)) - } - } - - /// Deletes a Redis key and all its associated data. - fn delete_key(&self, key: String) -> Result<(), String> { - let result = redis::common::delete_key(key); - log("debug", "DEBUG", &result); - - if result.starts_with("Error:") { - Err(result) - } else { - Ok(()) - } - } -} diff --git a/arma/server/extension/src/bank.rs b/arma/server/extension/src/bank.rs index 0a6f426..8ec899d 100644 --- a/arma/server/extension/src/bank.rs +++ b/arma/server/extension/src/bank.rs @@ -19,7 +19,7 @@ use crate::storage::BankStorageRepository; /// Global bank service instance. /// -/// Lazily initialized singleton combining Redis adapter, repository, and service layers. +/// Lazily initialized singleton combining repository and service layers. static BANK_SERVICE: LazyLock> = LazyLock::new(|| BankService::new(BankStorageRepository::configured())); static HOT_BANK_SERVICE: LazyLock< diff --git a/arma/server/extension/src/config.rs b/arma/server/extension/src/config.rs new file mode 100644 index 0000000..7ffc835 --- /dev/null +++ b/arma/server/extension/src/config.rs @@ -0,0 +1,79 @@ +//! Extension configuration for SurrealDB-backed persistence. + +use serde::Deserialize; +use std::fs; +use std::path::PathBuf; +use std::sync::OnceLock; + +use crate::log::log; + +static CONFIG_CACHE: OnceLock = OnceLock::new(); + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct Config { + #[serde(default)] + pub surreal: SurrealConfig, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SurrealConfig { + pub endpoint: String, + pub namespace: String, + pub database: String, + pub username: Option, + pub password: Option, + pub connect_timeout_ms: Option, +} + +impl Default for SurrealConfig { + fn default() -> Self { + Self { + endpoint: "127.0.0.1:8000".to_string(), + namespace: "forge".to_string(), + database: "main".to_string(), + username: Some("root".to_string()), + password: Some("root".to_string()), + connect_timeout_ms: Some(5000), + } + } +} + +pub fn load() -> Config { + CONFIG_CACHE + .get_or_init(|| { + let config_path = std::env::current_exe() + .ok() + .and_then(|exe| { + exe.parent() + .map(|dir| dir.join("@forge_server").join("config.toml")) + }) + .filter(|path| path.exists()) + .unwrap_or_else(|| PathBuf::from("@forge_server/config.toml")); + + match fs::read_to_string(&config_path) { + Ok(contents) => { + log("main", "INFO", "Config file found. Loading."); + match toml::from_str::(&contents) { + Ok(config) => config, + Err(error) => { + log( + "main", + "ERROR", + &format!( + "Failed to parse config file '{}': {}. Using defaults.", + config_path.display(), + error + ), + ); + Config::default() + } + } + } + Err(_) => { + log("main", "INFO", "Config file not found. Using defaults."); + Config::default() + } + } + }) + .clone() +} diff --git a/arma/server/extension/src/icom.rs b/arma/server/extension/src/icom.rs index 92f3807..43144a1 100644 --- a/arma/server/extension/src/icom.rs +++ b/arma/server/extension/src/icom.rs @@ -80,20 +80,18 @@ pub async fn initialize(ctx: Context, address: String, server_id: String) { if let Some(client) = ICOM_CLIENT.get() { let result = client .listen_for_events(|msg| { - match msg { - Message::Event { - event_name, data, .. - } => { - log::log( - "icom", - "INFO", - &format!("Received event '{}': {}", event_name, data), - ); + if let Message::Event { + event_name, data, .. + } = msg + { + log::log( + "icom", + "INFO", + &format!("Received event '{}': {}", event_name, data), + ); - // Forward event to Arma - forward(&event_name, &data); - } - _ => {} + // Forward event to Arma + forward(&event_name, &data); } Ok(()) }) diff --git a/arma/server/extension/src/lib.rs b/arma/server/extension/src/lib.rs index b7b7ce6..7d4e4a1 100644 --- a/arma/server/extension/src/lib.rs +++ b/arma/server/extension/src/lib.rs @@ -1,20 +1,20 @@ //! Entry point and runtime bootstrap for the Forge Arma server extension. //! -//! Initializes a global async runtime, the Redis connection pool, and registers +//! Initializes a global async runtime, SurrealDB persistence, and registers //! all command groups. Provides status/version commands and maintains a shared //! Arma `Context` for engine interop. //! #![allow(future_incompatible)] // Future-incompatible lint is triggered by arma_rs use arma_rs::{Context, Extension, Group, arma}; -use std::sync::{LazyLock, OnceLock, RwLock as StdRwLock}; +use std::sync::LazyLock; use tokio::runtime::{Builder, Runtime}; use tokio::sync::RwLock as TokioRwLock; pub mod actor; -pub mod adapters; pub mod bank; pub mod cad; +pub mod config; pub mod garage; pub mod helpers; pub mod icom; @@ -22,7 +22,6 @@ pub mod locker; mod log; pub mod org; pub mod phone; -pub mod redis; pub mod schema; pub mod storage; pub mod store; @@ -37,10 +36,6 @@ pub mod v_locker; /// commands that need engine interop. Stored inside an async `RwLock` to /// allow mutation by the startup task and later reads. static CONTEXT: LazyLock>> = LazyLock::new(|| TokioRwLock::new(None)); -/// Global Redis connection pool, created once and shared by all commands. -/// Initialized asynchronously after `init()` returns so the extension starts -/// quickly without blocking the main thread. -static REDIS_POOL: OnceLock = OnceLock::new(); /// Global multi-threaded Tokio runtime used to execute async operations from /// command handlers and startup tasks. pub(crate) static RUNTIME: LazyLock = LazyLock::new(|| { @@ -50,16 +45,6 @@ pub(crate) static RUNTIME: LazyLock = LazyLock::new(|| { .expect("Failed to create tokio runtime") }); -#[derive(Clone, Copy, PartialEq)] -/// Connection state for the Redis pool so SQF can gate behavior on readiness. -enum ConnectionState { - Initializing, - Connected, - Failed, -} -static CONNECTION_STATE: LazyLock> = - LazyLock::new(|| StdRwLock::new(ConnectionState::Initializing)); - pub(crate) fn enqueue_persistence_task(module: &'static str, job: F) where F: FnOnce() -> Result<(), String> + Send + 'static, @@ -77,14 +62,12 @@ where #[arma] /// Initializes the extension, registers commands/groups, and asynchronously -/// creates the Redis connection pool on the global runtime. +/// connects SurrealDB on the global runtime. fn init() -> Extension { - let config = redis::config::load(); - let storage_backend = config.storage.backend; + let config = config::load(); let ext = Extension::build() .command("version", get_version) .command("status", get_status) - .group("redis", redis::group()) .group("surreal", surreal::group()) .group("actor", actor::group()) .group("bank", bank::group()) @@ -106,34 +89,20 @@ fn init() -> Extension { ) .finish(); - // Spawn initialization tasks for Redis and ICOM - // These run asynchronously and don't block extension startup - // Redis initialization will set the global CONTEXT - if storage_backend == redis::config::StorageBackend::Surreal { - let surreal_config = config.surreal.clone(); - surreal::prepare(); - RUNTIME.spawn(async move { - surreal::initialize(surreal_config).await; - }); - } - + let surreal_config = config.surreal.clone(); + surreal::prepare(); RUNTIME.spawn(async move { - redis::initialize(config.redis).await; + surreal::initialize(surreal_config).await; }); ext } -/// Returns current Redis connection state as a string: `initializing`, +/// Returns current persistence connection state as a string: `initializing`, /// `connected`, or `failed`. Intended for SQF polling before issuing -/// operations that require Redis. +/// operations that require persistence. fn get_status() -> String { - let state = *CONNECTION_STATE.read().unwrap(); - match state { - ConnectionState::Initializing => "initializing".into(), - ConnectionState::Connected => "connected".into(), - ConnectionState::Failed => "failed".into(), - } + surreal::status() } /// Returns the extension version string for diagnostics and tooling. diff --git a/arma/server/extension/src/log.rs b/arma/server/extension/src/log.rs index 8ec4660..7103451 100644 --- a/arma/server/extension/src/log.rs +++ b/arma/server/extension/src/log.rs @@ -40,7 +40,7 @@ pub fn log(category: &str, level: &str, message: &str) { .create(true) .append(true) .open(path) - .expect(&format!("Failed to open {} log file", category)) + .unwrap_or_else(|_| panic!("Failed to open {} log file", category)) }); let _ = file.write_all(log_entry.as_bytes()); diff --git a/arma/server/extension/src/org.rs b/arma/server/extension/src/org.rs index a328260..89a7d11 100644 --- a/arma/server/extension/src/org.rs +++ b/arma/server/extension/src/org.rs @@ -21,7 +21,7 @@ use crate::storage::OrgStorageRepository; /// Global organization service instance. /// -/// Lazily initialized singleton combining Redis adapter, repository, and service layers. +/// Lazily initialized singleton combining repository and service layers. static ORG_SERVICE: LazyLock> = LazyLock::new(|| OrgService::new(OrgStorageRepository::configured())); static HOT_ORG_SERVICE: LazyLock< @@ -504,7 +504,7 @@ pub fn get_members(key: String) -> String { /// Adds a new member to an organization by their UID. /// /// Resolves organization key to ID and adds the member UID. -/// Redis sets automatically prevent duplicate members. +/// Member collections automatically prevent duplicate members. pub fn add_member(key: String, member_uid: String) -> String { match ORG_SERVICE.add_member(key, member_uid) { Ok(_) => "OK".to_string(), diff --git a/arma/server/extension/src/redis/README.md b/arma/server/extension/src/redis/README.md deleted file mode 100644 index 5fd4165..0000000 --- a/arma/server/extension/src/redis/README.md +++ /dev/null @@ -1,281 +0,0 @@ -# Redis Module - -This module provides comprehensive Redis operations for the Forge extension, enabling persistent data storage and retrieval from SQF scripts. - -## Architecture - -The Redis module is organized into specialized operation groups: - -- **Common**: Basic key-value operations -- **Hash**: Structured data storage (field-value pairs) -- **List**: Ordered collections and queues -- **Set**: Unique collections and membership tracking - -## Connection Management - -### Connection Pool - -The module uses `bb8` for connection pooling, providing: - -- **Automatic connection reuse**: Reduces overhead -- **Configurable pool size**: Control max/min connections -- **Idle timeout**: Prevents stale connections -- **Lazy initialization**: Pool created on first use - -### Configuration - -Redis connection settings are loaded from `@forge_server/config.toml`: - -```toml -[redis] -host = "127.0.0.1" -port = 6379 -password = "" # Optional -max_connections = 10 -min_connections = 2 -idle_timeout = 300 # seconds -``` - -## Common Operations - -Basic key-value operations for simple data storage. - -### Available Commands - -| Command | Description | Returns | -| ------------------- | ------------------------- | ---------------------- | -| `redis:common:set` | Set a string value | "OK" | -| `redis:common:get` | Get a string value | Value or empty string | -| `redis:common:incr` | Increment a numeric value | New value | -| `redis:common:decr` | Decrement a numeric value | New value | -| `redis:common:del` | Delete a key | Number of keys removed | -| `redis:common:keys` | List all keys | Comma-separated keys | - -### SQF Examples - -```sqf -// Set a value -"forge_server" callExtension ["redis:common:set", ["player_count", "42"]]; - -// Get a value -private _result = "forge_server" callExtension ["redis:common:get", ["player_count"]]; -private _count = _result select 0; // "42" - -// Increment -"forge_server" callExtension ["redis:common:incr", ["player_count", 1]]; - -// Delete -"forge_server" callExtension ["redis:common:del", ["player_count"]]; -``` - -## Hash Operations - -Hash operations store structured data as field-value pairs, ideal for objects and entities. - -### Available Commands - -| Command | Description | Returns | -| ------------------- | ------------------------------ | ------------------------ | -| `redis:hash:set` | Set a single field | 1 if new, 0 if updated | -| `redis:hash:mset` | Set multiple fields atomically | "OK" | -| `redis:hash:get` | Get a field value | Value or empty string | -| `redis:hash:getall` | Get all fields and values | Comma-separated pairs | -| `redis:hash:del` | Delete a field | Number of fields removed | -| `redis:hash:keys` | Get all field names | Comma-separated keys | -| `redis:hash:vals` | Get all values | Comma-separated values | -| `redis:hash:len` | Get number of fields | Field count | -| `redis:hash:exists` | Check if field exists | "1" or "0" | - -### SQF Examples - -```sqf -// Set a single field -"forge_server" callExtension ["redis:hash:set", ["actor:76561198123456789", "name", "John Doe"]]; - -// Set multiple fields atomically -private _fields = [ - ["name", "John Doe"], - ["bank", "1000"], - ["level", "5"] -]; -"forge_server" callExtension ["redis:hash:mset", ["actor:76561198123456789", _fields]]; - -// Get a field -private _result = "forge_server" callExtension ["redis:hash:get", ["actor:76561198123456789", "name"]]; -private _name = _result select 0; // "John Doe" - -// Get all fields -private _result = "forge_server" callExtension ["redis:hash:getall", ["actor:76561198123456789"]]; -// Returns: "name, John Doe, bank, 1000, level, 5" - -// Check if field exists -private _result = "forge_server" callExtension ["redis:hash:exists", ["actor:76561198123456789", "name"]]; -private _exists = (_result select 0) == "1"; -``` - -## List Operations - -List operations manage ordered collections, useful for queues, logs, and sequential data. - -### Available Commands - -| Command | Description | Returns | -| ------------------ | --------------------- | ------------------------------ | -| `redis:list:set` | Set element at index | "OK" | -| `redis:list:get` | Get element at index | Value (base64 decoded) | -| `redis:list:len` | Get list length | Element count | -| `redis:list:range` | Get range of elements | JSON array | -| `redis:list:lpush` | Prepend to list | New length | -| `redis:list:rpush` | Append to list | New length | -| `redis:list:lpop` | Remove from beginning | JSON array of removed elements | -| `redis:list:rpop` | Remove from end | JSON array of removed elements | -| `redis:list:trim` | Trim to range | "OK" | -| `redis:list:del` | Remove by value | Number removed | - -### SQF Examples - -```sqf -// Append to list -"forge_server" callExtension ["redis:list:rpush", ["event_log", "Player joined"]]; -"forge_server" callExtension ["redis:list:rpush", ["event_log", "Player spawned"]]; - -// Get range -private _result = "forge_server" callExtension ["redis:list:range", ["event_log", 0, -1]]; -private _events = parseJSON (_result select 0); // Array of all events - -// Pop from end -private _result = "forge_server" callExtension ["redis:list:rpop", ["event_log", 1]]; -private _lastEvent = parseJSON (_result select 0); // ["Player spawned"] - -// Trim to last 100 entries -"forge_server" callExtension ["redis:list:trim", ["event_log", -100, -1]]; -``` - -> [!NOTE] -> List values are automatically base64 encoded/decoded to handle special characters safely. - -## Set Operations - -Set operations manage unique collections, perfect for membership tracking and preventing duplicates. - -### Available Commands - -| Command | Description | Returns | -| ----------------------- | ---------------------- | ---------------------------- | -| `redis:set:add` | Add member to set | 1 if new, 0 if exists | -| `redis:set:members` | Get all members | Comma-separated members | -| `redis:set:card` | Get member count | Cardinality | -| `redis:set:ismember` | Check membership | "1" or "0" | -| `redis:set:randmember` | Get random member | Member value | -| `redis:set:randmembers` | Get N random members | Comma-separated members | -| `redis:set:pop` | Remove random member | Removed member | -| `redis:set:del` | Remove specific member | 1 if removed, 0 if not found | - -### SQF Examples - -```sqf -// Add members to a set -"forge_server" callExtension ["redis:set:add", ["org:elite_squad:members", "76561198123456789"]]; -"forge_server" callExtension ["redis:set:add", ["org:elite_squad:members", "76561198987654321"]]; - -// Check membership -private _result = "forge_server" callExtension ["redis:set:ismember", ["org:elite_squad:members", "76561198123456789"]]; -private _isMember = (_result select 0) == "1"; - -// Get all members -private _result = "forge_server" callExtension ["redis:set:members", ["org:elite_squad:members"]]; -private _memberUIDs = (_result select 0) splitString ","; - -// Get member count -private _result = "forge_server" callExtension ["redis:set:card", ["org:elite_squad:members"]]; -private _memberCount = parseNumber (_result select 0); - -// Remove member -"forge_server" callExtension ["redis:set:del", ["org:elite_squad:members", "76561198123456789"]]; -``` - -## Helper Utilities - -### Base64 Encoding - -List operations use base64 encoding to safely store complex strings: - -```rust -use crate::redis::helpers::{encode_b64, decode_b64}; - -let encoded = encode_b64("Complex [string] with {special} chars"); -let decoded = decode_b64(&encoded)?; // Original string -``` - -### Value Parsing - -The `parse_redis_value` function intelligently converts Redis strings to JSON types: - -```rust -use crate::redis::helpers::parse_redis_value; - -parse_redis_value("42"); // Number(42) -parse_redis_value("true"); // Bool(true) -parse_redis_value("{\"key\":1}"); // Object -parse_redis_value("text"); // String("text") -``` - -## Macro Usage - -The `redis_operation!` macro handles all connection and async boilerplate: - -```rust -use crate::redis_operation; -use bb8_redis::redis::AsyncCommands; - -pub fn my_redis_command(key: String) -> String { - redis_operation!(conn => { - match conn.get::<_, String>(&key).await { - Ok(value) => value, - Err(e) => format!("Error: {}", e), - } - }) -} -``` - -The macro automatically: - -- Acquires a connection from the pool -- Handles lazy initialization if needed -- Executes the operation asynchronously -- Returns the result to SQF - -## Error Handling - -All Redis operations return strings: - -- **Success**: Operation result (e.g., "OK", value, count) -- **Error**: String starting with "Error: " followed by the error message - -```sqf -private _result = "forge_server" callExtension ["redis:common:get", ["mykey"]]; -private _value = _result select 0; - -if (_value find "Error:" == 0) then { - diag_log format ["Redis error: %1", _value]; -} else { - // Use the value - systemChat format ["Value: %1", _value]; -}; -``` - -## Performance Considerations - -- **Connection Pooling**: Reuses connections to minimize overhead -- **Async Operations**: Non-blocking I/O prevents server lag -- **Atomic Operations**: `hash:mset` sets multiple fields in one operation -- **Batch Operations**: Use lists and sets for bulk data - -## Best Practices - -1. **Use Hashes for Objects**: Store actor/org data as hash fields -2. **Use Sets for Membership**: Track organization members, online players -3. **Use Lists for Logs**: Event logs, chat history, audit trails -4. **Prefix Keys**: Use namespaces like `actor:`, `org:`, `vehicle:` -5. **Handle Errors**: Always check for "Error:" prefix in results -6. **Atomic Updates**: Use `hash:mset` instead of multiple `hash:set` calls diff --git a/arma/server/extension/src/redis/client.rs b/arma/server/extension/src/redis/client.rs deleted file mode 100644 index 0ebc910..0000000 --- a/arma/server/extension/src/redis/client.rs +++ /dev/null @@ -1,48 +0,0 @@ -use super::config::RedisConfig; -use bb8_redis::{RedisConnectionManager, bb8}; -use std::error::Error; -use std::time::Duration; - -/// Redis connection pool type alias. -pub type RedisClient = bb8::Pool; - -/// Creates a Redis connection pool with the specified configuration. -pub async fn create_redis_pool( - config: &RedisConfig, -) -> Result> { - // Generate the Redis connection string from configuration - let connection_string = config.connection_string(); - - // Create the connection manager that will handle individual connections - let manager = RedisConnectionManager::new(connection_string)?; - - // Start building the connection pool with default settings - let mut pool_builder = bb8::Pool::builder(); - - // Configure maximum number of connections if specified - // This prevents overwhelming the Redis server with too many connections - if let Some(max_conn) = config.max_connections { - pool_builder = pool_builder.max_size(max_conn as u32); - } - - // Configure minimum idle connections if specified - // This ensures quick response times by keeping connections ready - if let Some(min_conn) = config.min_connections { - pool_builder = pool_builder.min_idle(Some(min_conn as u32)); - } - - // Configure idle connection timeout if specified - // This prevents keeping stale connections that might be closed by the server - if let Some(idle_timeout) = config.idle_timeout { - pool_builder = pool_builder.idle_timeout(Some(Duration::from_secs(idle_timeout))); - } - - // Bound connection acquisition from the pool so game thread calls fail fast - if let Some(connect_timeout_ms) = config.connect_timeout_ms { - pool_builder = pool_builder.connection_timeout(Duration::from_millis(connect_timeout_ms)); - } - - // Build the final connection pool with all configured parameters - let pool = pool_builder.build(manager).await?; - Ok(pool) -} diff --git a/arma/server/extension/src/redis/common.rs b/arma/server/extension/src/redis/common.rs deleted file mode 100644 index 4ddce19..0000000 --- a/arma/server/extension/src/redis/common.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Common Redis operations for basic key-value functionality. - -use crate::redis_operation; -use bb8_redis::redis::AsyncCommands; - -/// Sets a string value for the specified Redis key. -pub fn set_key(key: String, value: String) -> String { - redis_operation!(conn => { - match conn.set(&key, &value).await { - Ok(()) => "OK".to_string(), - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Retrieves the string value for the specified Redis key. -pub fn get_key(key: String) -> String { - redis_operation!(conn => { - match conn.get::<_, String>(&key).await { - Ok(value) => value, - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Increments a numeric value stored at the specified key. -pub fn incr_key(key: String, count: usize) -> String { - redis_operation!(conn => { - match conn.incr::<_, _, i64>(&key, count).await { - Ok(value) => value.to_string(), - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Decrements a numeric value stored at the specified key. -pub fn decr_key(key: String, count: usize) -> String { - redis_operation!(conn => { - match conn.decr::<_, _, i64>(&key, count).await { - Ok(value) => value.to_string(), - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Checks if a Redis key exists. -pub fn key_exists(key: String) -> String { - redis_operation!(conn => { - match conn.exists::<_, i32>(&key).await { - Ok(exists) => exists.to_string(), - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Deletes a Redis key and its associated value. -pub fn delete_key(key: String) -> String { - redis_operation!(conn => { - match conn.del::<_, usize>(&key).await { - Ok(removed) => removed.to_string(), - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Lists all Redis keys matching the wildcard pattern "*". -pub fn list_keys() -> String { - redis_operation!(conn => { - match conn.keys::<_, Vec>("*").await { - Ok(keys) => keys.join(","), - Err(e) => format!("Error: {}", e), - } - }) -} diff --git a/arma/server/extension/src/redis/config.rs b/arma/server/extension/src/redis/config.rs deleted file mode 100644 index 0da1ea4..0000000 --- a/arma/server/extension/src/redis/config.rs +++ /dev/null @@ -1,215 +0,0 @@ -//! Configuration management for Redis connection and application settings. - -use serde::Deserialize; -use std::fs; -use std::path::PathBuf; -use std::sync::OnceLock; - -use crate::log::log; - -static CONFIG_CACHE: OnceLock = OnceLock::new(); - -/// Main configuration structure for the entire application. -#[derive(Debug, Clone, Deserialize)] -pub struct Config { - /// Durable storage backend selector. - #[serde(default)] - pub storage: StorageConfig, - /// Redis configuration with automatic defaults if not specified - #[serde(default)] - pub redis: RedisConfig, - /// SurrealDB configuration with automatic defaults if not specified - #[serde(default)] - pub surreal: SurrealConfig, -} - -impl Default for Config { - /// Creates a default configuration with sensible values for development. - fn default() -> Self { - Self { - storage: StorageConfig::default(), - redis: RedisConfig::default(), - surreal: SurrealConfig::default(), - } - } -} - -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum StorageBackend { - Redis, - Surreal, -} - -impl Default for StorageBackend { - fn default() -> Self { - Self::Redis - } -} - -/// Durable storage backend selection. -#[derive(Debug, Clone, Deserialize)] -pub struct StorageConfig { - #[serde(default)] - pub backend: StorageBackend, -} - -impl Default for StorageConfig { - fn default() -> Self { - Self { - backend: StorageBackend::Redis, - } - } -} - -/// Redis connection and connection pool configuration. -#[derive(Debug, Clone, Deserialize)] -pub struct RedisConfig { - /// Redis server hostname or IP address - pub host: String, - /// Redis server port number - pub port: u16, - /// Redis database number (0-15) - pub db: u8, - /// Username for Redis ACL authentication (Redis 6.0+) - pub username: Option, - /// Password for Redis authentication - pub password: Option, - /// Maximum number of connections in the pool - pub max_connections: Option, - /// Minimum number of idle connections to maintain - pub min_connections: Option, - /// Idle connection timeout in seconds - pub idle_timeout: Option, - /// Maximum time to wait for pool connection checkout in milliseconds - pub pool_get_timeout_ms: Option, - /// Maximum time to wait for individual Redis command execution in milliseconds - pub command_timeout_ms: Option, - /// Maximum time to wait for pool connection establishment in milliseconds - pub connect_timeout_ms: Option, -} - -impl Default for RedisConfig { - /// Creates default Redis configuration suitable for local development. - fn default() -> Self { - Self { - host: "127.0.0.1".to_string(), - port: 6379, - db: 0, - username: None, - password: None, - max_connections: Some(10), - min_connections: Some(2), - idle_timeout: Some(60), - pool_get_timeout_ms: Some(2000), - command_timeout_ms: Some(2000), - connect_timeout_ms: Some(2000), - } - } -} - -/// SurrealDB connection configuration. -#[derive(Debug, Clone, Deserialize)] -pub struct SurrealConfig { - /// SurrealDB HTTP endpoint, for example `127.0.0.1:8000`. - pub endpoint: String, - /// SurrealDB namespace. - pub namespace: String, - /// SurrealDB database. - pub database: String, - /// Optional root username for authentication. - pub username: Option, - /// Optional root password for authentication. - pub password: Option, - /// Maximum time to wait for initial connection in milliseconds. - pub connect_timeout_ms: Option, -} - -impl Default for SurrealConfig { - fn default() -> Self { - Self { - endpoint: "127.0.0.1:8000".to_string(), - namespace: "forge".to_string(), - database: "main".to_string(), - username: Some("root".to_string()), - password: Some("root".to_string()), - connect_timeout_ms: Some(5000), - } - } -} - -impl RedisConfig { - /// Generates a Redis connection string from the configuration. - pub fn connection_string(&self) -> String { - // Build authentication part of the URL - let auth_part = match (&self.username, &self.password) { - (Some(username), Some(password)) => format!("{}:{}@", username, password), - (None, Some(password)) => format!(":{}@", password), - (Some(username), None) => format!("{}@", username), - (None, None) => String::new(), - }; - - let mut conn_str = format!("redis://{}{}", auth_part, self.host); - - if self.port != 6379 { - conn_str.push_str(&format!(":{}", self.port)); - } - - if self.db != 0 { - conn_str.push_str(&format!("/{}", self.db)); - } - - log( - "main", - "INFO", - &format!("Redis connection string: {}", conn_str), - ); - - conn_str - } -} - -/// Loads configuration from the `config.toml` file with graceful fallback to defaults. -pub fn load() -> Config { - CONFIG_CACHE - .get_or_init(|| { - let config_path = std::env::current_exe() - .ok() - .and_then(|exe| { - exe.parent() - .map(|dir| dir.join("@forge_server").join("config.toml")) - }) - .filter(|p| p.exists()) - .unwrap_or_else(|| PathBuf::from("@forge_server/config.toml")); - - match fs::read_to_string(&config_path) { - Ok(contents) => { - log("main", "INFO", &format!("Config file found! Loading...")); - match toml::from_str::(&contents) { - Ok(config) => config, - Err(error) => { - log( - "main", - "ERROR", - &format!( - "Failed to parse config file '{}': {}. Using defaults.", - config_path.display(), - error - ), - ); - Config::default() - } - } - } - Err(_) => { - log( - "main", - "INFO", - &format!("Config file not found. Using default configuration."), - ); - Config::default() - } - } - }) - .clone() -} diff --git a/arma/server/extension/src/redis/hash.rs b/arma/server/extension/src/redis/hash.rs deleted file mode 100644 index 399ba45..0000000 --- a/arma/server/extension/src/redis/hash.rs +++ /dev/null @@ -1,99 +0,0 @@ -//! Redis hash operations for structured data storage. - -use crate::redis_operation; -use bb8_redis::redis::AsyncCommands; -use std::collections::HashMap; - -/// Sets a single field in a Redis hash. -pub fn hash_set(key: String, field: String, value: String) -> String { - redis_operation!(conn => { - match conn.hset::<_, _, _, i32>(&key, &field, &value).await { - Ok(added) => added.to_string(), - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Sets multiple fields in a Redis hash atomically. -pub fn hash_mset(key: String, items: Vec<(String, String)>) -> String { - redis_operation!(conn => { - match conn.hset_multiple(&key, &items).await { - Ok(()) => "OK".to_string(), - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Retrieves the value of a specific field from a Redis hash. -pub fn hash_get(key: String, field: String) -> String { - redis_operation!(conn => { - match conn.hget::<_, _, Option>(&key, &field).await { - Ok(Some(value)) => value, - Ok(None) => String::new(), - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Retrieves all fields and values from a Redis hash. -pub fn hash_get_all(key: String) -> String { - redis_operation!(conn => { - match conn.hgetall::<_, HashMap>(&key).await { - Ok(hash_map) => match serde_json::to_string(&hash_map) { - Ok(json) => json, - Err(e) => format!("Error: Failed to serialize hash map: {}", e), - }, - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Removes a field from a Redis hash. -pub fn hash_del(key: String, field: String) -> String { - redis_operation!(conn => { - match conn.hdel::<_, _, i32>(&key, &field).await { - Ok(removed) => removed.to_string(), - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Retrieves all field names from a Redis hash. -pub fn hash_keys(key: String) -> String { - redis_operation!(conn => { - match conn.hkeys::<_, Vec>(&key).await { - Ok(fields) => fields.join(","), - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Retrieves all values from a Redis hash. -pub fn hash_values(key: String) -> String { - redis_operation!(conn => { - match conn.hvals::<_, Vec>(&key).await { - Ok(values) => values.join(","), - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Returns the number of fields in a Redis hash. -pub fn hash_len(key: String) -> String { - redis_operation!(conn => { - match conn.hlen::<_, i32>(&key).await { - Ok(len) => len.to_string(), - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Tests if a field exists in a Redis hash. -pub fn hash_exists(key: String, field: String) -> String { - redis_operation!(conn => { - match conn.hexists::<_, _, bool>(&key, &field).await { - Ok(exists) => if exists { "1" } else { "0" }.to_string(), - Err(e) => format!("Error: {}", e), - } - }) -} diff --git a/arma/server/extension/src/redis/helpers.rs b/arma/server/extension/src/redis/helpers.rs deleted file mode 100644 index 04bfcd5..0000000 --- a/arma/server/extension/src/redis/helpers.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Helper utilities for Redis data processing and encoding. - -use serde_json; - -/// Intelligently parses a Redis string value into the appropriate JSON type. -#[allow(dead_code)] -pub fn parse_redis_value(value: &str) -> serde_json::Value { - // Handle empty strings as null values - if value.is_empty() { - return serde_json::Value::Null; - } - - // Try to parse as JSON first (handles objects, arrays, and JSON primitives) - if let Ok(json_val) = serde_json::from_str(value) { - // Special handling: unwrap single-element arrays - if let serde_json::Value::Array(arr) = &json_val { - if arr.len() == 1 { - return arr[0].clone(); - } - } - return json_val; - } - - // Try to parse as integer - if let Ok(int_val) = value.parse::() { - return serde_json::Value::Number(serde_json::Number::from(int_val)); - } - - // Try to parse as float - if let Ok(float_val) = value.parse::() { - if let Some(num) = serde_json::Number::from_f64(float_val) { - return serde_json::Value::Number(num); - } - } - - // Try to parse as boolean (case-insensitive) - match value.to_lowercase().as_str() { - "true" => return serde_json::Value::Bool(true), - "false" => return serde_json::Value::Bool(false), - _ => {} - } - - // Fallback: treat as string - serde_json::Value::String(value.to_string()) -} - -/// Converts a JSON value to a string by wrapping it in an array. -#[allow(dead_code)] -pub fn parse_json_value(value: &serde_json::Value) -> String { - // Wrap the value in a single-element array - let wrapped = serde_json::Value::Array(vec![value.clone()]); - - // Serialize the wrapped array to a JSON string - wrapped.to_string() -} - -/// Encodes a string to base64 for safe Redis storage. -pub fn encode_b64(data: &str) -> String { - use base64::{Engine as _, engine::general_purpose}; - general_purpose::STANDARD.encode(data.as_bytes()) -} - -/// Decodes a base64 string back to its original form. -pub fn decode_b64(encoded: &str) -> Result { - use base64::{Engine as _, engine::general_purpose}; - match general_purpose::STANDARD.decode(encoded) { - Ok(bytes) => match String::from_utf8(bytes) { - Ok(string) => Ok(string), - Err(e) => Err(format!("Invalid UTF-8: {}", e)), - }, - Err(e) => Err(format!("Invalid base64: {}", e)), - } -} diff --git a/arma/server/extension/src/redis/list.rs b/arma/server/extension/src/redis/list.rs deleted file mode 100644 index 07068d8..0000000 --- a/arma/server/extension/src/redis/list.rs +++ /dev/null @@ -1,167 +0,0 @@ -//! Redis list operations for ordered collections and queues. - -use crate::redis_operation; -use bb8_redis::redis::AsyncCommands; - -/// Sets the value of an element at a specific index in a Redis list. -pub fn list_set(key: String, index: isize, value: String) -> String { - use crate::redis::helpers::encode_b64; - let encoded_value = encode_b64(&value); - redis_operation!(conn => { - match conn.lset(&key, index, &encoded_value).await { - Ok(()) => "OK".to_string(), - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Retrieves the value of an element at a specific index in a Redis list. -pub fn list_get(key: String, index: isize) -> String { - use crate::redis::helpers::decode_b64; - redis_operation!(conn => { - match conn.lindex::<_, String>(&key, index).await { - Ok(encoded_value) => { - match decode_b64(&encoded_value) { - Ok(decoded) => decoded, - Err(e) => format!("Error decoding base64: {}", e), - } - }, - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Returns the length (number of elements) of a Redis list. -pub fn list_len(key: String) -> String { - redis_operation!(conn => { - match conn.llen::<_, i32>(&key).await { - Ok(len) => len.to_string(), - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Retrieves a range of elements from a Redis list. -pub fn list_range(key: String, start: isize, end: isize) -> String { - use crate::redis::helpers::decode_b64; - redis_operation!(conn => { - match conn.lrange::<_, Vec>(&key, start, end).await { - Ok(encoded_values) => { - let mut decoded_values = Vec::new(); - for encoded in encoded_values { - match decode_b64(&encoded) { - Ok(decoded) => decoded_values.push(decoded), - Err(e) => return format!("Error decoding base64: {}", e), - } - } - match serde_json::to_string(&decoded_values) { - Ok(json_array) => json_array, - Err(e) => format!("Error: Failed to serialize to JSON: {}", e), - } - }, - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Prepends a value to the beginning (left) of a Redis list. -pub fn list_lpush(key: String, value: String) -> String { - use crate::redis::helpers::encode_b64; - let encoded_value = encode_b64(&value); - redis_operation!(conn => { - match conn.lpush::<_, _, usize>(&key, &encoded_value).await { - Ok(len) => len.to_string(), - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Appends a value to the end (right) of a Redis list. -pub fn list_rpush(key: String, value: String) -> String { - use crate::redis::helpers::encode_b64; - let encoded_value = encode_b64(&value); - redis_operation!(conn => { - match conn.rpush::<_, _, usize>(&key, &encoded_value).await { - Ok(len) => len.to_string(), - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Removes and returns elements from the beginning (left) of a Redis list. -pub fn list_lpop(key: String, count: usize) -> String { - use crate::redis::helpers::decode_b64; - redis_operation!(conn => { - let count_option = if count == 0 { - None - } else { - std::num::NonZeroUsize::new(count) - }; - match conn.lpop::<_, Vec>(&key, count_option).await { - Ok(encoded_values) => { - let mut decoded_values = Vec::new(); - for encoded in encoded_values { - match decode_b64(&encoded) { - Ok(decoded) => decoded_values.push(decoded), - Err(e) => return format!("Error decoding base64: {}", e), - } - } - match serde_json::to_string(&decoded_values) { - Ok(json_array) => json_array, - Err(e) => format!("Error: Failed to serialize to JSON: {}", e), - } - }, - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Removes and returns elements from the end (right) of a Redis list. -pub fn list_rpop(key: String, count: usize) -> String { - use crate::redis::helpers::decode_b64; - redis_operation!(conn => { - let count_option = if count == 0 { - None - } else { - std::num::NonZeroUsize::new(count) - }; - match conn.rpop::<_, Vec>(&key, count_option).await { - Ok(encoded_values) => { - let mut decoded_values = Vec::new(); - for encoded in encoded_values { - match decode_b64(&encoded) { - Ok(decoded) => decoded_values.push(decoded), - Err(e) => return format!("Error decoding base64: {}", e), - } - } - match serde_json::to_string(&decoded_values) { - Ok(json_array) => json_array, - Err(e) => format!("Error: Failed to serialize to JSON: {}", e), - } - }, - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Trims a Redis list to keep only elements within the specified range. -pub fn list_trim(key: String, start: isize, end: isize) -> String { - redis_operation!(conn => { - match conn.ltrim(&key, start, end).await { - Ok(()) => "OK".to_string(), - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Removes elements from a Redis list by value. -pub fn list_del(key: String, count: isize, value: String) -> String { - use crate::redis::helpers::encode_b64; - let encoded_value = encode_b64(&value); - redis_operation!(conn => { - match conn.lrem::<_, _, i32>(&key, count, &encoded_value).await { - Ok(removed) => removed.to_string(), - Err(e) => format!("Error: {}", e), - } - }) -} diff --git a/arma/server/extension/src/redis/macros.rs b/arma/server/extension/src/redis/macros.rs deleted file mode 100644 index 80e40ff..0000000 --- a/arma/server/extension/src/redis/macros.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! Macros for Redis operation boilerplate reduction. - -/// Macro for Redis operations that handles all connection and async boilerplate. -#[macro_export] -macro_rules! redis_operation { - ($conn:ident => $operation:block) => {{ - use tokio::time::{Duration, timeout}; - use $crate::redis; - use $crate::{CONNECTION_STATE, ConnectionState, REDIS_POOL, RUNTIME}; - - let timeout_config = redis::config::load().redis; - let pool_get_timeout = - Duration::from_millis(timeout_config.pool_get_timeout_ms.unwrap_or(2000)); - let command_timeout = - Duration::from_millis(timeout_config.command_timeout_ms.unwrap_or(2000)); - let init_timeout = Duration::from_millis(timeout_config.connect_timeout_ms.unwrap_or(2000)); - - // Get the Redis connection pool (initialized at startup) - let pool = match REDIS_POOL.get() { - Some(pool) => pool, - None => { - if *CONNECTION_STATE.read().unwrap() == ConnectionState::Failed { - return "Error: Redis connection unavailable".to_string(); - } - - // Attempt lazy initialization if not already initialized - let rt = &RUNTIME; - let init_result = rt.block_on(async move { - let cfg = redis::config::load(); - match timeout(init_timeout, redis::client::create_redis_pool(&cfg.redis)).await - { - Ok(Ok(pool)) => { - let _ = REDIS_POOL.set(pool); - Ok(()) - } - Ok(Err(_e)) => { - let default_cfg = redis::RedisConfig::default(); - match timeout( - init_timeout, - redis::client::create_redis_pool(&default_cfg), - ) - .await - { - Ok(Ok(pool)) => { - let _ = REDIS_POOL.set(pool); - Ok(()) - } - Ok(Err(e)) => Err(format!("{}", e)), - Err(_) => { - Err("Redis fallback initialization timed out".to_string()) - } - } - } - Err(_) => Err("Redis initialization timed out".to_string()), - } - }); - - match init_result { - Ok(()) => { - *CONNECTION_STATE.write().unwrap() = ConnectionState::Connected; - match REDIS_POOL.get() { - Some(pool) => pool, - None => return "Error: Redis pool not initialized".to_string(), - } - } - Err(err) => { - *CONNECTION_STATE.write().unwrap() = ConnectionState::Failed; - return format!("Error: {}", err); - } - } - } - }; - - // Use the global tokio runtime to execute async operations - let rt = &RUNTIME; - rt.block_on(async move { - // Acquire a connection from the pool - let mut $conn = match timeout(pool_get_timeout, pool.get()).await { - Ok(Ok(conn)) => conn, - Ok(Err(e)) => return format!("Error: {}", e), - Err(_) => return "Error: Redis connection checkout timed out".to_string(), - }; - - // Execute the user-provided Redis operation - match timeout(command_timeout, async move { $operation }).await { - Ok(result) => result, - Err(_) => "Error: Redis operation timed out".to_string(), - } - }) - }}; -} diff --git a/arma/server/extension/src/redis/mod.rs b/arma/server/extension/src/redis/mod.rs deleted file mode 100644 index a954ad7..0000000 --- a/arma/server/extension/src/redis/mod.rs +++ /dev/null @@ -1,138 +0,0 @@ -//! Redis operations and utilities for the Arma 3 server extension. - -use arma_rs::Group; -use tokio::time::{Duration, timeout}; - -pub use client::create_redis_pool; -pub use config::RedisConfig; -pub use helpers::{decode_b64, encode_b64}; - -use crate::{CONNECTION_STATE, ConnectionState, REDIS_POOL, log}; - -pub mod client; -pub mod common; -pub mod config; -pub mod hash; -pub mod helpers; -pub mod list; -pub mod macros; -pub mod set; - -/// Initialize Redis connection pool with fallback to default config -/// -/// This function attempts to connect to Redis using the provided config, -/// with a 5-second timeout. If the primary config fails, it tries the -/// default config as a fallback. -pub async fn initialize(config: RedisConfig) { - // Use timeout to prevent hanging if Redis is unavailable - let pool_result = timeout(Duration::from_secs(5), create_redis_pool(&config)).await; - - let pool = match pool_result { - Err(_) => { - log::log( - "redis", - "ERROR", - "Redis connection timed out after 5 seconds", - ); - *CONNECTION_STATE.write().unwrap() = ConnectionState::Failed; - return; // Exit early - } - Ok(Ok(pool)) => { - log::log("redis", "INFO", "Connected to Redis server"); - pool - } - Ok(Err(e)) => { - log::log( - "redis", - "WARN", - &format!("Failed to connect to Redis (primary config): {}", e), - ); - // Try default config as fallback with timeout - let default_config = RedisConfig::default(); - match timeout(Duration::from_secs(5), create_redis_pool(&default_config)).await { - Err(_) => { - log::log( - "redis", - "ERROR", - "Redis (default config) timed out after 5 seconds", - ); - *CONNECTION_STATE.write().unwrap() = ConnectionState::Failed; - return; - } - Ok(Ok(pool)) => { - log::log("redis", "INFO", "Connected to Redis using default config"); - pool - } - Ok(Err(e)) => { - log::log( - "redis", - "ERROR", - &format!("Failed to connect to Redis (all attempts): {}", e), - ); - *CONNECTION_STATE.write().unwrap() = ConnectionState::Failed; - return; // Exit early, don't set pool - } - } - } - }; - - if REDIS_POOL.set(pool).is_ok() { - *CONNECTION_STATE.write().unwrap() = ConnectionState::Connected; - } else { - log::log("redis", "ERROR", "Failed to set Redis pool (already set)"); - *CONNECTION_STATE.write().unwrap() = ConnectionState::Failed; - } -} - -pub fn group() -> Group { - Group::new() - .group( - "common", - Group::new() - .command("set", common::set_key) - .command("get", common::get_key) - .command("incr", common::incr_key) - .command("decr", common::decr_key) - .command("del", common::delete_key) - .command("keys", common::list_keys), - ) - .group( - "hash", - Group::new() - .command("set", hash::hash_set) - .command("mset", hash::hash_mset) - .command("get", hash::hash_get) - .command("getall", hash::hash_get_all) - .command("del", hash::hash_del) - .command("keys", hash::hash_keys) - .command("vals", hash::hash_values) - .command("len", hash::hash_len) - .command("exists", hash::hash_exists), - ) - .group( - "list", - Group::new() - .command("set", list::list_set) - .command("get", list::list_get) - .command("len", list::list_len) - .command("range", list::list_range) - .command("lpush", list::list_lpush) - .command("rpush", list::list_rpush) - .command("lpop", list::list_lpop) - .command("rpop", list::list_rpop) - .command("trim", list::list_trim) - .command("del", list::list_del), - ) - .group( - "set", - Group::new() - .command("add", set::set_add) - .command("members", set::set_members) - .command("card", set::set_card) - .command("ismember", set::set_is_member) - .command("randmember", set::set_random_member) - .command("randmembers", set::set_random_members) - .command("pop", set::set_pop) - .command("del", set::set_del), - ) -} diff --git a/arma/server/extension/src/redis/set.rs b/arma/server/extension/src/redis/set.rs deleted file mode 100644 index 1be6ce5..0000000 --- a/arma/server/extension/src/redis/set.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! Redis set operations for unique collections and membership tracking. - -use crate::redis_operation; -use bb8_redis::redis::AsyncCommands; - -/// Adds a value to a Redis set. -pub fn set_add(key: String, value: String) -> String { - redis_operation!(conn => { - match conn.sadd::<_, _, i32>(&key, &value).await { - Ok(added) => added.to_string(), - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Retrieves all members of a Redis set. -pub fn set_members(key: String) -> String { - redis_operation!(conn => { - match conn.smembers::<_, Vec>(&key).await { - Ok(members) => members.join(","), - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Returns the number of members in a Redis set (cardinality). -pub fn set_card(key: String) -> String { - redis_operation!(conn => { - match conn.scard::<_, i32>(&key).await { - Ok(card) => card.to_string(), - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Removes a value from a Redis set. -pub fn set_del(key: String, value: String) -> String { - redis_operation!(conn => { - match conn.srem::<_, _, i32>(&key, &value).await { - Ok(removed) => removed.to_string(), - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Tests if a value is a member of a Redis set. -pub fn set_is_member(key: String, value: String) -> String { - redis_operation!(conn => { - match conn.sismember::<_, _, bool>(&key, &value).await { - Ok(is_member) => if is_member { "1" } else { "0" }.to_string(), - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Removes and returns a random member from a Redis set. -pub fn set_pop(key: String) -> String { - redis_operation!(conn => { - match conn.spop::<_, String>(&key).await { - Ok(value) => value, - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Returns a random member from a Redis set without removing it. -pub fn set_random_member(key: String) -> String { - redis_operation!(conn => { - match conn.srandmember::<_, String>(&key).await { - Ok(value) => value, - Err(e) => format!("Error: {}", e), - } - }) -} - -/// Returns multiple random members from a Redis set without removing them. -pub fn set_random_members(key: String, count: isize) -> String { - redis_operation!(conn => { - match conn - .srandmember_multiple::<_, Vec>(&key, count.try_into().unwrap_or(0)) - .await - { - Ok(values) => values.join(","), - Err(e) => format!("Error: {}", e), - } - }) -} diff --git a/arma/server/extension/src/storage.rs b/arma/server/extension/src/storage.rs index ca98bd2..a41aa44 100644 --- a/arma/server/extension/src/storage.rs +++ b/arma/server/extension/src/storage.rs @@ -27,15 +27,11 @@ use forge_models::{ }; use forge_repositories::{ ActorRepository, BankRepository, GarageRepository, LockerRepository, OrgRepository, - PhoneRepository, RedisActorRepository, RedisBankRepository, RedisGarageRepository, - RedisLockerRepository, RedisOrgRepository, RedisPhoneRepository, RedisVGarageRepository, - RedisVLockerRepository, VGarageRepository, VLockerRepository, + PhoneRepository, VGarageRepository, VLockerRepository, }; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use crate::RUNTIME; -use crate::adapters::ExtensionRedisClient; -use crate::redis::config::{StorageBackend, load}; use crate::surreal; diff --git a/arma/server/extension/src/storage/actor.rs b/arma/server/extension/src/storage/actor.rs index 8b42e59..1174efb 100644 --- a/arma/server/extension/src/storage/actor.rs +++ b/arma/server/extension/src/storage/actor.rs @@ -2,53 +2,42 @@ use super::common::*; use super::*; pub enum ActorStorageRepository { - Redis(RedisActorRepository), Surreal(SurrealActorRepository), } impl ActorStorageRepository { pub fn configured() -> Self { - match load().storage.backend { - StorageBackend::Surreal => Self::Surreal(SurrealActorRepository), - StorageBackend::Redis => { - Self::Redis(RedisActorRepository::new(ExtensionRedisClient::new())) - } - } + Self::Surreal(SurrealActorRepository) } } impl ActorRepository for ActorStorageRepository { fn create(&self, actor: &Actor) -> Result<(), String> { match self { - Self::Redis(repository) => repository.create(actor), Self::Surreal(repository) => repository.create(actor), } } fn get_by_id(&self, id: &str) -> Result, String> { match self { - Self::Redis(repository) => repository.get_by_id(id), Self::Surreal(repository) => repository.get_by_id(id), } } fn update(&self, actor: &Actor) -> Result<(), String> { match self { - Self::Redis(repository) => repository.update(actor), Self::Surreal(repository) => repository.update(actor), } } fn delete(&self, id: &str) -> Result<(), String> { match self { - Self::Redis(repository) => repository.delete(id), Self::Surreal(repository) => repository.delete(id), } } fn exists(&self, id: &str) -> Result { match self { - Self::Redis(repository) => repository.exists(id), Self::Surreal(repository) => repository.exists(id), } } diff --git a/arma/server/extension/src/storage/bank.rs b/arma/server/extension/src/storage/bank.rs index 8bad0e8..7c76f10 100644 --- a/arma/server/extension/src/storage/bank.rs +++ b/arma/server/extension/src/storage/bank.rs @@ -2,53 +2,42 @@ use super::common::*; use super::*; pub enum BankStorageRepository { - Redis(RedisBankRepository), Surreal(SurrealBankRepository), } impl BankStorageRepository { pub fn configured() -> Self { - match load().storage.backend { - StorageBackend::Surreal => Self::Surreal(SurrealBankRepository), - StorageBackend::Redis => { - Self::Redis(RedisBankRepository::new(ExtensionRedisClient::new())) - } - } + Self::Surreal(SurrealBankRepository) } } impl BankRepository for BankStorageRepository { fn create(&self, bank: &Bank) -> Result<(), String> { match self { - Self::Redis(repository) => repository.create(bank), Self::Surreal(repository) => repository.create(bank), } } fn get_by_id(&self, id: &str) -> Result, String> { match self { - Self::Redis(repository) => repository.get_by_id(id), Self::Surreal(repository) => repository.get_by_id(id), } } fn update(&self, bank: &Bank) -> Result<(), String> { match self { - Self::Redis(repository) => repository.update(bank), Self::Surreal(repository) => repository.update(bank), } } fn delete(&self, id: &str) -> Result<(), String> { match self { - Self::Redis(repository) => repository.delete(id), Self::Surreal(repository) => repository.delete(id), } } fn exists(&self, id: &str) -> Result { match self { - Self::Redis(repository) => repository.exists(id), Self::Surreal(repository) => repository.exists(id), } } diff --git a/arma/server/extension/src/storage/garage.rs b/arma/server/extension/src/storage/garage.rs index bbff0b6..6504c04 100644 --- a/arma/server/extension/src/storage/garage.rs +++ b/arma/server/extension/src/storage/garage.rs @@ -2,53 +2,42 @@ use super::common::*; use super::*; pub enum GarageStorageRepository { - Redis(RedisGarageRepository), Surreal(SurrealGarageRepository), } impl GarageStorageRepository { pub fn configured() -> Self { - match load().storage.backend { - StorageBackend::Surreal => Self::Surreal(SurrealGarageRepository), - StorageBackend::Redis => { - Self::Redis(RedisGarageRepository::new(ExtensionRedisClient::new())) - } - } + Self::Surreal(SurrealGarageRepository) } } impl GarageRepository for GarageStorageRepository { fn create(&self, uid: &str, garage: &Garage) -> Result<(), String> { match self { - Self::Redis(repository) => repository.create(uid, garage), Self::Surreal(repository) => repository.create(uid, garage), } } fn update(&self, uid: &str, garage: &Garage) -> Result<(), String> { match self { - Self::Redis(repository) => repository.update(uid, garage), Self::Surreal(repository) => repository.update(uid, garage), } } fn get(&self, uid: &str) -> Result, String> { match self { - Self::Redis(repository) => repository.get(uid), Self::Surreal(repository) => repository.get(uid), } } fn delete(&self, uid: &str) -> Result<(), String> { match self { - Self::Redis(repository) => repository.delete(uid), Self::Surreal(repository) => repository.delete(uid), } } fn exists(&self, uid: &str) -> Result { match self { - Self::Redis(repository) => repository.exists(uid), Self::Surreal(repository) => repository.exists(uid), } } @@ -152,60 +141,48 @@ impl GarageRepository for SurrealGarageRepository { } pub enum VGarageStorageRepository { - Redis(RedisVGarageRepository), Surreal(SurrealVGarageRepository), } impl VGarageStorageRepository { pub fn configured() -> Self { - match load().storage.backend { - StorageBackend::Surreal => Self::Surreal(SurrealVGarageRepository), - StorageBackend::Redis => { - Self::Redis(RedisVGarageRepository::new(ExtensionRedisClient::new())) - } - } + Self::Surreal(SurrealVGarageRepository) } } impl VGarageRepository for VGarageStorageRepository { fn create(&self, uid: &str, garage: &VGarage) -> Result<(), String> { match self { - Self::Redis(repository) => repository.create(uid, garage), Self::Surreal(repository) => repository.create(uid, garage), } } fn update(&self, uid: &str, garage: &VGarage) -> Result<(), String> { match self { - Self::Redis(repository) => repository.update(uid, garage), Self::Surreal(repository) => repository.update(uid, garage), } } fn fetch(&self, uid: &str) -> Result, String> { match self { - Self::Redis(repository) => repository.fetch(uid), Self::Surreal(repository) => repository.fetch(uid), } } fn get(&self, uid: &str, field: &str) -> Result, String> { match self { - Self::Redis(repository) => repository.get(uid, field), Self::Surreal(repository) => repository.get(uid, field), } } fn delete(&self, uid: &str) -> Result<(), String> { match self { - Self::Redis(repository) => repository.delete(uid), Self::Surreal(repository) => repository.delete(uid), } } fn exists(&self, uid: &str) -> Result { match self { - Self::Redis(repository) => repository.exists(uid), Self::Surreal(repository) => repository.exists(uid), } } diff --git a/arma/server/extension/src/storage/locker.rs b/arma/server/extension/src/storage/locker.rs index 69ccf7d..c7194be 100644 --- a/arma/server/extension/src/storage/locker.rs +++ b/arma/server/extension/src/storage/locker.rs @@ -2,53 +2,42 @@ use super::common::*; use super::*; pub enum LockerStorageRepository { - Redis(RedisLockerRepository), Surreal(SurrealLockerRepository), } impl LockerStorageRepository { pub fn configured() -> Self { - match load().storage.backend { - StorageBackend::Surreal => Self::Surreal(SurrealLockerRepository), - StorageBackend::Redis => { - Self::Redis(RedisLockerRepository::new(ExtensionRedisClient::new())) - } - } + Self::Surreal(SurrealLockerRepository) } } impl LockerRepository for LockerStorageRepository { fn create(&self, uid: &str, locker: &Locker) -> Result<(), String> { match self { - Self::Redis(repository) => repository.create(uid, locker), Self::Surreal(repository) => repository.create(uid, locker), } } fn update(&self, uid: &str, locker: &Locker) -> Result<(), String> { match self { - Self::Redis(repository) => repository.update(uid, locker), Self::Surreal(repository) => repository.update(uid, locker), } } fn get(&self, uid: &str) -> Result, String> { match self { - Self::Redis(repository) => repository.get(uid), Self::Surreal(repository) => repository.get(uid), } } fn delete(&self, uid: &str) -> Result<(), String> { match self { - Self::Redis(repository) => repository.delete(uid), Self::Surreal(repository) => repository.delete(uid), } } fn exists(&self, uid: &str) -> Result { match self { - Self::Redis(repository) => repository.exists(uid), Self::Surreal(repository) => repository.exists(uid), } } @@ -141,60 +130,48 @@ impl LockerRepository for SurrealLockerRepository { } pub enum VLockerStorageRepository { - Redis(RedisVLockerRepository), Surreal(SurrealVLockerRepository), } impl VLockerStorageRepository { pub fn configured() -> Self { - match load().storage.backend { - StorageBackend::Surreal => Self::Surreal(SurrealVLockerRepository), - StorageBackend::Redis => { - Self::Redis(RedisVLockerRepository::new(ExtensionRedisClient::new())) - } - } + Self::Surreal(SurrealVLockerRepository) } } impl VLockerRepository for VLockerStorageRepository { fn create(&self, uid: &str, locker: &VLocker) -> Result<(), String> { match self { - Self::Redis(repository) => repository.create(uid, locker), Self::Surreal(repository) => repository.create(uid, locker), } } fn update(&self, uid: &str, locker: &VLocker) -> Result<(), String> { match self { - Self::Redis(repository) => repository.update(uid, locker), Self::Surreal(repository) => repository.update(uid, locker), } } fn fetch(&self, uid: &str) -> Result, String> { match self { - Self::Redis(repository) => repository.fetch(uid), Self::Surreal(repository) => repository.fetch(uid), } } fn get(&self, uid: &str, field: &str) -> Result, String> { match self { - Self::Redis(repository) => repository.get(uid, field), Self::Surreal(repository) => repository.get(uid, field), } } fn delete(&self, uid: &str) -> Result<(), String> { match self { - Self::Redis(repository) => repository.delete(uid), Self::Surreal(repository) => repository.delete(uid), } } fn exists(&self, uid: &str) -> Result { match self { - Self::Redis(repository) => repository.exists(uid), Self::Surreal(repository) => repository.exists(uid), } } diff --git a/arma/server/extension/src/storage/org.rs b/arma/server/extension/src/storage/org.rs index 50e46e0..af69acd 100644 --- a/arma/server/extension/src/storage/org.rs +++ b/arma/server/extension/src/storage/org.rs @@ -2,74 +2,60 @@ use super::common::*; use super::*; pub enum OrgStorageRepository { - Redis(RedisOrgRepository), Surreal(SurrealOrgRepository), } impl OrgStorageRepository { pub fn configured() -> Self { - match load().storage.backend { - StorageBackend::Surreal => Self::Surreal(SurrealOrgRepository), - StorageBackend::Redis => { - Self::Redis(RedisOrgRepository::new(ExtensionRedisClient::new())) - } - } + Self::Surreal(SurrealOrgRepository) } } impl OrgRepository for OrgStorageRepository { fn create(&self, org: &Org) -> Result<(), String> { match self { - Self::Redis(repository) => repository.create(org), Self::Surreal(repository) => repository.create(org), } } fn get_by_id(&self, id: &str) -> Result, String> { match self { - Self::Redis(repository) => repository.get_by_id(id), Self::Surreal(repository) => repository.get_by_id(id), } } fn update(&self, org: &Org) -> Result<(), String> { match self { - Self::Redis(repository) => repository.update(org), Self::Surreal(repository) => repository.update(org), } } fn delete(&self, id: &str) -> Result<(), String> { match self { - Self::Redis(repository) => repository.delete(id), Self::Surreal(repository) => repository.delete(id), } } fn exists(&self, id: &str) -> Result { match self { - Self::Redis(repository) => repository.exists(id), Self::Surreal(repository) => repository.exists(id), } } fn add_member(&self, org_id: &str, member_uid: &str) -> Result<(), String> { match self { - Self::Redis(repository) => repository.add_member(org_id, member_uid), Self::Surreal(repository) => repository.add_member(org_id, member_uid), } } fn get_members(&self, org_id: &str) -> Result, String> { match self { - Self::Redis(repository) => repository.get_members(org_id), Self::Surreal(repository) => repository.get_members(org_id), } } fn remove_member(&self, org_id: &str, member_uid: &str) -> Result<(), String> { match self { - Self::Redis(repository) => repository.remove_member(org_id, member_uid), Self::Surreal(repository) => repository.remove_member(org_id, member_uid), } } @@ -79,7 +65,6 @@ impl OrgRepository for OrgStorageRepository { org_id: &str, ) -> Result>, String> { match self { - Self::Redis(repository) => repository.get_assets(org_id), Self::Surreal(repository) => repository.get_assets(org_id), } } @@ -90,14 +75,12 @@ impl OrgRepository for OrgStorageRepository { assets: &HashMap>, ) -> Result<(), String> { match self { - Self::Redis(repository) => repository.update_assets(org_id, assets), Self::Surreal(repository) => repository.update_assets(org_id, assets), } } fn get_fleet(&self, org_id: &str) -> Result, String> { match self { - Self::Redis(repository) => repository.get_fleet(org_id), Self::Surreal(repository) => repository.get_fleet(org_id), } } @@ -108,7 +91,6 @@ impl OrgRepository for OrgStorageRepository { fleet: &HashMap, ) -> Result<(), String> { match self { - Self::Redis(repository) => repository.update_fleet(org_id, fleet), Self::Surreal(repository) => repository.update_fleet(org_id, fleet), } } diff --git a/arma/server/extension/src/storage/phone.rs b/arma/server/extension/src/storage/phone.rs index 68f6461..2c57e65 100644 --- a/arma/server/extension/src/storage/phone.rs +++ b/arma/server/extension/src/storage/phone.rs @@ -2,116 +2,96 @@ use super::common::*; use super::*; pub enum PhoneStorageRepository { - Redis(RedisPhoneRepository), Surreal(SurrealPhoneRepository), } impl PhoneStorageRepository { pub fn configured() -> Self { - match load().storage.backend { - StorageBackend::Surreal => Self::Surreal(SurrealPhoneRepository), - StorageBackend::Redis => { - Self::Redis(RedisPhoneRepository::new(ExtensionRedisClient::new())) - } - } + Self::Surreal(SurrealPhoneRepository) } } impl PhoneRepository for PhoneStorageRepository { fn init(&self, uid: &str) -> Result<(), String> { match self { - Self::Redis(repository) => repository.init(uid), Self::Surreal(repository) => repository.init(uid), } } fn add_contact(&self, uid: &str, contact_uid: &str) -> Result { match self { - Self::Redis(repository) => repository.add_contact(uid, contact_uid), Self::Surreal(repository) => repository.add_contact(uid, contact_uid), } } fn remove_contact(&self, uid: &str, contact_uid: &str) -> Result { match self { - Self::Redis(repository) => repository.remove_contact(uid, contact_uid), Self::Surreal(repository) => repository.remove_contact(uid, contact_uid), } } fn list_contacts(&self, uid: &str) -> Result, String> { match self { - Self::Redis(repository) => repository.list_contacts(uid), Self::Surreal(repository) => repository.list_contacts(uid), } } fn remove_phone(&self, uid: &str) -> Result<(), String> { match self { - Self::Redis(repository) => repository.remove_phone(uid), Self::Surreal(repository) => repository.remove_phone(uid), } } fn append_message(&self, uid: &str, message: PhoneMessage) -> Result<(), String> { match self { - Self::Redis(repository) => repository.append_message(uid, message), Self::Surreal(repository) => repository.append_message(uid, message), } } fn list_messages(&self, uid: &str) -> Result, String> { match self { - Self::Redis(repository) => repository.list_messages(uid), Self::Surreal(repository) => repository.list_messages(uid), } } fn mark_message_read(&self, uid: &str, message_id: &str) -> Result { match self { - Self::Redis(repository) => repository.mark_message_read(uid, message_id), Self::Surreal(repository) => repository.mark_message_read(uid, message_id), } } fn delete_message(&self, uid: &str, message_id: &str) -> Result { match self { - Self::Redis(repository) => repository.delete_message(uid, message_id), Self::Surreal(repository) => repository.delete_message(uid, message_id), } } fn append_email(&self, uid: &str, email: PhoneEmail) -> Result<(), String> { match self { - Self::Redis(repository) => repository.append_email(uid, email), Self::Surreal(repository) => repository.append_email(uid, email), } } fn list_emails(&self, uid: &str) -> Result, String> { match self { - Self::Redis(repository) => repository.list_emails(uid), Self::Surreal(repository) => repository.list_emails(uid), } } fn mark_email_read(&self, uid: &str, email_id: &str) -> Result { match self { - Self::Redis(repository) => repository.mark_email_read(uid, email_id), Self::Surreal(repository) => repository.mark_email_read(uid, email_id), } } fn delete_email(&self, uid: &str, email_id: &str) -> Result { match self { - Self::Redis(repository) => repository.delete_email(uid, email_id), Self::Surreal(repository) => repository.delete_email(uid, email_id), } } fn next_sequence(&self) -> Result { match self { - Self::Redis(repository) => repository.next_sequence(), Self::Surreal(repository) => repository.next_sequence(), } } diff --git a/arma/server/extension/src/surreal.rs b/arma/server/extension/src/surreal.rs index 6cb1aac..560d969 100644 --- a/arma/server/extension/src/surreal.rs +++ b/arma/server/extension/src/surreal.rs @@ -7,8 +7,8 @@ use surrealdb::engine::remote::http::{Client, Http}; use surrealdb::opt::auth::Root; use tokio::time::{Duration, sleep, timeout}; +use crate::config::SurrealConfig; use crate::log; -use crate::redis::config::SurrealConfig; use crate::schema; pub type SurrealDb = Surreal; @@ -144,7 +144,8 @@ async fn wait_for_client() -> Result<&'static SurrealDb, String> { return Ok(db); } - match *SURREAL_CONNECTION_STATE.read().unwrap() { + let state = *SURREAL_CONNECTION_STATE.read().unwrap(); + match state { SurrealConnectionState::Disabled => { return Err("SurrealDB connection is disabled".to_string()); } diff --git a/arma/server/extension/src/transport.rs b/arma/server/extension/src/transport.rs index 1ee2749..1289234 100644 --- a/arma/server/extension/src/transport.rs +++ b/arma/server/extension/src/transport.rs @@ -131,10 +131,10 @@ fn parse_transport_argument_value(value: serde_json::Value) -> Result { let trimmed = value.trim(); - if trimmed.starts_with('[') || trimmed.starts_with('{') || trimmed.eq("null") { - if let Ok(nested_value) = serde_json::from_str::(trimmed) { - return parse_transport_argument_value(nested_value); - } + if (trimmed.starts_with('[') || trimmed.starts_with('{') || trimmed.eq("null")) + && let Ok(nested_value) = serde_json::from_str::(trimmed) + { + return parse_transport_argument_value(nested_value); } Ok(vec![value]) diff --git a/bin/icom/src/config.rs b/bin/icom/src/config.rs index 53a0025..0f3a353 100644 --- a/bin/icom/src/config.rs +++ b/bin/icom/src/config.rs @@ -4,21 +4,13 @@ use std::fs; use std::path::PathBuf; /// ICOM server configuration. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Default)] pub struct Config { /// Server bind address configuration #[serde(default)] pub server: ServerConfig, } -impl Default for Config { - fn default() -> Self { - Self { - server: ServerConfig::default(), - } - } -} - /// Server bind configuration. #[derive(Debug, Clone, Deserialize)] pub struct ServerConfig { diff --git a/docs/GARAGE_USAGE_GUIDE.md b/docs/GARAGE_USAGE_GUIDE.md index fe8d8d3..2afc4a7 100644 --- a/docs/GARAGE_USAGE_GUIDE.md +++ b/docs/GARAGE_USAGE_GUIDE.md @@ -6,7 +6,7 @@ The garage system provides complete vehicle storage, retrieval, and management f ## Data Storage -- Each player's garage is stored as a single JSON object (map) at Redis key: `garage:{playerUID}` +- Each player's garage is persisted by the server extension through SurrealDB. - The map is keyed by the vehicle's unique plate (UUID) - Each vehicle tracks: plate (UUID), classname, overall damage, fuel, and detailed hit points - **Plates are auto-generated** when vehicles are added via `garage:add` diff --git a/docs/LOCKER_USAGE_GUIDE.md b/docs/LOCKER_USAGE_GUIDE.md index 87eff08..a29d495 100644 --- a/docs/LOCKER_USAGE_GUIDE.md +++ b/docs/LOCKER_USAGE_GUIDE.md @@ -6,7 +6,7 @@ The locker system provides persistent item storage for Arma 3 players. Each play ## Data Storage -- Each player's locker is stored as a single JSON object (map) at Redis key: `locker:{playerUID}` +- Each player's locker is persisted by the server extension through SurrealDB. - The map is keyed by the item's **classname** (String) - Each item tracks: `category`, `classname`, and `amount` - **Maximum Capacity**: 25 unique items per locker diff --git a/lib/README.md b/lib/README.md index 05c2b62..a828334 100644 --- a/lib/README.md +++ b/lib/README.md @@ -1,264 +1,26 @@ -# Forge Library +# Forge Shared Libraries -This directory contains the core business logic and data layers for the Forge framework, organized into modular, reusable crates that follow clean architecture principles. +The `lib` workspace contains reusable Rust crates for Forge domain models, +repository traits, services, and shared helpers. -## Architecture Overview +## Crates -The library follows a **layered architecture** pattern, ensuring separation of concerns and maintainability: +- `forge-models`: serializable domain models shared by services and extension + routes. +- `forge-repositories`: repository traits plus in-memory implementations used + by tests and transient hot-state stores. +- `forge-services`: business logic for actor, bank, garage, locker, org, + phone, store, task, and CAD workflows. +- `forge-shared`: validation and cross-crate helpers. -```mermaid -graph TD - Extension[Extension Layer
#40;ArmA 3 Interface#41;] - Services[Services Layer
#40;Business Logic#41;] - Repositories[Repositories Layer
#40;Data Persistence#41;] - Models[Models Layer
#40;Data Structures#41;] +Durable persistence is implemented in the server extension with SurrealDB +repository implementations. - Extension --> Services - Services --> Repositories - Repositories --> Models +## Test + +```powershell +cargo test -p forge-models +cargo test -p forge-repositories +cargo test -p forge-services +cargo test -p forge-shared ``` - -## Modules - -### Models (`lib/models`) - -**Purpose**: Defines strict data structures and validation rules for domain entities. - -**Responsibilities**: - -- Define entity structures (`Actor`, `Org`) -- Implement validation logic -- Handle serialization/deserialization (JSON, Arma) -- Enforce business rules at the data level - -**Key Features**: - -- Strong typing with Rust structs -- Built-in validation on creation and updates -- Automatic email generation for actors -- Arma-specific type conversions - -**Documentation**: [models/README.md](models/README.md) - -### Repositories (`lib/repositories`) - -**Purpose**: Manages data persistence and retrieval using Redis. - -**Responsibilities**: - -- Abstract database operations -- Implement CRUD operations -- Handle data serialization to Redis formats -- Manage Redis keys and data structures - -**Key Features**: - -- Generic over Redis client implementations -- Hash-based storage for structured data -- Set-based storage for collections (e.g., org members) -- Thread-safe operations (`Send + Sync`) - -**Documentation**: [repositories/README.md](repositories/README.md) - -### Services (`lib/services`) - -**Purpose**: Implements business logic, validation, and orchestration of operations. - -**Responsibilities**: - -- Coordinate between repositories -- Enforce business rules -- Handle complex workflows -- Provide high-level APIs for the extension layer - -**Key Features**: - -- Generic over repository implementations -- Stateless service design -- Get-or-create patterns for entities -- Comprehensive error handling - -**Documentation**: [services/README.md](services/README.md) - -### Shared (`lib/shared`) - -**Purpose**: Provides common utilities, traits, and helper functions used across all layers. - -**Responsibilities**: - -- Define shared traits (`RedisClient`) -- Provide utility functions -- Common type definitions -- Cross-cutting concerns - -**Key Features**: - -- `RedisClient` trait for repository abstraction -- JSON/Redis value parsing utilities -- Arma value conversion helpers -- Reusable helper functions - -## How It All Works Together - -### Example: Creating an Actor - -Here's how the layers interact when creating a new actor: - -1. **Extension Layer** receives SQF command: - - ```rust - // arma/server/extension/src/actor.rs - pub fn create_actor(key: String, data: String) -> String { - // Parse JSON and call service - ACTOR_SERVICE.create_actor(uid, json_data) - } - ``` - -2. **Service Layer** validates and orchestrates: - - ```rust - // lib/services/src/actor.rs - impl ActorService { - pub fn create_actor(&self, uid: String, data: String) -> Result { - // Create actor model (validates data) - let actor = Actor::new(uid, data)?; - - // Persist via repository - self.repository.create(&actor)?; - - Ok(actor) - } - } - ``` - -3. **Repository Layer** persists to Redis: - - ```rust - // lib/repositories/src/actor.rs - impl ActorRepository for RedisActorRepository { - fn create(&self, actor: &Actor) -> Result<(), String> { - // Convert actor to Redis hash - let fields = actor.to_redis_fields(); - - // Store in Redis - self.client.hash_mset(format!("actor:{}", actor.uid), fields) - } - } - ``` - -4. **Model Layer** ensures data integrity: - - ```rust - // lib/models/src/actor.rs - impl Actor { - pub fn new(uid: String, data: String) -> Result { - // Validate all fields - Self::validate(&uid, &data)?; - - // Create actor with validated data - Ok(Actor { uid, /* ... */ }) - } - } - ``` - -## Contributing - -We welcome contributions to the Forge library! Follow these guidelines to maintain consistency and quality. - -### Adding a New Model - -See [models/README.md - Contributing](models/README.md#contributing) - -**Summary**: - -1. Define struct with validation rules -2. Implement `new` and `validate` methods -3. Add serialization traits (`Serialize`, `Deserialize`) -4. Implement Arma conversions (`FromArma`, `IntoArma`) - -### Adding a New Repository - -See [repositories/README.md - Contributing](repositories/README.md#contributing) - -**Summary**: - -1. Define repository trait with `Send + Sync` -2. Implement trait for `RedisXRepository` -3. Use `forge_shared::RedisClient` for operations -4. Register module in `lib.rs` - -### Adding a New Service - -See [services/README.md - Contributing](services/README.md#contributing) - -**Summary**: - -1. Create service struct generic over repository -2. Implement constructor and business logic methods -3. Delegate data operations to repository -4. Register module in `lib.rs` - -### Best Practices - -#### Separation of Concerns - -- **Models**: Only data structures and validation -- **Repositories**: Only data persistence logic -- **Services**: Only business logic and orchestration -- **Shared**: Only common utilities and traits - -#### Error Handling - -- Use `Result` for all fallible operations -- Provide descriptive error messages -- Propagate errors up the stack with `?` - -#### Testing - -- **Models**: Test validation rules -- **Repositories**: Test with mock Redis clients -- **Services**: Test with mock repositories -- **Integration**: Test full stack in extension layer - -#### Dependencies - -- Models should have minimal dependencies -- Repositories depend on models and shared -- Services depend on repositories and models -- Avoid circular dependencies - -#### Thread Safety - -- All repository traits require `Send + Sync` -- Services are stateless and thread-safe -- Use appropriate synchronization primitives when needed - -## Module Dependencies - -```mermaid -graph TD - Shared[Shared
#40;No Dependencies#41;] - Models[Models
#40;Depends on Shared#41;] - Repositories[Repositories
#40;Depends on Models, Shared#41;] - Services[Services
#40;Depends on Repositories, Models#41;] - Extension[Extension
#40;Depends on Services, Repositories, Models, Shared#41;] - - Shared --> Services - Services --> Repositories - Repositories --> Models - Models --> Extension -``` - -## Development Workflow - -1. **Define Model**: Start with data structure and validation -2. **Create Repository**: Implement persistence layer -3. **Build Service**: Add business logic -4. **Expose in Extension**: Create SQF-callable commands -5. **Test**: Verify each layer independently and together - -## Additional Resources - -- [Extension Documentation](../arma/server/extension/README.md) -- [Redis Operations](../arma/server/extension/src/redis/README.md) -- [Adapters](../arma/server/extension/src/adapters/README.md) diff --git a/lib/models/src/actor.rs b/lib/models/src/actor.rs index 203eed1..51e8b53 100644 --- a/lib/models/src/actor.rs +++ b/lib/models/src/actor.rs @@ -77,10 +77,10 @@ impl Actor { return Err(ActorValidationError::InvalidUid(self.uid.clone())); } - if let Some(ref name) = self.name { - if name.trim().is_empty() || name.len() > 50 { - return Err(ActorValidationError::InvalidName(name.clone())); - } + if let Some(ref name) = self.name + && (name.trim().is_empty() || name.len() > 50) + { + return Err(ActorValidationError::InvalidName(name.clone())); } if let Some(ref pos) = self.position { @@ -102,18 +102,16 @@ impl Actor { return Err(ActorValidationError::InvalidDirection(self.direction)); } - if !self.phone_number.is_empty() { - if !self.phone_number.starts_with("0160") || self.phone_number.len() != 10 { - return Err(ActorValidationError::InvalidPhoneNumber( - self.phone_number.clone(), - )); - } + if !self.phone_number.is_empty() + && (!self.phone_number.starts_with("0160") || self.phone_number.len() != 10) + { + return Err(ActorValidationError::InvalidPhoneNumber( + self.phone_number.clone(), + )); } - if !self.email.is_empty() { - if !self.email.contains('@') || !self.email.ends_with(".mil") { - return Err(ActorValidationError::InvalidEmail(self.email.clone())); - } + if !self.email.is_empty() && (!self.email.contains('@') || !self.email.ends_with(".mil")) { + return Err(ActorValidationError::InvalidEmail(self.email.clone())); } if !self.organization.is_empty() && self.organization.len() > 100 { diff --git a/lib/models/src/bank.rs b/lib/models/src/bank.rs index fcabed4..3e16819 100644 --- a/lib/models/src/bank.rs +++ b/lib/models/src/bank.rs @@ -66,7 +66,7 @@ impl Bank { bank: 0.0, cash: 0.0, earnings: 0.0, - pin: pin, + pin, transactions: Vec::new(), }; diff --git a/lib/models/src/garage.rs b/lib/models/src/garage.rs index d4f4a14..578fe79 100644 --- a/lib/models/src/garage.rs +++ b/lib/models/src/garage.rs @@ -52,6 +52,12 @@ impl HitPoints { } } +impl Default for HitPoints { + fn default() -> Self { + Self::new() + } +} + impl Vehicle { pub fn new>( plate: S, diff --git a/lib/models/src/v_garage.rs b/lib/models/src/v_garage.rs index 6e54df9..dcb528b 100644 --- a/lib/models/src/v_garage.rs +++ b/lib/models/src/v_garage.rs @@ -75,11 +75,16 @@ impl VGarage { VehicleCategory::Other => &mut self.other, }; - if let Some(pos) = target_array.iter().position(|x| x == classname) { - Some(target_array.remove(pos)) - } else { - None - } + target_array + .iter() + .position(|x| x == classname) + .map(|pos| target_array.remove(pos)) + } +} + +impl Default for VGarage { + fn default() -> Self { + Self::new() } } diff --git a/lib/models/src/v_locker.rs b/lib/models/src/v_locker.rs index f67de35..f9f66a3 100644 --- a/lib/models/src/v_locker.rs +++ b/lib/models/src/v_locker.rs @@ -91,11 +91,10 @@ impl VLocker { EquipmentCategory::Backpacks => &mut self.backpacks, }; - if let Some(pos) = target_array.iter().position(|x| x == classname) { - Some(target_array.remove(pos)) - } else { - None - } + target_array + .iter() + .position(|x| x == classname) + .map(|pos| target_array.remove(pos)) } } diff --git a/lib/repositories/Cargo.toml b/lib/repositories/Cargo.toml index 05bc30f..c343ce9 100644 --- a/lib/repositories/Cargo.toml +++ b/lib/repositories/Cargo.toml @@ -9,8 +9,3 @@ forge-shared = { path = "../shared" } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true } - -# Redis dependencies (only in repository layer) -redis = { workspace = true } -bb8-redis = "0.25.0-rc.1" -base64 = "0.22.1" diff --git a/lib/repositories/README.md b/lib/repositories/README.md index d9e3c45..c7beb5d 100644 --- a/lib/repositories/README.md +++ b/lib/repositories/README.md @@ -1,207 +1,20 @@ # Forge Repositories -This crate provides the data access layer for the Forge application, implementing the repository pattern to abstract database operations from business logic. +This crate defines repository traits used by the service layer. It also +provides in-memory implementations for tests and transient server state. -## Architecture +Durable repository implementations live in the server extension because they +depend on extension configuration and the SurrealDB runtime client. -The repository layer sits between the service layer and the database, providing a clean abstraction for data persistence. +## Contents -```mermaid -graph TD - Services[Services Layer] - Repositories[Repositories Layer
#40;This Module#41;] - Database[Database] +- Actor, bank, garage, locker, org, phone, task, CAD, owned garage, and owned + locker repository traits. +- In-memory stores for unit tests and hot-state services. - Services --> Repositories - Repositories --> Database -``` +## Guidelines -### Dual Storage Strategy - -The implementation uses a dual storage strategy in Redis to optimize for different access patterns: - -- **Hash Maps (`HMSET`/`HGETALL`):** Used for entity data (Actors, Organizations) to allow O(1) access to specific fields and efficient partial updates. -- **Sets (`SADD`/`SMEMBERS`):** Used for relationships (e.g., Organization Members) to ensure uniqueness and provide efficient membership testing. - -## Key Features - -- **Redis Integration:** Efficient hash-based storage for data -- **JSON Serialization:** Automatic conversion between Rust structs and Redis -- **Type Safety:** Strong typing with error handling -- **Performance Optimized:** Hash operations for fast field-level access -- **Flexible Client:** Generic over Redis client implementations -- **Atomic Operations:** Uses Redis atomicity guarantees for data integrity - -## Actor Repository - -The `ActorRepository` handles persistence for player data. - -### Storage Format - -Actors are stored in Redis as hash maps: - -```text -actor:{uid} -> Hash { - "uid": "76561198123456789", - "name": "PlayerName", - "bank": "1500.0", - ... -} -``` - -### Usage Example - -```rust -use forge_repositories::ActorRepository; -use forge_models::Actor; - -async fn example_usage(repo: &R) -> Result<(), String> { - // 1. Create - let actor = Actor::new("76561198123456789".to_string())?; - repo.create(&actor)?; - - // 2. Retrieve - if let Some(retrieved) = repo.get_by_id("76561198123456789")? { - println!("Found actor: {}", retrieved.name()); - } - - // 3. Update - // Updates are atomic and preserve fields not present in the update - let mut actor_to_update = repo.get_by_id("76561198123456789")?.unwrap(); - // ... modify actor ... - repo.update(&actor_to_update)?; - - // 4. Check Existence - if repo.exists("76561198123456789")? { - println!("Actor exists"); - } - - // 5. Delete - repo.delete("76561198123456789")?; - - Ok(()) -} -``` - -## Organization Repository - -The `OrgRepository` handles persistence for organizations (guilds/clans) and their members. - -### Storage Format - -- **Org Data:** `org:{org_id}` (Hash) -- **Members:** `org:{org_id}:members` (Set) - -### Usage Example - -```rust -use forge_repositories::OrgRepository; -use forge_models::{Org, MemberSummary}; - -async fn example_usage(repo: &R) -> Result<(), String> { - // 1. Create Organization - let org = Org::new("elite_squad".to_string(), "leader_uid".to_string(), "Elite Squad".to_string())?; - repo.create(&org)?; - - // 2. Manage Members - // Add a member (idempotent, handles duplicates) - repo.add_member("elite_squad", "member_uid_1")?; - - // Get all members - let members = repo.get_members("elite_squad")?; - for member in members { - println!("Member: {} ({})", member.name, member.uid); - } - - // Remove a member - repo.remove_member("elite_squad", "member_uid_1")?; - - // 3. Update Organization - let mut org_update = repo.get_by_id("elite_squad")?.unwrap(); - // ... modify org ... - repo.update(&org_update)?; - - // 4. Delete Organization - // Note: This removes the org data but may require separate cleanup for members depending on implementation - repo.delete("elite_squad")?; - - Ok(()) -} -``` - -## Performance & Implementation Details - -### Atomicity - -- **Upserts:** `create` and `update` operations use `HMSET` which is atomic. This means either all fields are updated or none are. -- **Schema Evolution:** New fields added to the Rust structs are automatically persisted to Redis. Old fields in Redis that are no longer in the struct are **preserved** (not deleted) during updates, allowing for safe backward compatibility. - -### Thread Safety - -All repository implementations are `Send + Sync`, allowing them to be safely shared across threads. The underlying `RedisClient` handles connection pooling and concurrent access. - -### Error Handling - -Repositories return `Result` (or custom error types) to propagate database failures, serialization errors, or validation issues up to the service layer. - -## Contributing - -We welcome contributions to the Forge Repository Layer! This guide will help you understand how to add new repositories and maintain the existing codebase. - -### Adding a New Repository - -To add a new repository (e.g., `ItemRepository`), follow these steps: - -1. **Create the Module**: Create a new file in `src/` (e.g., `src/item.rs`). -2. **Define the Trait**: Define a trait that specifies the data access operations. Ensure it requires `Send + Sync`. - ```rust - pub trait ItemRepository: Send + Sync { - fn create(&self, item: &Item) -> Result<(), String>; - fn get_by_id(&self, id: &str) -> Result, String>; - // ... other methods - } - ``` -3. **Implement for Redis**: Implement the trait for a generic `RedisClient`. Use `forge_shared` helpers for value conversion if needed. - - ```rust - use forge_shared::{RedisClient, parse_json_value, parse_redis_value}; - - pub struct RedisItemRepository { - client: C, - } - - impl RedisItemRepository { - pub fn new(client: C) -> Self { - Self { client } - } - } - - impl ItemRepository for RedisItemRepository { - fn create(&self, item: &Item) -> Result<(), String> { - let redis_key = format!("item:{}", item.id); - // ... serialization logic ... - // Use self.client to interact with Redis - self.client.hash_mset(redis_key, fields) - } - - // ... other methods - } - ``` - -4. **Register the Module**: Add your new module to `src/lib.rs` and export the trait and implementation. - ```rust - pub mod item; - pub use item::{ItemRepository, RedisItemRepository}; - ``` - -### Testing - -- **Integration Tests**: Write integration tests that use a real Redis instance (if available) or a mock. -- **Mocking**: For unit testing services, you don't need to test the repository implementation itself, but you should ensure the repository trait is easy to mock. - -### Best Practices - -- **Abstraction**: Keep the repository trait agnostic of the underlying database technology (e.g., don't expose Redis-specific types in the trait signature). -- **Serialization**: Handle serialization/deserialization within the repository implementation. The service layer should work with domain models, not raw JSON or database rows. -- **Keyspace**: Use a consistent naming convention for Redis keys (e.g., `entity:id`). -- **Atomicity**: Use Redis transactions or atomic commands where possible to ensure data consistency. +- Keep traits storage-agnostic. +- Return domain models instead of raw database records. +- Keep serialization and database-specific mapping in concrete implementations. +- Prefer focused in-memory tests for service behavior. diff --git a/lib/repositories/src/actor.rs b/lib/repositories/src/actor.rs index 72107e1..57bbcda 100644 --- a/lib/repositories/src/actor.rs +++ b/lib/repositories/src/actor.rs @@ -6,14 +6,13 @@ //! For full documentation and examples, see the [crate README](../README.md). use forge_models::Actor; -use forge_shared::{RedisClient, parse_json_value, parse_redis_value}; use std::collections::HashMap; use std::sync::{Arc, RwLock}; /// Repository trait defining the contract for actor data operations. /// /// This trait abstracts the data persistence layer, allowing different -/// implementations (Redis, PostgreSQL, etc.) while maintaining a consistent +/// implementations while maintaining a consistent /// interface for the service layer. All implementations must be thread-safe. pub trait ActorRepository: Send + Sync { /// Creates a new actor in the repository. @@ -81,126 +80,3 @@ impl ActorHotRepository for InMemoryActorHotRepository { Ok(()) } } - -/// Redis-based implementation of the ActorRepository trait. -/// -/// This implementation uses Redis hash maps to store actor data, providing -/// efficient field-level access and atomic operations. Each actor is stored -/// as a separate hash with the key format `actor:{uid}`. -pub struct RedisActorRepository { - /// The Redis client used for all database operations. - /// - /// This client handles the actual communication with Redis, including - /// connection management, command execution, and error handling. - client: C, -} - -impl RedisActorRepository { - /// Creates a new Redis actor repository with the provided client. - pub fn new(client: C) -> Self { - Self { client } - } -} - -impl ActorRepository for RedisActorRepository { - /// Creates a new actor in Redis using hash map storage. - /// - /// Stores each actor as a Redis hash map with the key format `actor:{uid}`. - /// Each field of the actor struct becomes a field in the Redis hash. - fn create(&self, actor: &Actor) -> Result<(), String> { - // Generate Redis key using actor UID - let redis_key = format!("actor:{}", actor.uid()); - - // Serialize actor to JSON string - let actor_json = serde_json::to_string(actor) - .map_err(|e| format!("Failed to serialize actor: {}", e))?; - - // Parse JSON string back to Value for field extraction - let json_value: serde_json::Value = serde_json::from_str(&actor_json) - .map_err(|e| format!("Failed to parse actor JSON: {}", e))?; - - // Extract fields from JSON object - if let serde_json::Value::Object(actor_map) = json_value { - // Convert each field to Redis-compatible format - let fields: Vec<(String, String)> = actor_map - .into_iter() - .map(|(field, value)| (field, parse_json_value(&value))) - .collect(); - - // Store all fields atomically using Redis HMSET - self.client.hash_mset(redis_key, fields) - } else { - Err("Failed to convert actor to object".to_string()) - } - } - - /// Retrieves an actor from Redis by their unique identifier. - /// - /// Uses Redis HGETALL to retrieve all fields of the actor hash map, - /// then reconstructs the Actor struct through JSON deserialization. - fn get_by_id(&self, id: &str) -> Result, String> { - // Generate Redis key using actor UID - let redis_key = format!("actor:{}", id); - - // Retrieve all hash fields from Redis - let actor_string = self.client.hash_get_all(redis_key)?; - - // Return None if no data found (actor doesn't exist) - if actor_string.is_empty() { - return Ok(None); - } - - let redis_map: std::collections::HashMap = - serde_json::from_str(&actor_string) - .map_err(|e| format!("Failed to parse actor hash response: {}", e))?; - let mut json_map = serde_json::Map::new(); - - for (key, value) in redis_map { - let json_value = parse_redis_value(&value); - json_map.insert(key, json_value); - } - - // Reconstruct Actor from JSON object - let json_obj = serde_json::Value::Object(json_map); - match serde_json::from_value::(json_obj) { - Ok(actor) => Ok(Some(actor)), - // Return None for any deserialization errors (corrupted data) - Err(_) => Ok(None), - } - } - - /// Updates an existing actor with the provided data. - /// - /// Uses `HMSET` to atomically update the provided fields. Fields present in Redis - /// but missing from the input are preserved. - fn update(&self, actor: &Actor) -> Result<(), String> { - // Delegate to create() which handles both creation and updates - // Redis HMSET naturally supports upsert behavior - self.create(actor) - } - - /// Permanently deletes an actor and all associated data from Redis. - /// - /// Removes the entire Redis hash containing the actor's data. - /// This operation is irreversible. - fn delete(&self, id: &str) -> Result<(), String> { - // Generate Redis key using actor UID - let redis_key = format!("actor:{}", id); - - // Delete the entire hash key from Redis - // This removes all fields and the key itself atomically - self.client.delete_key(redis_key) - } - - /// Checks if an actor exists in Redis without retrieving the data. - /// - /// Uses Redis EXISTS command for a lightweight check. - fn exists(&self, id: &str) -> Result { - // Generate Redis key using actor UID - let redis_key = format!("actor:{}", id); - - // Check if the key exists in Redis - // This is a lightweight operation that doesn't retrieve data - self.client.key_exists(redis_key) - } -} diff --git a/lib/repositories/src/bank.rs b/lib/repositories/src/bank.rs index a1f557d..5beb6f1 100644 --- a/lib/repositories/src/bank.rs +++ b/lib/repositories/src/bank.rs @@ -6,14 +6,13 @@ //! For full documentation and examples, see the [crate README](../README.md). use forge_models::Bank; -use forge_shared::{RedisClient, parse_json_value, parse_redis_value}; use std::collections::HashMap; use std::sync::{Arc, RwLock}; /// Repository trait defining the contract for bank data operations. /// /// This trait abstracts the data persistence layer, allowing different -/// implementations (Redis, PostgreSQL, etc.) while maintaining a consistent +/// implementations while maintaining a consistent /// interface for the service layer. All implementations must be thread-safe. pub trait BankRepository: Send + Sync { /// Creates a new bank in the repository. @@ -73,126 +72,3 @@ impl BankHotRepository for InMemoryBankHotRepository { Ok(()) } } - -/// Redis-based implementation of the BankRepository trait. -/// -/// This implementation uses Redis hash maps to store bank data, providing -/// efficient field-level access and atomic operations. Each bank is stored -/// as a separate hash with the key format `bank:{uid}`. -pub struct RedisBankRepository { - /// The Redis client used for all database operations. - /// - /// This client handles the actual communication with Redis, including - /// connection management, command execution, and error handling. - client: C, -} - -impl RedisBankRepository { - /// Creates a new Redis bank repository with the provided client. - pub fn new(client: C) -> Self { - Self { client } - } -} - -impl BankRepository for RedisBankRepository { - /// Creates a new bank in Redis using hash map storage. - /// - /// Stores each bank as a Redis hash map with the key format `bank:{uid}`. - /// Each field of the bank struct becomes a field in the Redis hash. - fn create(&self, bank: &Bank) -> Result<(), String> { - // Generate Redis key using bank UID - let redis_key = format!("bank:{}", bank.uid()); - - // Serialize bank to JSON string - let bank_json = - serde_json::to_string(bank).map_err(|e| format!("Failed to serialize bank: {}", e))?; - - // Parse JSON string back to Value for field extraction - let json_value: serde_json::Value = serde_json::from_str(&bank_json) - .map_err(|e| format!("Failed to parse bank JSON: {}", e))?; - - // Extract fields from JSON object - if let serde_json::Value::Object(bank_map) = json_value { - // Convert each field to Redis-compatible format - let fields: Vec<(String, String)> = bank_map - .into_iter() - .map(|(field, value)| (field, parse_json_value(&value))) - .collect(); - - // Store all fields atomically using Redis HMSET - self.client.hash_mset(redis_key, fields) - } else { - Err("Failed to convert bank to object".to_string()) - } - } - - /// Retrieves an bank from Redis by their unique identifier. - /// - /// Uses Redis HGETALL to retrieve all fields of the bank hash map, - /// then reconstructs the Bank struct through JSON deserialization. - fn get_by_id(&self, id: &str) -> Result, String> { - // Generate Redis key using bank UID - let redis_key = format!("bank:{}", id); - - // Retrieve all hash fields from Redis - let bank_string = self.client.hash_get_all(redis_key)?; - - // Return None if no data found (bank doesn't exist) - if bank_string.is_empty() { - return Ok(None); - } - - let redis_map: std::collections::HashMap = - serde_json::from_str(&bank_string) - .map_err(|e| format!("Failed to parse bank hash response: {}", e))?; - let mut json_map = serde_json::Map::new(); - - for (key, value) in redis_map { - let json_value = parse_redis_value(&value); - json_map.insert(key, json_value); - } - - // Reconstruct Bank from JSON object - let json_obj = serde_json::Value::Object(json_map); - match serde_json::from_value::(json_obj) { - Ok(bank) => Ok(Some(bank)), - // Return None for any deserialization errors (corrupted data) - Err(_) => Ok(None), - } - } - - /// Updates an existing bank with the provided data. - /// - /// Uses `HMSET` to atomically update the provided fields. Fields present in Redis - /// but missing from the input are preserved. - fn update(&self, bank: &Bank) -> Result<(), String> { - // Delegate to create() which handles both creation and updates - // Redis HMSET naturally supports upsert behavior - self.create(bank) - } - - /// Permanently deletes an bank and all associated data from Redis. - /// - /// Removes the entire Redis hash containing the bank's data. - /// This operation is irreversible. - fn delete(&self, id: &str) -> Result<(), String> { - // Generate Redis key using bank UID - let redis_key = format!("bank:{}", id); - - // Delete the entire hash key from Redis - // This removes all fields and the key itself atomically - self.client.delete_key(redis_key) - } - - /// Checks if an bank exists in Redis without retrieving the data. - /// - /// Uses Redis EXISTS command for a lightweight check. - fn exists(&self, id: &str) -> Result { - // Generate Redis key using bank UID - let redis_key = format!("bank:{}", id); - - // Check if the key exists in Redis - // This is a lightweight operation that doesn't retrieve data - self.client.key_exists(redis_key) - } -} diff --git a/lib/repositories/src/garage.rs b/lib/repositories/src/garage.rs index fc1e864..2461db6 100644 --- a/lib/repositories/src/garage.rs +++ b/lib/repositories/src/garage.rs @@ -3,8 +3,7 @@ //! This module provides the data access layer for vehicle garage management. //! Each player's garage is stored as a single JSON string containing all their vehicles. -use forge_models::{Garage, Vehicle}; -use forge_shared::RedisClient; +use forge_models::Garage; use std::collections::HashMap; use std::sync::{Arc, RwLock}; @@ -67,68 +66,3 @@ impl GarageHotRepository for InMemoryGarageHotRepository { Ok(()) } } - -/// Redis-based implementation of the GarageRepository trait. -/// -/// Stores each player's garage as a single JSON string array with the key format `garage:{uid}`. -pub struct RedisGarageRepository { - client: C, -} - -impl RedisGarageRepository { - pub fn new(client: C) -> Self { - Self { client } - } -} - -impl GarageRepository for RedisGarageRepository { - fn create(&self, uid: &str, garage: &Garage) -> Result<(), String> { - let redis_key = format!("garage:{}", uid); - - // Serialize just the vehicles array - let garage_json = serde_json::to_string(&garage.vehicles) - .map_err(|e| format!("Failed to serialize garage: {}", e))?; - - // Store as a simple string value - self.client.set_key(redis_key, garage_json) - } - - fn update(&self, uid: &str, garage: &Garage) -> Result<(), String> { - let redis_key = format!("garage:{}", uid); - - // Serialize just the vehicles array - let garage_json = serde_json::to_string(&garage.vehicles) - .map_err(|e| format!("Failed to serialize garage: {}", e))?; - - // Update the existing garage - self.client.set_key(redis_key, garage_json) - } - - fn get(&self, uid: &str) -> Result, String> { - let redis_key = format!("garage:{}", uid); - - // Get the JSON string from Redis - let garage_string = self.client.get_key(redis_key)?; - - // Return None if no data found - if garage_string.is_empty() { - return Ok(None); - } - - // Deserialize the vehicles data - match serde_json::from_str::>(&garage_string) { - Ok(vehicles) => Ok(Some(Garage { vehicles })), - Err(e) => Err(format!("Failed to deserialize garage: {}", e)), - } - } - - fn delete(&self, uid: &str) -> Result<(), String> { - let redis_key = format!("garage:{}", uid); - self.client.delete_key(redis_key) - } - - fn exists(&self, uid: &str) -> Result { - let redis_key = format!("garage:{}", uid); - self.client.key_exists(redis_key) - } -} diff --git a/lib/repositories/src/lib.rs b/lib/repositories/src/lib.rs index 6d39ea9..7e64bc6 100644 --- a/lib/repositories/src/lib.rs +++ b/lib/repositories/src/lib.rs @@ -9,26 +9,13 @@ pub mod task; pub mod v_garage; pub mod v_locker; -pub use actor::{ - ActorHotRepository, ActorRepository, InMemoryActorHotRepository, RedisActorRepository, -}; -pub use bank::{BankHotRepository, BankRepository, InMemoryBankHotRepository, RedisBankRepository}; +pub use actor::{ActorHotRepository, ActorRepository, InMemoryActorHotRepository}; +pub use bank::{BankHotRepository, BankRepository, InMemoryBankHotRepository}; pub use cad::{CadRepository, InMemoryCadRepository}; -pub use garage::{ - GarageHotRepository, GarageRepository, InMemoryGarageHotRepository, RedisGarageRepository, -}; -pub use locker::{ - InMemoryLockerHotRepository, LockerHotRepository, LockerRepository, RedisLockerRepository, -}; -pub use org::{InMemoryOrgHotRepository, OrgHotRepository, OrgRepository, RedisOrgRepository}; -pub use phone::{InMemoryPhoneRepository, PhoneRepository, RedisPhoneRepository}; +pub use garage::{GarageHotRepository, GarageRepository, InMemoryGarageHotRepository}; +pub use locker::{InMemoryLockerHotRepository, LockerHotRepository, LockerRepository}; +pub use org::{InMemoryOrgHotRepository, OrgHotRepository, OrgRepository}; +pub use phone::{InMemoryPhoneRepository, PhoneRepository}; pub use task::{InMemoryTaskRepository, TaskRepository}; -pub use v_garage::{ - InMemoryVGarageHotRepository, RedisVGarageRepository, VGarageHotRepository, VGarageRepository, -}; -pub use v_locker::{ - InMemoryVLockerHotRepository, RedisVLockerRepository, VLockerHotRepository, VLockerRepository, -}; - -// Re-export RedisClient from shared library for convenience -pub use forge_shared::RedisClient; +pub use v_garage::{InMemoryVGarageHotRepository, VGarageHotRepository, VGarageRepository}; +pub use v_locker::{InMemoryVLockerHotRepository, VLockerHotRepository, VLockerRepository}; diff --git a/lib/repositories/src/locker.rs b/lib/repositories/src/locker.rs index 73724f8..ea35c39 100644 --- a/lib/repositories/src/locker.rs +++ b/lib/repositories/src/locker.rs @@ -3,8 +3,7 @@ //! This module provides the data access layer for locker management. //! Each player's locker is stored as a single JSON string containing all their items. -use forge_models::{Item, Locker}; -use forge_shared::RedisClient; +use forge_models::Locker; use std::collections::HashMap; use std::sync::{Arc, RwLock}; @@ -67,68 +66,3 @@ impl LockerHotRepository for InMemoryLockerHotRepository { Ok(()) } } - -/// Redis-based implementation of the LockerRepository trait. -/// -/// Stores each player's locker as a single JSON string array with the key format `locker:{uid}`. -pub struct RedisLockerRepository { - client: C, -} - -impl RedisLockerRepository { - pub fn new(client: C) -> Self { - Self { client } - } -} - -impl LockerRepository for RedisLockerRepository { - fn create(&self, uid: &str, locker: &Locker) -> Result<(), String> { - let redis_key = format!("locker:{}", uid); - - // Serialize just the items array - let locker_json = serde_json::to_string(&locker.items) - .map_err(|e| format!("Failed to serialize locker: {}", e))?; - - // Store as a simple string value - self.client.set_key(redis_key, locker_json) - } - - fn update(&self, uid: &str, locker: &Locker) -> Result<(), String> { - let redis_key = format!("locker:{}", uid); - - // Serialize just the items array - let locker_json = serde_json::to_string(&locker.items) - .map_err(|e| format!("Failed to serialize locker: {}", e))?; - - // Update the existing locker - self.client.set_key(redis_key, locker_json) - } - - fn get(&self, uid: &str) -> Result, String> { - let redis_key = format!("locker:{}", uid); - - // Get the JSON string from Redis - let locker_string = self.client.get_key(redis_key)?; - - // Return None if no data found - if locker_string.is_empty() { - return Ok(None); - } - - // Deserialize the items data - match serde_json::from_str::>(&locker_string) { - Ok(items) => Ok(Some(Locker { items })), - Err(e) => Err(format!("Failed to deserialize locker: {}", e)), - } - } - - fn delete(&self, uid: &str) -> Result<(), String> { - let redis_key = format!("locker:{}", uid); - self.client.delete_key(redis_key) - } - - fn exists(&self, uid: &str) -> Result { - let redis_key = format!("locker:{}", uid); - self.client.key_exists(redis_key) - } -} diff --git a/lib/repositories/src/org.rs b/lib/repositories/src/org.rs index 57027ec..1636639 100644 --- a/lib/repositories/src/org.rs +++ b/lib/repositories/src/org.rs @@ -6,14 +6,13 @@ //! For full documentation and examples, see the [crate README](../README.md). use forge_models::{HotOrgRecord, MemberSummary, Org, OrgAssetEntry, OrgFleetEntry}; -use forge_shared::{RedisClient, parse_json_value, parse_redis_value}; use std::collections::HashMap; use std::sync::{Arc, RwLock}; /// Repository trait defining the contract for organization data operations. /// /// This trait abstracts the data persistence layer, allowing different -/// implementations (Redis, PostgreSQL, etc.) while maintaining a consistent +/// implementations while maintaining a consistent /// interface for the service layer. All implementations must be thread-safe. pub trait OrgRepository: Send + Sync { /// Creates a new organization in the repository. @@ -113,316 +112,3 @@ impl OrgHotRepository for InMemoryOrgHotRepository { Ok(()) } } - -/// Redis-based implementation of the OrgRepository trait. -/// -/// Uses Redis hash maps for organization data providing -/// efficient field-level access and atomic operations. Each organization is stored -/// as a seperate hash with the key format `org:{org_id}`. -/// Member lists are stored as sets with the key format `org:{org_id}:members`. -pub struct RedisOrgRepository { - /// The Redis client used for all database operations. - /// - /// This client handles the actual communication with Redis, including - /// connection management, command execution, and error handling for - /// both organization and member data operations. - client: C, -} - -impl RedisOrgRepository { - /// Creates a new Redis organization repository with the provided client. - pub fn new(client: C) -> Self { - Self { client } - } -} - -impl OrgRepository for RedisOrgRepository { - /// Creates a new organization in Redis using hash map storage. - /// - /// Stores each organization as a Redis hash map with the key format `{org_id}:org`. - /// Each field of the organization struct becomes a field in the Redis hash. - fn create(&self, org: &Org) -> Result<(), String> { - // Generate Redis key using organization ID - let redis_key = format!("org:{}", org.id()); - - // Serialize organization to JSON string - let org_json = - serde_json::to_string(org).map_err(|e| format!("Failed to serialize org: {}", e))?; - - // Parse JSON string back to Value for field extraction - let json_value: serde_json::Value = serde_json::from_str(&org_json) - .map_err(|e| format!("Failed to parse org JSON: {}", e))?; - - // Extract fields from JSON object - if let serde_json::Value::Object(org_map) = json_value { - // Convert each field to Redis-compatible format - let fields: Vec<(String, String)> = org_map - .into_iter() - .map(|(field, value)| (field, parse_json_value(&value))) - .collect(); - - // Store all fields atomically using Redis HMSET - self.client.hash_mset(redis_key, fields) - } else { - Err("Failed to convert org to object".to_string()) - } - } - - /// Retrieves an organization from Redis by its unique identifier. - /// - /// Uses Redis HGETALL to retrieve all fields of the organization hash map, - /// then reconstructs the Org struct through JSON deserialization. - fn get_by_id(&self, id: &str) -> Result, String> { - // Generate Redis key using organization ID - let redis_key = format!("org:{}", id); - - // Retrieve all hash fields from Redis - let org_string = self.client.hash_get_all(redis_key)?; - - // Return None if no data found (organization doesn't exist) - if org_string.is_empty() { - return Ok(None); - } - - let redis_map: std::collections::HashMap = - serde_json::from_str(&org_string) - .map_err(|e| format!("Failed to parse org hash response: {}", e))?; - let mut json_map = serde_json::Map::new(); - - for (key, value) in redis_map { - let json_value = parse_redis_value(&value); - json_map.insert(key, json_value); - } - - // Reconstruct Org from JSON object - if matches!( - json_map.get("credit_lines"), - Some(serde_json::Value::Array(lines)) if lines.is_empty() - ) { - json_map.insert( - "credit_lines".to_string(), - serde_json::Value::Object(serde_json::Map::new()), - ); - } - - let json_obj = serde_json::Value::Object(json_map); - match serde_json::from_value::(json_obj) { - Ok(org) => Ok(Some(org)), - // Return None for any deserialization errors (corrupted data) - Err(_) => Ok(None), - } - } - - /// Updates an existing organization with the provided data. - /// - /// Uses `HMSET` to atomically update the provided fields. Fields present in Redis - /// but missing from the input are preserved. - fn update(&self, org: &Org) -> Result<(), String> { - // Delegate to create() which handles both creation and updates - // Redis HMSET naturally supports upsert behavior - self.create(org) - } - - /// Permanently deletes an organization and all associated data from Redis. - /// - /// Removes the organization hash and related subordinate keys. - /// This operation is irreversible. - fn delete(&self, id: &str) -> Result<(), String> { - let redis_keys = [ - format!("org:{}", id), - format!("org:{}:members", id), - format!("org:{}:assets", id), - format!("org:{}:fleet", id), - ]; - - for redis_key in redis_keys { - self.client.delete_key(redis_key)?; - } - - Ok(()) - } - - /// Checks if an organization exists in Redis without retrieving the data. - /// - /// Uses Redis EXISTS command for a lightweight check. - fn exists(&self, id: &str) -> Result { - // Generate Redis key using organization ID - let redis_key = format!("org:{}", id); - - // Check if the key exists in Redis - // This is a lightweight operation that doesn't retrieve data - self.client.key_exists(redis_key) - } - - /// Adds a new member to the organization. - /// - /// Stores member data in a Redis list associated with the organization. - /// Validates that the organization exists before adding the member. - fn add_member(&self, org_id: &str, member_uid: &str) -> Result<(), String> { - // Check if organization exists - if !self.exists(org_id)? { - return Err(format!("Organization {} does not exist", org_id)); - } - - // Generate Redis key for organization member set - let redis_key = format!("org:{}:members", org_id); - - // Add member UID to set using SADD - self.client.set_add(redis_key, member_uid.to_string()) - } - - /// Retrieves all members of the organization. - /// - /// Uses Redis SMEMBERS to get all member UIDs, then retrieves member details. - /// Returns a list of `MemberSummary` objects. - fn get_members(&self, org_id: &str) -> Result, String> { - // Generate Redis key for organization member set - let redis_key = format!("org:{}:members", org_id); - - // Retrieve all member UIDs from the set; fall back to empty on error - let uids: Vec = match self.client.set_members(redis_key) { - Ok(v) => v, - Err(_) => Vec::new(), - }; - - // Pre-allocate result vector - let mut result: Vec = Vec::with_capacity(uids.len()); - - for uid in uids { - if uid.trim().is_empty() { - continue; - } - - // Lookup actor name by UID; fall back to "Unknown" on error/missing - let actor_key = format!("actor:{}", uid); - let raw_name = match self.client.hash_get(actor_key, "name".to_string()) { - Ok(n) => n, - _ => String::new(), - }; - - let name = match parse_redis_value(&raw_name) { - serde_json::Value::String(s) => s, - serde_json::Value::Number(n) => n.to_string(), - serde_json::Value::Bool(b) => b.to_string(), - _ => "Unknown".to_string(), - }; - - let name = if name.trim().is_empty() { - "Unknown".to_string() - } else { - name - }; - - result.push(MemberSummary { uid, name }); - } - - Ok(result) - } - - /// Removes a specific member UID from an organization. - /// - /// Uses Redis SREM to remove the UID from the organization's member set. - fn remove_member(&self, org_id: &str, member_uid: &str) -> Result<(), String> { - // Generate Redis key for organization member set - let redis_key = format!("org:{}:members", org_id); - - // Remove the UID from the set using SREM - self.client.set_del(redis_key, member_uid.to_string()) - } - - fn get_assets( - &self, - org_id: &str, - ) -> Result>, String> { - let redis_key = format!("org:{}:assets", org_id); - let assets_string = self.client.hash_get_all(redis_key)?; - - if assets_string.is_empty() { - return Ok(HashMap::new()); - } - - let redis_map: HashMap = serde_json::from_str(&assets_string) - .map_err(|e| format!("Failed to parse org asset hash response: {}", e))?; - - let mut assets = HashMap::new(); - for (category, value) in redis_map { - let json_value = parse_redis_value(&value); - let category_assets = - serde_json::from_value::>(json_value) - .map_err(|e| format!("Failed to parse asset category '{}': {}", category, e))?; - assets.insert(category, category_assets); - } - - Ok(assets) - } - - fn update_assets( - &self, - org_id: &str, - assets: &HashMap>, - ) -> Result<(), String> { - let redis_key = format!("org:{}:assets", org_id); - - if assets.is_empty() { - return self.client.delete_key(redis_key); - } - - let fields: Vec<(String, String)> = assets - .iter() - .map(|(category, value)| { - let json_value = serde_json::to_value(value) - .unwrap_or(serde_json::Value::Object(serde_json::Map::new())); - (category.clone(), parse_json_value(&json_value)) - }) - .collect(); - - self.client.delete_key(redis_key.clone())?; - self.client.hash_mset(redis_key, fields) - } - - fn get_fleet(&self, org_id: &str) -> Result, String> { - let redis_key = format!("org:{}:fleet", org_id); - let fleet_string = self.client.hash_get_all(redis_key)?; - - if fleet_string.is_empty() { - return Ok(HashMap::new()); - } - - let redis_map: HashMap = serde_json::from_str(&fleet_string) - .map_err(|e| format!("Failed to parse org fleet hash response: {}", e))?; - - let mut fleet = HashMap::new(); - for (fleet_key, value) in redis_map { - let json_value = parse_redis_value(&value); - let fleet_entry = serde_json::from_value::(json_value) - .map_err(|e| format!("Failed to parse fleet entry '{}': {}", fleet_key, e))?; - fleet.insert(fleet_key, fleet_entry); - } - - Ok(fleet) - } - - fn update_fleet( - &self, - org_id: &str, - fleet: &HashMap, - ) -> Result<(), String> { - let redis_key = format!("org:{}:fleet", org_id); - - if fleet.is_empty() { - return self.client.delete_key(redis_key); - } - - let fields: Vec<(String, String)> = fleet - .iter() - .map(|(fleet_key, value)| { - let json_value = serde_json::to_value(value) - .unwrap_or(serde_json::Value::Object(serde_json::Map::new())); - (fleet_key.clone(), parse_json_value(&json_value)) - }) - .collect(); - - self.client.delete_key(redis_key.clone())?; - self.client.hash_mset(redis_key, fields) - } -} diff --git a/lib/repositories/src/phone.rs b/lib/repositories/src/phone.rs index d3f194d..1a0b2b6 100644 --- a/lib/repositories/src/phone.rs +++ b/lib/repositories/src/phone.rs @@ -1,5 +1,4 @@ use forge_models::{PhoneEmail, PhoneMessage}; -use forge_shared::{RedisClient, parse_json_value, parse_redis_value}; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, RwLock}; @@ -230,373 +229,3 @@ impl PhoneRepository for InMemoryPhoneRepository { Ok(state.sequence) } } - -pub struct RedisPhoneRepository { - client: C, -} - -impl RedisPhoneRepository { - pub fn new(client: C) -> Self { - Self { client } - } - - fn contact_key(uid: &str) -> String { - format!("phone:{}:contacts", uid) - } - - fn user_messages_key(uid: &str) -> String { - format!("phone:{}:messages", uid) - } - - fn message_thread_key(uid: &str, other_uid: &str) -> String { - format!("phone:{}:thread:{}", uid, other_uid) - } - - fn message_record_key(message_id: &str) -> String { - format!("phone:message:{}", message_id) - } - - fn message_read_key(uid: &str) -> String { - format!("phone:{}:message_read", uid) - } - - fn user_emails_key(uid: &str) -> String { - format!("phone:{}:emails", uid) - } - - fn email_record_key(email_id: &str) -> String { - format!("phone:email:{}", email_id) - } - - fn email_read_key(uid: &str) -> String { - format!("phone:{}:email_read", uid) - } - - fn sequence_key() -> String { - "phone:sequence".to_string() - } - - fn save_message_record(&self, message: &PhoneMessage) -> Result<(), String> { - let json_value = serde_json::to_value(message) - .map_err(|error| format!("Failed to serialize phone message: {}", error))?; - let Some(fields) = json_value.as_object() else { - return Err("Failed to convert phone message to object.".to_string()); - }; - - let fields = fields - .iter() - .filter(|(key, _)| key.as_str() != "read") - .map(|(key, value)| (key.clone(), parse_json_value(value))) - .collect(); - - self.client - .hash_mset(Self::message_record_key(&message.id), fields) - } - - fn load_message_record( - &self, - uid: &str, - message_id: &str, - ) -> Result, String> { - let raw_record = self - .client - .hash_get_all(Self::message_record_key(message_id))?; - if raw_record.trim().is_empty() || raw_record.trim() == "{}" { - return Ok(None); - } - - let redis_map: HashMap = serde_json::from_str(&raw_record) - .map_err(|error| format!("Failed to parse phone message hash response: {}", error))?; - let mut json_map = serde_json::Map::new(); - for (key, value) in redis_map { - json_map.insert(key, parse_redis_value(&value)); - } - - let raw_read = self - .client - .hash_get(Self::message_read_key(uid), message_id.to_string()) - .unwrap_or_default(); - let read_value = if raw_read.trim().is_empty() { - serde_json::Value::Bool(false) - } else { - parse_redis_value(&raw_read) - }; - json_map.insert("read".to_string(), read_value); - - serde_json::from_value::(serde_json::Value::Object(json_map)) - .map(Some) - .map_err(|error| { - format!( - "Failed to deserialize phone message '{}': {}", - message_id, error - ) - }) - } - - fn save_email_record(&self, email: &PhoneEmail) -> Result<(), String> { - let json_value = serde_json::to_value(email) - .map_err(|error| format!("Failed to serialize phone email: {}", error))?; - let Some(fields) = json_value.as_object() else { - return Err("Failed to convert phone email to object.".to_string()); - }; - - let fields = fields - .iter() - .filter(|(key, _)| key.as_str() != "read") - .map(|(key, value)| (key.clone(), parse_json_value(value))) - .collect(); - - self.client - .hash_mset(Self::email_record_key(&email.id), fields) - } - - fn load_email_record(&self, uid: &str, email_id: &str) -> Result, String> { - let raw_record = self.client.hash_get_all(Self::email_record_key(email_id))?; - if raw_record.trim().is_empty() || raw_record.trim() == "{}" { - return Ok(None); - } - - let redis_map: HashMap = serde_json::from_str(&raw_record) - .map_err(|error| format!("Failed to parse phone email hash response: {}", error))?; - let mut json_map = serde_json::Map::new(); - for (key, value) in redis_map { - json_map.insert(key, parse_redis_value(&value)); - } - - let raw_read = self - .client - .hash_get(Self::email_read_key(uid), email_id.to_string()) - .unwrap_or_default(); - let read_value = if raw_read.trim().is_empty() { - serde_json::Value::Bool(false) - } else { - parse_redis_value(&raw_read) - }; - json_map.insert("read".to_string(), read_value); - - serde_json::from_value::(serde_json::Value::Object(json_map)) - .map(Some) - .map_err(|error| { - format!( - "Failed to deserialize phone email '{}': {}", - email_id, error - ) - }) - } - - fn set_message_read(&self, uid: &str, message_id: &str, read: bool) -> Result<(), String> { - self.client.hash_mset( - Self::message_read_key(uid), - vec![(message_id.to_string(), read.to_string())], - ) - } - - fn set_email_read(&self, uid: &str, email_id: &str, read: bool) -> Result<(), String> { - self.client.hash_mset( - Self::email_read_key(uid), - vec![(email_id.to_string(), read.to_string())], - ) - } -} - -impl PhoneRepository for RedisPhoneRepository { - fn init(&self, uid: &str) -> Result<(), String> { - let _ = self.list_contacts(uid)?; - let _ = self.list_messages(uid)?; - let _ = self.list_emails(uid)?; - Ok(()) - } - - fn add_contact(&self, uid: &str, contact_uid: &str) -> Result { - self.client - .set_add(Self::contact_key(uid), contact_uid.to_string())?; - Ok(true) - } - - fn remove_contact(&self, uid: &str, contact_uid: &str) -> Result { - self.client - .set_del(Self::contact_key(uid), contact_uid.to_string())?; - Ok(true) - } - - fn list_contacts(&self, uid: &str) -> Result, String> { - let mut contacts = self.client.set_members(Self::contact_key(uid))?; - contacts.sort(); - contacts.dedup(); - Ok(contacts) - } - - fn remove_phone(&self, uid: &str) -> Result<(), String> { - for message in self.list_messages(uid)? { - self.client - .list_del(Self::user_messages_key(uid), 0, message.id.clone())?; - let other_uid = if message.from == uid { - &message.to - } else { - &message.from - }; - self.client - .list_del(Self::message_thread_key(uid, other_uid), 0, message.id)?; - } - for email in self.list_emails(uid)? { - self.client - .list_del(Self::user_emails_key(uid), 0, email.id)?; - } - - self.client.delete_key(Self::contact_key(uid))?; - self.client.delete_key(Self::user_messages_key(uid))?; - self.client.delete_key(Self::message_read_key(uid))?; - self.client.delete_key(Self::user_emails_key(uid))?; - self.client.delete_key(Self::email_read_key(uid))?; - Ok(()) - } - - fn append_message(&self, uid: &str, message: PhoneMessage) -> Result<(), String> { - self.save_message_record(&message)?; - self.client - .list_rpush(Self::user_messages_key(uid), message.id.clone())?; - - let other_uid = if message.from == uid { - message.to.as_str() - } else { - message.from.as_str() - }; - self.client - .list_rpush(Self::message_thread_key(uid, other_uid), message.id.clone())?; - - let read = message.from == uid; - self.set_message_read(uid, &message.id, read) - } - - fn list_messages(&self, uid: &str) -> Result, String> { - let message_ids = self - .client - .list_range(Self::user_messages_key(uid), 0, -1)?; - let mut messages = Vec::with_capacity(message_ids.len()); - for message_id in message_ids { - if message_id.trim().is_empty() { - continue; - } - if let Some(message) = self.load_message_record(uid, &message_id)? { - messages.push(message); - } - } - - messages.sort_by(|left, right| { - left.timestamp - .partial_cmp(&right.timestamp) - .unwrap_or(std::cmp::Ordering::Equal) - }); - Ok(messages) - } - - fn mark_message_read(&self, uid: &str, message_id: &str) -> Result { - let exists = self - .client - .list_range(Self::user_messages_key(uid), 0, -1)? - .iter() - .any(|id| id == message_id); - if !exists { - return Ok(false); - } - - self.set_message_read(uid, message_id, true)?; - Ok(true) - } - - fn delete_message(&self, uid: &str, message_id: &str) -> Result { - let exists = self - .client - .list_range(Self::user_messages_key(uid), 0, -1)? - .iter() - .any(|id| id == message_id); - if !exists { - return Ok(false); - } - - let message = self.load_message_record(uid, message_id)?; - self.client - .list_del(Self::user_messages_key(uid), 0, message_id.to_string())?; - self.client - .hash_del(Self::message_read_key(uid), message_id.to_string())?; - - if let Some(message) = message { - let other_uid = if message.from == uid { - &message.to - } else { - &message.from - }; - self.client.list_del( - Self::message_thread_key(uid, other_uid), - 0, - message_id.to_string(), - )?; - } - - Ok(true) - } - - fn append_email(&self, uid: &str, email: PhoneEmail) -> Result<(), String> { - self.save_email_record(&email)?; - self.client - .list_rpush(Self::user_emails_key(uid), email.id.clone())?; - self.set_email_read(uid, &email.id, false) - } - - fn list_emails(&self, uid: &str) -> Result, String> { - let email_ids = self.client.list_range(Self::user_emails_key(uid), 0, -1)?; - let mut emails = Vec::with_capacity(email_ids.len()); - for email_id in email_ids { - if email_id.trim().is_empty() { - continue; - } - if let Some(email) = self.load_email_record(uid, &email_id)? { - emails.push(email); - } - } - - emails.sort_by(|left, right| { - right - .timestamp - .partial_cmp(&left.timestamp) - .unwrap_or(std::cmp::Ordering::Equal) - }); - Ok(emails) - } - - fn mark_email_read(&self, uid: &str, email_id: &str) -> Result { - let exists = self - .client - .list_range(Self::user_emails_key(uid), 0, -1)? - .iter() - .any(|id| id == email_id); - if !exists { - return Ok(false); - } - - self.set_email_read(uid, email_id, true)?; - Ok(true) - } - - fn delete_email(&self, uid: &str, email_id: &str) -> Result { - let exists = self - .client - .list_range(Self::user_emails_key(uid), 0, -1)? - .iter() - .any(|id| id == email_id); - if !exists { - return Ok(false); - } - - self.client - .list_del(Self::user_emails_key(uid), 0, email_id.to_string())?; - self.client - .hash_del(Self::email_read_key(uid), email_id.to_string())?; - Ok(true) - } - - fn next_sequence(&self) -> Result { - let value = self.client.incr_key(Self::sequence_key(), 1)?; - u64::try_from(value).map_err(|_| "Phone sequence overflowed.".to_string()) - } -} diff --git a/lib/repositories/src/v_garage.rs b/lib/repositories/src/v_garage.rs index 71bba6f..5c13915 100644 --- a/lib/repositories/src/v_garage.rs +++ b/lib/repositories/src/v_garage.rs @@ -1,7 +1,7 @@ //! Virtual garage repository implementation for item data persistence operations. //! //! This module provides the data access layer for virtual garage management. -//! Each player's virtual garage is stored as a Redis hash with six fields: +//! Each player's virtual garage is represented by six category fields: //! - cars: JSON array of car classnames //! - armor: JSON array of armor classnames //! - helis: JSON array of helis classnames @@ -10,7 +10,6 @@ //! - other: JSON array of other classnames use forge_models::VGarage; -use forge_shared::{RedisClient, parse_json_value, parse_redis_value}; use std::collections::HashMap; use std::sync::{Arc, RwLock}; @@ -77,113 +76,3 @@ impl VGarageHotRepository for InMemoryVGarageHotRepository { Ok(()) } } - -/// Redis-based implementation of the VGarageRepository trait. -/// -/// Stores each player's virtual garage as a Redis hash with six fields: -/// - cars: JSON array of car classnames -/// - armor: JSON array of armor classnames -/// - helis: JSON array of helis classnames -/// - planes: JSON array of plane classnames -/// - naval: JSON array of naval classnames -/// - other: JSON array of other classnames -/// -/// Key format: `vgarage:{uid}` -pub struct RedisVGarageRepository { - client: C, -} - -impl RedisVGarageRepository { - pub fn new(client: C) -> Self { - Self { client } - } -} - -impl VGarageRepository for RedisVGarageRepository { - fn create(&self, uid: &str, garage: &VGarage) -> Result<(), String> { - let redis_key = format!("owned:garage:{}", uid); - - // Serialize locker to JSON string - let garage_json = serde_json::to_string(garage) - .map_err(|e| format!("Failed to serialize garage: {}", e))?; - - // Parse JSON string back to Value for field extraction - let json_value: serde_json::Value = serde_json::from_str(&garage_json) - .map_err(|e| format!("Failed to parse garage JSON: {}", e))?; - - // Extract fields from JSON object - if let serde_json::Value::Object(garage_map) = json_value { - // Convert each field to Redis-compatible format - let fields: Vec<(String, String)> = garage_map - .into_iter() - .map(|(field, value)| (field, parse_json_value(&value))) - .collect(); - - // Store all fields atomically using Redis HMSET - self.client.hash_mset(redis_key, fields) - } else { - Err("Failed to convert garage to object".to_string()) - } - } - - fn update(&self, uid: &str, garage: &VGarage) -> Result<(), String> { - // For a hash, update is the same as create - it's atomic - self.create(uid, garage) - } - - fn fetch(&self, uid: &str) -> Result, String> { - let redis_key = format!("owned:garage:{}", uid); - - // Retrieve all hash fields from Redis - let garage_string = self.client.hash_get_all(redis_key)?; - - // Return None if no data found (garage doesn't exist) - if garage_string.is_empty() { - return Ok(None); - } - - let redis_map: std::collections::HashMap = - serde_json::from_str(&garage_string) - .map_err(|e| format!("Failed to parse virtual garage hash response: {}", e))?; - let mut json_map = serde_json::Map::new(); - - for (key, value) in redis_map { - let json_value = parse_redis_value(&value); - json_map.insert(key, json_value); - } - - // Reconstruct VLocker from JSON object - let json_obj = serde_json::Value::Object(json_map); - match serde_json::from_value::(json_obj) { - Ok(garage) => Ok(Some(garage)), - // Return None for any deserialization errors (corrupted data) - Err(_) => Ok(None), - } - } - - fn get(&self, uid: &str, field: &str) -> Result, String> { - let redis_key = format!("owned:garage:{}", uid); - - // Retrieve the specific field from the hash - let field_json = self.client.hash_get(redis_key, field.to_string())?; - - // Return empty vector if field is empty - if field_json.is_empty() { - return Ok(Vec::new()); - } - - // Deserialize the JSON array - serde_json::from_str::>(&field_json) - .map_err(|e| format!("Failed to deserialize field '{}': {}", field, e)) - } - - fn delete(&self, uid: &str) -> Result<(), String> { - let redis_key = format!("owned:garage:{}", uid); - self.client.delete_key(redis_key) - } - - fn exists(&self, uid: &str) -> Result { - let redis_key = format!("owned:garage:{}", uid); - self.client.key_exists(redis_key) - } -} diff --git a/lib/repositories/src/v_locker.rs b/lib/repositories/src/v_locker.rs index 83c50a9..d14e768 100644 --- a/lib/repositories/src/v_locker.rs +++ b/lib/repositories/src/v_locker.rs @@ -1,14 +1,13 @@ //! Virtual locker repository implementation for item data persistence operations. //! //! This module provides the data access layer for virtual locker management. -//! Each player's virtual locker is stored as a Redis hash with four fields: +//! Each player's virtual locker is represented by four category fields: //! - items: JSON array of item classnames //! - weapons: JSON array of weapon classnames //! - magazines: JSON array of magazine classnames //! - backpacks: JSON array of backpack classnames use forge_models::VLocker; -use forge_shared::{RedisClient, parse_json_value, parse_redis_value}; use std::collections::HashMap; use std::sync::{Arc, RwLock}; @@ -75,111 +74,3 @@ impl VLockerHotRepository for InMemoryVLockerHotRepository { Ok(()) } } - -/// Redis-based implementation of the VLockerRepository trait. -/// -/// Stores each player's virtual locker as a Redis hash with four fields: -/// - items: JSON array of item classnames -/// - weapons: JSON array of weapon classnames -/// - magazines: JSON array of magazine classnames -/// - backpacks: JSON array of backpack classnames -/// -/// Key format: `owned:locker:{uid}` -pub struct RedisVLockerRepository { - client: C, -} - -impl RedisVLockerRepository { - pub fn new(client: C) -> Self { - Self { client } - } -} - -impl VLockerRepository for RedisVLockerRepository { - fn create(&self, uid: &str, locker: &VLocker) -> Result<(), String> { - let redis_key = format!("owned:locker:{}", uid); - - // Serialize locker to JSON string - let locker_json = serde_json::to_string(locker) - .map_err(|e| format!("Failed to serialize locker: {}", e))?; - - // Parse JSON string back to Value for field extraction - let json_value: serde_json::Value = serde_json::from_str(&locker_json) - .map_err(|e| format!("Failed to parse locker JSON: {}", e))?; - - // Extract fields from JSON object - if let serde_json::Value::Object(locker_map) = json_value { - // Convert each field to Redis-compatible format - let fields: Vec<(String, String)> = locker_map - .into_iter() - .map(|(field, value)| (field, parse_json_value(&value))) - .collect(); - - // Store all fields atomically using Redis HMSET - self.client.hash_mset(redis_key, fields) - } else { - Err("Failed to convert locker to object".to_string()) - } - } - - fn update(&self, uid: &str, locker: &VLocker) -> Result<(), String> { - // For a hash, update is the same as create - it's atomic - self.create(uid, locker) - } - - fn fetch(&self, uid: &str) -> Result, String> { - let redis_key = format!("owned:locker:{}", uid); - - // Retrieve all hash fields from Redis - let locker_string = self.client.hash_get_all(redis_key)?; - - // Return None if no data found (locker doesn't exist) - if locker_string.is_empty() { - return Ok(None); - } - - let redis_map: std::collections::HashMap = - serde_json::from_str(&locker_string) - .map_err(|e| format!("Failed to parse virtual locker hash response: {}", e))?; - let mut json_map = serde_json::Map::new(); - - for (key, value) in redis_map { - let json_value = parse_redis_value(&value); - json_map.insert(key, json_value); - } - - // Reconstruct VLocker from JSON object - let json_obj = serde_json::Value::Object(json_map); - match serde_json::from_value::(json_obj) { - Ok(locker) => Ok(Some(locker)), - // Return None for any deserialization errors (corrupted data) - Err(_) => Ok(None), - } - } - - fn get(&self, uid: &str, field: &str) -> Result, String> { - let redis_key = format!("owned:locker:{}", uid); - - // Retrieve the specific field from the hash - let field_json = self.client.hash_get(redis_key, field.to_string())?; - - // Return empty vector if field is empty - if field_json.is_empty() { - return Ok(Vec::new()); - } - - // Deserialize the JSON array - serde_json::from_str::>(&field_json) - .map_err(|e| format!("Failed to deserialize field '{}': {}", field, e)) - } - - fn delete(&self, uid: &str) -> Result<(), String> { - let redis_key = format!("owned:locker:{}", uid); - self.client.delete_key(redis_key) - } - - fn exists(&self, uid: &str) -> Result { - let redis_key = format!("owned:locker:{}", uid); - self.client.key_exists(redis_key) - } -} diff --git a/lib/services/README.md b/lib/services/README.md index cf88b86..c642953 100644 --- a/lib/services/README.md +++ b/lib/services/README.md @@ -1,184 +1,18 @@ # Forge Services -This crate implements the service layer for the Forge application, containing the core business logic and orchestration. - -## Architecture - -The service layer sits between the API/Extension layer and the Repository layer: - -```mermaid -graph TD - Extension[Extension Layer] - Services[Services Layer
#40;This Module#41;] - Repositories[Repositories Layer] - Database[Database] - - Extension --> Services - Services --> Repositories - Repositories --> Database -``` +This crate owns domain behavior for Forge systems. Services depend on +repository traits, which keeps business logic testable with in-memory stores +and independent from the concrete persistence backend. ## Responsibilities -- **Business Logic:** Enforces game rules and constraints. -- **Validation:** Validates input data before processing. -- **Orchestration:** Coordinates operations across multiple repositories. -- **Error Handling:** Converts technical errors into business-friendly messages. -- **Data Transformation:** Handles JSON parsing and model conversion. +- Validate command inputs. +- Apply domain rules and mutation workflows. +- Return structured results for extension/SQF callers. +- Keep persistence details behind repository traits. -## Operational State Policy +## Test -Most hot-state services in Forge back durable player or organization records and -are expected to flush through the save path. `CAD` and `Task` are the current -exceptions: they are extension-backed operational state services that are -intentionally transient and restart clean with the active server or mission -lifecycle. - -## Actor Service - -The `ActorService` manages player lifecycle and state. - -### Key Features - -- **Get-or-Create:** Automatically creates new actors with default values if they don't exist. -- **JSON Integration:** Directly accepts JSON strings for updates and creation. -- **Partial Updates:** Supports updating specific fields without overwriting the entire actor. -- **UID Protection:** Prevents modification of immutable fields like UIDs. - -### Usage Example - -```rust -use forge_services::ActorService; -use forge_repositories::RedisActorRepository; - -// Initialize -let repo = RedisActorRepository::new(client); -let service = ActorService::new(repo); - -// 1. Get Actor (creates default if missing) -let actor = service.get_actor("76561198123456789".to_string())?; - -// 2. Create/Overwrite Actor -let json_data = r#"{"name": "NewPlayer", "bank": 1000.0}"#; -service.create_actor("76561198123456789".to_string(), json_data.to_string())?; - -// 3. Update Actor (Partial) -let update_json = r#"{"bank": 1500.0}"#; -service.update_actor("76561198123456789".to_string(), update_json.to_string())?; - -// 4. Check Existence -if service.actor_exists("76561198123456789".to_string())? { - println!("Actor exists"); -} - -// 5. Delete Actor -// 5. Delete Actor -service.delete_actor("76561198123456789".to_string())?; +```powershell +cargo test -p forge-services ``` - -## Organization Service - -The `OrgService` manages organization (guild/clan) lifecycle and member management. - -### Key Features - -- **Get-or-Create:** Automatically creates new organizations with default values if they don't exist. -- **Member Management:** Handles adding and removing members with validation. -- **Duplicate Prevention:** Ensures unique organization IDs and member UIDs. -- **Name Validation:** Enforces non-empty organization names. - -### Usage Example - -```rust -use forge_services::OrgService; -use forge_repositories::RedisOrgRepository; - -// Initialize -let repo = RedisOrgRepository::new(client); -let service = OrgService::new(repo); - -// 1. Get Organization (creates default if missing) -let org = service.get_org("elite_squad".to_string())?; - -// 2. Create/Overwrite Organization -let json_data = r#"{"name": "Elite Squad", "description": "Best players", "leader": "76561198123456789"}"#; -service.create_org("elite_squad".to_string(), json_data.to_string())?; - -// 3. Add Member -let member_json = r#"{"uid": "76561198987654321", "rank": "member"}"#; -service.add_member("elite_squad".to_string(), member_json.to_string())?; - -// 4. Update Organization -let update_json = r#"{"description": "New description"}"#; -service.update_org("elite_squad".to_string(), update_json.to_string())?; - -// 5. Check Existence -if service.org_exists("elite_squad".to_string())? { - println!("Organization exists"); -} -``` - -## Error Handling - -The service layer returns `Result` where the error string is a descriptive message suitable for logging or displaying to administrators. It wraps lower-level repository errors with additional context. - -## Contributing - -We welcome contributions to the Forge Service Layer! This guide will help you understand how to add new services and maintain the existing codebase. - -### Adding a New Service - -To add a new service (e.g., `ItemService`), follow these steps: - -1. **Create the Module**: Create a new file in `src/` (e.g., `src/item.rs`). -2. **Define the Struct**: Define your service struct with a generic repository. - ```rust - pub struct ItemService { - repository: R, - } - ``` -3. **Implement `new`**: Provide a constructor that accepts the repository. - ```rust - impl ItemService { - pub fn new(repository: R) -> Self { - Self { repository } - } - } - ``` -4. **Implement Business Logic**: Add methods for your business logic (e.g., `create_item`, `transfer_item`). - ```rust - impl ItemService { - pub fn create_item(&self, item_id: String, data: String) -> Result { - // Validation logic... - if self.repository.exists(&item_id)? { - return Err("Item already exists".to_string()); - } - // ... logic to create item - let item = Item::new(item_id); - self.repository.create(&item)?; - Ok(item) - } - } - ``` -5. **Register the Module**: Add your new module to `src/lib.rs` and export the service struct. - ```rust - pub mod item; - pub use item::ItemService; - ``` - -### Testing - -- **Unit Tests**: Write unit tests for your business logic. -- **Mocking**: Since services use generic repositories, you can easily mock them for testing without a real database. - ```rust - // Example Mock - struct MockRepo; - impl ItemRepository for MockRepo { ... } - ``` - -### Best Practices - -- **Validation**: Always validate data at the service boundary. Do not rely on the API layer or database constraints alone. -- **Error Messages**: Return user-friendly error messages. Avoid exposing internal database errors directly. -- **Immutability**: Respect immutable fields (like UIDs). -- **Documentation**: Document public methods with doc comments (`///`) explaining their purpose, arguments, and return values. diff --git a/lib/services/src/cad.rs b/lib/services/src/cad.rs index a165af0..f79cb84 100644 --- a/lib/services/src/cad.rs +++ b/lib/services/src/cad.rs @@ -911,40 +911,39 @@ impl CadViewService { } let mut entry = order.fields.clone(); - if let Some(target_group_id) = Self::string_field(&entry, "targetGroupId") { - if let Some(target_group) = groups.iter().find_map(|group| { + if let Some(target_group_id) = Self::string_field(&entry, "targetGroupId") + && let Some(target_group) = groups.iter().find_map(|group| { let object = Self::as_object_ref(group)?; (Self::string_field(object, "groupId").unwrap_or_default() == target_group_id) .then_some(object) - }) { - if let Some(callsign) = Self::string_field(target_group, "callsign") { - entry.insert( - "targetGroupCallsign".to_string(), - Value::String(callsign.clone()), - ); - entry.insert( - "title".to_string(), - Value::String(format!("Backup {callsign}")), - ); - } + }) + { + if let Some(callsign) = Self::string_field(target_group, "callsign") { + entry.insert( + "targetGroupCallsign".to_string(), + Value::String(callsign.clone()), + ); + entry.insert( + "title".to_string(), + Value::String(format!("Backup {callsign}")), + ); + } - if let Some(position) = target_group.get("position") { - entry.insert("position".to_string(), position.clone()); - } + if let Some(position) = target_group.get("position") { + entry.insert("position".to_string(), position.clone()); + } - if Self::string_field(&entry, "note") - .unwrap_or_default() - .is_empty() - { - if let Some(callsign) = Self::string_field(&entry, "targetGroupCallsign") { - entry.insert( - "description".to_string(), - Value::String(format!( - "Dispatch order to back up {callsign} at its current position." - )), - ); - } - } + if Self::string_field(&entry, "note") + .unwrap_or_default() + .is_empty() + && let Some(callsign) = Self::string_field(&entry, "targetGroupCallsign") + { + entry.insert( + "description".to_string(), + Value::String(format!( + "Dispatch order to back up {callsign} at its current position." + )), + ); } } diff --git a/lib/services/src/garage.rs b/lib/services/src/garage.rs index 3e14f90..fbc05af 100644 --- a/lib/services/src/garage.rs +++ b/lib/services/src/garage.rs @@ -84,10 +84,10 @@ impl GarageService { if let Some(f) = fuel { vehicle.fuel = f; } - if let Some(hp_json) = hit_points_json { - if let Ok(hp) = HitPoints::from_json_str(&hp_json) { - vehicle.hit_points = hp; - } + if let Some(hp_json) = hit_points_json + && let Ok(hp) = HitPoints::from_json_str(&hp_json) + { + vehicle.hit_points = hp; } } else { return Err(format!("Vehicle with plate {} not found", plate)); diff --git a/lib/services/src/org.rs b/lib/services/src/org.rs index 14ef310..066d72b 100644 --- a/lib/services/src/org.rs +++ b/lib/services/src/org.rs @@ -1164,10 +1164,11 @@ fn can_manage_treasury( fn resolve_member_uids(org: &HotOrgRecord, requester_uid: Option<&str>) -> Vec { let mut member_uids = org.members.keys().cloned().collect::>(); - if let Some(uid) = requester_uid { - if !uid.is_empty() && !member_uids.iter().any(|member_uid| member_uid == uid) { - member_uids.push(uid.to_string()); - } + if let Some(uid) = requester_uid + && !uid.is_empty() + && !member_uids.iter().any(|member_uid| member_uid == uid) + { + member_uids.push(uid.to_string()); } member_uids } diff --git a/lib/services/src/store.rs b/lib/services/src/store.rs index b0e06e1..d7656d0 100644 --- a/lib/services/src/store.rs +++ b/lib/services/src/store.rs @@ -521,11 +521,9 @@ where })(); if let Err(error) = commit_result { - if org_saved { - if let Some(org) = original_org { - let org_id = org.id.clone(); - let _ = self.org.override_org(&org_id, org); - } + if org_saved && let Some(org) = original_org { + let org_id = org.id.clone(); + let _ = self.org.override_org(&org_id, org); } if vgarage_saved { let _ = self @@ -640,10 +638,11 @@ fn can_manage_treasury( fn resolve_member_uids(org: &HotOrgRecord, requester_uid: Option<&str>) -> Vec { let mut member_uids = org.members.keys().cloned().collect::>(); - if let Some(uid) = requester_uid { - if !uid.is_empty() && !member_uids.iter().any(|member_uid| member_uid == uid) { - member_uids.push(uid.to_string()); - } + if let Some(uid) = requester_uid + && !uid.is_empty() + && !member_uids.iter().any(|member_uid| member_uid == uid) + { + member_uids.push(uid.to_string()); } member_uids } diff --git a/lib/shared/src/lib.rs b/lib/shared/src/lib.rs index cf3eef6..3f6a0b7 100644 --- a/lib/shared/src/lib.rs +++ b/lib/shared/src/lib.rs @@ -1,7 +1,5 @@ -pub mod redis_client; pub mod validation; -pub use redis_client::{RedisClient, parse_json_value, parse_redis_value}; pub use validation::{ ActorValidationError, BankValidationError, GarageValidationError, LockerValidationError, OrgValidationError, diff --git a/lib/shared/src/redis_client.rs b/lib/shared/src/redis_client.rs deleted file mode 100644 index 6106241..0000000 --- a/lib/shared/src/redis_client.rs +++ /dev/null @@ -1,70 +0,0 @@ -/// Redis client abstraction for dependency injection -pub trait RedisClient: Send + Sync { - // Hash operations - fn hash_mset(&self, key: String, fields: Vec<(String, String)>) -> Result<(), String>; - fn hash_get_all(&self, key: String) -> Result; - fn hash_get(&self, key: String, field: String) -> Result; - fn hash_del(&self, key: String, field: String) -> Result<(), String>; - - // List operations - fn list_rpush(&self, key: String, value: String) -> Result<(), String>; - fn list_range(&self, key: String, start: isize, end: isize) -> Result, String>; - fn list_del(&self, key: String, count: isize, value: String) -> Result<(), String>; - - // Set operations - fn set_add(&self, key: String, member: String) -> Result<(), String>; - fn set_members(&self, key: String) -> Result, String>; - fn set_del(&self, key: String, member: String) -> Result<(), String>; - - // Common operations - fn get_key(&self, key: String) -> Result; - fn set_key(&self, key: String, value: String) -> Result<(), String>; - fn incr_key(&self, key: String, count: usize) -> Result; - fn key_exists(&self, key: String) -> Result; - fn delete_key(&self, key: String) -> Result<(), String>; -} - -/// Converts a JSON value to a Redis-compatible string format. -pub fn parse_json_value(value: &serde_json::Value) -> String { - let wrapped = serde_json::Value::Array(vec![value.clone()]); - wrapped.to_string() -} - -/// Converts a Redis string value back to a JSON value with intelligent type detection. -pub fn parse_redis_value(value: &str) -> serde_json::Value { - // Handle empty values - if value.is_empty() { - return serde_json::Value::Null; - } - - // Try to parse as JSON first - if let Ok(json_val) = serde_json::from_str(value) { - // Special handling for single-element arrays (unwrap them) - if let serde_json::Value::Array(arr) = &json_val { - if arr.len() == 1 { - return arr[0].clone(); - } - } - return json_val; - } - - // Try to parse as integer - if let Ok(int_val) = value.parse::() { - return serde_json::Value::Number(serde_json::Number::from(int_val)); - } - - // Try to parse as float - if let Ok(float_val) = value.parse::() { - if let Some(num) = serde_json::Number::from_f64(float_val) { - return serde_json::Value::Number(num); - } - } - - // Try to parse as boolean (case-insensitive) - match value.to_lowercase().as_str() { - "true" => serde_json::Value::Bool(true), - "false" => serde_json::Value::Bool(false), - // Default to string if no other type matches - _ => serde_json::Value::String(value.to_string()), - } -}