Core Modules (game-agnostic, reusable for WarFactory): - ResourceModule: Inventory, crafting system (465 lines) - StorageModule: Save/load with pub/sub state collection (424 lines) - CombatModule: Combat resolver, damage/armor/morale (580 lines) - EventModule: JSON event scripting with choices/outcomes (651 lines) MC-Specific Modules: - GameModule v2: State machine + event subscriptions (updated) - TrainBuilderModule: 3 wagons, 2-axis balance, performance malus (530 lines) - ExpeditionModule: A→B expeditions, team management, events integration (641 lines) Features: - All modules hot-reload compatible (state preservation) - Pure pub/sub architecture (zero direct coupling) - 7 config files (resources, storage, combat, events, train, expeditions) - 7 test suites (GameModuleTest: 12/12 PASSED) - CMakeLists.txt updated for all modules + tests Total: ~3,500 lines of production code + comprehensive tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
15 KiB
StorageModule Implementation Documentation
Date: December 2, 2025 Version: 0.1.0 Status: Implemented
Overview
StorageModule is a game-agnostic core module that provides save/load functionality for the game state. It follows the GroveEngine architecture and pub/sub communication pattern.
Key Principles
- Game-Agnostic: No references to "train", "drone", "Mobile Command", or "WarFactory"
- Pub/Sub Communication: All interaction via
grove::IIOtopics - Hot-Reload Compatible: State serialization for seamless module replacement
- JSON Storage: Human-readable save files for debugging and manual editing
Architecture
File Structure
src/modules/core/
├── StorageModule.h # Module interface
└── StorageModule.cpp # Implementation
config/
└── storage.json # Configuration
tests/
└── StorageModuleTest.cpp # Independent validation tests
data/saves/ # Save game directory (created automatically)
Communication Protocol
Topics Published
| Topic | Payload | Description |
|---|---|---|
storage:save_complete |
{filename, timestamp} |
Save operation succeeded |
storage:load_complete |
{filename, version} |
Load operation succeeded |
storage:save_failed |
{error} |
Save operation failed |
storage:load_failed |
{error} |
Load operation failed |
storage:collect_states |
{action: "collect_state"} |
Broadcast request to all modules |
storage:restore_state:{moduleName} |
{module state data} |
Restore specific module state |
Topics Subscribed
| Topic | Description |
|---|---|
game:request_save |
Trigger manual save (payload: {filename?}) |
game:request_load |
Trigger load (payload: {filename}) |
storage:module_state |
Receive module state (payload: {moduleName, ...state}) |
State Collection Mechanism (Pub/Sub Pattern)
This is the critical architectural pattern that makes the module game-agnostic.
How It Works
1. Save Request Flow
User/Game StorageModule Other Modules
| | |
|--request_save--------->| |
| | |
| |--collect_states------->| (broadcast)
| | |
| |<----module_state-------| Module A
| |<----module_state-------| Module B
| |<----module_state-------| Module C
| | |
| | [Aggregate all states] |
| | [Write to JSON file] |
| | |
|<---save_complete-------| |
Key Points:
- StorageModule broadcasts
storage:collect_states - Each module responds with
storage:module_statecontaining their own state - StorageModule aggregates all responses into a single JSON file
- No direct coupling between modules
2. Load Request Flow
User/Game StorageModule Other Modules
| | |
|--request_load--------->| |
| | |
| | [Read JSON file] |
| | [Parse module states] |
| | |
| |--restore_state:ModA--->| Module A
| |--restore_state:ModB--->| Module B
| |--restore_state:ModC--->| Module C
| | |
|<---load_complete-------| |
Key Points:
- StorageModule reads save file
- For each module in the save, publishes
storage:restore_state:{moduleName} - Modules subscribe to their specific restore topic
- Each module handles its own state restoration
Example Module Integration
Any module that wants save/load support must:
class ExampleModule : public grove::IModule {
public:
void setConfiguration(..., grove::IIO* io, ...) override {
m_io = io;
// Subscribe to state collection
m_io->subscribe("storage:collect_states");
// Subscribe to your own restore topic
m_io->subscribe("storage:restore_state:ExampleModule");
}
void process(const grove::IDataNode& input) override {
while (m_io->hasMessages() > 0) {
auto msg = m_io->pullMessage();
if (msg.topic == "storage:collect_states") {
// Respond with your state
auto state = getState(); // Get your current state
state->setString("moduleName", "ExampleModule");
m_io->publish("storage:module_state", std::move(state));
}
else if (msg.topic == "storage:restore_state:ExampleModule") {
// Restore your state from the message
setState(*msg.data);
}
}
}
};
Save File Format
Structure
{
"version": "0.1.0",
"game": "MobileCommand",
"timestamp": "2025-12-02T10:00:00Z",
"gameTime": 3600.0,
"modules": {
"GameModule": {
"frameCount": 54321,
"gameState": "InGame"
},
"ResourceModule": {
"inventory": {
"scrap_metal": 150,
"ammunition_9mm": 500
},
"craftQueue": []
},
"TrainBuilderModule": {
"wagons": [
{"type": "locomotive", "health": 100},
{"type": "cargo", "health": 85}
],
"balance": {
"lateral": -2.5,
"longitudinal": 1.0
}
}
}
}
Fields
- version: StorageModule version (for compatibility)
- game: Game name (informational)
- timestamp: ISO 8601 UTC timestamp
- gameTime: In-game time elapsed (seconds)
- modules: Map of module name → module state
Configuration
storage.json
{
"savePath": "data/saves/",
"autoSaveInterval": 300.0,
"maxAutoSaves": 3
}
| Parameter | Type | Default | Description |
|---|---|---|---|
savePath |
string | "data/saves/" |
Directory for save files |
autoSaveInterval |
float | 300.0 |
Auto-save interval (seconds) |
maxAutoSaves |
int | 3 |
Max auto-save files to keep |
Auto-Save System
Behavior
- Timer accumulates
deltaTimefrom eachprocess()call - When
timeSinceLastAutoSave >= autoSaveInterval, trigger auto-save - Auto-save filename:
autosave_YYYYMMDD_HHMMSS.json - Old auto-saves are rotated (oldest deleted when count exceeds
maxAutoSaves)
Rotation Algorithm
1. Scan savePath for files starting with "autosave"
2. Sort by modification time (oldest first)
3. While count >= maxAutoSaves:
- Delete oldest file
- Remove from list
4. Create new auto-save
Usage Examples
Mobile Command Use Case
// GameModule requests save on player action
void onPlayerPressSaveKey() {
auto request = std::make_unique<grove::JsonDataNode>("request");
request->setString("filename", "player_quicksave");
m_io->publish("game:request_save", std::move(request));
}
// TrainBuilderModule responds to state collection
void onCollectStates() {
auto state = std::make_unique<grove::JsonDataNode>("state");
state->setString("moduleName", "TrainBuilderModule");
// Serialize train composition
auto wagons = std::make_unique<grove::JsonDataNode>("wagons");
for (auto& wagon : m_wagons) {
auto wagonNode = std::make_unique<grove::JsonDataNode>(wagon.id);
wagonNode->setString("type", wagon.type);
wagonNode->setInt("health", wagon.health);
wagons->setChild(wagon.id, std::move(wagonNode));
}
state->setChild("wagons", std::move(wagons));
m_io->publish("storage:module_state", std::move(state));
}
WarFactory Use Case (Future)
// FactoryModule responds to state collection
void onCollectStates() {
auto state = std::make_unique<grove::JsonDataNode>("state");
state->setString("moduleName", "FactoryModule");
// Serialize production lines
auto lines = std::make_unique<grove::JsonDataNode>("productionLines");
for (auto& line : m_productionLines) {
auto lineNode = std::make_unique<grove::JsonDataNode>(line.id);
lineNode->setString("recipe", line.currentRecipe);
lineNode->setDouble("progress", line.progress);
lines->setChild(line.id, std::move(lineNode));
}
state->setChild("productionLines", std::move(lines));
m_io->publish("storage:module_state", std::move(state));
}
Same StorageModule, different game data!
Hot-Reload Support
State Serialization
StorageModule preserves:
timeSinceLastAutoSave: Auto-save timer progress
std::unique_ptr<grove::IDataNode> getState() override {
auto state = std::make_unique<grove::JsonDataNode>("state");
state->setDouble("timeSinceLastAutoSave", m_timeSinceLastAutoSave);
return state;
}
void setState(const grove::IDataNode& state) override {
m_timeSinceLastAutoSave = state.getDouble("timeSinceLastAutoSave", 0.0);
}
Reload Safety
isIdle()returnsfalsewhen collecting states (prevents reload during save)- Transient data (collected states) is cleared on shutdown
- Configuration is reloaded from
storage.json
Testing
Independent Validation Tests
Located in: tests/StorageModuleTest.cpp
Test Suite:
- Save/Load Cycle: Verify data preservation
- Auto-save Triggers: Verify timer functionality
- Invalid Save File: Verify error handling
- Version Compatibility: Verify version field handling
- Auto-save Rotation: Verify max auto-saves limit
- Hot-reload State: Verify state preservation
Running Tests
# Compile test (standalone, no GroveEngine required)
g++ -std=c++17 -I../external/GroveEngine/include \
tests/StorageModuleTest.cpp \
src/modules/core/StorageModule.cpp \
-lspdlog -lstdc++fs -o test_storage
# Run
./test_storage
Expected Output:
========================================
StorageModule Independent Validation Tests
========================================
=== Test 1: Save/Load Cycle ===
✓ Save completed successfully
✓ Save file created
✓ Load completed successfully
✓ Module state restore requested
✓ Test 1 PASSED
...
========================================
ALL TESTS PASSED ✓
========================================
Error Handling
Save Failures
Scenarios:
- Directory creation fails
- File write permission denied
- Disk full
- JSON serialization error
Behavior:
- Log error with
spdlog::error - Publish
storage:save_failedwith error message - Do not crash, continue operation
Load Failures
Scenarios:
- File not found
- Corrupted JSON
- Missing version field
- Invalid module data
Behavior:
- Log error with
spdlog::error - Publish
storage:load_failedwith error message - Do not load partial state
- Game continues with current state
Performance Considerations
Save Operation
- Synchronous: Blocks during file write (~10-50ms for typical save)
- Async Future: Could use
ITaskSchedulerfor background saves - Size: ~10-100KB JSON files for typical game state
Load Operation
- Synchronous: Blocks during file read and parse (~5-20ms)
- State Restoration: Modules handle their own state asynchronously via pub/sub
Memory
- Collected States: Temporarily holds all module states in memory
- Cleared: After save completes or on shutdown
- Typical: ~1-5MB for all module states combined
Future Enhancements
Planned (Phase 2)
- Async save/load via
ITaskScheduler - Compression for large save files
- Cloud save integration
- Save metadata (playtime, screenshot)
Possible (Post-MVP)
- Incremental saves (delta compression)
- Save file encryption
- Multiple save slots UI
- Steam Cloud integration
Game-Agnostic Checklist
Verification that StorageModule follows game-agnostic principles:
- ✅ No mentions of "train", "drone", "expedition", "factory"
- ✅ No mentions of "Mobile Command", "WarFactory"
- ✅ Pure pub/sub communication (no direct module coupling)
- ✅ Configuration via JSON (no hardcoded behavior)
- ✅ Comments explain MC AND WF usage
- ✅ Independent tests (no game-specific data)
- ✅ Hot-reload state preservation
- ✅ GroveEngine
IModuleinterface compliance
Integration with GroveEngine
Module Loading
// In main.cpp or engine initialization
auto storageLoader = std::make_unique<grove::ModuleLoader>();
storageLoader->load("build/StorageModule.dll", "StorageModule");
// Configure
auto config = loadConfig("config/storage.json");
auto module = storageLoader->getModule();
module->setConfiguration(*config, io, scheduler);
Hot-Reload Workflow
# 1. Game running with StorageModule loaded
./build/mobilecommand.exe
# 2. Edit StorageModule.cpp
# 3. Rebuild module only (fast)
cmake --build build --target StorageModule
# 4. Module auto-reloads (state preserved)
# - Auto-save timer continues from previous value
# - No data loss
Troubleshooting
"Save directory not found"
Cause: data/saves/ doesn't exist
Fix: Module creates it automatically on startup. Check permissions.
"Auto-save not triggering"
Cause: deltaTime not provided in process() input
Fix: Ensure GameModule passes deltaTime in process input:
auto input = std::make_unique<grove::JsonDataNode>("input");
input->setDouble("deltaTime", deltaTime);
"Module states not collected"
Cause: Modules not responding to storage:collect_states
Fix: Verify modules subscribe to the topic and publish storage:module_state
"Load fails with 'Invalid JSON'"
Cause: Corrupted save file Fix: Check file manually, restore from backup auto-save
References
- Architecture:
C:\Users\alexi\Documents\projects\mobilecommand\ARCHITECTURE.md - GroveEngine IModule:
external/GroveEngine/include/grove/IModule.h - GroveEngine IIO:
external/GroveEngine/include/grove/IIO.h - Prototype Plan:
plans/PROTOTYPE_PLAN.md(lines 158-207)
Summary
StorageModule is a production-ready, game-agnostic save/load system that:
- ✅ Works for Mobile Command NOW
- ✅ Will work for WarFactory LATER (zero code changes)
- ✅ Uses pure pub/sub (no coupling)
- ✅ Supports hot-reload (dev velocity)
- ✅ Has independent tests (quality assurance)
- ✅ Follows GroveEngine patterns (architecture compliance)
Next Steps: Integrate with GameModule and test save/load during gameplay.