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>
459 lines
14 KiB
C++
459 lines
14 KiB
C++
#include "../src/modules/core/StorageModule.h"
|
|
#include <grove/JsonDataNode.h>
|
|
#include <grove/IIO.h>
|
|
#include <cassert>
|
|
#include <iostream>
|
|
#include <fstream>
|
|
#include <filesystem>
|
|
#include <vector>
|
|
#include <memory>
|
|
#include <queue>
|
|
#include <thread>
|
|
#include <chrono>
|
|
|
|
namespace fs = std::filesystem;
|
|
|
|
/**
|
|
* Mock IIO implementation for testing
|
|
* Simulates the pub/sub system without requiring full GroveEngine
|
|
*/
|
|
class MockIO : public grove::IIO {
|
|
public:
|
|
void publish(const std::string& topic, std::unique_ptr<grove::IDataNode> message) override {
|
|
m_publishedMessages.push({topic, std::move(message), 0});
|
|
std::cout << "[MockIO] Published: " << topic << std::endl;
|
|
}
|
|
|
|
void subscribe(const std::string& topicPattern, const grove::SubscriptionConfig& config = {}) override {
|
|
m_subscriptions.push_back(topicPattern);
|
|
std::cout << "[MockIO] Subscribed: " << topicPattern << std::endl;
|
|
}
|
|
|
|
void subscribeLowFreq(const std::string& topicPattern, const grove::SubscriptionConfig& config = {}) override {
|
|
m_subscriptions.push_back(topicPattern);
|
|
}
|
|
|
|
int hasMessages() const override {
|
|
return static_cast<int>(m_messageQueue.size());
|
|
}
|
|
|
|
grove::Message pullMessage() override {
|
|
if (m_messageQueue.empty()) {
|
|
throw std::runtime_error("No messages available");
|
|
}
|
|
auto msg = std::move(m_messageQueue.front());
|
|
m_messageQueue.pop();
|
|
return std::move(msg);
|
|
}
|
|
|
|
grove::IOHealth getHealth() const override {
|
|
grove::IOHealth health;
|
|
health.queueSize = static_cast<int>(m_messageQueue.size());
|
|
health.maxQueueSize = 1000;
|
|
health.dropping = false;
|
|
health.averageProcessingRate = 100.0f;
|
|
health.droppedMessageCount = 0;
|
|
return health;
|
|
}
|
|
|
|
grove::IOType getType() const override {
|
|
return grove::IOType::INTRA;
|
|
}
|
|
|
|
// Test helpers
|
|
void injectMessage(const std::string& topic, std::unique_ptr<grove::IDataNode> data) {
|
|
m_messageQueue.push({topic, std::move(data), 0});
|
|
}
|
|
|
|
bool wasPublished(const std::string& topic) const {
|
|
std::queue<grove::Message> tempQueue = m_publishedMessages;
|
|
while (!tempQueue.empty()) {
|
|
if (tempQueue.front().topic == topic) {
|
|
return true;
|
|
}
|
|
tempQueue.pop();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
std::unique_ptr<grove::IDataNode> getLastPublished(const std::string& topic) {
|
|
std::vector<grove::Message> messages;
|
|
while (!m_publishedMessages.empty()) {
|
|
messages.push_back(std::move(m_publishedMessages.front()));
|
|
m_publishedMessages.pop();
|
|
}
|
|
|
|
grove::Message* found = nullptr;
|
|
for (auto& msg : messages) {
|
|
if (msg.topic == topic) {
|
|
found = &msg;
|
|
}
|
|
}
|
|
|
|
if (found) {
|
|
return std::move(found->data);
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
void clearPublished() {
|
|
while (!m_publishedMessages.empty()) {
|
|
m_publishedMessages.pop();
|
|
}
|
|
}
|
|
|
|
private:
|
|
std::vector<std::string> m_subscriptions;
|
|
std::queue<grove::Message> m_messageQueue;
|
|
std::queue<grove::Message> m_publishedMessages;
|
|
};
|
|
|
|
/**
|
|
* Test helper: Clean up test save files
|
|
*/
|
|
void cleanupTestSaves(const std::string& savePath = "data/saves/") {
|
|
try {
|
|
if (fs::exists(savePath)) {
|
|
for (const auto& entry : fs::directory_iterator(savePath)) {
|
|
if (entry.is_regular_file()) {
|
|
std::string filename = entry.path().filename().string();
|
|
if (filename.find("test_") == 0 || filename.find("autosave") == 0) {
|
|
fs::remove(entry.path());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (const std::exception& e) {
|
|
std::cerr << "Failed to cleanup test saves: " << e.what() << std::endl;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test 1: Save/Load Cycle Preserves Data
|
|
*/
|
|
void testSaveLoadCycle() {
|
|
std::cout << "\n=== Test 1: Save/Load Cycle ===" << std::endl;
|
|
|
|
cleanupTestSaves();
|
|
|
|
MockIO mockIO;
|
|
StorageModule storage;
|
|
|
|
// Configure module
|
|
auto config = std::make_unique<grove::JsonDataNode>("config");
|
|
config->setString("savePath", "data/saves/");
|
|
config->setDouble("autoSaveInterval", 300.0);
|
|
config->setInt("maxAutoSaves", 3);
|
|
|
|
storage.setConfiguration(*config, &mockIO, nullptr);
|
|
|
|
// Simulate module state response
|
|
auto moduleState = std::make_unique<grove::JsonDataNode>("state");
|
|
moduleState->setString("moduleName", "TestModule");
|
|
moduleState->setInt("testValue", 42);
|
|
moduleState->setString("testString", "Hello World");
|
|
mockIO.injectMessage("storage:module_state", std::move(moduleState));
|
|
|
|
// Request save
|
|
auto saveRequest = std::make_unique<grove::JsonDataNode>("request");
|
|
saveRequest->setString("filename", "test_save_load");
|
|
mockIO.injectMessage("game:request_save", std::move(saveRequest));
|
|
|
|
// Process messages
|
|
auto input = std::make_unique<grove::JsonDataNode>("input");
|
|
input->setDouble("deltaTime", 0.016);
|
|
storage.process(*input);
|
|
|
|
// Verify save_complete was published
|
|
assert(mockIO.wasPublished("storage:save_complete"));
|
|
std::cout << "✓ Save completed successfully" << std::endl;
|
|
|
|
// Verify file exists
|
|
assert(fs::exists("data/saves/test_save_load.json"));
|
|
std::cout << "✓ Save file created" << std::endl;
|
|
|
|
// Clear published messages
|
|
mockIO.clearPublished();
|
|
|
|
// Request load
|
|
auto loadRequest = std::make_unique<grove::JsonDataNode>("request");
|
|
loadRequest->setString("filename", "test_save_load");
|
|
mockIO.injectMessage("game:request_load", std::move(loadRequest));
|
|
|
|
storage.process(*input);
|
|
|
|
// Verify load_complete was published
|
|
assert(mockIO.wasPublished("storage:load_complete"));
|
|
std::cout << "✓ Load completed successfully" << std::endl;
|
|
|
|
// Verify restore messages were published
|
|
assert(mockIO.wasPublished("storage:restore_state:TestModule"));
|
|
std::cout << "✓ Module state restore requested" << std::endl;
|
|
|
|
std::cout << "✓ Test 1 PASSED\n" << std::endl;
|
|
}
|
|
|
|
/**
|
|
* Test 2: Auto-save Triggers Correctly
|
|
*/
|
|
void testAutoSave() {
|
|
std::cout << "\n=== Test 2: Auto-save Triggers ===" << std::endl;
|
|
|
|
cleanupTestSaves();
|
|
|
|
MockIO mockIO;
|
|
StorageModule storage;
|
|
|
|
// Configure with short auto-save interval
|
|
auto config = std::make_unique<grove::JsonDataNode>("config");
|
|
config->setString("savePath", "data/saves/");
|
|
config->setDouble("autoSaveInterval", 1.0); // 1 second for testing
|
|
config->setInt("maxAutoSaves", 3);
|
|
|
|
storage.setConfiguration(*config, &mockIO, nullptr);
|
|
|
|
// Process with accumulated time
|
|
auto input = std::make_unique<grove::JsonDataNode>("input");
|
|
input->setDouble("deltaTime", 1.5); // Exceed auto-save interval
|
|
|
|
storage.process(*input);
|
|
|
|
// Verify auto-save was triggered
|
|
assert(mockIO.wasPublished("storage:save_complete"));
|
|
std::cout << "✓ Auto-save triggered after interval" << std::endl;
|
|
|
|
// Verify autosave file was created
|
|
bool foundAutoSave = false;
|
|
for (const auto& entry : fs::directory_iterator("data/saves/")) {
|
|
std::string filename = entry.path().filename().string();
|
|
if (filename.find("autosave") == 0) {
|
|
foundAutoSave = true;
|
|
break;
|
|
}
|
|
}
|
|
assert(foundAutoSave);
|
|
std::cout << "✓ Auto-save file created" << std::endl;
|
|
|
|
std::cout << "✓ Test 2 PASSED\n" << std::endl;
|
|
}
|
|
|
|
/**
|
|
* Test 3: Invalid Save File Handling
|
|
*/
|
|
void testInvalidSaveFile() {
|
|
std::cout << "\n=== Test 3: Invalid Save File Handling ===" << std::endl;
|
|
|
|
cleanupTestSaves();
|
|
|
|
MockIO mockIO;
|
|
StorageModule storage;
|
|
|
|
auto config = std::make_unique<grove::JsonDataNode>("config");
|
|
config->setString("savePath", "data/saves/");
|
|
config->setDouble("autoSaveInterval", 300.0);
|
|
config->setInt("maxAutoSaves", 3);
|
|
|
|
storage.setConfiguration(*config, &mockIO, nullptr);
|
|
|
|
// Try to load non-existent file
|
|
auto loadRequest = std::make_unique<grove::JsonDataNode>("request");
|
|
loadRequest->setString("filename", "nonexistent_file");
|
|
mockIO.injectMessage("game:request_load", std::move(loadRequest));
|
|
|
|
auto input = std::make_unique<grove::JsonDataNode>("input");
|
|
storage.process(*input);
|
|
|
|
// Verify load_failed was published
|
|
assert(mockIO.wasPublished("storage:load_failed"));
|
|
std::cout << "✓ Load failure detected and published" << std::endl;
|
|
|
|
// Create corrupted save file
|
|
std::ofstream corruptedFile("data/saves/corrupted_save.json");
|
|
corruptedFile << "{ invalid json content }}}";
|
|
corruptedFile.close();
|
|
|
|
mockIO.clearPublished();
|
|
|
|
// Try to load corrupted file
|
|
loadRequest = std::make_unique<grove::JsonDataNode>("request");
|
|
loadRequest->setString("filename", "corrupted_save");
|
|
mockIO.injectMessage("game:request_load", std::move(loadRequest));
|
|
|
|
storage.process(*input);
|
|
|
|
// Verify load_failed was published
|
|
assert(mockIO.wasPublished("storage:load_failed"));
|
|
std::cout << "✓ Corrupted file detected and handled" << std::endl;
|
|
|
|
std::cout << "✓ Test 3 PASSED\n" << std::endl;
|
|
}
|
|
|
|
/**
|
|
* Test 4: Version Compatibility Check
|
|
*/
|
|
void testVersionCompatibility() {
|
|
std::cout << "\n=== Test 4: Version Compatibility ===" << std::endl;
|
|
|
|
cleanupTestSaves();
|
|
|
|
MockIO mockIO;
|
|
StorageModule storage;
|
|
|
|
auto config = std::make_unique<grove::JsonDataNode>("config");
|
|
config->setString("savePath", "data/saves/");
|
|
config->setDouble("autoSaveInterval", 300.0);
|
|
config->setInt("maxAutoSaves", 3);
|
|
|
|
storage.setConfiguration(*config, &mockIO, nullptr);
|
|
|
|
// Create a save file with different version
|
|
nlohmann::json saveJson = {
|
|
{"version", "0.2.0"},
|
|
{"game", "MobileCommand"},
|
|
{"timestamp", "2025-12-02T10:00:00Z"},
|
|
{"gameTime", 3600.0},
|
|
{"modules", {
|
|
{"TestModule", {
|
|
{"testValue", 123}
|
|
}}
|
|
}}
|
|
};
|
|
|
|
std::ofstream versionFile("data/saves/version_test.json");
|
|
versionFile << saveJson.dump(2);
|
|
versionFile.close();
|
|
|
|
// Load file with different version
|
|
auto loadRequest = std::make_unique<grove::JsonDataNode>("request");
|
|
loadRequest->setString("filename", "version_test");
|
|
mockIO.injectMessage("game:request_load", std::move(loadRequest));
|
|
|
|
auto input = std::make_unique<grove::JsonDataNode>("input");
|
|
storage.process(*input);
|
|
|
|
// Should load successfully (version is informational, not blocking)
|
|
assert(mockIO.wasPublished("storage:load_complete"));
|
|
std::cout << "✓ Different version loaded (with warning)" << std::endl;
|
|
|
|
std::cout << "✓ Test 4 PASSED\n" << std::endl;
|
|
}
|
|
|
|
/**
|
|
* Test 5: Auto-save Rotation (Max Auto-saves)
|
|
*/
|
|
void testAutoSaveRotation() {
|
|
std::cout << "\n=== Test 5: Auto-save Rotation ===" << std::endl;
|
|
|
|
cleanupTestSaves();
|
|
|
|
MockIO mockIO;
|
|
StorageModule storage;
|
|
|
|
// Configure with max 3 auto-saves
|
|
auto config = std::make_unique<grove::JsonDataNode>("config");
|
|
config->setString("savePath", "data/saves/");
|
|
config->setDouble("autoSaveInterval", 0.5);
|
|
config->setInt("maxAutoSaves", 3);
|
|
|
|
storage.setConfiguration(*config, &mockIO, nullptr);
|
|
|
|
auto input = std::make_unique<grove::JsonDataNode>("input");
|
|
|
|
// Trigger 5 auto-saves
|
|
for (int i = 0; i < 5; i++) {
|
|
input->setDouble("deltaTime", 0.6); // Exceed interval
|
|
storage.process(*input);
|
|
mockIO.clearPublished();
|
|
|
|
// Small delay to ensure different timestamps
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
|
}
|
|
|
|
// Count auto-save files
|
|
int autoSaveCount = 0;
|
|
for (const auto& entry : fs::directory_iterator("data/saves/")) {
|
|
std::string filename = entry.path().filename().string();
|
|
if (filename.find("autosave") == 0) {
|
|
autoSaveCount++;
|
|
}
|
|
}
|
|
|
|
// Should have exactly 3 (oldest were deleted)
|
|
assert(autoSaveCount == 3);
|
|
std::cout << "✓ Auto-save rotation working (kept 3 newest)" << std::endl;
|
|
|
|
std::cout << "✓ Test 5 PASSED\n" << std::endl;
|
|
}
|
|
|
|
/**
|
|
* Test 6: State Preservation During Hot-Reload
|
|
*/
|
|
void testHotReloadState() {
|
|
std::cout << "\n=== Test 6: Hot-reload State Preservation ===" << std::endl;
|
|
|
|
MockIO mockIO;
|
|
StorageModule storage;
|
|
|
|
auto config = std::make_unique<grove::JsonDataNode>("config");
|
|
config->setString("savePath", "data/saves/");
|
|
config->setDouble("autoSaveInterval", 100.0);
|
|
config->setInt("maxAutoSaves", 3);
|
|
|
|
storage.setConfiguration(*config, &mockIO, nullptr);
|
|
|
|
// Simulate some time passing
|
|
auto input = std::make_unique<grove::JsonDataNode>("input");
|
|
input->setDouble("deltaTime", 50.0);
|
|
storage.process(*input);
|
|
|
|
// Get state
|
|
auto state = storage.getState();
|
|
double savedTime = state->getDouble("timeSinceLastAutoSave", 0.0);
|
|
assert(savedTime == 50.0);
|
|
std::cout << "✓ State captured: timeSinceLastAutoSave = " << savedTime << std::endl;
|
|
|
|
// Simulate module reload
|
|
StorageModule newStorage;
|
|
newStorage.setConfiguration(*config, &mockIO, nullptr);
|
|
newStorage.setState(*state);
|
|
|
|
// Verify state was restored
|
|
auto restoredState = newStorage.getState();
|
|
double restoredTime = restoredState->getDouble("timeSinceLastAutoSave", 0.0);
|
|
assert(restoredTime == 50.0);
|
|
std::cout << "✓ State restored: timeSinceLastAutoSave = " << restoredTime << std::endl;
|
|
|
|
std::cout << "✓ Test 6 PASSED\n" << std::endl;
|
|
}
|
|
|
|
/**
|
|
* Main test runner
|
|
*/
|
|
int main() {
|
|
std::cout << "========================================" << std::endl;
|
|
std::cout << "StorageModule Independent Validation Tests" << std::endl;
|
|
std::cout << "========================================" << std::endl;
|
|
|
|
try {
|
|
testSaveLoadCycle();
|
|
testAutoSave();
|
|
testInvalidSaveFile();
|
|
testVersionCompatibility();
|
|
testAutoSaveRotation();
|
|
testHotReloadState();
|
|
|
|
std::cout << "\n========================================" << std::endl;
|
|
std::cout << "ALL TESTS PASSED ✓" << std::endl;
|
|
std::cout << "========================================\n" << std::endl;
|
|
|
|
cleanupTestSaves();
|
|
return 0;
|
|
}
|
|
catch (const std::exception& e) {
|
|
std::cerr << "\n✗ TEST FAILED: " << e.what() << std::endl;
|
|
cleanupTestSaves();
|
|
return 1;
|
|
}
|
|
}
|