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>
345 lines
13 KiB
C++
345 lines
13 KiB
C++
#include <gtest/gtest.h>
|
|
#include "../src/modules/mc_specific/TrainBuilderModule.h"
|
|
#include <grove/JsonDataNode.h>
|
|
#include <grove/IntraIO.h>
|
|
#include <grove/IntraIOManager.h>
|
|
#include <memory>
|
|
|
|
/**
|
|
* TrainBuilderModule Tests
|
|
*
|
|
* These tests validate the TrainBuilderModule implementation independently
|
|
* without requiring the full game engine to be running.
|
|
*
|
|
* Test Coverage:
|
|
* 1. Configuration loading (3 wagons)
|
|
* 2. Balance calculation (lateral + longitudinal)
|
|
* 3. Performance malus (speed + fuel)
|
|
* 4. Cargo weight updates balance
|
|
* 5. Hot-reload state preservation
|
|
* 6. Damage updates wagon health
|
|
*/
|
|
|
|
class TrainBuilderModuleTest : public ::testing::Test {
|
|
protected:
|
|
void SetUp() override {
|
|
// Create test configuration with 3 wagons
|
|
config = std::make_unique<grove::JsonDataNode>("config");
|
|
|
|
// Wagons node
|
|
auto wagonsNode = std::make_unique<grove::JsonDataNode>("wagons");
|
|
|
|
// Locomotive (centered, front)
|
|
auto locoNode = std::make_unique<grove::JsonDataNode>("locomotive");
|
|
locoNode->setString("type", "locomotive");
|
|
locoNode->setDouble("health", 100.0);
|
|
locoNode->setDouble("armor", 50.0);
|
|
locoNode->setDouble("weight", 20000.0);
|
|
locoNode->setDouble("capacity", 0.0);
|
|
auto locoPos = std::make_unique<grove::JsonDataNode>("position");
|
|
locoPos->setDouble("x", 0.0);
|
|
locoPos->setDouble("y", 0.0);
|
|
locoPos->setDouble("z", 0.0);
|
|
locoNode->setChild("position", std::move(locoPos));
|
|
wagonsNode->setChild("locomotive", std::move(locoNode));
|
|
|
|
// Cargo wagon (left side)
|
|
auto cargoNode = std::make_unique<grove::JsonDataNode>("cargo_1");
|
|
cargoNode->setString("type", "cargo");
|
|
cargoNode->setDouble("health", 80.0);
|
|
cargoNode->setDouble("armor", 30.0);
|
|
cargoNode->setDouble("weight", 5000.0);
|
|
cargoNode->setDouble("capacity", 10000.0);
|
|
auto cargoPos = std::make_unique<grove::JsonDataNode>("position");
|
|
cargoPos->setDouble("x", -5.0);
|
|
cargoPos->setDouble("y", 0.0);
|
|
cargoPos->setDouble("z", 0.0);
|
|
cargoNode->setChild("position", std::move(cargoPos));
|
|
wagonsNode->setChild("cargo_1", std::move(cargoNode));
|
|
|
|
// Workshop wagon (right side)
|
|
auto workshopNode = std::make_unique<grove::JsonDataNode>("workshop_1");
|
|
workshopNode->setString("type", "workshop");
|
|
workshopNode->setDouble("health", 70.0);
|
|
workshopNode->setDouble("armor", 20.0);
|
|
workshopNode->setDouble("weight", 8000.0);
|
|
workshopNode->setDouble("capacity", 5000.0);
|
|
auto workshopPos = std::make_unique<grove::JsonDataNode>("position");
|
|
workshopPos->setDouble("x", 5.0);
|
|
workshopPos->setDouble("y", 0.0);
|
|
workshopPos->setDouble("z", 0.0);
|
|
workshopNode->setChild("position", std::move(workshopPos));
|
|
wagonsNode->setChild("workshop_1", std::move(workshopNode));
|
|
|
|
config->setChild("wagons", std::move(wagonsNode));
|
|
|
|
// Balance thresholds
|
|
auto thresholdsNode = std::make_unique<grove::JsonDataNode>("balanceThresholds");
|
|
thresholdsNode->setDouble("lateral_warning", 0.2);
|
|
thresholdsNode->setDouble("longitudinal_warning", 0.3);
|
|
config->setChild("balanceThresholds", std::move(thresholdsNode));
|
|
|
|
// Create IIO instance
|
|
io = grove::createIntraIOInstance("test_module");
|
|
|
|
// Create module
|
|
module = std::make_unique<mc::TrainBuilderModule>();
|
|
}
|
|
|
|
void TearDown() override {
|
|
module->shutdown();
|
|
module.reset();
|
|
io.reset();
|
|
config.reset();
|
|
}
|
|
|
|
std::unique_ptr<grove::JsonDataNode> config;
|
|
std::shared_ptr<grove::IntraIO> io;
|
|
std::unique_ptr<mc::TrainBuilderModule> module;
|
|
};
|
|
|
|
// Test 1: 3 wagons load correctly
|
|
TEST_F(TrainBuilderModuleTest, LoadsThreeWagonsFromConfig) {
|
|
module->setConfiguration(*config, io.get(), nullptr);
|
|
|
|
const auto& wagons = module->getWagons();
|
|
ASSERT_EQ(wagons.size(), 3) << "Should load 3 wagons from config";
|
|
|
|
// Verify locomotive
|
|
auto* loco = module->getWagon("locomotive");
|
|
ASSERT_NE(loco, nullptr);
|
|
EXPECT_EQ(loco->type, "locomotive");
|
|
EXPECT_FLOAT_EQ(loco->weight, 20000.0f);
|
|
EXPECT_FLOAT_EQ(loco->health, 100.0f);
|
|
EXPECT_FLOAT_EQ(loco->armor, 50.0f);
|
|
|
|
// Verify cargo
|
|
auto* cargo = module->getWagon("cargo_1");
|
|
ASSERT_NE(cargo, nullptr);
|
|
EXPECT_EQ(cargo->type, "cargo");
|
|
EXPECT_FLOAT_EQ(cargo->weight, 5000.0f);
|
|
EXPECT_FLOAT_EQ(cargo->capacity, 10000.0f);
|
|
|
|
// Verify workshop
|
|
auto* workshop = module->getWagon("workshop_1");
|
|
ASSERT_NE(workshop, nullptr);
|
|
EXPECT_EQ(workshop->type, "workshop");
|
|
EXPECT_FLOAT_EQ(workshop->weight, 8000.0f);
|
|
EXPECT_FLOAT_EQ(workshop->capacity, 5000.0f);
|
|
}
|
|
|
|
// Test 2: Balance calculation correct (lateral + longitudinal)
|
|
TEST_F(TrainBuilderModuleTest, CalculatesBalanceCorrectly) {
|
|
module->setConfiguration(*config, io.get(), nullptr);
|
|
|
|
auto balance = module->getBalance();
|
|
|
|
// Total weight: 20000 (loco) + 5000 (cargo) + 8000 (workshop) = 33000
|
|
// Left weight: 5000 (cargo at x=-5)
|
|
// Right weight: 20000 (loco at x=0) + 8000 (workshop at x=5) = 28000
|
|
// Lateral offset: (28000 - 5000) / 33000 = 23000 / 33000 ≈ 0.697 (right-heavy)
|
|
|
|
// All wagons at z=0, so longitudinal should be 0
|
|
EXPECT_NEAR(balance.longitudinalOffset, 0.0f, 0.01f) << "Longitudinal should be balanced";
|
|
EXPECT_GT(balance.lateralOffset, 0.5f) << "Should be right-heavy";
|
|
EXPECT_LT(balance.lateralOffset, 1.0f) << "Lateral offset should be < 1.0";
|
|
|
|
// Balance score should be non-zero due to lateral imbalance
|
|
EXPECT_GT(balance.balanceScore, 0.0f) << "Balance score should indicate imbalance";
|
|
}
|
|
|
|
// Test 3: Malus applied correctly
|
|
TEST_F(TrainBuilderModuleTest, AppliesPerformanceMalus) {
|
|
module->setConfiguration(*config, io.get(), nullptr);
|
|
|
|
auto balance = module->getBalance();
|
|
|
|
// With imbalance, speed should be reduced
|
|
EXPECT_LT(balance.speedMalus, 1.0f) << "Speed should be reduced due to imbalance";
|
|
EXPECT_GT(balance.speedMalus, 0.5f) << "Speed malus should not be too extreme";
|
|
|
|
// Fuel consumption should be increased
|
|
EXPECT_GT(balance.fuelMalus, 1.0f) << "Fuel consumption should increase with imbalance";
|
|
EXPECT_LT(balance.fuelMalus, 1.5f) << "Fuel malus should not be too extreme";
|
|
|
|
// Relationship: higher balance score = lower speed, higher fuel
|
|
float expectedSpeedMalus = 1.0f - (balance.balanceScore * 0.5f);
|
|
float expectedFuelMalus = 1.0f + (balance.balanceScore * 0.5f);
|
|
|
|
EXPECT_NEAR(balance.speedMalus, expectedSpeedMalus, 0.01f) << "Speed malus formula incorrect";
|
|
EXPECT_NEAR(balance.fuelMalus, expectedFuelMalus, 0.01f) << "Fuel malus formula incorrect";
|
|
}
|
|
|
|
// Test 4: Cargo weight updates balance
|
|
TEST_F(TrainBuilderModuleTest, CargoWeightUpdatesBalance) {
|
|
module->setConfiguration(*config, io.get(), nullptr);
|
|
|
|
// Get initial balance
|
|
auto initialBalance = module->getBalance();
|
|
|
|
// Simulate cargo being added (resource:inventory_changed)
|
|
auto inventoryMsg = std::make_unique<grove::JsonDataNode>("inventory_changed");
|
|
inventoryMsg->setString("resource_id", "scrap_metal");
|
|
inventoryMsg->setInt("total", 5000); // 5000 units = 5000kg at 1kg/unit
|
|
inventoryMsg->setInt("delta", 5000);
|
|
|
|
io->publish("resource:inventory_changed", std::move(inventoryMsg));
|
|
|
|
// Process the message
|
|
auto input = std::make_unique<grove::JsonDataNode>("input");
|
|
input->setDouble("deltaTime", 0.1);
|
|
module->process(*input);
|
|
|
|
// Get new balance
|
|
auto newBalance = module->getBalance();
|
|
|
|
// Balance should have changed due to cargo weight
|
|
EXPECT_NE(initialBalance.balanceScore, newBalance.balanceScore)
|
|
<< "Balance should change when cargo is added";
|
|
|
|
// Cargo wagons should have weight
|
|
auto* cargo = module->getWagon("cargo_1");
|
|
ASSERT_NE(cargo, nullptr);
|
|
EXPECT_GT(cargo->cargoWeight, 0.0f) << "Cargo wagon should have cargo weight";
|
|
}
|
|
|
|
// Test 5: Hot-reload state preservation
|
|
TEST_F(TrainBuilderModuleTest, PreservesStateOnHotReload) {
|
|
module->setConfiguration(*config, io.get(), nullptr);
|
|
|
|
// Simulate some cargo
|
|
auto inventoryMsg = std::make_unique<grove::JsonDataNode>("inventory_changed");
|
|
inventoryMsg->setString("resource_id", "scrap_metal");
|
|
inventoryMsg->setInt("total", 3000);
|
|
inventoryMsg->setInt("delta", 3000);
|
|
io->publish("resource:inventory_changed", std::move(inventoryMsg));
|
|
|
|
auto input = std::make_unique<grove::JsonDataNode>("input");
|
|
input->setDouble("deltaTime", 0.1);
|
|
module->process(*input);
|
|
|
|
// Get state before reload
|
|
auto state = module->getState();
|
|
auto balanceBefore = module->getBalance();
|
|
size_t wagonCountBefore = module->getWagons().size();
|
|
|
|
// Create new module instance (simulating hot-reload)
|
|
auto newModule = std::make_unique<mc::TrainBuilderModule>();
|
|
newModule->setConfiguration(*config, io.get(), nullptr);
|
|
newModule->setState(*state);
|
|
|
|
// Verify state restored
|
|
EXPECT_EQ(newModule->getWagons().size(), wagonCountBefore)
|
|
<< "Wagon count should be preserved";
|
|
|
|
auto balanceAfter = newModule->getBalance();
|
|
EXPECT_NEAR(balanceAfter.balanceScore, balanceBefore.balanceScore, 0.001f)
|
|
<< "Balance score should be preserved";
|
|
EXPECT_NEAR(balanceAfter.lateralOffset, balanceBefore.lateralOffset, 0.001f)
|
|
<< "Lateral offset should be preserved";
|
|
EXPECT_NEAR(balanceAfter.speedMalus, balanceBefore.speedMalus, 0.001f)
|
|
<< "Speed malus should be preserved";
|
|
|
|
newModule->shutdown();
|
|
}
|
|
|
|
// Test 6: Damage updates wagon health
|
|
TEST_F(TrainBuilderModuleTest, DamageUpdatesWagonHealth) {
|
|
module->setConfiguration(*config, io.get(), nullptr);
|
|
|
|
auto* cargo = module->getWagon("cargo_1");
|
|
ASSERT_NE(cargo, nullptr);
|
|
float initialHealth = cargo->health;
|
|
float armor = cargo->armor;
|
|
|
|
// Simulate damage to cargo wagon
|
|
auto damageMsg = std::make_unique<grove::JsonDataNode>("damage_received");
|
|
damageMsg->setString("target", "cargo_1");
|
|
damageMsg->setDouble("damage", 50.0); // 50 damage
|
|
|
|
io->publish("combat:damage_received", std::move(damageMsg));
|
|
|
|
// Process the message
|
|
auto input = std::make_unique<grove::JsonDataNode>("input");
|
|
input->setDouble("deltaTime", 0.1);
|
|
module->process(*input);
|
|
|
|
// Verify health reduced
|
|
// Effective damage = 50 - (30 armor * 0.5) = 50 - 15 = 35
|
|
float expectedHealth = initialHealth - 35.0f;
|
|
EXPECT_NEAR(cargo->health, expectedHealth, 0.1f)
|
|
<< "Wagon health should be reduced by effective damage";
|
|
}
|
|
|
|
// Test 7: Add/Remove wagon operations
|
|
TEST_F(TrainBuilderModuleTest, AddAndRemoveWagons) {
|
|
module->setConfiguration(*config, io.get(), nullptr);
|
|
|
|
ASSERT_EQ(module->getWagons().size(), 3);
|
|
|
|
// Add a new wagon
|
|
mc::Wagon newWagon;
|
|
newWagon.id = "cargo_2";
|
|
newWagon.type = "cargo";
|
|
newWagon.health = 80.0f;
|
|
newWagon.maxHealth = 80.0f;
|
|
newWagon.armor = 30.0f;
|
|
newWagon.weight = 5000.0f;
|
|
newWagon.capacity = 10000.0f;
|
|
newWagon.cargoWeight = 0.0f;
|
|
newWagon.totalWeight = 5000.0f;
|
|
newWagon.position.x = -10.0f;
|
|
newWagon.position.y = 0.0f;
|
|
newWagon.position.z = 0.0f;
|
|
|
|
EXPECT_TRUE(module->addWagon(newWagon));
|
|
EXPECT_EQ(module->getWagons().size(), 4);
|
|
|
|
// Verify wagon added
|
|
auto* addedWagon = module->getWagon("cargo_2");
|
|
ASSERT_NE(addedWagon, nullptr);
|
|
EXPECT_EQ(addedWagon->type, "cargo");
|
|
|
|
// Remove wagon
|
|
EXPECT_TRUE(module->removeWagon("cargo_2"));
|
|
EXPECT_EQ(module->getWagons().size(), 3);
|
|
|
|
// Verify wagon removed
|
|
auto* removedWagon = module->getWagon("cargo_2");
|
|
EXPECT_EQ(removedWagon, nullptr);
|
|
}
|
|
|
|
// Test 8: Capacity tracking
|
|
TEST_F(TrainBuilderModuleTest, TracksCargoCapacity) {
|
|
module->setConfiguration(*config, io.get(), nullptr);
|
|
|
|
// Total capacity: cargo (10000) + workshop (5000) = 15000
|
|
float totalCapacity = module->getTotalCargoCapacity();
|
|
EXPECT_FLOAT_EQ(totalCapacity, 15000.0f);
|
|
|
|
// Initially no cargo
|
|
float usedCapacity = module->getTotalCargoUsed();
|
|
EXPECT_FLOAT_EQ(usedCapacity, 0.0f);
|
|
|
|
// Add cargo
|
|
auto inventoryMsg = std::make_unique<grove::JsonDataNode>("inventory_changed");
|
|
inventoryMsg->setString("resource_id", "scrap_metal");
|
|
inventoryMsg->setInt("total", 2000);
|
|
inventoryMsg->setInt("delta", 2000);
|
|
io->publish("resource:inventory_changed", std::move(inventoryMsg));
|
|
|
|
auto input = std::make_unique<grove::JsonDataNode>("input");
|
|
input->setDouble("deltaTime", 0.1);
|
|
module->process(*input);
|
|
|
|
// Used capacity should increase
|
|
usedCapacity = module->getTotalCargoUsed();
|
|
EXPECT_GT(usedCapacity, 0.0f);
|
|
EXPECT_LE(usedCapacity, totalCapacity);
|
|
}
|
|
|
|
int main(int argc, char** argv) {
|
|
::testing::InitGoogleTest(&argc, argv);
|
|
return RUN_ALL_TESTS();
|
|
}
|