Remove Redis backend support
This commit is contained in:
parent
06c634c642
commit
0b2b6265f3
@ -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<br/>- loadout<br/>- position<br/>- stats]
|
||||
end
|
||||
|
||||
ClientA --- OptimisticCache
|
||||
ClientB --- OptimisticCache
|
||||
ClientN --- OptimisticCache
|
||||
end
|
||||
|
||||
subgraph Server [ArmA 3 SERVER #40;Hot Cache#41;]
|
||||
Registry["GVAR(Registry)<br/>In-Memory HashMap<br/>UID -> {loadout, position, stats...}"]
|
||||
SessionMgmt[Session Management<br/>- Token Generation<br/>- UID Resolution<br/>- Player State]
|
||||
end
|
||||
|
||||
subgraph Rust [EXTENSION #40;Cold Storage#41;]
|
||||
ConnPool["Connection Pool<br/>(bb8-redis)<br/>2-10 connections"]
|
||||
RedisOps[Redis Operations<br/>- actor_get/set/update<br/>- Async I/O]
|
||||
end
|
||||
|
||||
subgraph Redis [DATABASE #40;Saved to Disc#41;]
|
||||
ActorDataStore[Actor Data Store<br/>actor:UID -> JSON]
|
||||
Modules[Additional Modules<br/>garage, locker, bank, org]
|
||||
end
|
||||
|
||||
Clients -->|Event Driven<br/>#40;CBA A3 Events#41;| Server
|
||||
Server -->|Extension Calls<br/>#40;Rust FFI#41;| Rust
|
||||
Rust -->|Redis Protocol<br/>#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<br/>#40;Generated on server#41;]
|
||||
Token --> UID[UID Resolution<br/>#40;Steam UID mapping#41;]
|
||||
UID --> State[Player State Tracking<br/>#40;Tracked in Registry#41;]
|
||||
State --> Access[Data Access Authorized<br/>#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
|
||||
```
|
||||
|
||||
@ -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"] }
|
||||
|
||||
345
README.md
345
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<br/>ArmA 3 Interface <---> Rust]
|
||||
Services[Services Layer<br/>#40;Business Logic#41;]
|
||||
Repositories[Repositories Layer<br/>#40;Data Persistence#41;]
|
||||
Models[Models Layer<br/>#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.
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: <error description>"`
|
||||
- **Pool errors**: `"Error: Redis pool not initialized"`
|
||||
- **Connection errors**: `"Error: <connection error>"`
|
||||
- **Redis errors**: `"Error: <Redis operation 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)
|
||||
|
||||
@ -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: <description>"`
|
||||
|
||||
### 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`.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"] }
|
||||
|
||||
@ -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<f64> = 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<VehicleService<RedisVehicleRepository<ExtensionRedisClient>>> =
|
||||
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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<ActorService<ActorStorageRepository>> =
|
||||
LazyLock::new(|| ActorService::new(ActorStorageRepository::configured()));
|
||||
static HOT_ACTOR_SERVICE: LazyLock<
|
||||
|
||||
@ -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<br/>#40;forge-repositories#41;]
|
||||
Trait[RedisClient Trait<br/>#40;forge-shared#41;]
|
||||
Adapter[ExtensionRedisClient<br/>#40;adapter#41;]
|
||||
Redis[Redis Module<br/>#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<String, String>` |
|
||||
| `hash_get` | Get a single field value | `Result<String, String>` |
|
||||
| `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<Vec<String>, 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<Vec<String>, String>` |
|
||||
| `set_del` | Remove member | `Result<(), String>` |
|
||||
|
||||
#### Common Operations
|
||||
|
||||
| Method | Description | Returns |
|
||||
| ------------ | ------------------- | ---------------------- |
|
||||
| `key_exists` | Check if key exists | `Result<bool, String>` |
|
||||
| `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<String> = 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<bool, String>;
|
||||
}
|
||||
```
|
||||
|
||||
2. **Implement the Method**: Add the implementation to `ExtensionRedisClient`.
|
||||
|
||||
```rust
|
||||
impl RedisClient for ExtensionRedisClient {
|
||||
fn hash_exists(&self, key: String, field: String) -> Result<bool, String> {
|
||||
// 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<bool, String> {
|
||||
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<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
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<String, String> {
|
||||
// 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<HashMap>`** | Read-heavy workloads, concurrent readers | Good (multiple readers) | Standard library |
|
||||
| **`Mutex<HashMap>`** | 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
|
||||
@ -1,3 +0,0 @@
|
||||
pub mod redis_client;
|
||||
|
||||
pub use redis_client::ExtensionRedisClient;
|
||||
@ -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<String, String> {
|
||||
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<String, String> {
|
||||
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<Vec<String>, 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::<Vec<String>>(&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<Vec<String>, 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::<Vec<String>>(&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<bool, String> {
|
||||
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<String, String> {
|
||||
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<i64, String> {
|
||||
let result = redis::common::incr_key(key, count);
|
||||
log("debug", "DEBUG", &result);
|
||||
|
||||
if result.starts_with("Error:") {
|
||||
Err(result)
|
||||
} else {
|
||||
result
|
||||
.parse::<i64>()
|
||||
.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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<BankService<BankStorageRepository>> =
|
||||
LazyLock::new(|| BankService::new(BankStorageRepository::configured()));
|
||||
static HOT_BANK_SERVICE: LazyLock<
|
||||
|
||||
79
arma/server/extension/src/config.rs
Normal file
79
arma/server/extension/src/config.rs
Normal file
@ -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<Config> = 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<String>,
|
||||
pub password: Option<String>,
|
||||
pub connect_timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
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::<Config>(&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()
|
||||
}
|
||||
@ -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(())
|
||||
})
|
||||
|
||||
@ -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<TokioRwLock<Option<Context>>> = 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<redis::client::RedisClient> = OnceLock::new();
|
||||
/// Global multi-threaded Tokio runtime used to execute async operations from
|
||||
/// command handlers and startup tasks.
|
||||
pub(crate) static RUNTIME: LazyLock<Runtime> = LazyLock::new(|| {
|
||||
@ -50,16 +45,6 @@ pub(crate) static RUNTIME: LazyLock<Runtime> = 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<StdRwLock<ConnectionState>> =
|
||||
LazyLock::new(|| StdRwLock::new(ConnectionState::Initializing));
|
||||
|
||||
pub(crate) fn enqueue_persistence_task<F>(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.
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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<OrgService<OrgStorageRepository>> =
|
||||
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(),
|
||||
|
||||
@ -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
|
||||
@ -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<RedisConnectionManager>;
|
||||
|
||||
/// Creates a Redis connection pool with the specified configuration.
|
||||
pub async fn create_redis_pool(
|
||||
config: &RedisConfig,
|
||||
) -> Result<RedisClient, Box<dyn Error + Send + Sync>> {
|
||||
// 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)
|
||||
}
|
||||
@ -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<String>>("*").await {
|
||||
Ok(keys) => keys.join(","),
|
||||
Err(e) => format!("Error: {}", e),
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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<Config> = 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<String>,
|
||||
/// Password for Redis authentication
|
||||
pub password: Option<String>,
|
||||
/// Maximum number of connections in the pool
|
||||
pub max_connections: Option<usize>,
|
||||
/// Minimum number of idle connections to maintain
|
||||
pub min_connections: Option<usize>,
|
||||
/// Idle connection timeout in seconds
|
||||
pub idle_timeout: Option<u64>,
|
||||
/// Maximum time to wait for pool connection checkout in milliseconds
|
||||
pub pool_get_timeout_ms: Option<u64>,
|
||||
/// Maximum time to wait for individual Redis command execution in milliseconds
|
||||
pub command_timeout_ms: Option<u64>,
|
||||
/// Maximum time to wait for pool connection establishment in milliseconds
|
||||
pub connect_timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
/// Optional root password for authentication.
|
||||
pub password: Option<String>,
|
||||
/// Maximum time to wait for initial connection in milliseconds.
|
||||
pub connect_timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
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::<Config>(&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()
|
||||
}
|
||||
@ -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<String>>(&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<String, String>>(&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<String>>(&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<String>>(&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),
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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::<i64>() {
|
||||
return serde_json::Value::Number(serde_json::Number::from(int_val));
|
||||
}
|
||||
|
||||
// Try to parse as float
|
||||
if let Ok(float_val) = value.parse::<f64>() {
|
||||
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<String, String> {
|
||||
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)),
|
||||
}
|
||||
}
|
||||
@ -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<String>>(&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<String>>(&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<String>>(&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),
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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(),
|
||||
}
|
||||
})
|
||||
}};
|
||||
}
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
@ -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<String>>(&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<String>>(&key, count.try_into().unwrap_or(0))
|
||||
.await
|
||||
{
|
||||
Ok(values) => values.join(","),
|
||||
Err(e) => format!("Error: {}", e),
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -2,53 +2,42 @@ use super::common::*;
|
||||
use super::*;
|
||||
|
||||
pub enum ActorStorageRepository {
|
||||
Redis(RedisActorRepository<ExtensionRedisClient>),
|
||||
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<Option<Actor>, 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<bool, String> {
|
||||
match self {
|
||||
Self::Redis(repository) => repository.exists(id),
|
||||
Self::Surreal(repository) => repository.exists(id),
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,53 +2,42 @@ use super::common::*;
|
||||
use super::*;
|
||||
|
||||
pub enum BankStorageRepository {
|
||||
Redis(RedisBankRepository<ExtensionRedisClient>),
|
||||
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<Option<Bank>, 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<bool, String> {
|
||||
match self {
|
||||
Self::Redis(repository) => repository.exists(id),
|
||||
Self::Surreal(repository) => repository.exists(id),
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,53 +2,42 @@ use super::common::*;
|
||||
use super::*;
|
||||
|
||||
pub enum GarageStorageRepository {
|
||||
Redis(RedisGarageRepository<ExtensionRedisClient>),
|
||||
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<Option<Garage>, 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<bool, String> {
|
||||
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<ExtensionRedisClient>),
|
||||
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<Option<VGarage>, String> {
|
||||
match self {
|
||||
Self::Redis(repository) => repository.fetch(uid),
|
||||
Self::Surreal(repository) => repository.fetch(uid),
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, uid: &str, field: &str) -> Result<Vec<String>, 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<bool, String> {
|
||||
match self {
|
||||
Self::Redis(repository) => repository.exists(uid),
|
||||
Self::Surreal(repository) => repository.exists(uid),
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,53 +2,42 @@ use super::common::*;
|
||||
use super::*;
|
||||
|
||||
pub enum LockerStorageRepository {
|
||||
Redis(RedisLockerRepository<ExtensionRedisClient>),
|
||||
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<Option<Locker>, 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<bool, String> {
|
||||
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<ExtensionRedisClient>),
|
||||
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<Option<VLocker>, String> {
|
||||
match self {
|
||||
Self::Redis(repository) => repository.fetch(uid),
|
||||
Self::Surreal(repository) => repository.fetch(uid),
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, uid: &str, field: &str) -> Result<Vec<String>, 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<bool, String> {
|
||||
match self {
|
||||
Self::Redis(repository) => repository.exists(uid),
|
||||
Self::Surreal(repository) => repository.exists(uid),
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,74 +2,60 @@ use super::common::*;
|
||||
use super::*;
|
||||
|
||||
pub enum OrgStorageRepository {
|
||||
Redis(RedisOrgRepository<ExtensionRedisClient>),
|
||||
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<Option<Org>, 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<bool, String> {
|
||||
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<Vec<MemberSummary>, 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<HashMap<String, HashMap<String, OrgAssetEntry>>, 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<String, HashMap<String, OrgAssetEntry>>,
|
||||
) -> 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<HashMap<String, OrgFleetEntry>, 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<String, OrgFleetEntry>,
|
||||
) -> Result<(), String> {
|
||||
match self {
|
||||
Self::Redis(repository) => repository.update_fleet(org_id, fleet),
|
||||
Self::Surreal(repository) => repository.update_fleet(org_id, fleet),
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,116 +2,96 @@ use super::common::*;
|
||||
use super::*;
|
||||
|
||||
pub enum PhoneStorageRepository {
|
||||
Redis(RedisPhoneRepository<ExtensionRedisClient>),
|
||||
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<bool, String> {
|
||||
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<bool, String> {
|
||||
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<Vec<String>, 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<Vec<PhoneMessage>, 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<bool, String> {
|
||||
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<bool, String> {
|
||||
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<Vec<PhoneEmail>, 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<bool, String> {
|
||||
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<bool, String> {
|
||||
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<u64, String> {
|
||||
match self {
|
||||
Self::Redis(repository) => repository.next_sequence(),
|
||||
Self::Surreal(repository) => repository.next_sequence(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Client>;
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -131,10 +131,10 @@ fn parse_transport_argument_value(value: serde_json::Value) -> Result<Vec<String
|
||||
.collect()),
|
||||
serde_json::Value::String(value) => {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.starts_with('[') || trimmed.starts_with('{') || trimmed.eq("null") {
|
||||
if let Ok(nested_value) = serde_json::from_str::<serde_json::Value>(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::<serde_json::Value>(trimmed)
|
||||
{
|
||||
return parse_transport_argument_value(nested_value);
|
||||
}
|
||||
|
||||
Ok(vec![value])
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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
|
||||
|
||||
278
lib/README.md
278
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<br/>#40;ArmA 3 Interface#41;]
|
||||
Services[Services Layer<br/>#40;Business Logic#41;]
|
||||
Repositories[Repositories Layer<br/>#40;Data Persistence#41;]
|
||||
Models[Models Layer<br/>#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<R: ActorRepository> ActorService<R> {
|
||||
pub fn create_actor(&self, uid: String, data: String) -> Result<Actor, String> {
|
||||
// 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<C: RedisClient> ActorRepository for RedisActorRepository<C> {
|
||||
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<Self, String> {
|
||||
// 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<C: RedisClient>`
|
||||
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<T, String>` 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<br/>#40;No Dependencies#41;]
|
||||
Models[Models<br/>#40;Depends on Shared#41;]
|
||||
Repositories[Repositories<br/>#40;Depends on Models, Shared#41;]
|
||||
Services[Services<br/>#40;Depends on Repositories, Models#41;]
|
||||
Extension[Extension<br/>#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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -66,7 +66,7 @@ impl Bank {
|
||||
bank: 0.0,
|
||||
cash: 0.0,
|
||||
earnings: 0.0,
|
||||
pin: pin,
|
||||
pin,
|
||||
transactions: Vec::new(),
|
||||
};
|
||||
|
||||
|
||||
@ -52,6 +52,12 @@ impl HitPoints {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HitPoints {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Vehicle {
|
||||
pub fn new<S: Into<String>>(
|
||||
plate: S,
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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<br/>#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<R: ActorRepository>(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<R: OrgRepository>(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<T, String>` (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<Option<Item>, 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<C: RedisClient> {
|
||||
client: C,
|
||||
}
|
||||
|
||||
impl<C: RedisClient> RedisItemRepository<C> {
|
||||
pub fn new(client: C) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: RedisClient> ItemRepository for RedisItemRepository<C> {
|
||||
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.
|
||||
|
||||
@ -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<C: RedisClient> {
|
||||
/// 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<C: RedisClient> RedisActorRepository<C> {
|
||||
/// Creates a new Redis actor repository with the provided client.
|
||||
pub fn new(client: C) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: RedisClient> ActorRepository for RedisActorRepository<C> {
|
||||
/// 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<Option<Actor>, 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<String, String> =
|
||||
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::<Actor>(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<bool, String> {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<C: RedisClient> {
|
||||
/// 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<C: RedisClient> RedisBankRepository<C> {
|
||||
/// Creates a new Redis bank repository with the provided client.
|
||||
pub fn new(client: C) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: RedisClient> BankRepository for RedisBankRepository<C> {
|
||||
/// 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<Option<Bank>, 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<String, String> =
|
||||
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::<Bank>(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<bool, String> {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<C: RedisClient> {
|
||||
client: C,
|
||||
}
|
||||
|
||||
impl<C: RedisClient> RedisGarageRepository<C> {
|
||||
pub fn new(client: C) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: RedisClient> GarageRepository for RedisGarageRepository<C> {
|
||||
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<Option<Garage>, 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::<HashMap<String, Vehicle>>(&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<bool, String> {
|
||||
let redis_key = format!("garage:{}", uid);
|
||||
self.client.key_exists(redis_key)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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};
|
||||
|
||||
@ -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<C: RedisClient> {
|
||||
client: C,
|
||||
}
|
||||
|
||||
impl<C: RedisClient> RedisLockerRepository<C> {
|
||||
pub fn new(client: C) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: RedisClient> LockerRepository for RedisLockerRepository<C> {
|
||||
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<Option<Locker>, 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::<HashMap<String, Item>>(&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<bool, String> {
|
||||
let redis_key = format!("locker:{}", uid);
|
||||
self.client.key_exists(redis_key)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<C: RedisClient> {
|
||||
/// 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<C: RedisClient> RedisOrgRepository<C> {
|
||||
/// Creates a new Redis organization repository with the provided client.
|
||||
pub fn new(client: C) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: RedisClient> OrgRepository for RedisOrgRepository<C> {
|
||||
/// 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<Option<Org>, 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<String, String> =
|
||||
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::<Org>(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<bool, String> {
|
||||
// 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<Vec<MemberSummary>, 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<String> = match self.client.set_members(redis_key) {
|
||||
Ok(v) => v,
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
// Pre-allocate result vector
|
||||
let mut result: Vec<MemberSummary> = 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<HashMap<String, HashMap<String, OrgAssetEntry>>, 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<String, String> = 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::<HashMap<String, OrgAssetEntry>>(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<String, HashMap<String, OrgAssetEntry>>,
|
||||
) -> 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<HashMap<String, OrgFleetEntry>, 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<String, String> = 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::<OrgFleetEntry>(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<String, OrgFleetEntry>,
|
||||
) -> 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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<C: RedisClient> {
|
||||
client: C,
|
||||
}
|
||||
|
||||
impl<C: RedisClient> RedisPhoneRepository<C> {
|
||||
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<Option<PhoneMessage>, 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<String, String> = 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::<PhoneMessage>(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<Option<PhoneEmail>, 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<String, String> = 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::<PhoneEmail>(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<C: RedisClient> PhoneRepository for RedisPhoneRepository<C> {
|
||||
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<bool, String> {
|
||||
self.client
|
||||
.set_add(Self::contact_key(uid), contact_uid.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn remove_contact(&self, uid: &str, contact_uid: &str) -> Result<bool, String> {
|
||||
self.client
|
||||
.set_del(Self::contact_key(uid), contact_uid.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn list_contacts(&self, uid: &str) -> Result<Vec<String>, 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<Vec<PhoneMessage>, 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<bool, String> {
|
||||
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<bool, String> {
|
||||
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<Vec<PhoneEmail>, 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<bool, String> {
|
||||
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<bool, String> {
|
||||
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<u64, String> {
|
||||
let value = self.client.incr_key(Self::sequence_key(), 1)?;
|
||||
u64::try_from(value).map_err(|_| "Phone sequence overflowed.".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<C: RedisClient> {
|
||||
client: C,
|
||||
}
|
||||
|
||||
impl<C: RedisClient> RedisVGarageRepository<C> {
|
||||
pub fn new(client: C) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: RedisClient> VGarageRepository for RedisVGarageRepository<C> {
|
||||
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<Option<VGarage>, 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<String, String> =
|
||||
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::<VGarage>(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<Vec<String>, 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::<Vec<String>>(&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<bool, String> {
|
||||
let redis_key = format!("owned:garage:{}", uid);
|
||||
self.client.key_exists(redis_key)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<C: RedisClient> {
|
||||
client: C,
|
||||
}
|
||||
|
||||
impl<C: RedisClient> RedisVLockerRepository<C> {
|
||||
pub fn new(client: C) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: RedisClient> VLockerRepository for RedisVLockerRepository<C> {
|
||||
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<Option<VLocker>, 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<String, String> =
|
||||
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::<VLocker>(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<Vec<String>, 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::<Vec<String>>(&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<bool, String> {
|
||||
let redis_key = format!("owned:locker:{}", uid);
|
||||
self.client.key_exists(redis_key)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<br/>#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<T, String>` 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<R: ItemRepository> {
|
||||
repository: R,
|
||||
}
|
||||
```
|
||||
3. **Implement `new`**: Provide a constructor that accepts the repository.
|
||||
```rust
|
||||
impl<R: ItemRepository> ItemService<R> {
|
||||
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<R: ItemRepository> ItemService<R> {
|
||||
pub fn create_item(&self, item_id: String, data: String) -> Result<Item, String> {
|
||||
// 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.
|
||||
|
||||
@ -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."
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -84,10 +84,10 @@ impl<R: GarageRepository> GarageService<R> {
|
||||
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));
|
||||
|
||||
@ -1164,10 +1164,11 @@ fn can_manage_treasury(
|
||||
|
||||
fn resolve_member_uids(org: &HotOrgRecord, requester_uid: Option<&str>) -> Vec<String> {
|
||||
let mut member_uids = org.members.keys().cloned().collect::<Vec<_>>();
|
||||
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
|
||||
}
|
||||
|
||||
@ -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<String> {
|
||||
let mut member_uids = org.members.keys().cloned().collect::<Vec<_>>();
|
||||
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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<String, String>;
|
||||
fn hash_get(&self, key: String, field: String) -> Result<String, String>;
|
||||
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<Vec<String>, 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<Vec<String>, String>;
|
||||
fn set_del(&self, key: String, member: String) -> Result<(), String>;
|
||||
|
||||
// Common operations
|
||||
fn get_key(&self, key: String) -> Result<String, String>;
|
||||
fn set_key(&self, key: String, value: String) -> Result<(), String>;
|
||||
fn incr_key(&self, key: String, count: usize) -> Result<i64, String>;
|
||||
fn key_exists(&self, key: String) -> Result<bool, String>;
|
||||
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::<i64>() {
|
||||
return serde_json::Value::Number(serde_json::Number::from(int_val));
|
||||
}
|
||||
|
||||
// Try to parse as float
|
||||
if let Ok(float_val) = value.parse::<f64>() {
|
||||
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()),
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user