#include "common/ECS.h" #include "common/Identity.h" #include "common/Module.h" #include "common/Runtime.h" #include "rts/RTS.h" #include "rts/RTSAction.h" #include "rts/RTSSystems.h" #include "rts/RTSContent.h" #include "rts/RTSTech.h" #include "rts/RTSMatch.h" #include "rts/RTSEconomy.h" #include "action/Action.h" #include "crowd/Crowd.h" #include "map/Pathfinder.h" #include "map/Fov.h" #include "sensing/Sensing.h" #include "weapon/WeaponSystem.h" #include "weapon/WeaponDefinitionRuntime.h" #include "definitions/Definitions.h" #include "economy/Economy.h" #include "zeroerr/assert.h" #include "zeroerr/unittest.h" #include #include #include #include #include #include #include #include #include namespace { using eve::rts::Building; using eve::rts::CommandSpec; using eve::rts::Faction; using eve::rts::Match; using eve::rts::FormationKind; using eve::rts::FormationSpec; using eve::rts::OrderKind; using eve::rts::Player; using eve::rts::ResourceNode; using eve::rts::Unit; using eve::rts::WorldPosition; eve::SubjectRef subject(const char* text) { const auto id = eve::PersistentId::parse(text); REQUIRE(id.has_value()); return eve::SubjectRef::fromPersistentId(*id); } class Scout final : public Unit { public: ENTITY(Scout, Unit) void release() override { ecs::DestroyEntity(this); } }; TEST_CASE("rts.sandboxScriptCompilesThroughEveScriptFrontend") { const std::filesystem::path sourceRoot = std::filesystem::path(__FILE__).parent_path().parent_path(); const std::filesystem::path scriptPath = sourceRoot / "examples" / "rts-sandbox" / "main.nut"; std::ifstream input(scriptPath, std::ios::binary); REQUIRE(input.is_open()); std::ostringstream source; source << input.rdbuf(); // The full engine module surface plus the comprehensive RTS sandbox needs // the same enlarged VM stack used by the game runner. eve::Runtime runtime(8192, ssq::Libs::ALL); bool compiled = true; try { runtime.compileSource(source.str(), "examples/rts-sandbox/main.nut"); } catch (const std::exception& error) { std::cerr << error.what() << '\n'; compiled = false; } catch (...) { compiled = false; } REQUIRE(compiled); } TEST_CASE("rts.scriptFogQueriesResolveCreatedFactionIdentity") { ecs::Table world; ecs::ScopedTable guard(world); ssq::VM vm(1024, ssq::Libs::ALL); eve::ModuleManager::expose(vm); vm.run(vm.compileSource(R"( result <- "fail"; local sim = eve.RTS(); local configured = sim.configureScriptWorld(4, 4, 1.0, 0.0, 0.0); local created = sim.newFaction("00000000-0000-7000-8000-00000000fc01"); if (configured.ok && created.ok) { local explored = sim.scriptCellExplored(created.value, 0, 0); local visible = sim.scriptCellVisible(created.value, 0, 0); if (explored.ok && visible.ok) result = "ok"; } )")); CHECK_EQ(vm.find("result").toString(), std::string("ok")); } TEST_CASE("rts.scriptFacadeKeepsFactionIdentityAcrossFrameCallbacks") { ecs::Table world; ecs::ScopedTable guard(world); ssq::VM vm(1024, ssq::Libs::ALL); eve::ModuleManager::expose(vm); vm.run(vm.compileSource(R"( persist sim = null; persist faction = null; result <- "fail"; function init() { sim = eve.RTS(); local configured = sim.configureScriptWorld(4, 4, 1.0, 0.0, 0.0); local created = sim.newFaction("00000000-0000-7000-8000-00000000fc02"); if (configured.ok && created.ok) faction = created.value; } function update() { local stepped = sim.stepScript(1.0 / 30.0); if (!stepped.ok) result = stepped.status.summary; } function render() { local explored = sim.scriptCellExplored(faction, 0, 0); result = explored.ok ? "ok" : explored.status.summary; } )")); vm.callFunc(vm.findFunc("init"), vm); vm.callFunc(vm.findFunc("update"), vm); vm.callFunc(vm.findFunc("render"), vm); CHECK_EQ(vm.find("result").toString(), std::string("ok")); } TEST_CASE("rts.sandboxInitializesStepsAndRebuildsAcrossReload") { const std::filesystem::path sourceRoot = std::filesystem::path(__FILE__).parent_path().parent_path(); auto readAll = [](const std::filesystem::path& path) { std::ifstream input(path, std::ios::binary); std::ostringstream content; content << input.rdbuf(); return content.str(); }; const auto sandboxRoot = sourceRoot / "examples" / "rts-sandbox"; const std::string source = readAll(sandboxRoot / "main.nut"); const std::string content = readAll(sandboxRoot / "data" / "content.json"); ecs::Table world; ecs::ScopedTable guard(world); ssq::VM vm(8192, ssq::Libs::ALL); eve::ModuleManager::expose(vm); vm.run(vm.compileSource("eve_init <- null; eve_update <- null; eve_render <- null;")); vm.run(vm.compileSource(source.c_str())); vm.set("sandboxContent", content); vm.run(vm.compileSource(R"( readTextFile = function(path) { return sandboxContent; }; result <- "fail"; stepSandbox <- function() { local stepped = sim.stepScript(1.0 / 30.0); if (!stepped.ok) throw stepped.status.summary; }; )")); vm.callFunc(vm.findFunc("resetGame"), vm); const auto stepSandbox = vm.findFunc("stepSandbox"); for (int frame = 0; frame < 180; ++frame) vm.callFunc(stepSandbox, vm); vm.run(vm.compileSource(R"( local explored = sim.scriptCellExplored(RTS_SANDBOX_BLUE_FACTION_ID, 0, 0); result = explored.ok ? "ok" : explored.status.summary; )")); CHECK_EQ(vm.find("result").toString(), std::string("ok")); // The game runner can reload main.nut while persisted script state survives. // The native RTS facade must not survive that boundary because its ECS roots // belong to the discarded script generation. vm.run(vm.compileSource(source.c_str())); vm.run(vm.compileSource(R"( if (sim != null) throw "RTS facade survived script reload"; )")); } TEST_CASE("rts.legacySandboxContentMaterializesEveryArchetype") { const std::filesystem::path sourceRoot = std::filesystem::path(__FILE__).parent_path().parent_path(); std::ifstream input(sourceRoot / "examples" / "rts-sandbox" / "data" / "content.json", std::ios::binary); REQUIRE(input.is_open()); std::ostringstream json; json << input.rdbuf(); ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS rts; REQUIRE(rts.configureScriptWorld(36, 22, 1.0f).ok()); const auto loaded = rts.loadScriptContent(json.str()); REQUIRE(loaded.ok()); CHECK_EQ(loaded.value().inserted, std::size_t{18}); auto* faction = rts.newFaction(subject("00000000-0000-7000-8000-00000000fa01")).value(); const std::array unitTypes{ "worker", "engineer", "marine", "apc", "jammer", "skyguard", "drone"}; for (std::size_t index = 0; index < unitTypes.size(); ++index) { const auto id = eve::LogicalId::parse(std::string("unit:") + unitTypes[index]); REQUIRE(id.has_value()); const std::string suffix = std::to_string(100 + index); const auto persistent = eve::PersistentId::parse( "00000000-0000-7000-8000-000000000" + suffix); REQUIRE(persistent.has_value()); auto created = rts.newFactionUnit(*faction, eve::SubjectRef::fromPersistentId(*persistent), *id); REQUIRE(created.ok()); Unit* unit = created.value(); CHECK(unit->durability()->state.maxHealth > 0.0); if (std::string_view(unitTypes[index]) == "marine") { CHECK_EQ(unit->morale()->capacity, 100.0f); CHECK(unit->morale()->retreatEnabled); CHECK_EQ(unit->veterancy()->eliteThreshold, 250.0f); CHECK(unit->command()->requiresCommand); CHECK_EQ(unit->supply()->capacity, 36.0f); CHECK(unit->weapon()->link.resolve() != nullptr); } else if (std::string_view(unitTypes[index]) == "apc") { CHECK_EQ(unit->containment()->capacity, std::size_t{4}); CHECK_EQ(unit->supply()->capacity, 30.0f); CHECK(unit->supply()->autoDispatch); CHECK_EQ(unit->command()->capacity, 6); CHECK_EQ(unit->morale()->auraRange, 5.0f); } else if (std::string_view(unitTypes[index]) == "drone") { CHECK(unit->motion()->airborne); CHECK_EQ(unit->shield()->capacity, 30.0f); } } const std::array buildingTypes{ "command_center", "barracks", "supply_depot", "turret", "oil_derrick"}; for (std::size_t index = 0; index < buildingTypes.size(); ++index) { const auto id = eve::LogicalId::parse(std::string("building:") + buildingTypes[index]); REQUIRE(id.has_value()); const std::string suffix = std::to_string(200 + index); const auto persistent = eve::PersistentId::parse( "00000000-0000-7000-8000-000000000" + suffix); REQUIRE(persistent.has_value()); auto created = rts.newFactionBuilding(*faction, eve::SubjectRef::fromPersistentId(*persistent), *id); REQUIRE(created.ok()); Building* building = created.value(); CHECK(building->integrity()->state.maxHealth > 0.0); if (std::string_view(buildingTypes[index]) == "command_center") { CHECK_EQ(building->shield()->capacity, 100.0f); CHECK_EQ(building->infrastructure()->powerProduced, 20.0f); CHECK_EQ(building->command()->capacity, 12); CHECK(!building->dropoff()->acceptedResources.empty()); } else if (std::string_view(buildingTypes[index]) == "supply_depot") { CHECK_EQ(building->supply()->capacity, 100.0f); CHECK_EQ(building->supply()->productionRate, 2.0f); } else if (std::string_view(buildingTypes[index]) == "turret") { CHECK_EQ(building->garrison()->capacity, std::size_t{3}); CHECK_EQ(building->garrison()->damageBonusPerOccupant, 0.2f); CHECK(building->weapon()->link.resolve() != nullptr); CHECK_EQ(building->combat()->turnRateDegrees, 120.0f); } } } TEST_CASE("rts.scriptProductionUsesCanonicalEconomyActionAndProductionQueues") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS rts; REQUIRE(rts.configureScriptWorld(12, 12, 1.0f).ok()); REQUIRE(rts.loadScriptContent(R"({ "units":[{"id":"marine","producer":"barracks","costResource":"minerals", "cost":50,"buildTime":1.5,"health":80,"speed":3,"radius":0.3}], "buildings":[{"id":"barracks","health":500,"buildTime":2}] })").ok()); auto* faction = rts.newFaction(subject("00000000-0000-7000-8000-00000000fb01")).value(); REQUIRE(rts.addScriptResource(*faction, "minerals", 100).ok()); const auto barracksId = eve::LogicalId::parse("building:barracks"); const auto marineId = eve::LogicalId::parse("unit:marine"); REQUIRE(barracksId.has_value()); REQUIRE(marineId.has_value()); auto* barracks = rts.newFactionBuilding( *faction, subject("00000000-0000-7000-8000-00000000fb02"), *barracksId).value(); const auto marineSubject = subject("00000000-0000-7000-8000-00000000fb03"); auto queued = rts.queueScriptUnit(*barracks, marineSubject, *marineId); REQUIRE(queued.ok()); CHECK_EQ(rts.scriptResource(*faction, "minerals").value(), 50); CHECK(rts.findUnit(marineSubject) == nullptr); for (int index = 0; index < 4; ++index) { auto stepped = rts.stepScript(0.5); REQUIRE(stepped.ok()); } Unit* marine = rts.findUnit(marineSubject); REQUIRE(marine != nullptr); CHECK_EQ(marine->definition()->id, *marineId); CHECK_EQ(marine->faction()->link.resolve(), faction); CHECK_EQ(marine->motion()->x, barracks->placement()->worldX); } TEST_CASE("rts.scriptConstructionAtomicallyPaysBuildsAndCompletesCanonicalOrder") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS rts; REQUIRE(rts.configureScriptWorld(12, 12, 1.0f).ok()); REQUIRE(rts.loadScriptContent(R"({ "units":[{"id":"worker","role":"worker","health":60,"speed":3,"radius":0.3, "buildRate":1}], "buildings":[{"id":"barracks","costResource":"minerals","cost":150, "buildTime":2,"health":500}] })").ok()); auto* faction = rts.newFaction(subject("00000000-0000-7000-8000-00000000fc01")).value(); auto workerId = eve::LogicalId::parse("unit:worker"); auto barracksId = eve::LogicalId::parse("building:barracks"); REQUIRE(workerId.has_value()); REQUIRE(barracksId.has_value()); Unit* builder = rts.newFactionUnit( *faction, subject("00000000-0000-7000-8000-00000000fc02"), *workerId).value(); REQUIRE(rts.addScriptResource(*faction, "minerals", 200).ok()); const auto buildingSubject = subject("00000000-0000-7000-8000-00000000fc03"); auto started = rts.startScriptConstruction(*faction, buildingSubject, *barracksId, {4.0f, 5.0f}, *builder); REQUIRE(started.ok()); Building* barracks = started.value(); CHECK_EQ(rts.scriptResource(*faction, "minerals").value(), 50); CHECK_EQ(barracks->construction()->progress, 0.0f); auto order = builder->orders()->values.current(); REQUIRE(order.ok()); CHECK_EQ(static_cast(order.value().kind), static_cast(OrderKind::Move)); for (int index = 0; index < 8 && barracks->construction()->progress < 1.0f; ++index) REQUIRE(rts.stepScript(1.0).ok()); CHECK_EQ(barracks->construction()->progress, 1.0f); CHECK(builder->orders()->values.empty()); const std::size_t before = rts.buildingCount(); auto rejected = rts.startScriptConstruction( *faction, subject("00000000-0000-7000-8000-00000000fc04"), *barracksId, {6.0f, 5.0f}, *builder); CHECK(!rejected.ok()); CHECK_EQ(rts.buildingCount(), before); CHECK_EQ(rts.scriptResource(*faction, "minerals").value(), 50); } TEST_CASE("rts.scriptConstructionCancellationRefundsProgressAndRestoresThroughCheckpoint") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS rts; REQUIRE(rts.configureScriptWorld(12, 12, 1.0f).ok()); REQUIRE(rts.loadScriptContent(R"({ "units":[{"id":"worker","role":"worker","health":60,"speed":3,"radius":0.3, "buildRate":1}], "buildings":[{"id":"depot","costResource":"minerals","cost":100, "buildTime":4,"health":400}] })").ok()); const auto factionSubject = subject("00000000-0000-7000-8000-00000000fc11"); const auto builderSubject = subject("00000000-0000-7000-8000-00000000fc12"); const auto buildingSubject = subject("00000000-0000-7000-8000-00000000fc13"); auto* faction = rts.newFaction(factionSubject).value(); const auto workerId = eve::LogicalId::parse("unit:worker"); const auto depotId = eve::LogicalId::parse("building:depot"); REQUIRE(workerId.has_value()); REQUIRE(depotId.has_value()); auto* builder = rts.newFactionUnit(*faction, builderSubject, *workerId).value(); REQUIRE(rts.addScriptResource(*faction, "minerals", 200).ok()); auto* building = rts.startScriptConstruction( *faction, buildingSubject, *depotId, {5.0f, 5.0f}, *builder).value(); building->construction()->progress = 0.5f; REQUIRE(rts.captureScriptCheckpoint("half-built").ok()); auto cancelled = rts.cancelScriptConstruction(*building); REQUIRE(cancelled.ok()); CHECK_EQ(rts.scriptResource(*faction, "minerals").value(), 175); CHECK(rts.findBuilding(buildingSubject) == nullptr); CHECK(builder->orders()->values.empty()); REQUIRE(rts.restoreScriptCheckpoint("half-built").ok()); faction = rts.findFaction(factionSubject); builder = rts.findUnit(builderSubject); building = rts.findBuilding(buildingSubject); REQUIRE(faction != nullptr); REQUIRE(builder != nullptr); REQUIRE(building != nullptr); CHECK_EQ(rts.scriptResource(*faction, "minerals").value(), 100); REQUIRE(rts.cancelScriptConstruction(*building).ok()); CHECK_EQ(rts.scriptResource(*faction, "minerals").value(), 175); CHECK(rts.findBuilding(buildingSubject) == nullptr); } TEST_CASE("rts.scriptBuildingSaleRefundsQueuesEvacuatesGarrisonAndRestoresThroughCheckpoint") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS rts; REQUIRE(rts.configureScriptWorld(12, 12, 1.0f).ok()); REQUIRE(rts.loadScriptContent(R"({ "units":[{"id":"marine","producer":"barracks","costResource":"minerals", "cost":50,"buildTime":2,"health":80,"speed":3,"radius":0.3}], "buildings":[{"id":"barracks","costResource":"minerals","cost":100, "sellRefundRatio":0.5,"health":500,"buildTime":2}], "upgrades":[{"id":"weapons","producer":"barracks","targetUnit":"marine", "costResource":"minerals","cost":100,"researchTime":3, "attackMultiplier":1.2}] })").ok()); const auto factionSubject = subject("00000000-0000-7000-8000-00000000fc21"); const auto buildingSubject = subject("00000000-0000-7000-8000-00000000fc22"); const auto occupantSubject = subject("00000000-0000-7000-8000-00000000fc23"); const auto queuedSubject = subject("00000000-0000-7000-8000-00000000fc24"); const auto barracksId = eve::LogicalId::parse("building:barracks"); const auto marineId = eve::LogicalId::parse("unit:marine"); REQUIRE(barracksId.has_value()); REQUIRE(marineId.has_value()); auto* faction = rts.newFaction(factionSubject).value(); auto* barracks = rts.newFactionBuilding(*faction, buildingSubject, *barracksId).value(); auto* occupant = rts.newFactionUnit(*faction, occupantSubject, *marineId).value(); barracks->garrison()->capacity = 1; auto container = eve::rts::ContainerLink::bind(ecs::handle_of(barracks)); REQUIRE(container.ok()); occupant->containment()->container = std::move(container).takeValue(); barracks->garrison()->occupants.push_back(ecs::handle_of(occupant)); REQUIRE(rts.addScriptResource(*faction, "minerals", 500).ok()); REQUIRE(rts.queueScriptUnit(*barracks, queuedSubject, *marineId).ok()); REQUIRE(rts.queueScriptResearch(*barracks, "weapons").ok()); CHECK_EQ(rts.scriptResource(*faction, "minerals").value(), 350); REQUIRE(rts.captureScriptCheckpoint("before-sale").ok()); REQUIRE(rts.sellScriptBuilding(*barracks).ok()); CHECK_EQ(rts.scriptResource(*faction, "minerals").value(), 550); CHECK(rts.findBuilding(buildingSubject) == nullptr); CHECK(rts.findUnit(queuedSubject) == nullptr); CHECK(!occupant->containment()->container.isBound()); REQUIRE(rts.restoreScriptCheckpoint("before-sale").ok()); faction = rts.findFaction(factionSubject); barracks = rts.findBuilding(buildingSubject); occupant = rts.findUnit(occupantSubject); REQUIRE(faction != nullptr); REQUIRE(barracks != nullptr); REQUIRE(occupant != nullptr); CHECK_EQ(rts.scriptResource(*faction, "minerals").value(), 350); REQUIRE(rts.sellScriptBuilding(*barracks).ok()); CHECK_EQ(rts.scriptResource(*faction, "minerals").value(), 550); CHECK(!occupant->containment()->container.isBound()); } TEST_CASE("rts.scriptReinforcementFacadeAndReplayResolveFallbackThroughPaidProduction") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS rts; REQUIRE(rts.configureScriptWorld(12, 8, 1.0f).ok()); REQUIRE(rts.loadScriptContent(R"({ "units":[ {"id":"tank","producer":"factory","costResource":"minerals","cost":100, "buildTime":2,"health":200,"speed":2,"radius":0.6}, {"id":"marine","producer":"barracks","costResource":"minerals","cost":50, "buildTime":1,"health":80,"speed":3,"radius":0.3}], "buildings":[{"id":"barracks","health":500,"buildTime":2}, {"id":"factory","health":700,"buildTime":3}] })").ok()); auto* faction = rts.newFaction(subject("00000000-0000-7000-8000-00000000fc31")).value(); const auto barracksId = eve::LogicalId::parse("building:barracks"); const auto tankId = eve::LogicalId::parse("unit:tank"); REQUIRE(barracksId.has_value()); REQUIRE(tankId.has_value()); auto* barracks = rts.newFactionBuilding( *faction, subject("00000000-0000-7000-8000-00000000fc32"), *barracksId).value(); CommandSpec rally; rally.kind = OrderKind::AttackMove; rally.target = {8.0f, 4.0f}; REQUIRE(rts.setBuildingRally(*barracks, rally, true).ok()); REQUIRE(rts.setReinforcementFallback(*barracks, "tank", "marine").ok()); REQUIRE(rts.addScriptResource(*faction, "minerals", 150).ok()); const auto directSubject = subject("00000000-0000-7000-8000-00000000fc33"); auto direct = rts.queueScriptReinforcement(*barracks, directSubject, *tankId); REQUIRE(direct.ok()); CHECK_EQ(direct.value().requestedProduct, "tank"); CHECK_EQ(direct.value().queuedProduct, "marine"); CHECK_EQ(rts.scriptResource(*faction, "minerals").value(), 100); const auto replaySubject = subject("00000000-0000-7000-8000-00000000fc34"); eve::rts::RTSReplayCommand replay; replay.tick = eve::SimulationTick{1}; replay.operation = eve::rts::RTSReplayOperation::ReinforcementProduction; replay.producer = barracks->identity()->subject; replay.resultSubject = replaySubject; replay.definition = *tankId; REQUIRE(rts.queueScriptCommand(replay).ok()); auto exported = rts.exportScriptCommandLog(); REQUIRE(exported.ok()); REQUIRE(rts.importScriptCommandLog(exported.value(), true).ok()); REQUIRE(rts.stepScript(1.0).ok()); CHECK_EQ(rts.scriptResource(*faction, "minerals").value(), 50); REQUIRE(rts.stepScript(1.0).ok()); CHECK(rts.findUnit(directSubject) != nullptr); CHECK(rts.findUnit(replaySubject) != nullptr); } TEST_CASE("rts.scriptAIReplenishesWorkersBuildsArmyAndRestoresDeterministicIdentitySequence") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS rts; REQUIRE(rts.configureScriptWorld(16, 8, 1.0f).ok()); REQUIRE(rts.loadScriptContent(R"({ "units":[ {"id":"worker","role":"worker","producer":"barracks","costResource":"minerals", "cost":25,"buildTime":0.5,"health":60,"speed":3,"radius":0.3}, {"id":"marine","producer":"barracks","costResource":"minerals","cost":50, "buildTime":0.5,"health":80,"speed":3,"radius":0.3}], "buildings":[ {"id":"barracks","health":500,"buildTime":2}, {"id":"headquarters","health":900,"buildTime":3}] })").ok()); const auto aiSubject = subject("00000000-0000-7000-8000-00000000fc41"); auto* ai = rts.newFaction(aiSubject).value(); auto* enemy = rts.newFaction(subject("00000000-0000-7000-8000-00000000fc42")).value(); const auto workerId = eve::LogicalId::parse("unit:worker"); const auto marineId = eve::LogicalId::parse("unit:marine"); const auto barracksId = eve::LogicalId::parse("building:barracks"); const auto headquartersId = eve::LogicalId::parse("building:headquarters"); REQUIRE(workerId.has_value()); REQUIRE(marineId.has_value()); REQUIRE(barracksId.has_value()); REQUIRE(headquartersId.has_value()); auto* barracks = rts.newFactionBuilding( *ai, subject("00000000-0000-7000-8000-00000000fc43"), *barracksId).value(); barracks->placement()->worldX = 2.0f; barracks->placement()->worldY = 3.0f; auto* headquarters = rts.newFactionBuilding( *enemy, subject("00000000-0000-7000-8000-00000000fc44"), *headquartersId).value(); headquarters->placement()->worldX = 12.0f; headquarters->placement()->worldY = 3.0f; REQUIRE(rts.addScriptResource(*ai, "minerals", 300).ok()); REQUIRE(rts.configureScriptAI( *ai, *workerId, *marineId, *headquartersId, 1, 2, 0.5f, 1.25f, true).ok()); REQUIRE(rts.captureScriptCheckpoint("before-ai").ok()); for (int tick = 0; tick < 5; ++tick) { auto stepped = rts.stepScript(0.5); REQUIRE(stepped.ok()); } CHECK_EQ(rts.unitCount(), std::size_t{5}); CHECK_EQ(rts.scriptResource(*ai, "minerals").value(), 75); auto firstState = rts.canonicalStateJson(); REQUIRE(firstState.ok()); REQUIRE(rts.restoreScriptCheckpoint("before-ai").ok()); ai = rts.findFaction(aiSubject); REQUIRE(ai != nullptr); for (int tick = 0; tick < 5; ++tick) { auto stepped = rts.stepScript(0.5); REQUIRE(stepped.ok()); } CHECK_EQ(rts.unitCount(), std::size_t{5}); CHECK_EQ(rts.scriptResource(*ai, "minerals").value(), 75); auto secondState = rts.canonicalStateJson(); REQUIRE(secondState.ok()); CHECK_EQ(secondState.value(), firstState.value()); } TEST_CASE("rts.scriptResearchPaysOnceAndUpgradesExistingAndFutureUnits") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS rts; REQUIRE(rts.configureScriptWorld(12, 12, 1.0f).ok()); REQUIRE(rts.loadScriptContent(R"({ "units":[{"id":"marine","producer":"barracks","costResource":"minerals","cost":50, "buildTime":1,"health":80,"speed":3,"radius":0.3}], "buildings":[{"id":"barracks","health":500,"buildTime":2}], "upgrades":[{"id":"infantry_weapons_1","producer":"barracks","targetUnit":"marine", "costResource":"minerals","cost":100,"researchTime":2,"attackMultiplier":1.25}] })").ok()); auto* faction = rts.newFaction(subject("00000000-0000-7000-8000-00000000fd01")).value(); auto marineId = eve::LogicalId::parse("unit:marine"); auto barracksId = eve::LogicalId::parse("building:barracks"); REQUIRE(marineId.has_value()); REQUIRE(barracksId.has_value()); Unit* marine = rts.newFactionUnit( *faction, subject("00000000-0000-7000-8000-00000000fd02"), *marineId).value(); Building* barracks = rts.newFactionBuilding( *faction, subject("00000000-0000-7000-8000-00000000fd03"), *barracksId).value(); REQUIRE(rts.addScriptResource(*faction, "minerals", 150).ok()); auto queued = rts.queueScriptResearch(*barracks, "infantry_weapons_1"); REQUIRE(queued.ok()); CHECK_EQ(rts.scriptResource(*faction, "minerals").value(), 50); CHECK(!rts.queueScriptResearch(*barracks, "infantry_weapons_1").ok()); CHECK_EQ(rts.scriptResource(*faction, "minerals").value(), 50); REQUIRE(rts.stepScript(1.0).ok()); REQUIRE(rts.stepScript(1.0).ok()); CHECK_EQ(marine->combat()->upgradeDamageFactor, 1.25f); CHECK(std::binary_search(faction->technology()->unlocked.begin(), faction->technology()->unlocked.end(), "infantry_weapons_1")); CHECK(!rts.queueScriptResearch(*barracks, "infantry_weapons_1").ok()); Unit* reinforcement = rts.newFactionUnit( *faction, subject("00000000-0000-7000-8000-00000000fd04"), *marineId).value(); CHECK_EQ(reinforcement->combat()->upgradeDamageFactor, 1.0f); REQUIRE(rts.stepScript(0.1).ok()); CHECK_EQ(reinforcement->combat()->upgradeDamageFactor, 1.25f); } TEST_CASE("rts.scriptAbilityLoadsCanonicalDefinitionDebitsAndSettlesChannelDamage") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS rts; REQUIRE(rts.configureScriptWorld(12, 12, 1.0f).ok()); REQUIRE(rts.loadScriptContent(R"({ "units":[{"id":"marine","health":80,"speed":3,"radius":0.3}], "abilities":[{"id":"frag_grenade","casterUnit":"marine","targetType":"point", "range":6,"radius":1.8,"cooldown":6,"damage":28,"damageType":"normal", "castTime":0.5,"interruptOnDamage":true,"resourceType":"energy","resourceCost":10}] })").ok()); auto* blue = rts.newFaction(subject("00000000-0000-7000-8000-00000000fe01")).value(); auto* red = rts.newFaction(subject("00000000-0000-7000-8000-00000000fe02")).value(); auto marineId = eve::LogicalId::parse("unit:marine"); REQUIRE(marineId.has_value()); Unit* caster = rts.newFactionUnit( *blue, subject("00000000-0000-7000-8000-00000000fe03"), *marineId).value(); Unit* target = rts.newFactionUnit( *red, subject("00000000-0000-7000-8000-00000000fe04"), *marineId).value(); caster->motion()->x = 1.0f; caster->motion()->y = 1.0f; target->motion()->x = 4.0f; target->motion()->y = 1.0f; REQUIRE(rts.addScriptResource(*blue, "energy", 20).ok()); REQUIRE(rts.castScriptAbility(*caster, "frag_grenade", {}, {4.0f, 1.0f}).ok()); CHECK_EQ(rts.scriptResource(*blue, "energy").value(), 10); CHECK_EQ(target->durability()->state.health, 80.0); CHECK(!rts.castScriptAbility(*caster, "frag_grenade", {}, {4.0f, 1.0f}).ok()); CHECK_EQ(rts.scriptResource(*blue, "energy").value(), 10); REQUIRE(rts.stepScript(0.5).ok()); CHECK_EQ(target->durability()->state.health, 52.0); const auto damageProjection = rts.inspectFrameEvents(); const auto* damageEvents = damageProjection.getIf(); REQUIRE(damageEvents != nullptr); REQUIRE_EQ(damageEvents->size(), 3); const auto* damageEvent = (*damageEvents)[0].getIf(); REQUIRE(damageEvent != nullptr); CHECK_EQ(*damageEvent->at("channel").getIf(), "ability"); CHECK_EQ(*damageEvent->at("damageType").getIf(), "normal"); CHECK_EQ(*damageEvent->at("appliedHealthDamage").getIf(), 28.0); CHECK_EQ(*damageEvent->at("target").getIf(), target->identity()->subject.format()); const auto* castEvent = (*damageEvents)[1].getIf(); const auto* completedEvent = (*damageEvents)[2].getIf(); REQUIRE(castEvent != nullptr); REQUIRE(completedEvent != nullptr); CHECK_EQ(*castEvent->at("type").getIf(), "ability_cast"); CHECK_EQ(*completedEvent->at("type").getIf(), "ability_channel_completed"); REQUIRE(rts.stepScript(0.1).ok()); const auto clearedProjection = rts.inspectFrameEvents(); const auto* clearedEvents = clearedProjection.getIf(); REQUIRE(clearedEvents != nullptr); CHECK(clearedEvents->empty()); } TEST_CASE("rts.scriptStatusEffectsDriveMovementDamageIntakeRegenerationAndExpiry") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS rts; REQUIRE(rts.configureScriptWorld(20, 12, 1.0f).ok()); REQUIRE(rts.loadScriptContent(R"({ "units":[{"id":"marine","health":100,"speed":2,"radius":0.3,"sightRange":8}], "statusEffects":[ {"id":"stimmed","duration":2,"speedMultiplier":2,"damageMultiplier":1.5}, {"id":"marked","duration":2,"incomingDamageMultiplier":1.5}, {"id":"regenerating","duration":2,"healingPerSecond":5} ], "abilities":[ {"id":"stim","casterUnit":"marine","targetType":"self","cooldown":0.1, "statusEffect":"stimmed"}, {"id":"mark","casterUnit":"marine","targetType":"enemy","range":8,"cooldown":0.1, "statusEffect":"marked"}, {"id":"regenerate","casterUnit":"marine","targetType":"self","cooldown":0.1, "statusEffect":"regenerating"}, {"id":"blast","casterUnit":"marine","targetType":"point","range":8,"radius":1, "cooldown":0.1,"damage":20} ] })").ok()); auto* blue = rts.newFaction(subject("00000000-0000-7000-8000-00000000ff01")).value(); auto* red = rts.newFaction(subject("00000000-0000-7000-8000-00000000ff02")).value(); const auto marineId = eve::LogicalId::parse("unit:marine"); REQUIRE(marineId.has_value()); Unit* caster = rts.newFactionUnit( *blue, subject("00000000-0000-7000-8000-00000000ff03"), *marineId).value(); Unit* target = rts.newFactionUnit( *red, subject("00000000-0000-7000-8000-00000000ff04"), *marineId).value(); caster->motion()->x = 1.0f; caster->motion()->y = 1.0f; target->motion()->x = 4.0f; target->motion()->y = 1.0f; REQUIRE(rts.stepScript(0.01).ok()); REQUIRE(rts.castScriptAbility(*caster, "stim", caster->identity()->subject, {}).ok()); CHECK_EQ(caster->effects()->values.multiplier("speedMultiplier"), 2.0); CHECK_EQ(caster->effects()->values.multiplier("damageMultiplier"), 1.5); REQUIRE(rts.castScriptAbility(*caster, "mark", target->identity()->subject, {}).ok()); CHECK_EQ(target->effects()->values.multiplier("incomingDamageMultiplier"), 1.5); REQUIRE(rts.castScriptAbility(*caster, "blast", {}, {4.0f, 1.0f}).ok()); CHECK_EQ(target->durability()->state.health, 70.0); caster->durability()->state.health = 70.0; REQUIRE(rts.castScriptAbility(*caster, "regenerate", caster->identity()->subject, {}).ok()); REQUIRE(rts.stepScript(1.0).ok()); CHECK_EQ(caster->durability()->state.health, 75.0); REQUIRE(rts.stepScript(1.1).ok()); CHECK_EQ(caster->effects()->values.multiplier("speedMultiplier"), 1.0); CHECK_EQ(caster->effects()->values.multiplier("damageMultiplier"), 1.0); CHECK_EQ(target->effects()->values.multiplier("incomingDamageMultiplier"), 1.0); const auto expiredProjection = rts.inspectFrameEvents(); const auto* expiredEvents = expiredProjection.getIf(); REQUIRE(expiredEvents != nullptr); int expiredCount = 0; for (const auto& event : *expiredEvents) { const auto* object = event.getIf(); if (object != nullptr && *object->at("type").getIf() == "status_expired") ++expiredCount; } CHECK_EQ(expiredCount, 3); } TEST_CASE("rts.facadeCommandsContainmentAndCloakUseOwnedStableRoots") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS rts; Faction* faction = rts.newFaction(subject("00000000-0000-7000-8000-00000000ee01")).value(); Unit* transport = rts.newFactionUnit( *faction, subject("00000000-0000-7000-8000-00000000ee02")).value(); Unit* passenger = rts.newFactionUnit( *faction, subject("00000000-0000-7000-8000-00000000ee03")).value(); Building* bunker = rts.newFactionBuilding( *faction, subject("00000000-0000-7000-8000-00000000ee04")).value(); transport->containment()->capacity = 2; transport->motion()->x = 3.0f; transport->motion()->y = 4.0f; passenger->motion()->x = 3.0f; passenger->motion()->y = 4.0f; passenger->motion()->arrived = true; CommandSpec board; board.kind = OrderKind::BoardTransport; board.target = {3.0f, 4.0f}; board.targetEntity = ecs::handle_of(transport); const std::array passengerSelection{passenger->identity()->subject}; REQUIRE(rts.commandUnits(passengerSelection, board).ok()); REQUIRE(eve::rts::ContainmentSystem::step().ok()); CHECK(passenger->containment()->container.resolve() == transport); auto unloaded = rts.unloadTransport(*transport, {8.0f, 5.0f}); REQUIRE(unloaded.ok()); CHECK_EQ(unloaded.value(), std::size_t{1}); CHECK(!passenger->containment()->container.isBound()); auto bunkerLink = eve::rts::ContainerLink::bind(ecs::handle_of(bunker)); REQUIRE(bunkerLink.ok()); passenger->containment()->container = std::move(bunkerLink).takeValue(); bunker->garrison()->occupants.push_back(ecs::handle_of(passenger)); bunker->capture()->blockedByGarrison = true; auto evacuated = rts.evacuateBuilding(*bunker, {10.0f, 6.0f}); REQUIRE(evacuated.ok()); CHECK_EQ(evacuated.value(), std::size_t{1}); CHECK(!bunker->capture()->blockedByGarrison); REQUIRE(rts.setUnitCloaked(*passenger, true).ok()); CHECK(passenger->vision()->cloaked); Unit* foreign = Unit::createUnit(subject("00000000-0000-7000-8000-00000000ee05")); CHECK(!rts.setUnitCloaked(*foreign, true).ok()); CHECK(!rts.unloadTransport(*foreign, {}).ok()); foreign->release(); } TEST_CASE("rts.scriptCommandLogSchedulesStableCommandsAtExactFixedTicks") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS rts; REQUIRE(rts.configureScriptWorld(16, 8, 1.0f).ok()); Faction* faction = rts.newFaction(subject("00000000-0000-7000-8000-00000000ed01")).value(); Unit* unit = rts.newFactionUnit( *faction, subject("00000000-0000-7000-8000-00000000ed02")).value(); unit->motion()->speed = 1.0f; unit->motion()->arrivalRadius = 0.01f; eve::rts::RTSReplayCommand replay; replay.tick = eve::SimulationTick{3}; replay.units = {unit->identity()->subject}; replay.command.kind = OrderKind::Move; replay.command.target = {6.0f, 0.0f}; REQUIRE(rts.queueScriptCommand(replay).ok()); auto exported = rts.exportScriptCommandLog(); REQUIRE(exported.ok()); CHECK(exported.value().find("EVERTS_COMMANDS 1") == 0); REQUIRE(rts.importScriptCommandLog(exported.value(), true).ok()); REQUIRE(rts.stepScript(1.0).ok()); CHECK_EQ(rts.scriptTick(), std::uint64_t{1}); CHECK(unit->orders()->values.empty()); REQUIRE(rts.stepScript(1.0).ok()); CHECK_EQ(rts.scriptTick(), std::uint64_t{2}); CHECK(unit->orders()->values.empty()); REQUIRE(rts.stepScript(1.0).ok()); CHECK_EQ(rts.scriptTick(), std::uint64_t{3}); CHECK(unit->motion()->x > 0.0f); eve::rts::RTSReplayCommand past = replay; past.tick = eve::SimulationTick{2}; CHECK(!rts.queueScriptCommand(std::move(past)).ok()); CHECK(!rts.importScriptCommandLog(exported.value(), true).ok()); } TEST_CASE("rts.scriptCommandLogReplaysConstructionCancellationAndBuildingSale") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS rts; REQUIRE(rts.configureScriptWorld(12, 8, 1.0f).ok()); REQUIRE(rts.loadScriptContent(R"({ "units":[{"id":"worker","role":"worker","health":60,"speed":3,"radius":0.3, "buildRate":1}], "buildings":[{"id":"depot","costResource":"minerals","cost":100, "sellRefundRatio":0.5,"buildTime":4,"health":400}] })").ok()); auto* faction = rts.newFaction(subject("00000000-0000-7000-8000-00000000ed11")).value(); const auto workerId = eve::LogicalId::parse("unit:worker"); const auto depotId = eve::LogicalId::parse("building:depot"); REQUIRE(workerId.has_value()); REQUIRE(depotId.has_value()); auto* builder = rts.newFactionUnit( *faction, subject("00000000-0000-7000-8000-00000000ed12"), *workerId).value(); const auto unfinishedSubject = subject("00000000-0000-7000-8000-00000000ed13"); const auto completedSubject = subject("00000000-0000-7000-8000-00000000ed14"); REQUIRE(rts.addScriptResource(*faction, "minerals", 200).ok()); auto* unfinished = rts.startScriptConstruction( *faction, unfinishedSubject, *depotId, {3.0f, 3.0f}, *builder).value(); unfinished->construction()->progress = 0.5f; REQUIRE(rts.newFactionBuilding(*faction, completedSubject, *depotId).ok()); eve::rts::RTSReplayCommand cancel; cancel.tick = eve::SimulationTick{1}; cancel.operation = eve::rts::RTSReplayOperation::CancelConstruction; cancel.producer = unfinishedSubject; REQUIRE(rts.queueScriptCommand(cancel).ok()); eve::rts::RTSReplayCommand sell; sell.tick = eve::SimulationTick{1}; sell.operation = eve::rts::RTSReplayOperation::SellBuilding; sell.producer = completedSubject; REQUIRE(rts.queueScriptCommand(sell).ok()); const auto exported = rts.exportScriptCommandLog(); REQUIRE(exported.ok()); CHECK(exported.value().find("EVERTS_COMMANDS 2") == 0); REQUIRE(rts.importScriptCommandLog(exported.value(), true).ok()); REQUIRE(rts.stepScript(1.0).ok()); CHECK(rts.findBuilding(unfinishedSubject) == nullptr); CHECK(rts.findBuilding(completedSubject) == nullptr); CHECK_EQ(rts.scriptResource(*faction, "minerals").value(), 225); } TEST_CASE("rts.scriptCheckpointRestoresRootsEconomyAndTimelineTogether") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS rts; REQUIRE(rts.configureScriptWorld(10, 8, 1.0f).ok()); Faction* faction = rts.newFaction(subject("00000000-0000-7000-8000-00000000eb01")).value(); Unit* unit = rts.newFactionUnit( *faction, subject("00000000-0000-7000-8000-00000000eb02")).value(); unit->motion()->x = 2.0f; unit->motion()->y = 3.0f; REQUIRE(rts.addScriptResource(*faction, "minerals", 50).ok()); REQUIRE(rts.stepScript(0.1).ok()); REQUIRE(rts.captureScriptCheckpoint("slot-1").ok()); unit->motion()->x = 9.0f; REQUIRE(rts.addScriptResource(*faction, "minerals", 25).ok()); REQUIRE(rts.stepScript(0.1).ok()); CHECK_EQ(rts.scriptTick(), std::uint64_t{2}); REQUIRE_EQ(rts.scriptResource(*faction, "minerals").value(), std::int64_t{75}); REQUIRE(rts.restoreScriptCheckpoint("slot-1").ok()); CHECK_EQ(rts.scriptTick(), std::uint64_t{1}); CHECK_EQ(unit->motion()->x, 2.0f); CHECK_EQ(unit->motion()->y, 3.0f); CHECK_EQ(rts.scriptResource(*faction, "minerals").value(), std::int64_t{50}); REQUIRE(rts.removeScriptCheckpoint("slot-1").ok()); CHECK(!rts.restoreScriptCheckpoint("slot-1").ok()); } TEST_CASE("rts.scriptCheckpointRebuildsChangedRootTopology") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS rts; REQUIRE(rts.configureScriptWorld(10, 8, 1.0f).ok()); const auto factionId = subject("00000000-0000-7000-8000-00000000ea01"); const auto originalId = subject("00000000-0000-7000-8000-00000000ea02"); const auto laterId = subject("00000000-0000-7000-8000-00000000ea03"); Faction* faction = rts.newFaction(factionId).value(); Unit* original = rts.newFactionUnit(*faction, originalId).value(); original->motion()->x = 4.0f; REQUIRE(rts.addScriptResource(*faction, "minerals", 30).ok()); REQUIRE(rts.captureScriptCheckpoint("before-production").ok()); REQUIRE(rts.newFactionUnit(*faction, laterId).ok()); REQUIRE_EQ(rts.unitCount(), std::size_t{2}); REQUIRE(rts.addScriptResource(*faction, "minerals", 20).ok()); REQUIRE(rts.restoreScriptCheckpoint("before-production").ok()); CHECK_EQ(rts.unitCount(), std::size_t{1}); CHECK(rts.findUnit(laterId) == nullptr); Unit* restored = rts.findUnit(originalId); REQUIRE(restored != nullptr); CHECK_EQ(restored->motion()->x, 4.0f); Faction* restoredFaction = rts.findFaction(factionId); REQUIRE(restoredFaction != nullptr); CHECK_EQ(rts.scriptResource(*restoredFaction, "minerals").value(), std::int64_t{30}); CHECK(restored->crowd()->link.isBound()); CHECK(restored->sensing()->link.isBound()); } TEST_CASE("rts.scriptFogUsesCanonicalFovAndProjectsStableLastKnownContacts") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS rts; REQUIRE(rts.configureScriptWorld(12, 8, 1.0f).ok()); Faction* blue = rts.newFaction(subject("00000000-0000-7000-8000-00000000ec01")).value(); Faction* red = rts.newFaction(subject("00000000-0000-7000-8000-00000000ec02")).value(); Unit* scout = rts.newFactionUnit( *blue, subject("00000000-0000-7000-8000-00000000ec03")).value(); Unit* enemy = rts.newFactionUnit( *red, subject("00000000-0000-7000-8000-00000000ec04")).value(); scout->motion()->x = 2.0f; scout->motion()->y = 2.0f; scout->vision()->sightRange = 4.0f; enemy->motion()->x = 4.0f; enemy->motion()->y = 2.0f; REQUIRE(rts.stepScript(0.1).ok()); CHECK(rts.scriptCellVisible(*blue, 4, 2).value()); CHECK(rts.scriptCellExplored(*blue, 4, 2).value()); auto visible = rts.scriptContact(*blue, enemy->identity()->subject); REQUIRE(visible.ok()); const auto* visibleObject = visible.value().getIf(); REQUIRE(visibleObject != nullptr); CHECK(visibleObject->at("visible").getIf() != nullptr); CHECK(*visibleObject->at("visible").getIf()); enemy->motion()->x = 10.0f; REQUIRE(rts.stepScript(0.5).ok()); auto remembered = rts.scriptContact(*blue, enemy->identity()->subject); REQUIRE(remembered.ok()); const auto* rememberedObject = remembered.value().getIf(); REQUIRE(rememberedObject != nullptr); CHECK(!*rememberedObject->at("visible").getIf()); CHECK(*rememberedObject->at("age").getIf() >= 0.5); CHECK(rts.scriptCellExplored(*blue, 4, 2).value()); CHECK(!rts.scriptCellVisible(*blue, 10, 2).value()); CHECK(!rts.scriptCellVisible(*red, -1, 2).ok()); } TEST_CASE("rts.scriptMatchFacadeRunsResourceVictoryAndStableSurrenderLifecycle") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS rts; REQUIRE(rts.configureScriptWorld(8, 8, 1.0f).ok()); Faction* blue = rts.newFaction(subject("00000000-0000-7000-8000-00000000eb01")).value(); Faction* red = rts.newFaction(subject("00000000-0000-7000-8000-00000000eb02")).value(); Match* economyMatch = rts.newMatch(subject("00000000-0000-7000-8000-00000000eb03")).value(); REQUIRE(rts.configureMatch(*economyMatch, eve::rts::VictoryRule::ResourceTarget, "minerals", 100.0).ok()); REQUIRE(rts.addMatchParticipant(*economyMatch, *blue, 1).ok()); REQUIRE(rts.addMatchParticipant(*economyMatch, *red, 2).ok()); REQUIRE(rts.startMatch(*economyMatch).ok()); CHECK(!rts.configureMatch(*economyMatch, eve::rts::VictoryRule::Annihilation).ok()); REQUIRE(rts.addScriptResource(*blue, "minerals", 100).ok()); REQUIRE(rts.stepScript(0.1).ok()); CHECK_EQ(static_cast(economyMatch->state()->phase), static_cast(eve::rts::MatchPhase::Finished)); CHECK_EQ(economyMatch->state()->winningTeam, 1); auto projected = rts.inspectMatch(*economyMatch); REQUIRE(projected.ok()); const auto* matchObject = projected.value().getIf(); REQUIRE(matchObject != nullptr); CHECK_EQ(*matchObject->at("phase").getIf(), "finished"); CHECK_EQ(*matchObject->at("winningTeam").getIf(), std::int64_t{1}); Match* surrender = rts.newMatch(subject("00000000-0000-7000-8000-00000000eb04")).value(); REQUIRE(rts.addMatchParticipant(*surrender, *blue, 1).ok()); REQUIRE(rts.addMatchParticipant(*surrender, *red, 2).ok()); REQUIRE(rts.startMatch(*surrender).ok()); REQUIRE(rts.surrenderMatch(*surrender, *red).ok()); CHECK_EQ(static_cast(surrender->state()->phase), static_cast(eve::rts::MatchPhase::Finished)); CHECK_EQ(surrender->state()->winningTeam, 1); Match* foreign = Match::createMatch(subject("00000000-0000-7000-8000-00000000eb05")); CHECK(!rts.startMatch(*foreign).ok()); foreign->release(); } class PendingRTSExecutor final : public eve::rts::IRTSActionExecutor { public: eve::Result execute( Unit&, const eve::rts::OrderRecord&, const eve::SimulationStep&) override { return eve::Result::success( {eve::rts::ActionDisposition::Pending}); } }; class FireControlArmorRule final : public eve::combat::IDamageRule { public: eve::SubjectRef armored; eve::SubjectRef light; eve::Result evaluate( const eve::combat::DamageRequest& request, const eve::combat::CombatState& target) const override { const bool strong = (request.damageType == "damage.piercing" && target.subject == armored) || (request.damageType == "damage.explosive" && target.subject == light); const double factor = strong ? 2.0 : 0.5; return eve::Result::success( {request.healthDamage * factor, request.poiseDamage * factor, 1.0}); } }; void initializeScout(Scout& scout) { scout.identity()->self = ecs::handle_of(&scout); (void)scout.motion(); (void)scout.orders(); (void)scout.action(); } } // namespace static_assert(std::is_base_of_v); static_assert(std::is_base_of_v); static_assert(std::is_base_of_v); static_assert(std::is_base_of_v); static_assert(!std::is_base_of_v); static_assert(!std::is_base_of_v); static_assert(!std::is_base_of_v); TEST_CASE("rts.compositionRootsAndBatchViews") { ecs::Table world; ecs::ScopedTable guard(world); Unit* unit = Unit::createUnit(); Building* building = Building::createBuilding(); unit->motion()->x = 10.0f; building->placement()->cellX = 4; int units = 0; auto unitView = ecs::View(); for (auto it = unitView.begin(); it != unitView.end(); ++it) { auto [identity, motion, orders] = *it; CHECK(identity != nullptr); CHECK(motion != nullptr); CHECK(orders != nullptr); ++units; } CHECK_EQ(units, 1); int buildings = 0; auto buildingView = ecs::View(); for (auto it = buildingView.begin(); it != buildingView.end(); ++it) { auto [identity, placement] = *it; CHECK(identity != nullptr); CHECK(placement != nullptr); ++buildings; } CHECK_EQ(buildings, 1); unit->release(); building->release(); } TEST_CASE("rts.baseViewIncludesSubclassButNotOtherRoot") { ecs::Table world; ecs::ScopedTable guard(world); Unit* unit = Unit::createUnit(); Scout* scout = Scout::create(); initializeScout(*scout); Building* building = Building::createBuilding(); int allUnits = 0; auto baseView = ecs::View(); for (auto it = baseView.begin(); it != baseView.end(); ++it) { auto [identity, motion] = *it; CHECK(identity != nullptr); CHECK(motion != nullptr); ++allUnits; } CHECK_EQ(allUnits, 2); int exactScouts = 0; auto scoutView = ecs::View(); for (auto it = scoutView.begin(); it != scoutView.end(); ++it) ++exactScouts; CHECK_EQ(exactScouts, 1); int unitOrderCount = 0; auto orderView = ecs::View(); for (auto it = orderView.begin(); it != orderView.end(); ++it) ++unitOrderCount; CHECK_EQ(unitOrderCount, 2); int buildingAsUnit = 0; auto noBuildingView = ecs::View(); for (auto it = noBuildingView.begin(); it != noBuildingView.end(); ++it) { auto [identity, motion] = *it; (void)identity; (void)motion; ++buildingAsUnit; } CHECK_EQ(buildingAsUnit, 2); unit->release(); scout->release(); building->release(); } TEST_CASE("rts.typedLinkDetectsStaleTarget") { ecs::Table world; ecs::ScopedTable guard(world); Faction* faction = Faction::createFaction(); Unit* unit = Unit::createUnit(); auto linked = eve::rts::FactionLink::bind(ecs::handle_of(faction)); REQUIRE(linked.ok()); unit->faction()->link = std::move(linked).takeValue(); CHECK(unit->faction()->link.isBound()); CHECK(!unit->faction()->link.isStale()); CHECK(unit->faction()->link.resolve() == faction); faction->release(); CHECK(unit->faction()->link.isStale()); CHECK(unit->faction()->link.resolve() == nullptr); unit->release(); } TEST_CASE("rts.formationPlannerIsDeterministic") { const FormationSpec spec{FormationKind::Grid, 10.0f, 2}; auto planned = eve::rts::FormationPlanner::plan(3, {100.0f, 200.0f}, spec); REQUIRE(planned.ok()); const auto positions = std::move(planned).takeValue(); REQUIRE_EQ(positions.size(), 3u); CHECK(std::abs(positions[0].x - 95.0f) < 1e-5f); CHECK(std::abs(positions[0].y - 195.0f) < 1e-5f); CHECK(std::abs(positions[1].x - 105.0f) < 1e-5f); CHECK(std::abs(positions[1].y - 195.0f) < 1e-5f); CHECK(std::abs(positions[2].x - 95.0f) < 1e-5f); CHECK(std::abs(positions[2].y - 205.0f) < 1e-5f); } TEST_CASE("rts.commandFanOutUsesGenericOrdersAndFormation") { ecs::Table world; ecs::ScopedTable guard(world); std::vector units; std::vector handles; for (int index = 0; index < 3; ++index) { Unit* unit = Unit::createUnit(); unit->motion()->speed = 100.0f; units.push_back(unit); handles.push_back(ecs::handle_of(unit)); } const CommandSpec command{OrderKind::Move, {100.0f, 200.0f}, {}, 0, 0.0}; const FormationSpec formation{FormationKind::Grid, 10.0f, 2}; auto receiptResult = eve::rts::CommandFanOutSystem::fanOut(handles, command, formation); REQUIRE(receiptResult.ok()); const auto receipt = std::move(receiptResult).takeValue(); CHECK_EQ(receipt.requested, 3u); CHECK_EQ(receipt.accepted, 3u); CHECK_EQ(receipt.orderIds.size(), 3u); for (std::size_t index = 0; index < units.size(); ++index) { auto current = units[index]->orders()->values.current(); REQUIRE(current.ok()); const auto order = std::move(current).takeValue(); CHECK_EQ(static_cast(order.kind), static_cast(OrderKind::Move)); CHECK_EQ(order.formationSlot, static_cast(index)); CHECK_EQ(order.id, receipt.orderIds[index]); } const std::size_t beforeRejectedFanOut = units[0]->orders()->values.orderCount(); units[2]->release(); auto staleResult = eve::rts::CommandFanOutSystem::fanOut(handles, command, formation); CHECK(!staleResult.ok()); staleResult.ignore("expected stale selection in fan-out test"); CHECK_EQ(units[0]->orders()->values.orderCount(), beforeRejectedFanOut); units[0]->release(); units[1]->release(); } TEST_CASE("rts.commandFanOutReplacesDirectCommandsAndAppendsQueuedWaypoints") { ecs::Table world; ecs::ScopedTable guard(world); Unit* unit = Unit::createUnit(); std::array selection{ecs::handle_of(unit)}; FormationSpec formation{FormationKind::Line, 1.0f, 0}; CommandSpec oldMove; oldMove.kind = OrderKind::Move; oldMove.target = {1.0f, 0.0f}; REQUIRE(unit->orders()->values.enqueue(oldMove).ok()); CommandSpec direct = oldMove; direct.target = {5.0f, 0.0f}; auto replaced = eve::rts::CommandFanOutSystem::fanOut(selection, direct, formation); REQUIRE(replaced.ok()); CHECK_EQ(unit->orders()->values.orderCount(), 1u); auto current = unit->orders()->values.current(); REQUIRE(current.ok()); CHECK(std::abs(current.value().target.x - 5.0f) < 1e-5f); CommandSpec queued = direct; queued.target = {9.0f, 0.0f}; queued.append = true; auto appended = eve::rts::CommandFanOutSystem::fanOut(selection, queued, formation); REQUIRE(appended.ok()); CHECK_EQ(unit->orders()->values.orderCount(), 2u); current = unit->orders()->values.current(); REQUIRE(current.ok()); CHECK(std::abs(current.value().target.x - 5.0f) < 1e-5f); unit->release(); } TEST_CASE("rts.extendedOrdersPreserveEntityAndAreaPayload") { ecs::Table world; ecs::ScopedTable guard(world); Unit* unit = Unit::createUnit(); Unit* target = Unit::createUnit(); CommandSpec suppress; suppress.kind = OrderKind::SuppressArea; suppress.target = {12.0f, 8.0f}; suppress.secondaryTarget = {20.0f, 14.0f}; suppress.targetEntity = ecs::handle_of(target); suppress.radius = 6.0f; suppress.append = true; auto queued = unit->orders()->values.enqueue(suppress); REQUIRE(queued.ok()); std::move(queued).takeValue(); auto current = unit->orders()->values.current(); REQUIRE(current.ok()); const auto order = std::move(current).takeValue(); CHECK_EQ(static_cast(order.kind), static_cast(OrderKind::SuppressArea)); CHECK(std::abs(order.secondaryTarget.x - 20.0f) < 1e-5f); CHECK(std::abs(order.radius - 6.0f) < 1e-5f); CHECK(order.append); CHECK(order.targetEntity.id == ecs::handle_of(target).id); CHECK(std::abs(unit->worker()->cargo) < 1e-5f); CHECK(!unit->combat()->holdPosition); unit->release(); target->release(); } TEST_CASE("rts.buildingTouchesConstructionDropoffAndRally") { ecs::Table world; ecs::ScopedTable guard(world); Building* building = Building::createBuilding(); CHECK(std::abs(building->construction()->progress - 1.0f) < 1e-5f); CHECK(building->dropoff()->acceptedResources.empty()); CHECK(!building->rally()->enabled); building->release(); } TEST_CASE("rts.autoAssignedWorkerGathersAndCreditsCanonicalAccount") { ecs::Table world; ecs::ScopedTable guard(world); Unit* worker = Unit::createUnit(); Building* base = Building::createBuilding(); ResourceNode* node = ResourceNode::createResourceNode(); base->placement()->worldX = 0.0f; base->placement()->worldY = 0.0f; node->position()->x = 0.0f; node->position()->y = 0.0f; node->stock()->resourceType = "ore"; node->stock()->remaining = 10.0f; node->stock()->maximum = 10.0f; worker->worker()->resourceType = "ore"; worker->worker()->capacity = 2.0f; worker->worker()->gatherRate = 2.0f; worker->worker()->autoAssign = true; auto dropoff = eve::rts::BuildingLink::bind(ecs::handle_of(base)); REQUIRE(dropoff.ok()); worker->worker()->dropoff = std::move(dropoff).takeValue(); auto assigned = eve::rts::WorkerAssignmentSystem::step(); REQUIRE(assigned.ok()); CHECK_EQ(assigned.value(), 1u); const eve::SimulationStep step{eve::SimulationTick{1}, eve::Duration::fromSeconds(1.0).expect("mining dt")}; auto moved = eve::rts::MotionSystem::step(step); REQUIRE(moved.ok()); std::int64_t creditedOre = 0; eve::rts::ResourceCredit credit = [&creditedOre](Unit&, const eve::resource::CostSpec& cost) { creditedOre += cost.items().front().amount.value(); return eve::Result::success(eve::resource::Receipt{}); }; auto gathered = eve::rts::MiningSystem::step(step, credit); REQUIRE(gathered.ok()); CHECK(std::abs(worker->worker()->cargo - 2.0f) < 1e-5f); moved = eve::rts::MotionSystem::step(step); REQUIRE(moved.ok()); auto delivered = eve::rts::MiningSystem::step(step, credit); REQUIRE(delivered.ok()); CHECK_EQ(creditedOre, 2); CHECK(worker->worker()->cargo < 1.0f); worker->release(); base->release(); node->release(); } TEST_CASE("rts.workerAssignmentFacadeMaintainsMiningLinksAndAutoAssignmentAtomically") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS rts; auto* faction = rts.newFaction(subject("00000000-0000-7000-8000-0000000003a1")).value(); auto* worker = rts.newFactionUnit( *faction, subject("00000000-0000-7000-8000-0000000003a2")).value(); auto* nonWorker = rts.newFactionUnit( *faction, subject("00000000-0000-7000-8000-0000000003a3")).value(); auto* base = rts.newFactionBuilding( *faction, subject("00000000-0000-7000-8000-0000000003a4")).value(); auto* first = rts.newResourceNode( subject("00000000-0000-7000-8000-0000000003a5"), "ore", 20.0f, {4.0f, 5.0f}, 1).value(); auto* second = rts.newResourceNode( subject("00000000-0000-7000-8000-0000000003a6"), "ore", 20.0f, {7.0f, 8.0f}, 1).value(); worker->worker()->resourceType = "ore"; worker->worker()->capacity = 5.0f; worker->worker()->gatherRate = 1.0f; base->dropoff()->acceptedResources.push_back("ore"); REQUIRE(rts.assignWorker(*worker, *first, *base).ok()); CHECK(worker->worker()->resourceNode.resolve() == first); CHECK(worker->worker()->dropoff.resolve() == base); CHECK_EQ(first->harvest()->workers.size(), std::size_t{1}); auto current = worker->orders()->values.current(); REQUIRE(current.ok()); CHECK_EQ(static_cast(current.value().kind), static_cast(OrderKind::Gather)); REQUIRE(rts.assignWorker(*worker, *second, *base).ok()); CHECK(first->harvest()->workers.empty()); CHECK_EQ(second->harvest()->workers.size(), std::size_t{1}); const std::vector invalid{ worker->identity()->subject, nonWorker->identity()->subject}; auto rejected = rts.setWorkerAutoAssignment(invalid, true); CHECK(!rejected.ok()); CHECK(!worker->worker()->autoAssign); const std::vector workers{worker->identity()->subject}; REQUIRE(rts.setWorkerAutoAssignment(workers, true).ok()); CHECK(worker->worker()->autoAssign); } TEST_CASE("rts.constructionRepairAndCaptureUseTypedBuildingTargets") { ecs::Table world; ecs::ScopedTable guard(world); Faction* defenders = Faction::createFaction(); Faction* attackers = Faction::createFaction(); Building* building = Building::createBuilding(); Unit* builder = Unit::createUnit(); Unit* capturer = Unit::createUnit(); auto defenderLink = eve::rts::FactionLink::bind(ecs::handle_of(defenders)); auto attackerLink = eve::rts::FactionLink::bind(ecs::handle_of(attackers)); REQUIRE(defenderLink.ok()); REQUIRE(attackerLink.ok()); building->faction()->link = std::move(defenderLink).takeValue(); auto builderFaction = eve::rts::FactionLink::bind(ecs::handle_of(defenders)); auto capturerFaction = eve::rts::FactionLink::bind(ecs::handle_of(attackers)); REQUIRE(builderFaction.ok()); REQUIRE(capturerFaction.ok()); builder->faction()->link = std::move(builderFaction).takeValue(); capturer->faction()->link = std::move(capturerFaction).takeValue(); building->construction()->progress = 0.0f; building->construction()->buildTimeSeconds = 2.0f; builder->worker()->buildRate = 1.0f; CommandSpec build; build.kind = OrderKind::Build; build.targetEntity = ecs::handle_of(building); auto buildOrder = builder->orders()->values.enqueue(build); REQUIRE(buildOrder.ok()); std::move(buildOrder).takeValue(); const eve::SimulationStep oneSecond{eve::SimulationTick{1}, eve::Duration::fromSeconds(1.0).expect("building dt")}; auto construction = eve::rts::ConstructionSystem::step(oneSecond); REQUIRE(construction.ok()); CHECK(std::abs(building->construction()->progress - 0.5f) < 1e-5f); std::vector constructionEvents; construction = eve::rts::ConstructionSystem::step(oneSecond, [&](const eve::rts::LifecycleEvent& event, eve::SimulationTick tick) { CHECK_EQ(tick.value(), 1u); constructionEvents.push_back(event); }); REQUIRE(construction.ok()); CHECK(std::abs(building->construction()->progress - 1.0f) < 1e-5f); CHECK(builder->orders()->values.empty()); REQUIRE_EQ(constructionEvents.size(), 1u); CHECK_EQ(static_cast(constructionEvents[0].kind), static_cast(eve::rts::LifecycleEventKind::ConstructionCompleted)); building->integrity()->state.subject = subject("00000000-0000-7000-8000-000000000021"); building->integrity()->state.health = 8.0; building->integrity()->state.maxHealth = 10.0; building->integrity()->repairCostPerHealth = 0.5f; building->integrity()->repairResource = "ore"; builder->worker()->repairRate = 2.0f; CommandSpec repair; repair.kind = OrderKind::Repair; repair.targetEntity = ecs::handle_of(building); auto repairOrder = builder->orders()->values.enqueue(repair); REQUIRE(repairOrder.ok()); std::move(repairOrder).takeValue(); std::int64_t debited = 0; eve::rts::RepairDebit debit = [&debited](Unit&, Building&, const eve::resource::CostSpec& cost) { debited += cost.items().front().amount.value(); return eve::Result::success(eve::resource::Receipt{}); }; auto repaired = eve::rts::RepairSystem::step(oneSecond, debit); REQUIRE(repaired.ok()); CHECK(std::abs(building->integrity()->state.health - 10.0) < 1e-5); CHECK_EQ(debited, 1); building->capture()->capturable = true; building->capture()->durationSeconds = 5.0f; building->rally()->enabled = true; building->rally()->combatGroup = 41; building->rally()->reinforcementCapped = true; building->rally()->reinforcementPolicyPausedTask = "old-owner-task"; building->rally()->reinforcements.push_back(builder->identity()->self); const auto oldRallyGroup = building->rally()->combatGroup; capturer->capture()->rate = 1.0f; CommandSpec capture; capture.kind = OrderKind::Capture; capture.targetEntity = ecs::handle_of(building); auto captureOrder = capturer->orders()->values.enqueue(capture); REQUIRE(captureOrder.ok()); std::move(captureOrder).takeValue(); const eve::SimulationStep fiveSeconds{eve::SimulationTick{2}, eve::Duration::fromSeconds(5.0).expect("capture dt")}; std::vector captureEvents; auto captured = eve::rts::CaptureSystem::step(fiveSeconds, [&](const eve::rts::LifecycleEvent& event, eve::SimulationTick tick) { CHECK_EQ(tick.value(), 2u); captureEvents.push_back(event); }); REQUIRE(captured.ok()); CHECK(building->faction()->link.resolve() == attackers); CHECK_NE(building->rally()->combatGroup, oldRallyGroup); CHECK(building->rally()->reinforcements.empty()); CHECK(!building->rally()->reinforcementCapped); CHECK(building->rally()->reinforcementPolicyPausedTask.empty()); CHECK(capturer->orders()->values.empty()); REQUIRE_EQ(captureEvents.size(), 1u); CHECK_EQ(static_cast(captureEvents[0].kind), static_cast(eve::rts::LifecycleEventKind::BuildingCaptured)); CHECK(captureEvents[0].source == building->identity()->subject); CHECK(captureEvents[0].target == capturer->identity()->subject); builder->release(); capturer->release(); building->release(); defenders->release(); attackers->release(); } TEST_CASE("rts.crowdProviderOwnsLinkedMovementAndOverlapResolution") { ecs::Table world; ecs::ScopedTable guard(world); eve::crowd::Crowd crowd; crowd.setResolveOverlaps(true); crowd.setSeparationRadius(3.0f); crowd.setSeparationWeight(2.0f); Unit* first = Unit::createUnit(); Unit* second = Unit::createUnit(); auto firstLink = eve::rts::CrowdLink::bind("rts/unit/first"); auto secondLink = eve::rts::CrowdLink::bind("rts/unit/second"); REQUIRE(firstLink.ok()); REQUIRE(secondLink.ok()); first->crowd()->link = std::move(firstLink).takeValue(); second->crowd()->link = std::move(secondLink).takeValue(); first->crowd()->radius = 0.75f; second->crowd()->radius = 0.75f; first->motion()->speed = 4.0f; second->motion()->speed = 4.0f; CommandSpec move; move.kind = OrderKind::Move; move.target = {10.0f, 0.0f}; auto firstOrder = first->orders()->values.enqueue(move); auto secondOrder = second->orders()->values.enqueue(move); REQUIRE(firstOrder.ok()); REQUIRE(secondOrder.ok()); std::move(firstOrder).takeValue(); std::move(secondOrder).takeValue(); const eve::SimulationStep step{eve::SimulationTick{1}, eve::Duration::fromSeconds(0.25).expect("crowd dt")}; auto moved = eve::rts::CrowdMotionSystem::step(step, crowd); REQUIRE(moved.ok()); CHECK_EQ(moved.value(), 2u); CHECK(crowd.hasNamedAgent("rts/unit/first")); CHECK(crowd.hasNamedAgent("rts/unit/second")); const float dx = first->motion()->x - second->motion()->x; const float dy = first->motion()->y - second->motion()->y; CHECK(std::hypot(dx, dy) > 0.0f); first->release(); second->release(); } TEST_CASE("rts.crowdProjectsMovementPriorityAndUnlocksExactOverlap") { ecs::Table world; ecs::ScopedTable guard(world); eve::crowd::Crowd crowd; crowd.setClampToField(false); crowd.setResolveOverlaps(true); crowd.setSeparationRadius(3.0f); crowd.setSeparationWeight(0.0f); Unit* high = Unit::createUnit(subject("00000000-0000-7000-8000-000000000401")); Unit* low = Unit::createUnit(subject("00000000-0000-7000-8000-000000000402")); auto highLink = eve::rts::CrowdLink::bind("rts/priority/high"); auto lowLink = eve::rts::CrowdLink::bind("rts/priority/low"); REQUIRE(highLink.ok()); REQUIRE(lowLink.ok()); high->crowd()->link = std::move(highLink).takeValue(); low->crowd()->link = std::move(lowLink).takeValue(); high->crowd()->radius = low->crowd()->radius = 1.0f; high->navigation()->movementPriority = 10; low->navigation()->movementPriority = -10; CommandSpec move; move.kind = OrderKind::Move; move.target = {10.0f, 0.0f}; REQUIRE(high->orders()->values.enqueue(move).ok()); REQUIRE(low->orders()->values.enqueue(move).ok()); const eve::SimulationStep step{eve::SimulationTick{1}, eve::Duration::fromSeconds(0.1).expect("priority avoidance dt")}; REQUIRE(eve::rts::CrowdMotionSystem::step(step, crowd).ok()); const int highAgent = crowd.getNamedAgentIndex("rts/priority/high"); const int lowAgent = crowd.getNamedAgentIndex("rts/priority/low"); CHECK_EQ(crowd.getAgentAvoidancePriority(highAgent), 10); CHECK_EQ(crowd.getAgentAvoidancePriority(lowAgent), -10); CHECK(std::hypot(low->motion()->x, low->motion()->y) > std::hypot(high->motion()->x, high->motion()->y)); high->release(); low->release(); } TEST_CASE("rts.largeExactlyOverlappingCrowdUnlocksDeterministically") { auto simulate = [&] { ecs::Table world; ecs::ScopedTable guard(world); eve::crowd::Crowd crowd; crowd.setClampToField(false); crowd.setResolveOverlaps(true); crowd.setSeparationRadius(2.0f); crowd.setSeparationWeight(1.0f); std::vector units; units.reserve(96); for (int index = 0; index < 96; ++index) { Unit* unit = Unit::createUnit(); auto link = eve::rts::CrowdLink::bind("rts/overlap/" + std::to_string(index)); REQUIRE(link.ok()); unit->crowd()->link = std::move(link).takeValue(); unit->crowd()->radius = 0.45f; unit->motion()->speed = 4.0f; CommandSpec move; move.kind = OrderKind::Move; move.target = {20.0f, 10.0f}; REQUIRE(unit->orders()->values.enqueue(move).ok()); units.push_back(unit); } for (std::uint64_t tick = 1; tick <= 120; ++tick) { const eve::SimulationStep step{eve::SimulationTick{tick}, eve::Duration::fromSeconds(0.05).expect("large crowd dt")}; REQUIRE(eve::rts::CrowdMotionSystem::step(step, crowd).ok()); } std::vector positions; positions.reserve(units.size()); for (Unit* unit : units) positions.push_back({unit->motion()->x, unit->motion()->y}); for (Unit* unit : units) unit->release(); return positions; }; const auto first = simulate(); const auto second = simulate(); REQUIRE_EQ(first.size(), second.size()); int progressed = 0; for (std::size_t index = 0; index < first.size(); ++index) { progressed += first[index].x > 5.0f; CHECK(std::abs(first[index].x - second[index].x) < 1e-5f); CHECK(std::abs(first[index].y - second[index].y) < 1e-5f); } CHECK(progressed > 80); } TEST_CASE("rts.autoCombatUsesCanonicalSensingWeaponAndDamage") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(subject("00000000-0000-7000-8000-000000000031")); Faction* red = Faction::createFaction(subject("00000000-0000-7000-8000-000000000032")); Unit* attacker = Unit::createUnit(subject("00000000-0000-7000-8000-000000000033")); Unit* target = Unit::createUnit(subject("00000000-0000-7000-8000-000000000034")); auto blueLink = eve::rts::FactionLink::bind(ecs::handle_of(blue)); auto redLink = eve::rts::FactionLink::bind(ecs::handle_of(red)); REQUIRE(blueLink.ok()); REQUIRE(redLink.ok()); attacker->faction()->link = std::move(blueLink).takeValue(); target->faction()->link = std::move(redLink).takeValue(); attacker->motion()->x = 0.0f; target->motion()->x = 5.0f; attacker->durability()->state.health = 20.0; attacker->durability()->state.maxHealth = 20.0; target->durability()->state.health = 20.0; target->durability()->state.maxHealth = 20.0; target->shield()->capacity = 4.0f; target->shield()->value = 4.0f; target->shield()->regenDelay = 1.0f; attacker->veterancy()->veteranThreshold = 20.0f; attacker->veterancy()->eliteThreshold = 40.0f; attacker->veterancy()->veteranDamageFactor = 1.25f; attacker->veterancy()->eliteDamageFactor = 1.5f; attacker->veterancy()->veteranHealthFactor = 1.2f; attacker->veterancy()->eliteHealthFactor = 1.5f; attacker->combat()->acquisitionRange = 10.0f; attacker->combat()->leashRange = 12.0f; attacker->combat()->suppressionPerShot = 3.0f; target->morale()->capacity = 10.0f; eve::weapon::WeaponEntity* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = "rts-rifle"; definition.kind = eve::weapon::WeaponKind::Ranged; definition.logic = "rts-hitscan"; definition.damage = 6.0f; definition.damageType = "damage.physical"; definition.range = 8.0f; definition.cooldown = 0.1f; definition.magSize = 10; definition.reserveSize = 10; definition.reloadTime = 1.0f; definition.blockedByObstacles = true; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); weapon->state()->stages = &weapon->definition()->def->stages; weapon->state()->resource.kind = eve::weapon::ResourceKind::Ammo; weapon->state()->resource.value = 10.0f; weapon->state()->resource.max = 10.0f; auto weaponLink = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(weaponLink.ok()); attacker->weapon()->link = std::move(weaponLink).takeValue(); eve::sensing::SensingWorld sensing; eve::combat::DamageRuntime damage; eve::rts::CombatFireSystem::State combatState; const eve::SimulationStep obstructedStep{eve::SimulationTick{1}, eve::Duration::fromSeconds(0.2).expect("obstructed combat dt")}; std::vector blockedEvents; auto blockedLine = [&](eve::rts::WorldPosition origin, eve::rts::WorldPosition destination, ecs::EntityHandle source, ecs::EntityHandle targetHandle, const eve::weapon::WeaponDefinition& queriedWeapon) { CHECK(std::abs(origin.x) < 1e-5f); CHECK(std::abs(destination.x - 5.0f) < 1e-5f); CHECK(ecs::try_get(source) == attacker); CHECK(ecs::try_get(targetHandle) == target); CHECK_EQ(queriedWeapon.id, "rts-rifle"); return eve::Result::success(false); }; auto blockedSink = [&](const eve::rts::CombatFireEvent& event, eve::SimulationTick tick) { CHECK_EQ(tick.value(), 1u); blockedEvents.push_back(event); }; auto obstructed = eve::rts::CombatFireSystem::step(obstructedStep, combatState, sensing, damage, nullptr, blockedLine, nullptr, {}, {}, {}, blockedSink); REQUIRE(obstructed.ok()); CHECK_EQ(obstructed.value(), 0u); CHECK(std::abs(target->durability()->state.health - 20.0) < 1e-5); CHECK(std::abs(weapon->state()->resource.value - 10.0f) < 1e-5f); REQUIRE_EQ(blockedEvents.size(), 1u); CHECK_EQ(static_cast(blockedEvents[0].kind), static_cast(eve::rts::CombatFireEventKind::FireBlocked)); CHECK(blockedEvents[0].source == attacker->identity()->subject); CHECK(blockedEvents[0].target == target->identity()->subject); auto stillObstructed = eve::rts::CombatFireSystem::step(obstructedStep, combatState, sensing, damage, nullptr, blockedLine, nullptr, {}, {}, {}, blockedSink); REQUIRE(stillObstructed.ok()); CHECK_EQ(blockedEvents.size(), 1u); auto inaccurate = definition; inaccurate.accuracy = 0.0f; inaccurate.scatterRadius = 3.0f; weapon->definition()->owned = std::make_shared(inaccurate); weapon->definition()->def = weapon->definition()->owned.get(); weapon->state()->resource.value = 1.0f; std::vector fireEvents; auto missed = eve::rts::CombatFireSystem::step( {eve::SimulationTick{2}, eve::Duration::fromSeconds(0.2).expect("miss dt")}, combatState, sensing, damage, nullptr, {}, nullptr, {}, {}, {}, [&](const eve::rts::CombatFireEvent& event, eve::SimulationTick tick) { CHECK_EQ(tick.value(), 2u); fireEvents.push_back(event); }); REQUIRE(missed.ok()); CHECK_EQ(missed.value(), 1u); REQUIRE_EQ(fireEvents.size(), 3u); CHECK_EQ(static_cast(fireEvents[0].kind), static_cast(eve::rts::CombatFireEventKind::WeaponFired)); CHECK_EQ(static_cast(fireEvents[1].kind), static_cast(eve::rts::CombatFireEventKind::ShotMissed)); CHECK_EQ(static_cast(fireEvents[2].kind), static_cast(eve::rts::CombatFireEventKind::WeaponDry)); CHECK(fireEvents[0].source == attacker->identity()->subject); CHECK(fireEvents[0].target == target->identity()->subject); CHECK(std::abs(fireEvents[0].point.x - fireEvents[1].point.x) < 1e-5f); CHECK(std::abs(fireEvents[0].point.y - fireEvents[1].point.y) < 1e-5f); CHECK(std::abs(target->durability()->state.health - 20.0) < 1e-5); CHECK_EQ(attacker->combat()->shotSequence, 1u); weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); weapon->state()->resource.value = 0.0f; weapon->state()->resource.reserve = 10; weapon->state()->cooldown = 0.1f; std::vector reloadEvents; auto reloading = eve::rts::CombatFireSystem::step( {eve::SimulationTick{3}, eve::Duration::fromSeconds(0.2).expect("reload dt")}, combatState, sensing, damage, nullptr, {}, nullptr, {}, {}, {}, [&](const eve::rts::CombatFireEvent& event, eve::SimulationTick tick) { CHECK_EQ(tick.value(), 3u); reloadEvents.push_back(event); }); REQUIRE(reloading.ok()); REQUIRE_EQ(reloadEvents.size(), 1u); CHECK_EQ(static_cast(reloadEvents[0].kind), static_cast(eve::rts::CombatFireEventKind::ReloadStarted)); attacker->combat()->stance = eve::rts::CombatStance::Passive; attacker->combat()->target = {}; reloadEvents.clear(); auto reloaded = eve::rts::CombatFireSystem::step( {eve::SimulationTick{4}, eve::Duration::fromSeconds(0.8).expect("reload completion dt")}, combatState, sensing, damage, nullptr, {}, nullptr, {}, {}, {}, [&](const eve::rts::CombatFireEvent& event, eve::SimulationTick tick) { CHECK_EQ(tick.value(), 4u); reloadEvents.push_back(event); }); REQUIRE(reloaded.ok()); REQUIRE_EQ(reloadEvents.size(), 1u); CHECK_EQ(static_cast(reloadEvents[0].kind), static_cast(eve::rts::CombatFireEventKind::ReloadCompleted)); attacker->combat()->stance = eve::rts::CombatStance::Defensive; weapon->state()->resource.reloading = false; weapon->state()->resource.reloadProgress = 0.0f; weapon->state()->resource.value = 10.0f; weapon->state()->cooldown = 0.0f; for (std::uint64_t tick = 3; tick <= 6; ++tick) { const eve::SimulationStep step{eve::SimulationTick{tick}, eve::Duration::fromSeconds(0.2).expect("combat dt")}; auto fired = eve::rts::CombatFireSystem::step(step, combatState, sensing, damage); REQUIRE(fired.ok()); CHECK_EQ(fired.value(), 1u); } CHECK(!target->durability()->alive); CHECK(std::abs(target->durability()->state.health) < 1e-5); CHECK(std::abs(target->shield()->value) < 1e-5f); CHECK_EQ(attacker->veterancy()->level, 1); CHECK(std::abs(attacker->veterancy()->experience - 20.0f) < 1e-5f); CHECK(std::abs(attacker->combat()->upgradeDamageFactor - 1.25f) < 1e-5f); CHECK(std::abs(attacker->durability()->state.maxHealth - 24.0) < 1e-5); CHECK(target->morale()->active); CHECK(std::abs(target->morale()->suppression - 10.0f) < 1e-5f); CHECK(ecs::try_get(attacker->combat()->target) == nullptr); weapon->release(); attacker->release(); target->release(); blue->release(); red->release(); } TEST_CASE("rts.shieldsRegenerateOnlyAfterDamageCooldown") { ecs::Table world; ecs::ScopedTable guard(world); Unit* unit = Unit::createUnit(); unit->durability()->state.health = 10.0; unit->durability()->state.maxHealth = 10.0; unit->shield()->capacity = 10.0f; unit->shield()->value = 2.0f; unit->shield()->regenRate = 3.0f; unit->shield()->regenDelay = 2.0f; unit->shield()->cooldown = 2.0f; auto first = eve::rts::ShieldSystem::step({eve::SimulationTick{1}, eve::Duration::fromSeconds(1.0).expect("shield dt")}); REQUIRE(first.ok()); CHECK(std::abs(unit->shield()->value - 2.0f) < 1e-5f); auto second = eve::rts::ShieldSystem::step({eve::SimulationTick{2}, eve::Duration::fromSeconds(1.0).expect("shield dt")}); REQUIRE(second.ok()); CHECK(std::abs(unit->shield()->value - 5.0f) < 1e-5f); std::vector shieldEvents; auto full = eve::rts::ShieldSystem::step({eve::SimulationTick{3}, eve::Duration::fromSeconds(2.0).expect("shield full dt")}, [&](const eve::rts::LifecycleEvent& event, eve::SimulationTick tick) { CHECK_EQ(tick.value(), 3u); shieldEvents.push_back(event); }); REQUIRE(full.ok()); CHECK_EQ(unit->shield()->value, 10.0f); REQUIRE_EQ(shieldEvents.size(), 1u); CHECK_EQ(static_cast(shieldEvents[0].kind), static_cast(eve::rts::LifecycleEventKind::ShieldRecharged)); unit->release(); } TEST_CASE("rts.moraleAuraAcceleratesSuppressionRecovery") { ecs::Table world; ecs::ScopedTable guard(world); Faction* faction = Faction::createFaction(); Unit* suppressed = Unit::createUnit(); Unit* leader = Unit::createUnit(); auto suppressedFaction = eve::rts::FactionLink::bind(ecs::handle_of(faction)); auto leaderFaction = eve::rts::FactionLink::bind(ecs::handle_of(faction)); REQUIRE(suppressedFaction.ok()); REQUIRE(leaderFaction.ok()); suppressed->faction()->link = std::move(suppressedFaction).takeValue(); leader->faction()->link = std::move(leaderFaction).takeValue(); suppressed->morale()->capacity = 10.0f; suppressed->morale()->suppression = 6.0f; suppressed->morale()->recoveryRate = 1.0f; suppressed->morale()->active = true; leader->morale()->auraRange = 4.0f; leader->morale()->auraRecoveryBonus = 2.0f; const eve::SimulationStep step{eve::SimulationTick{1}, eve::Duration::fromSeconds(1.0).expect("morale dt")}; std::vector moraleEvents; auto recovered = eve::rts::MoraleSystem::step(step, [&](const eve::rts::LifecycleEvent& event, eve::SimulationTick tick) { CHECK_EQ(tick.value(), 1u); moraleEvents.push_back(event); }); REQUIRE(recovered.ok()); CHECK(std::abs(suppressed->morale()->suppression - 3.0f) < 1e-5f); CHECK(!suppressed->morale()->active); REQUIRE_EQ(moraleEvents.size(), 1u); CHECK_EQ(static_cast(moraleEvents[0].kind), static_cast(eve::rts::LifecycleEventKind::SuppressionRecovered)); suppressed->release(); leader->release(); faction->release(); } TEST_CASE("rts.attackGroundFiresThroughCanonicalWeaponRuntime") { ecs::Table world; ecs::ScopedTable guard(world); Faction* faction = Faction::createFaction(subject("00000000-0000-7000-8000-000000000041")); Unit* artillery = Unit::createUnit(subject("00000000-0000-7000-8000-000000000042")); auto factionLink = eve::rts::FactionLink::bind(ecs::handle_of(faction)); REQUIRE(factionLink.ok()); artillery->faction()->link = std::move(factionLink).takeValue(); artillery->motion()->x = 0.0f; eve::weapon::WeaponEntity* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = "rts-howitzer"; definition.kind = eve::weapon::WeaponKind::Ranged; definition.logic = "rts-indirect"; definition.range = 20.0f; definition.cooldown = 0.1f; definition.magSize = 4; definition.projectile.speed = 12.0f; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); weapon->state()->stages = &weapon->definition()->def->stages; weapon->state()->resource.kind = eve::weapon::ResourceKind::Ammo; weapon->state()->resource.value = 4.0f; weapon->state()->resource.max = 4.0f; auto weaponLink = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(weaponLink.ok()); artillery->weapon()->link = std::move(weaponLink).takeValue(); CommandSpec attackGround; attackGround.kind = OrderKind::AttackGround; attackGround.target = {10.0f, 2.0f}; auto queued = artillery->orders()->values.enqueue(attackGround); REQUIRE(queued.ok()); std::move(queued).takeValue(); eve::sensing::SensingWorld sensing; eve::combat::DamageRuntime damage; eve::rts::CombatFireSystem::State state; const eve::SimulationStep step{eve::SimulationTick{1}, eve::Duration::fromSeconds(0.2).expect("artillery dt")}; auto fired = eve::rts::CombatFireSystem::step(step, state, sensing, damage); REQUIRE(fired.ok()); CHECK_EQ(fired.value(), 1u); CHECK(artillery->orders()->values.empty()); CHECK(std::abs(weapon->state()->resource.value - 3.0f) < 1e-5f); weapon->release(); artillery->release(); faction->release(); } TEST_CASE("rts.armedBuildingAcquiresAndFiresAtEnemyUnit") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(subject("00000000-0000-7000-8000-000000000051")); Faction* red = Faction::createFaction(subject("00000000-0000-7000-8000-000000000052")); Building* turret = Building::createBuilding(subject("00000000-0000-7000-8000-000000000053")); Unit* target = Unit::createUnit(subject("00000000-0000-7000-8000-000000000054")); auto blueLink = eve::rts::FactionLink::bind(ecs::handle_of(blue)); auto redLink = eve::rts::FactionLink::bind(ecs::handle_of(red)); REQUIRE(blueLink.ok()); REQUIRE(redLink.ok()); turret->faction()->link = std::move(blueLink).takeValue(); target->faction()->link = std::move(redLink).takeValue(); turret->placement()->placed = true; target->motion()->x = 4.0f; target->durability()->state.health = 12.0; target->durability()->state.maxHealth = 12.0; turret->combat()->acquisitionRange = 8.0f; eve::weapon::WeaponEntity* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = "rts-turret"; definition.kind = eve::weapon::WeaponKind::Ranged; definition.logic = "rts-hitscan"; definition.damage = 12.0f; definition.range = 8.0f; definition.magSize = 2; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); weapon->state()->stages = &weapon->definition()->def->stages; weapon->state()->resource.kind = eve::weapon::ResourceKind::Ammo; weapon->state()->resource.value = 2.0f; weapon->state()->resource.max = 2.0f; auto weaponLink = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(weaponLink.ok()); turret->weapon()->link = std::move(weaponLink).takeValue(); eve::sensing::SensingWorld sensing; eve::combat::DamageRuntime damage; eve::rts::CombatFireSystem::State state; const eve::SimulationStep step{eve::SimulationTick{1}, eve::Duration::fromSeconds(0.2).expect("turret dt")}; auto fired = eve::rts::CombatFireSystem::step(step, state, sensing, damage); REQUIRE(fired.ok()); CHECK_EQ(fired.value(), 1u); CHECK(!target->durability()->alive); weapon->release(); turret->release(); target->release(); blue->release(); red->release(); } TEST_CASE("rts.linkedAirDefensesFormStableNetworksAndDistributeAircraft") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(subject("00000000-0000-7000-8000-000000000061")); Faction* red = Faction::createFaction(subject("00000000-0000-7000-8000-000000000062")); Building* first = Building::createBuilding(subject("00000000-0000-7000-8000-000000000063")); Building* second = Building::createBuilding(subject("00000000-0000-7000-8000-000000000064")); Unit* left = Unit::createUnit(subject("00000000-0000-7000-8000-000000000065")); Unit* right = Unit::createUnit(subject("00000000-0000-7000-8000-000000000066")); auto bindBuilding = [&](Building& building) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(blue)); REQUIRE(link.ok()); building.faction()->link = std::move(link).takeValue(); }; auto bindAircraft = [&](Unit& unit) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(red)); REQUIRE(link.ok()); unit.faction()->link = std::move(link).takeValue(); unit.motion()->airborne = true; unit.durability()->state.health = unit.durability()->state.maxHealth = 100.0; }; bindBuilding(*first); bindBuilding(*second); bindAircraft(*left); bindAircraft(*right); first->placement()->worldX = 0.0f; second->placement()->worldX = 6.0f; left->motion()->x = 3.0f; left->motion()->y = 4.0f; right->motion()->x = 7.0f; right->motion()->y = 4.0f; std::vector weapons; for (Building* defense : {first, second}) { auto* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = "network-sam"; definition.damage = 10.0f; definition.range = 20.0f; definition.targetsGround = false; definition.targetsAir = true; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); weapon->state()->stages = &weapon->definition()->def->stages; auto link = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(link.ok()); defense->weapon()->link = std::move(link).takeValue(); defense->combat()->acquisitionRange = 20.0f; defense->combat()->airDefenseNetworkRange = 8.0f; weapons.push_back(weapon); } auto assigned = eve::rts::TacticsSystem::step(); REQUIRE(assigned.ok()); CHECK_EQ(first->combat()->airDefenseNetworkSize, 2u); CHECK_EQ(second->combat()->airDefenseNetworkSize, 2u); CHECK_EQ(ecs::try_get(first->combat()->airDefenseNetworkRoot), first); CHECK_EQ(ecs::try_get(second->combat()->airDefenseNetworkRoot), first); CHECK(ecs::try_get(first->combat()->target) != nullptr); CHECK(ecs::try_get(second->combat()->target) != nullptr); CHECK_NE(ecs::try_get(first->combat()->target), ecs::try_get(second->combat()->target)); for (auto* weapon : weapons) weapon->release(); first->release(); second->release(); left->release(); right->release(); blue->release(); red->release(); } TEST_CASE("rts.escortAndCombatGroupCoordinateTargets") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(); Faction* red = Faction::createFaction(); auto protectedHandle = ecs::handle_of(Unit::createUnit()); auto escortHandle = ecs::handle_of(Unit::createUnit()); auto shooterAHandle = ecs::handle_of(Unit::createUnit()); auto shooterBHandle = ecs::handle_of(Unit::createUnit()); auto enemyAHandle = ecs::handle_of(Unit::createUnit()); auto enemyBHandle = ecs::handle_of(Unit::createUnit()); auto* protectedUnit = dynamic_cast(ecs::try_get(protectedHandle)); auto* escort = dynamic_cast(ecs::try_get(escortHandle)); auto* shooterA = dynamic_cast(ecs::try_get(shooterAHandle)); auto* shooterB = dynamic_cast(ecs::try_get(shooterBHandle)); auto* enemyA = dynamic_cast(ecs::try_get(enemyAHandle)); auto* enemyB = dynamic_cast(ecs::try_get(enemyBHandle)); auto bindFaction = [&](Unit& unit, Faction& faction) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(&faction)); REQUIRE(link.ok()); unit.faction()->link = std::move(link).takeValue(); }; bindFaction(*protectedUnit, *blue); bindFaction(*escort, *blue); bindFaction(*shooterA, *blue); bindFaction(*shooterB, *blue); bindFaction(*enemyA, *red); bindFaction(*enemyB, *red); protectedUnit->motion()->x = 10.0f; escort->tactics()->escortOffsetX = -2.0f; escort->tactics()->protectionRange = 5.0f; enemyA->motion()->x = 12.0f; enemyA->durability()->state.health = 5.0; enemyB->motion()->x = 6.0f; enemyB->durability()->state.health = 5.0; CommandSpec escortOrder; escortOrder.kind = OrderKind::Escort; escortOrder.targetEntity = ecs::handle_of(protectedUnit); auto queued = escort->orders()->values.enqueue(escortOrder); REQUIRE(queued.ok()); std::move(queued).takeValue(); eve::weapon::WeaponEntity* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = "group-rifle"; definition.damage = 10.0f; definition.range = 20.0f; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); auto weaponA = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); auto weaponB = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(weaponA.ok()); REQUIRE(weaponB.ok()); shooterA->weapon()->link = std::move(weaponA).takeValue(); shooterB->weapon()->link = std::move(weaponB).takeValue(); shooterA->tactics()->combatGroup = 7; shooterB->tactics()->combatGroup = 7; shooterA->combat()->acquisitionRange = 20.0f; shooterB->combat()->acquisitionRange = 20.0f; auto coordinated = eve::rts::TacticsSystem::step(); REQUIRE(coordinated.ok()); CHECK(std::abs(escort->tactics()->guardX - 8.0f) < 1e-5f); CHECK(ecs::try_get(escort->combat()->target) == enemyA); CHECK(ecs::try_get(shooterA->combat()->target) != nullptr); CHECK(ecs::try_get(shooterB->combat()->target) != nullptr); CHECK(ecs::try_get(shooterA->combat()->target) != ecs::try_get(shooterB->combat()->target)); weapon->release(); protectedUnit->release(); escort->release(); shooterA->release(); shooterB->release(); enemyA->release(); enemyB->release(); blue->release(); red->release(); } TEST_CASE("rts.escortGroupPrioritizesDirectThreatAndDistributesInterceptors") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(); Faction* red = Faction::createFaction(); Unit* protectedUnit = Unit::createUnit(subject("00000000-0000-7000-8000-000000000301")); Unit* first = Unit::createUnit(subject("00000000-0000-7000-8000-000000000302")); Unit* second = Unit::createUnit(subject("00000000-0000-7000-8000-000000000303")); Unit* nearby = Unit::createUnit(subject("00000000-0000-7000-8000-000000000304")); Unit* attacker = Unit::createUnit(subject("00000000-0000-7000-8000-000000000305")); auto bind = [&](Unit& unit, Faction& faction) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(&faction)); REQUIRE(link.ok()); unit.faction()->link = std::move(link).takeValue(); }; bind(*protectedUnit, *blue); bind(*first, *blue); bind(*second, *blue); bind(*nearby, *red); bind(*attacker, *red); nearby->motion()->x = 1.0f; attacker->motion()->x = 5.0f; attacker->combat()->target = ecs::handle_of(protectedUnit); for (Unit* escort : {first, second}) { escort->tactics()->protectionRange = 8.0f; CommandSpec order; order.kind = OrderKind::Escort; order.targetEntity = ecs::handle_of(protectedUnit); REQUIRE(escort->orders()->values.enqueue(order).ok()); } REQUIRE(eve::rts::TacticsSystem::step().ok()); CHECK(ecs::try_get(first->combat()->target) == attacker); CHECK(ecs::try_get(second->combat()->target) == nearby); protectedUnit->release(); first->release(); second->release(); nearby->release(); attacker->release(); blue->release(); red->release(); } TEST_CASE("rts.escortProtectsWholeSupplyConvoyAndRotatesScreen") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(); Faction* red = Faction::createFaction(); Unit* leader = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003b1")); Unit* tail = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003b2")); Unit* escort = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003b3")); Unit* attacker = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003b4")); auto bind = [&](Unit& unit, Faction& faction) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(&faction)); REQUIRE(link.ok()); unit.faction()->link = std::move(link).takeValue(); }; bind(*leader, *blue); bind(*tail, *blue); bind(*escort, *blue); bind(*attacker, *red); leader->motion()->x = 0.0f; tail->motion()->x = 10.0f; leader->supply()->convoyLeader = leader->identity()->self; tail->supply()->convoyLeader = leader->identity()->self; CommandSpec supplyMission; supplyMission.kind = OrderKind::SupplyRelay; supplyMission.target = {20.0f, 0.0f}; supplyMission.targetEntity = tail->identity()->self; REQUIRE(leader->orders()->values.replace(supplyMission).ok()); escort->tactics()->escortOffsetX = -2.0f; escort->tactics()->protectionRange = 3.0f; CommandSpec escortOrder; escortOrder.kind = OrderKind::Escort; escortOrder.targetEntity = leader->identity()->self; REQUIRE(escort->orders()->values.replace(escortOrder).ok()); attacker->motion()->x = 11.0f; attacker->combat()->target = tail->identity()->self; REQUIRE(eve::rts::TacticsSystem::step().ok()); CHECK(std::abs(escort->combat()->guardX - 5.0f) < 1e-5f); CHECK(std::abs(escort->tactics()->guardX - 3.0f) < 1e-5f); CHECK(escort->combat()->leashRange >= 7.9f); CHECK_EQ(ecs::try_get(escort->combat()->target), attacker); attacker->release(); escort->release(); tail->release(); leader->release(); red->release(); blue->release(); } TEST_CASE("rts.convoyEscortMatchesThreatSectorsAndReinforcesGap") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(); Faction* red = Faction::createFaction(); Unit* leader = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003d1")); Unit* tail = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003d2")); Unit* vanguard = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003d3")); Unit* rearguard = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003d4")); Unit* frontThreat = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003d5")); Unit* rearThreat = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003d6")); auto bind = [&](Unit& unit, Faction& faction) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(&faction)); REQUIRE(link.ok()); unit.faction()->link = std::move(link).takeValue(); }; for (Unit* unit : {leader, tail, vanguard, rearguard}) bind(*unit, *blue); bind(*frontThreat, *red); bind(*rearThreat, *red); leader->motion()->x = 4.0f; tail->motion()->x = 0.0f; leader->supply()->convoyLeader = leader->identity()->self; tail->supply()->convoyLeader = leader->identity()->self; CommandSpec mission; mission.kind = OrderKind::SupplyRelay; mission.targetEntity = tail->identity()->self; mission.target = {20.0f, 0.0f}; REQUIRE(leader->orders()->values.replace(mission).ok()); vanguard->tactics()->escortOffsetX = 3.0f; rearguard->tactics()->escortOffsetX = -3.0f; for (Unit* escort : {vanguard, rearguard}) { escort->tactics()->protectionRange = 12.0f; CommandSpec order; order.kind = OrderKind::Escort; order.targetEntity = leader->identity()->self; REQUIRE(escort->orders()->values.replace(order).ok()); } frontThreat->motion()->x = 9.0f; rearThreat->motion()->x = -5.0f; REQUIRE(eve::rts::TacticsSystem::step().ok()); CHECK_EQ(ecs::try_get(vanguard->combat()->target), frontThreat); CHECK_EQ(ecs::try_get(rearguard->combat()->target), rearThreat); CHECK(vanguard->tactics()->escortSectorMatched); CHECK(rearguard->tactics()->escortSectorMatched); rearThreat->durability()->alive = false; REQUIRE(eve::rts::TacticsSystem::step().ok()); CHECK_EQ(ecs::try_get(rearguard->combat()->target), frontThreat); CHECK(rearguard->tactics()->escortReinforcing); CHECK_EQ(rearguard->tactics()->escortReinforcementSector, 2); rearThreat->release(); frontThreat->release(); rearguard->release(); vanguard->release(); tail->release(); leader->release(); red->release(); blue->release(); } TEST_CASE("rts.convoyFlanksAtomicallyHandoffCrossingThreats") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(); Faction* red = Faction::createFaction(); Unit* leader = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003f1")); Unit* tail = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003f2")); Unit* left = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003f3")); Unit* right = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003f4")); Unit* upper = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003f5")); Unit* lower = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003f6")); auto bind = [&](Unit& unit, Faction& faction) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(&faction)); REQUIRE(link.ok()); unit.faction()->link = std::move(link).takeValue(); }; for (Unit* unit : {leader, tail, left, right}) bind(*unit, *blue); bind(*upper, *red); bind(*lower, *red); leader->motion()->x = 4.0f; tail->motion()->x = 0.0f; leader->supply()->convoyLeader = leader->identity()->self; tail->supply()->convoyLeader = leader->identity()->self; CommandSpec mission; mission.kind = OrderKind::SupplyRelay; mission.targetEntity = tail->identity()->self; mission.target = {20.0f, 0.0f}; REQUIRE(leader->orders()->values.replace(mission).ok()); left->tactics()->escortOffsetY = 3.0f; right->tactics()->escortOffsetY = -3.0f; for (Unit* escort : {left, right}) { escort->tactics()->protectionRange = 20.0f; CommandSpec order; order.kind = OrderKind::Escort; order.targetEntity = leader->identity()->self; REQUIRE(escort->orders()->values.replace(order).ok()); } upper->motion()->x = lower->motion()->x = 2.0f; upper->motion()->y = 7.0f; lower->motion()->y = -7.0f; REQUIRE(eve::rts::TacticsSystem::step().ok()); CHECK_EQ(ecs::try_get(left->combat()->target), upper); CHECK_EQ(ecs::try_get(right->combat()->target), lower); CHECK_EQ(left->tactics()->escortHandoffCount, 0u); CHECK_EQ(right->tactics()->escortHandoffCount, 0u); upper->motion()->y = -7.0f; lower->motion()->y = 7.0f; REQUIRE(eve::rts::TacticsSystem::step().ok()); CHECK_EQ(ecs::try_get(left->combat()->target), lower); CHECK_EQ(ecs::try_get(right->combat()->target), upper); CHECK(left->tactics()->escortSectorMatched); CHECK(right->tactics()->escortSectorMatched); CHECK_EQ(left->tactics()->escortHandoffCount, 1u); CHECK_EQ(right->tactics()->escortHandoffCount, 1u); CHECK_EQ(left->tactics()->escortInterceptTarget, lower->identity()->subject); CHECK_EQ(right->tactics()->escortInterceptTarget, upper->identity()->subject); lower->release(); upper->release(); right->release(); left->release(); tail->release(); leader->release(); red->release(); blue->release(); } TEST_CASE("rts.escortsFormStableRearLineDuringSuppressionRetreat") { ecs::Table world; ecs::ScopedTable guard(world); Faction* faction = Faction::createFaction(); Unit* protectedUnit = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003e1")); Unit* first = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003e2")); Unit* second = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003e3")); for (Unit* unit : {protectedUnit, first, second}) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(faction)); REQUIRE(link.ok()); unit->faction()->link = std::move(link).takeValue(); } protectedUnit->motion()->x = 10.0f; protectedUnit->morale()->retreating = true; protectedUnit->navigation()->plannedGoal = {20.0f, 0.0f}; for (Unit* escort : {first, second}) { escort->tactics()->escortOffsetX = -2.0f; CommandSpec order; order.kind = OrderKind::Escort; order.targetEntity = protectedUnit->identity()->self; REQUIRE(escort->orders()->values.replace(order).ok()); } REQUIRE(eve::rts::TacticsSystem::step().ok()); CHECK(first->tactics()->escortRearGuard); CHECK(second->tactics()->escortRearGuard); CHECK(first->tactics()->guardX < protectedUnit->motion()->x); CHECK(second->tactics()->guardX < protectedUnit->motion()->x); CHECK_NE(first->tactics()->guardY, second->tactics()->guardY); second->release(); first->release(); protectedUnit->release(); faction->release(); } TEST_CASE("rts.retreatingCombatGroupAlternatesMovementAndCoverFire") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(subject("00000000-0000-7000-8000-000000000325")); Faction* red = Faction::createFaction(subject("00000000-0000-7000-8000-000000000326")); Unit* first = Unit::createUnit(subject("00000000-0000-7000-8000-000000000327")); Unit* second = Unit::createUnit(subject("00000000-0000-7000-8000-000000000328")); Unit* enemy = Unit::createUnit(subject("00000000-0000-7000-8000-000000000329")); auto bind = [&](Unit& unit, Faction& faction) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(&faction)); REQUIRE(link.ok()); unit.faction()->link = std::move(link).takeValue(); }; bind(*first, *blue); bind(*second, *blue); bind(*enemy, *red); first->motion()->y = -1.0f; second->motion()->y = 1.0f; enemy->motion()->x = -5.0f; auto* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = "retreat-rifle"; definition.damage = 1.0f; definition.range = 20.0f; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); for (Unit* unit : {first, second}) { auto weaponLink = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(weaponLink.ok()); unit->weapon()->link = std::move(weaponLink).takeValue(); unit->combat()->acquisitionRange = 20.0f; unit->tactics()->combatGroup = 17; unit->morale()->retreating = true; unit->motion()->speed = 2.0f; CommandSpec retreat; retreat.kind = OrderKind::Move; retreat.target = {20.0f, unit->motion()->y}; REQUIRE(unit->orders()->values.replace(retreat).ok()); } const eve::SimulationStep firstPhase{ eve::SimulationTick{1}, eve::Duration::fromSeconds(0.1).expect("retreat cover first phase")}; REQUIRE(eve::rts::TacticsSystem::step(nullptr, firstPhase).ok()); CHECK_EQ(first->tactics()->retreatFireTeam, 0); CHECK_EQ(second->tactics()->retreatFireTeam, 1); CHECK(first->tactics()->retreatCovering); CHECK(!second->tactics()->retreatCovering); CHECK_EQ(ecs::try_get(first->combat()->target), enemy); CHECK_EQ(ecs::try_get(second->combat()->target), nullptr); const float firstX = first->motion()->x; const float secondX = second->motion()->x; REQUIRE(eve::rts::MotionSystem::step(firstPhase).ok()); CHECK_EQ(first->motion()->x, firstX); CHECK(second->motion()->x > secondX); const eve::SimulationStep rotate{ eve::SimulationTick{2}, eve::Duration::fromSeconds(1.4).expect("retreat cover rotation")}; REQUIRE(eve::rts::TacticsSystem::step(nullptr, rotate).ok()); CHECK(!first->tactics()->retreatCovering); CHECK(second->tactics()->retreatCovering); CHECK_EQ(ecs::try_get(first->combat()->target), nullptr); CHECK_EQ(ecs::try_get(second->combat()->target), enemy); const float firstBeforeRotate = first->motion()->x; const float secondBeforeRotate = second->motion()->x; REQUIRE(eve::rts::MotionSystem::step(firstPhase).ok()); CHECK(first->motion()->x > firstBeforeRotate); CHECK_EQ(second->motion()->x, secondBeforeRotate); weapon->release(); first->release(); second->release(); enemy->release(); blue->release(); red->release(); } TEST_CASE("rts.combatGroupThreatSectorsPartitionTargetsAndFallBack") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(); Faction* red = Faction::createFaction(); Unit* left = Unit::createUnit(); Unit* right = Unit::createUnit(); Unit* leftTarget = Unit::createUnit(); Unit* rightTarget = Unit::createUnit(); auto bind = [&](Unit& unit, Faction& faction) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(&faction)); REQUIRE(link.ok()); unit.faction()->link = std::move(link).takeValue(); }; bind(*left, *blue); bind(*right, *blue); bind(*leftTarget, *red); bind(*rightTarget, *red); left->motion()->x = -1.0f; right->motion()->x = 1.0f; leftTarget->motion()->x = -5.0f; rightTarget->motion()->x = 5.0f; eve::weapon::WeaponEntity* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = "sector-rifle"; definition.damage = 1.0f; definition.range = 20.0f; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); for (Unit* shooter : {left, right}) { auto link = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(link.ok()); shooter->weapon()->link = std::move(link).takeValue(); shooter->combat()->acquisitionRange = 20.0f; shooter->tactics()->combatGroup = 11; } left->tactics()->threatSector = -1; right->tactics()->threatSector = 1; REQUIRE(eve::rts::TacticsSystem::step().ok()); CHECK(ecs::try_get(left->combat()->target) == leftTarget); CHECK(ecs::try_get(right->combat()->target) == rightTarget); leftTarget->durability()->alive = false; REQUIRE(eve::rts::TacticsSystem::step().ok()); CHECK(ecs::try_get(left->combat()->target) == rightTarget); weapon->release(); left->release(); right->release(); leftTarget->release(); rightTarget->release(); blue->release(); red->release(); } TEST_CASE("rts.combatFireControlMatchesDamageTypesToArmor") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(subject("00000000-0000-7000-8000-000000000331")); Faction* red = Faction::createFaction(subject("00000000-0000-7000-8000-000000000332")); Unit* hunter = Unit::createUnit(subject("00000000-0000-7000-8000-000000000333")); Unit* grenadier = Unit::createUnit(subject("00000000-0000-7000-8000-000000000334")); Unit* armored = Unit::createUnit(subject("00000000-0000-7000-8000-000000000335")); Unit* light = Unit::createUnit(subject("00000000-0000-7000-8000-000000000336")); auto bind = [&](Unit& unit, Faction& faction) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(&faction)); REQUIRE(link.ok()); unit.faction()->link = std::move(link).takeValue(); }; bind(*hunter, *blue); bind(*grenadier, *blue); bind(*armored, *red); bind(*light, *red); armored->motion()->x = light->motion()->x = 8.0f; armored->motion()->y = -1.0f; light->motion()->y = 1.0f; for (Unit* target : {armored, light}) target->durability()->state.health = target->durability()->state.maxHealth = 100.0; std::vector weapons; auto equip = [&](Unit& shooter, const char* id, const char* damageType) { auto* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = id; definition.damage = 10.0f; definition.damageType = damageType; definition.range = 20.0f; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); auto link = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(link.ok()); shooter.weapon()->link = std::move(link).takeValue(); shooter.combat()->acquisitionRange = 20.0f; shooter.tactics()->combatGroup = 23; weapons.push_back(weapon); }; equip(*hunter, "tank-hunter", "damage.piercing"); equip(*grenadier, "grenadier", "damage.explosive"); FireControlArmorRule rule; rule.armored = armored->identity()->subject; rule.light = light->identity()->subject; eve::combat::DamageRuntime damage(&rule); REQUIRE(eve::rts::TacticsSystem::step(&damage).ok()); CHECK_EQ(ecs::try_get(hunter->combat()->target), armored); CHECK_EQ(ecs::try_get(grenadier->combat()->target), light); CHECK(std::abs(hunter->tactics()->fireControlEffectiveness - 2.0f) < 1e-5f); CHECK(std::abs(grenadier->tactics()->fireControlEffectiveness - 2.0f) < 1e-5f); armored->durability()->alive = false; REQUIRE(eve::rts::TacticsSystem::step(&damage).ok()); CHECK_EQ(ecs::try_get(hunter->combat()->target), light); CHECK(std::abs(hunter->tactics()->fireControlEffectiveness - 0.5f) < 1e-5f); for (auto* weapon : weapons) weapon->release(); hunter->release(); grenadier->release(); armored->release(); light->release(); blue->release(); red->release(); } TEST_CASE("rts.separateCombatGroupsShareAutomaticVolleyBudget") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(subject("00000000-0000-7000-8000-000000000341")); Faction* red = Faction::createFaction(subject("00000000-0000-7000-8000-000000000342")); Unit* first = Unit::createUnit(subject("00000000-0000-7000-8000-000000000343")); Unit* second = Unit::createUnit(subject("00000000-0000-7000-8000-000000000344")); Unit* nearTarget = Unit::createUnit(subject("00000000-0000-7000-8000-000000000345")); Unit* farTarget = Unit::createUnit(subject("00000000-0000-7000-8000-000000000346")); auto bind = [&](Unit& unit, Faction& faction) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(&faction)); REQUIRE(link.ok()); unit.faction()->link = std::move(link).takeValue(); }; bind(*first, *blue); bind(*second, *blue); bind(*nearTarget, *red); bind(*farTarget, *red); nearTarget->motion()->x = 5.0f; farTarget->motion()->x = 7.0f; for (Unit* target : {nearTarget, farTarget}) target->durability()->state.health = target->durability()->state.maxHealth = 10.0; auto* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = "shared-budget-cannon"; definition.damage = 10.0f; definition.range = 20.0f; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); std::uint64_t group = 1; for (Unit* shooter : {first, second}) { auto link = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(link.ok()); shooter->weapon()->link = std::move(link).takeValue(); shooter->combat()->acquisitionRange = 20.0f; shooter->tactics()->combatGroup = group++; } eve::combat::DamageRuntime damage; REQUIRE(eve::rts::TacticsSystem::step(&damage).ok()); CHECK_EQ(ecs::try_get(first->combat()->target), nearTarget); CHECK_EQ(ecs::try_get(second->combat()->target), farTarget); CommandSpec focus; focus.kind = OrderKind::Attack; focus.targetEntity = ecs::handle_of(nearTarget); REQUIRE(first->orders()->values.replace(focus).ok()); REQUIRE(second->orders()->values.replace(focus).ok()); first->combat()->target = ecs::handle_of(nearTarget); second->combat()->target = ecs::handle_of(nearTarget); REQUIRE(eve::rts::TacticsSystem::step(&damage).ok()); CHECK_EQ(ecs::try_get(first->combat()->target), nearTarget); CHECK_EQ(ecs::try_get(second->combat()->target), nearTarget); weapon->release(); first->release(); second->release(); nearTarget->release(); farTarget->release(); blue->release(); red->release(); } TEST_CASE("rts.automaticTurretsShareDamageCommitmentsWhileExplicitAttackFocuses") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(subject("00000000-0000-7000-8000-000000000351")); Faction* red = Faction::createFaction(subject("00000000-0000-7000-8000-000000000352")); Building* first = Building::createBuilding(subject("00000000-0000-7000-8000-000000000353")); Building* second = Building::createBuilding(subject("00000000-0000-7000-8000-000000000354")); Unit* nearTarget = Unit::createUnit(subject("00000000-0000-7000-8000-000000000355")); Unit* farTarget = Unit::createUnit(subject("00000000-0000-7000-8000-000000000356")); auto bindBuilding = [&](Building& building) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(blue)); REQUIRE(link.ok()); building.faction()->link = std::move(link).takeValue(); }; auto bindTarget = [&](Unit& unit) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(red)); REQUIRE(link.ok()); unit.faction()->link = std::move(link).takeValue(); unit.durability()->state.health = unit.durability()->state.maxHealth = 50.0; }; bindBuilding(*first); bindBuilding(*second); bindTarget(*nearTarget); bindTarget(*farTarget); second->placement()->worldY = 1.0f; nearTarget->motion()->x = 4.0f; farTarget->motion()->x = 6.0f; std::vector weapons; for (Building* turret : {first, second}) { auto* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = "commitment-cannon"; definition.damage = 80.0f; definition.range = 10.0f; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); auto link = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(link.ok()); turret->weapon()->link = std::move(link).takeValue(); turret->combat()->acquisitionRange = 10.0f; weapons.push_back(weapon); } eve::combat::DamageRuntime damage; REQUIRE(eve::rts::TacticsSystem::step(&damage).ok()); CHECK_EQ(ecs::try_get(first->combat()->target), nearTarget); CHECK_EQ(ecs::try_get(second->combat()->target), farTarget); CommandSpec focus; focus.kind = OrderKind::Attack; focus.targetEntity = ecs::handle_of(nearTarget); REQUIRE(first->orders()->values.replace(focus).ok()); REQUIRE(second->orders()->values.replace(focus).ok()); first->combat()->target = ecs::handle_of(nearTarget); second->combat()->target = ecs::handle_of(nearTarget); REQUIRE(eve::rts::TacticsSystem::step(&damage).ok()); CHECK_EQ(ecs::try_get(first->combat()->target), nearTarget); CHECK_EQ(ecs::try_get(second->combat()->target), nearTarget); for (auto* weapon : weapons) weapon->release(); first->release(); second->release(); nearTarget->release(); farTarget->release(); blue->release(); red->release(); } TEST_CASE("rts.splashCommitmentsDistributeAutomaticFireAcrossHostileClusters") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(subject("00000000-0000-7000-8000-000000000361")); Faction* red = Faction::createFaction(subject("00000000-0000-7000-8000-000000000362")); std::vector artillery; std::vector targets; for (const char* id : {"00000000-0000-7000-8000-000000000363", "00000000-0000-7000-8000-000000000364", "00000000-0000-7000-8000-000000000365"}) artillery.push_back(Unit::createUnit(subject(id))); for (const char* id : {"00000000-0000-7000-8000-000000000366", "00000000-0000-7000-8000-000000000367", "00000000-0000-7000-8000-000000000368", "00000000-0000-7000-8000-000000000369"}) targets.push_back(Unit::createUnit(subject(id))); Unit* ally = Unit::createUnit(subject("00000000-0000-7000-8000-00000000036a")); auto bind = [&](Unit& unit, Faction& faction) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(&faction)); REQUIRE(link.ok()); unit.faction()->link = std::move(link).takeValue(); }; for (Unit* unit : artillery) bind(*unit, *blue); for (Unit* unit : targets) { bind(*unit, *red); unit->durability()->state.health = unit->durability()->state.maxHealth = 80.0; } bind(*ally, *blue); targets[0]->motion()->x = targets[1]->motion()->x = 5.0f; targets[0]->motion()->y = 0.0f; targets[1]->motion()->y = 1.0f; targets[2]->motion()->x = targets[3]->motion()->x = 9.0f; targets[2]->motion()->y = 0.0f; targets[3]->motion()->y = 1.0f; ally->motion()->x = 5.0f; ally->motion()->y = 0.5f; auto* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = "cluster-shell"; definition.damage = 100.0f; definition.range = 12.0f; definition.projectile.speed = 10.0f; definition.projectile.aoe = 2.0f; definition.splashMinimumDamageFactor = 0.1f; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); for (Unit* shooter : artillery) { auto link = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(link.ok()); shooter->weapon()->link = std::move(link).takeValue(); shooter->combat()->acquisitionRange = 12.0f; shooter->tactics()->combatGroup = 29; } eve::combat::DamageRuntime damage; REQUIRE(eve::rts::TacticsSystem::step(&damage).ok()); int firstCluster = 0; int secondCluster = 0; for (Unit* shooter : artillery) { ecs::Entity* selected = ecs::try_get(shooter->combat()->target); firstCluster += selected == targets[0] || selected == targets[1]; secondCluster += selected == targets[2] || selected == targets[3]; CHECK_NE(selected, ally); } CHECK_EQ(firstCluster, 2); CHECK_EQ(secondCluster, 1); weapon->release(); for (Unit* unit : artillery) unit->release(); for (Unit* unit : targets) unit->release(); ally->release(); blue->release(); red->release(); } TEST_CASE("rts.volleyCommitmentsBudgetShieldBeforeArmoredHealth") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(subject("00000000-0000-7000-8000-000000000371")); Faction* red = Faction::createFaction(subject("00000000-0000-7000-8000-000000000372")); std::vector shooters; for (const char* id : {"00000000-0000-7000-8000-000000000373", "00000000-0000-7000-8000-000000000374", "00000000-0000-7000-8000-000000000375"}) shooters.push_back(Unit::createUnit(subject(id))); Unit* shielded = Unit::createUnit(subject("00000000-0000-7000-8000-000000000376")); Unit* reserve = Unit::createUnit(subject("00000000-0000-7000-8000-000000000377")); auto bind = [&](Unit& unit, Faction& faction) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(&faction)); REQUIRE(link.ok()); unit.faction()->link = std::move(link).takeValue(); }; for (Unit* shooter : shooters) bind(*shooter, *blue); bind(*shielded, *red); bind(*reserve, *red); shielded->motion()->x = 5.0f; shielded->durability()->state.health = shielded->durability()->state.maxHealth = 10.0; shielded->shield()->value = shielded->shield()->capacity = 10.0f; reserve->motion()->x = 7.0f; reserve->durability()->state.health = reserve->durability()->state.maxHealth = 100.0; auto* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = "shield-budget-gun"; definition.damage = 10.0f; definition.range = 12.0f; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); for (Unit* shooter : shooters) { auto link = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(link.ok()); shooter->weapon()->link = std::move(link).takeValue(); shooter->combat()->acquisitionRange = 12.0f; shooter->tactics()->combatGroup = 31; } eve::combat::DamageRuntime damage; REQUIRE(eve::rts::TacticsSystem::step(&damage).ok()); CHECK_EQ(ecs::try_get(shooters[0]->combat()->target), shielded); CHECK_EQ(ecs::try_get(shooters[1]->combat()->target), shielded); CHECK_EQ(ecs::try_get(shooters[2]->combat()->target), reserve); weapon->release(); for (Unit* shooter : shooters) shooter->release(); shielded->release(); reserve->release(); blue->release(); red->release(); } TEST_CASE("rts.coordinatedVolleyHoldsAndReleasesGroupWhileExplicitAttackBypassesSchedule") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(subject("00000000-0000-7000-8000-000000000321")); Faction* red = Faction::createFaction(subject("00000000-0000-7000-8000-000000000322")); Unit* first = Unit::createUnit(subject("00000000-0000-7000-8000-000000000323")); Unit* second = Unit::createUnit(subject("00000000-0000-7000-8000-000000000324")); Unit* target = Unit::createUnit(subject("00000000-0000-7000-8000-000000000325")); auto bind = [&](Unit& unit, Faction& faction) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(&faction)); REQUIRE(link.ok()); unit.faction()->link = std::move(link).takeValue(); }; bind(*first, *blue); bind(*second, *blue); bind(*target, *red); target->motion()->x = 6.0f; target->durability()->state.health = target->durability()->state.maxHealth = 100.0; std::vector weapons; for (Unit* shooter : {first, second}) { auto* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = "volley-shell"; definition.damage = 10.0f; definition.range = 12.0f; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); weapon->state()->stages = &weapon->definition()->def->stages; auto link = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(link.ok()); shooter->weapon()->link = std::move(link).takeValue(); shooter->combat()->acquisitionRange = 12.0f; shooter->tactics()->combatGroup = 19; shooter->tactics()->coordinatedVolleyInterval = 0.5f; weapons.push_back(weapon); } eve::sensing::SensingWorld sensing; eve::combat::DamageRuntime damage; eve::rts::CombatFireSystem::State state; auto step = eve::SimulationStep{eve::SimulationTick{1}, eve::Duration::fromSeconds(0.1).expect("volley step")}; for (int index = 0; index < 4; ++index) { REQUIRE(eve::rts::TacticsSystem::step().ok()); auto fired = eve::rts::CombatFireSystem::step(step, state, sensing, damage); REQUIRE(fired.ok()); CHECK_EQ(fired.value(), 0u); step.tick = eve::SimulationTick{step.tick.value() + 1}; } CHECK(first->tactics()->volleyHolding); CHECK(second->tactics()->volleyHolding); CHECK(first->tactics()->volleyReleaseRemaining <= 0.11f); REQUIRE(eve::rts::TacticsSystem::step().ok()); auto released = eve::rts::CombatFireSystem::step(step, state, sensing, damage); REQUIRE(released.ok()); CHECK_EQ(released.value(), 2u); CHECK(!first->tactics()->volleyHolding); CHECK(!second->tactics()->volleyHolding); CHECK(std::abs(target->durability()->state.health - 80.0) < 1e-5); CommandSpec explicitAttack; explicitAttack.kind = OrderKind::Attack; explicitAttack.targetEntity = ecs::handle_of(target); REQUIRE(first->orders()->values.replace(explicitAttack).ok()); step.tick = eve::SimulationTick{step.tick.value() + 1}; REQUIRE(eve::rts::TacticsSystem::step().ok()); auto explicitShot = eve::rts::CombatFireSystem::step(step, state, sensing, damage); REQUIRE(explicitShot.ok()); CHECK_EQ(explicitShot.value(), 1u); CHECK(std::abs(target->durability()->state.health - 70.0) < 1e-5); for (auto* weapon : weapons) weapon->release(); first->release(); second->release(); target->release(); blue->release(); red->release(); } TEST_CASE("rts.aiRequestsProductionAndLaunchesAttackMove") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(); Faction* red = Faction::createFaction(); Building* producer = Building::createBuilding(); Building* target = Building::createBuilding(); const auto workerId = eve::LogicalId::parse("rts:worker"); const auto armyId = eve::LogicalId::parse("rts:marine"); const auto baseId = eve::LogicalId::parse("rts:command-center"); REQUIRE(workerId.has_value()); REQUIRE(armyId.has_value()); REQUIRE(baseId.has_value()); Unit* first = Unit::createUnit({}, *armyId); Unit* second = Unit::createUnit({}, *armyId); auto bindUnit = [&](Unit& unit, Faction& faction) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(&faction)); REQUIRE(link.ok()); unit.faction()->link = std::move(link).takeValue(); }; auto bindBuilding = [&](Building& building, Faction& faction) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(&faction)); REQUIRE(link.ok()); building.faction()->link = std::move(link).takeValue(); }; bindUnit(*first, *blue); bindUnit(*second, *blue); bindBuilding(*producer, *blue); bindBuilding(*target, *red); target->definition()->id = *baseId; target->placement()->worldX = 20.0f; target->placement()->worldY = 4.0f; blue->strategy()->enabled = true; blue->strategy()->workerDefinition = *workerId; blue->strategy()->armyDefinition = *armyId; blue->strategy()->targetBuildingDefinition = *baseId; blue->strategy()->desiredWorkers = 0; blue->strategy()->attackThreshold = 2; blue->strategy()->thinkInterval = 0.5f; int productionRequests = 0; eve::LogicalId requestedDefinition; const eve::SimulationStep step{eve::SimulationTick{1}, eve::Duration::fromSeconds(0.5).expect("AI dt")}; auto result = eve::rts::AISystem::step(step, [&](Faction& faction, Building& building, const eve::LogicalId& definition) { CHECK(&faction == blue); CHECK(&building == producer); ++productionRequests; requestedDefinition = definition; return eve::Result::success(eve::Status::success(eve::StatusCode::Applied)); }); REQUIRE(result.ok()); CHECK_EQ(productionRequests, 1); CHECK(requestedDefinition == *armyId); CHECK(!first->orders()->values.empty()); CHECK(!second->orders()->values.empty()); CHECK_EQ(first->tactics()->combatGroup, second->tactics()->combatGroup); CHECK(first->tactics()->combatGroup != 0); first->release(); second->release(); producer->release(); target->release(); blue->release(); red->release(); } TEST_CASE("rts.actionAdapterReusesSharedActionRuntime") { ecs::Table world; ecs::ScopedTable guard(world); Unit* unit = Unit::createUnit(); unit->motion()->speed = 10.0f; const CommandSpec move{OrderKind::Move, {20.0f, 0.0f}, {}, 0, 0.0}; auto order = unit->orders()->values.enqueue(move); REQUIRE(order.ok()); std::move(order).takeValue(); eve::action::ActionRuntime runtime; eve::rts::ActionAdapter adapter(runtime); const eve::SimulationStep first{eve::SimulationTick{1}, eve::Duration::fromSeconds(1.0).expect("first dt")}; auto moved = eve::rts::MotionSystem::step(first); REQUIRE(moved.ok()); std::move(moved).takeValue(); auto pending = eve::rts::OrderActionSystem::step(first, adapter); REQUIRE(pending.ok()); std::move(pending).takeValue(); CHECK(!unit->orders()->values.empty()); CHECK_EQ(runtime.executionCount(), 1u); const eve::SimulationStep second{eve::SimulationTick{2}, eve::Duration::fromSeconds(1.0).expect("second dt")}; auto completed = eve::rts::MotionSystem::step(second); REQUIRE(completed.ok()); std::move(completed).takeValue(); auto action = eve::rts::OrderActionSystem::step(second, adapter); REQUIRE(action.ok()); std::move(action).takeValue(); CHECK(unit->orders()->values.empty()); unit->release(); } TEST_CASE("rts.transportBoardingUsesGenerationCheckedContainment") { ecs::Table world; ecs::ScopedTable guard(world); Faction* faction = Faction::createFaction(); Unit* transport = Unit::createUnit(); Unit* passenger = Unit::createUnit(); auto transportFaction = eve::rts::FactionLink::bind(ecs::handle_of(faction)); auto passengerFaction = eve::rts::FactionLink::bind(ecs::handle_of(faction)); REQUIRE(transportFaction.ok()); REQUIRE(passengerFaction.ok()); transport->faction()->link = std::move(transportFaction).takeValue(); passenger->faction()->link = std::move(passengerFaction).takeValue(); transport->containment()->capacity = 2; transport->motion()->x = 4.0f; transport->motion()->y = 7.0f; passenger->motion()->arrived = true; CommandSpec board; board.kind = OrderKind::BoardTransport; board.target = {4.0f, 7.0f}; board.targetEntity = ecs::handle_of(transport); auto queued = passenger->orders()->values.enqueue(board); REQUIRE(queued.ok()); std::move(queued).takeValue(); auto boarded = eve::rts::ContainmentSystem::step(); REQUIRE(boarded.ok()); CHECK_EQ(boarded.value(), 1u); CHECK(passenger->containment()->container.resolve() == transport); CHECK_EQ(transport->containment()->occupants.size(), 1u); CHECK(passenger->orders()->values.empty()); transport->motion()->x = 9.0f; auto synchronized = eve::rts::ContainmentSystem::step(); REQUIRE(synchronized.ok()); CHECK(std::abs(passenger->motion()->x - 9.0f) < 1e-5f); auto unloaded = eve::rts::ContainmentSystem::unload(*transport, {12.0f, 3.0f}); REQUIRE(unloaded.ok()); CHECK_EQ(unloaded.value(), 1u); CHECK(transport->containment()->occupants.empty()); CHECK(!passenger->containment()->container.isBound()); CHECK(passenger->motion()->x > 12.0f); Building* bunker = Building::createBuilding(); auto bunkerFaction = eve::rts::FactionLink::bind(ecs::handle_of(faction)); REQUIRE(bunkerFaction.ok()); bunker->faction()->link = std::move(bunkerFaction).takeValue(); auto bunkerLink = eve::rts::ContainerLink::bind(ecs::handle_of(bunker)); REQUIRE(bunkerLink.ok()); passenger->containment()->container = std::move(bunkerLink).takeValue(); bunker->garrison()->occupants.push_back(ecs::handle_of(passenger)); bunker->capture()->blockedByGarrison = true; auto evacuated = eve::rts::ContainmentSystem::evacuate(*bunker, {15.0f, 5.0f}); REQUIRE(evacuated.ok()); CHECK_EQ(evacuated.value(), 1u); CHECK(bunker->garrison()->occupants.empty()); CHECK(!bunker->capture()->blockedByGarrison); CHECK(!passenger->containment()->container.isBound()); passenger->release(); transport->release(); bunker->release(); faction->release(); } TEST_CASE("rts.facadeRemovalRepairsRelationshipsAndPreservesExplicitBuildingEvacuation") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS module; auto factionResult = module.newFaction(subject("00000000-0000-7000-8000-00000000d101")); auto playerResult = module.newPlayer(subject("00000000-0000-7000-8000-00000000d102")); auto nodeResult = module.newResourceNode( subject("00000000-0000-7000-8000-00000000d103"), "ore", 100.0f, {2.0f, 3.0f}); REQUIRE(factionResult.ok()); REQUIRE(playerResult.ok()); REQUIRE(nodeResult.ok()); Faction* faction = factionResult.value(); Player* player = playerResult.value(); ResourceNode* node = nodeResult.value(); auto workerResult = module.newFactionUnit(*faction, subject("00000000-0000-7000-8000-00000000d104")); auto transportResult = module.newFactionUnit(*faction, subject("00000000-0000-7000-8000-00000000d105")); auto passengerResult = module.newFactionUnit(*faction, subject("00000000-0000-7000-8000-00000000d106")); auto buildingResult = module.newFactionBuilding(*faction, subject("00000000-0000-7000-8000-00000000d107")); auto occupantResult = module.newFactionUnit(*faction, subject("00000000-0000-7000-8000-00000000d108")); REQUIRE(workerResult.ok()); REQUIRE(transportResult.ok()); REQUIRE(passengerResult.ok()); REQUIRE(buildingResult.ok()); REQUIRE(occupantResult.ok()); Unit* worker = workerResult.value(); Unit* transport = transportResult.value(); Unit* passenger = passengerResult.value(); Building* building = buildingResult.value(); Unit* occupant = occupantResult.value(); auto nodeLink = eve::rts::ResourceNodeLink::bind(ecs::handle_of(node)); REQUIRE(nodeLink.ok()); worker->worker()->resourceNode = std::move(nodeLink).takeValue(); node->harvest()->workers.push_back(ecs::handle_of(worker)); player->selection()->units = {ecs::handle_of(worker), ecs::handle_of(transport)}; player->selection()->buildings = {ecs::handle_of(building)}; auto transportLink = eve::rts::ContainerLink::bind(ecs::handle_of(transport)); REQUIRE(transportLink.ok()); passenger->containment()->container = std::move(transportLink).takeValue(); transport->containment()->occupants.push_back(ecs::handle_of(passenger)); auto buildingLink = eve::rts::ContainerLink::bind(ecs::handle_of(building)); REQUIRE(buildingLink.ok()); occupant->containment()->container = std::move(buildingLink).takeValue(); building->garrison()->occupants.push_back(ecs::handle_of(occupant)); building->capture()->blockedByGarrison = true; building->placement()->worldX = 9.0f; building->placement()->worldY = 4.0f; REQUIRE(module.remove(transport->identity()->subject).ok()); CHECK(module.findUnit(subject("00000000-0000-7000-8000-00000000d105")) == nullptr); CHECK(module.findUnit(subject("00000000-0000-7000-8000-00000000d106")) == nullptr); CHECK_EQ(player->selection()->units.size(), 1u); REQUIRE(module.remove(building->identity()->subject).ok()); CHECK(module.findBuilding(subject("00000000-0000-7000-8000-00000000d107")) == nullptr); Unit* released = module.findUnit(subject("00000000-0000-7000-8000-00000000d108")); REQUIRE(released != nullptr); CHECK(!released->containment()->container.isBound()); CHECK_EQ(released->motion()->x, 9.0f); CHECK_EQ(released->motion()->y, 4.0f); CHECK(player->selection()->buildings.empty()); REQUIRE(module.remove(node->identity()->subject).ok()); CHECK(!worker->worker()->resourceNode.isBound()); CHECK(worker->orders()->values.empty()); CHECK_EQ(module.resourceNodeCount(), 0u); } TEST_CASE("rts.destroyedContainerCleanupCascadesOccupantsAndRunsAtStepBoundary") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS module; auto faction = module.newFaction(subject("00000000-0000-7000-8000-00000000d111")); REQUIRE(faction.ok()); auto building = module.newFactionBuilding(*faction.value(), subject("00000000-0000-7000-8000-00000000d112")); auto occupant = module.newFactionUnit(*faction.value(), subject("00000000-0000-7000-8000-00000000d113")); REQUIRE(building.ok()); REQUIRE(occupant.ok()); auto container = eve::rts::ContainerLink::bind(ecs::handle_of(building.value())); REQUIRE(container.ok()); occupant.value()->containment()->container = std::move(container).takeValue(); building.value()->garrison()->occupants.push_back(ecs::handle_of(occupant.value())); building.value()->capture()->blockedByGarrison = true; building.value()->integrity()->alive = false; building.value()->integrity()->state.health = 0.0; eve::action::ActionRuntime runtime; eve::rts::ActionAdapter adapter(runtime); const eve::SimulationStep simulationStep{ eve::SimulationTick{1}, eve::Duration::fromSeconds(0.1).expect("cleanup dt")}; auto stepped = module.step(simulationStep, adapter); REQUIRE(stepped.ok()); CHECK(module.findBuilding(subject("00000000-0000-7000-8000-00000000d112")) == nullptr); CHECK(module.findUnit(subject("00000000-0000-7000-8000-00000000d113")) == nullptr); CHECK_EQ(module.buildingCount(), 0u); CHECK_EQ(module.unitCount(), 0u); } TEST_CASE("rts.autoSupplyRefillsCanonicalWeaponAmmo") { ecs::Table world; ecs::ScopedTable guard(world); Faction* faction = Faction::createFaction(); Unit* supplier = Unit::createUnit(); Unit* recipient = Unit::createUnit(); auto supplierFaction = eve::rts::FactionLink::bind(ecs::handle_of(faction)); auto recipientFaction = eve::rts::FactionLink::bind(ecs::handle_of(faction)); REQUIRE(supplierFaction.ok()); REQUIRE(recipientFaction.ok()); supplier->faction()->link = std::move(supplierFaction).takeValue(); recipient->faction()->link = std::move(recipientFaction).takeValue(); supplier->supply()->stock = 8.0f; supplier->supply()->capacity = 8.0f; supplier->supply()->range = 3.0f; supplier->supply()->transferRate = 4.0f; supplier->supply()->autoDispatch = true; eve::weapon::WeaponEntity* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = "supply-rifle"; definition.kind = eve::weapon::WeaponKind::Ranged; definition.magSize = 10; definition.reserveSize = 10; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); weapon->state()->resource.kind = eve::weapon::ResourceKind::Ammo; weapon->state()->resource.value = 2.0f; weapon->state()->resource.max = 10.0f; weapon->state()->resource.reserve = 0; auto weaponLink = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(weaponLink.ok()); recipient->weapon()->link = std::move(weaponLink).takeValue(); const eve::SimulationStep step{eve::SimulationTick{1}, eve::Duration::fromSeconds(1.0).expect("supply dt")}; std::vector events; const eve::rts::LifecycleEventSink eventSink = [&](const eve::rts::LifecycleEvent& event, eve::SimulationTick tick) { CHECK_EQ(tick.value(), 1u); events.push_back(event); }; auto supplied = eve::rts::SupplySystem::step(step, {}, nullptr, {}, eventSink); REQUIRE(supplied.ok()); CHECK(supplied.value() >= 2u); CHECK(std::abs(weapon->state()->resource.value - 6.0f) < 1e-5f); CHECK(std::abs(supplier->supply()->stock - 4.0f) < 1e-5f); CHECK(std::abs(supplier->supply()->reservedStock - 4.0f) < 1e-5f); CHECK(!supplier->orders()->values.empty()); REQUIRE_EQ(events.size(), 2u); CHECK_EQ(static_cast(events[0].kind), static_cast(eve::rts::LifecycleEventKind::SupplyDispatched)); CHECK_EQ(static_cast(events[1].kind), static_cast(eve::rts::LifecycleEventKind::AmmoResupplied)); CHECK_EQ(events[1].value, 4.0); events.clear(); supplied = eve::rts::SupplySystem::step(step, {}, nullptr, {}, eventSink); REQUIRE(supplied.ok()); CHECK(std::abs(weapon->state()->resource.value - 10.0f) < 1e-5f); CHECK(std::abs(supplier->supply()->reservedStock) < 1e-5f); CHECK(supplier->supply()->returning); REQUIRE_EQ(events.size(), 2u); CHECK_EQ(static_cast(events[0].kind), static_cast(eve::rts::LifecycleEventKind::AmmoResupplied)); CHECK_EQ(static_cast(events[1].kind), static_cast(eve::rts::LifecycleEventKind::SupplyReturning)); auto returnOrder = supplier->orders()->values.current(); REQUIRE(returnOrder.ok()); CHECK_EQ(static_cast(returnOrder.value().kind), static_cast(OrderKind::Move)); weapon->release(); supplier->release(); recipient->release(); faction->release(); } TEST_CASE("rts.autoSupplyReservationsAvoidDuplicateDispatchAndManualOrdersCancelMission") { ecs::Table world; ecs::ScopedTable guard(world); Faction* faction = Faction::createFaction(subject("00000000-0000-7000-8000-000000000381")); Unit* first = Unit::createUnit(subject("00000000-0000-7000-8000-000000000382")); Unit* second = Unit::createUnit(subject("00000000-0000-7000-8000-000000000383")); Unit* critical = Unit::createUnit(subject("00000000-0000-7000-8000-000000000384")); Unit* waiting = Unit::createUnit(subject("00000000-0000-7000-8000-000000000385")); auto bind = [&](Unit& unit) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(faction)); REQUIRE(link.ok()); unit.faction()->link = std::move(link).takeValue(); }; for (Unit* unit : {first, second, critical, waiting}) bind(*unit); for (Unit* supplier : {first, second}) { supplier->supply()->stock = supplier->supply()->capacity = 10.0f; supplier->supply()->range = 2.0f; supplier->supply()->transferRate = 4.0f; supplier->supply()->autoDispatch = true; } std::vector weapons; auto arm = [&](Unit& unit, float rounds) { auto* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = "reservation-rifle"; definition.magSize = 4; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); weapon->state()->resource.kind = eve::weapon::ResourceKind::Ammo; weapon->state()->resource.value = rounds; weapon->state()->resource.max = 4.0f; weapon->state()->resource.reserve = -1; auto link = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(link.ok()); unit.weapon()->link = std::move(link).takeValue(); weapons.push_back(weapon); }; arm(*critical, 0.0f); arm(*waiting, 1.0f); const eve::SimulationStep noTime{eve::SimulationTick{1}, eve::Duration::fromSeconds(0.0).expect("zero supply dt")}; REQUIRE(eve::rts::SupplySystem::step(noTime).ok()); CHECK_EQ(ecs::try_get(first->supply()->assignedTarget), critical); CHECK_EQ(ecs::try_get(second->supply()->assignedTarget), waiting); CHECK(std::abs(first->supply()->reservedStock - 4.0f) < 1e-5f); CHECK(std::abs(second->supply()->reservedStock - 3.0f) < 1e-5f); CommandSpec manualMove; manualMove.kind = OrderKind::Move; manualMove.target = {-4.0f, 3.0f}; REQUIRE(first->orders()->values.replace(manualMove).ok()); REQUIRE(eve::rts::SupplySystem::step(noTime).ok()); CHECK(first->supply()->assignedTarget.table == nullptr); CHECK(std::abs(first->supply()->reservedStock) < 1e-5f); CHECK(!first->supply()->returning); auto active = first->orders()->values.current(); REQUIRE(active.ok()); CHECK_EQ(static_cast(active.value().kind), static_cast(OrderKind::Move)); for (auto* weapon : weapons) weapon->release(); first->release(); second->release(); critical->release(); waiting->release(); faction->release(); } TEST_CASE("rts.mobileSupplierTracksTheRecipientsLivePosition") { ecs::Table world; ecs::ScopedTable guard(world); Faction* faction = Faction::createFaction(subject("00000000-0000-7000-8000-000000000391")); Unit* supplier = Unit::createUnit(subject("00000000-0000-7000-8000-000000000392")); Unit* recipient = Unit::createUnit(subject("00000000-0000-7000-8000-000000000393")); for (Unit* unit : {supplier, recipient}) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(faction)); REQUIRE(link.ok()); unit->faction()->link = std::move(link).takeValue(); } supplier->motion()->speed = 5.0f; supplier->supply()->stock = supplier->supply()->capacity = 4.0f; supplier->supply()->range = 1.0f; supplier->supply()->transferRate = 2.0f; supplier->supply()->autoDispatch = true; recipient->motion()->x = 10.0f; auto* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = "moving-recipient-rifle"; definition.magSize = 4; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); weapon->state()->resource.kind = eve::weapon::ResourceKind::Ammo; weapon->state()->resource.value = 0.0f; weapon->state()->resource.max = 4.0f; weapon->state()->resource.reserve = -1; auto weaponLink = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(weaponLink.ok()); recipient->weapon()->link = std::move(weaponLink).takeValue(); const eve::SimulationStep noTime{eve::SimulationTick{1}, eve::Duration::fromSeconds(0.0).expect("zero dispatch dt")}; REQUIRE(eve::rts::SupplySystem::step(noTime).ok()); CHECK_EQ(ecs::try_get(supplier->supply()->assignedTarget), recipient); recipient->motion()->x = 0.0f; recipient->motion()->y = 10.0f; const eve::SimulationStep movement{eve::SimulationTick{2}, eve::Duration::fromSeconds(1.0).expect("supplier movement dt")}; REQUIRE(eve::rts::MotionSystem::step(movement).ok()); CHECK(std::abs(supplier->motion()->x) < 1e-5f); CHECK(supplier->motion()->y > 4.9f); weapon->release(); supplier->release(); recipient->release(); faction->release(); } TEST_CASE("rts.upstreamSupplyAutoDispatchesAndLeadsMovingRelay") { ecs::Table world; ecs::ScopedTable guard(world); Faction* faction = Faction::createFaction(subject("00000000-0000-7000-8000-00000000039a")); Unit* upstream = Unit::createUnit(subject("00000000-0000-7000-8000-00000000039b")); Unit* relay = Unit::createUnit(subject("00000000-0000-7000-8000-00000000039c")); for (Unit* unit : {upstream, relay}) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(faction)); REQUIRE(link.ok()); unit->faction()->link = std::move(link).takeValue(); } upstream->motion()->speed = 5.0f; upstream->supply()->stock = upstream->supply()->capacity = 20.0f; upstream->supply()->range = 1.0f; upstream->supply()->transferRate = 4.0f; upstream->supply()->autoDispatch = true; upstream->supply()->autoThreshold = 0.75f; relay->motion()->x = 10.0f; relay->motion()->speed = 2.0f; relay->supply()->capacity = 8.0f; relay->supply()->stock = 0.0f; relay->supply()->range = 2.0f; relay->supply()->transferRate = 2.0f; relay->supply()->relayEnabled = true; CommandSpec move; move.kind = OrderKind::Move; move.target = {20.0f, 0.0f}; REQUIRE(relay->orders()->values.replace(move).ok()); const eve::SimulationStep noTime{eve::SimulationTick{1}, eve::Duration::fromSeconds(0.0).expect("zero relay dispatch dt")}; auto dispatched = eve::rts::SupplySystem::step(noTime); REQUIRE(dispatched.ok()); CHECK_EQ(ecs::try_get(upstream->supply()->assignedTarget), relay); auto order = upstream->orders()->values.current(); REQUIRE(order.ok()); CHECK_EQ(static_cast(order.value().kind), static_cast(OrderKind::SupplyRelay)); CHECK(upstream->supply()->rendezvousActive); CHECK(upstream->supply()->rendezvousPoint.x > relay->motion()->x + 7.9f); CHECK(std::abs(upstream->supply()->reservedStock - 8.0f) < 1e-5f); const eve::SimulationStep movement{eve::SimulationTick{2}, eve::Duration::fromSeconds(1.0).expect("relay intercept dt")}; REQUIRE(eve::rts::MotionSystem::step(movement).ok()); CHECK(upstream->motion()->x > 4.9f); CHECK(relay->motion()->x > 11.9f); relay->release(); upstream->release(); faction->release(); } TEST_CASE("rts.supplyRendezvousAvoidsVisibleHostileCoverage") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(subject("00000000-0000-7000-8000-0000000003a1")); Faction* red = Faction::createFaction(subject("00000000-0000-7000-8000-0000000003a2")); Unit* supplier = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003a3")); Unit* relay = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003a4")); Unit* hostile = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003a5")); auto bind = [&](Unit& unit, Faction& faction) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(&faction)); REQUIRE(link.ok()); unit.faction()->link = std::move(link).takeValue(); }; bind(*supplier, *blue); bind(*relay, *blue); bind(*hostile, *red); supplier->motion()->y = 10.0f; supplier->supply()->range = 1.0f; relay->motion()->x = 10.0f; relay->motion()->y = 10.0f; CommandSpec move; move.kind = OrderKind::Move; move.target = {20.0f, 10.0f}; REQUIRE(relay->orders()->values.replace(move).ok()); hostile->motion()->x = 18.0f; hostile->motion()->y = 10.0f; auto* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = "rendezvous-threat"; definition.damage = 20.0f; definition.cooldown = 1.0f; definition.range = 2.0f; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); auto weaponLink = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(weaponLink.ok()); hostile->weapon()->link = std::move(weaponLink).takeValue(); eve::map::Pathfinder pathfinder(32, 32); auto selected = eve::rts::SupplyRendezvousSystem::select( *supplier, *relay, {18.0f, 10.0f}, pathfinder, {}); REQUIRE(selected.ok()); CHECK(selected.value().avoidedThreat); CHECK(std::abs(selected.value().threat) < 1e-5f); CHECK(std::hypot(selected.value().target.x - 18.0f, selected.value().target.y - 10.0f) > 3.9f); supplier->motion()->speed = 5.0f; supplier->supply()->stock = supplier->supply()->capacity = 20.0f; supplier->supply()->range = 1.0f; supplier->supply()->transferRate = 4.0f; supplier->supply()->autoDispatch = true; relay->supply()->capacity = 8.0f; relay->supply()->stock = 0.0f; relay->supply()->relayEnabled = true; const eve::SimulationStep noTime{eve::SimulationTick{1}, eve::Duration::fromSeconds(0.0).expect("safe relay dispatch dt")}; REQUIRE(eve::rts::SupplySystem::step(noTime, {}, &pathfinder, {}).ok()); CHECK_EQ(ecs::try_get(supplier->supply()->assignedTarget), relay); CHECK(supplier->supply()->rendezvousActive); CHECK(supplier->supply()->rendezvousAvoidedThreat); CHECK(std::abs(supplier->supply()->rendezvousThreat) < 1e-5f); CHECK(std::hypot(supplier->supply()->rendezvousPoint.x - 18.0f, supplier->supply()->rendezvousPoint.y - 10.0f) > 3.9f); weapon->release(); hostile->release(); relay->release(); supplier->release(); red->release(); blue->release(); } TEST_CASE("rts.supplyNavigationUsesCanonicalThreatCostOverlay") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(subject("00000000-0000-7000-8000-0000000003c1")); Faction* red = Faction::createFaction(subject("00000000-0000-7000-8000-0000000003c2")); Unit* supplier = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003c3")); Unit* recipient = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003c4")); Unit* hostile = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003c5")); auto bind = [&](Unit& unit, Faction& faction) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(&faction)); REQUIRE(link.ok()); unit.faction()->link = std::move(link).takeValue(); }; bind(*supplier, *blue); bind(*recipient, *blue); bind(*hostile, *red); supplier->motion()->x = 0.0f; supplier->motion()->y = 2.0f; recipient->motion()->x = 8.0f; recipient->motion()->y = 2.0f; hostile->motion()->x = 4.0f; hostile->motion()->y = 2.0f; auto* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = "route-threat"; definition.damage = 30.0f; definition.cooldown = 1.0f; definition.range = 1.5f; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); auto weaponLink = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(weaponLink.ok()); hostile->weapon()->link = std::move(weaponLink).takeValue(); CommandSpec mission; mission.kind = OrderKind::Resupply; mission.targetEntity = recipient->identity()->self; mission.target = {8.0f, 2.0f}; REQUIRE(supplier->orders()->values.replace(mission).ok()); eve::map::Pathfinder pathfinder(9, 5); REQUIRE(eve::rts::NavigationSystem::step(pathfinder, {}).ok()); CHECK(supplier->supply()->routeAvoidedThreat); CHECK(supplier->supply()->routeThreat < 1e-4f); REQUIRE(!supplier->navigation()->waypoints.empty()); CHECK(std::any_of(supplier->navigation()->waypoints.begin(), supplier->navigation()->waypoints.end(), [](const WorldPosition& point) { return std::abs(point.y - 2.0f) > 1.9f; })); weapon->release(); hostile->release(); recipient->release(); supplier->release(); red->release(); blue->release(); } TEST_CASE("rts.supplyConvoyLeaderWaitsForLaggingVehicle") { ecs::Table world; ecs::ScopedTable guard(world); Faction* faction = Faction::createFaction(subject("00000000-0000-7000-8000-00000000039d")); Unit* leader = Unit::createUnit(subject("00000000-0000-7000-8000-00000000039e")); Unit* follower = Unit::createUnit(subject("00000000-0000-7000-8000-00000000039f")); Unit* relay = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003a0")); for (Unit* unit : {leader, follower, relay}) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(faction)); REQUIRE(link.ok()); unit->faction()->link = std::move(link).takeValue(); } leader->motion()->x = 8.0f; follower->motion()->x = 0.0f; relay->motion()->x = 10.0f; for (Unit* supplier : {leader, follower}) { supplier->motion()->speed = 2.0f; supplier->supply()->range = 1.0f; supplier->supply()->stock = supplier->supply()->capacity = 4.0f; CommandSpec order; order.kind = OrderKind::SupplyRelay; order.targetEntity = relay->identity()->self; order.target = {10.0f, 0.0f}; REQUIRE(supplier->orders()->values.replace(order).ok()); } auto paced = eve::rts::SupplyConvoySystem::step(); REQUIRE(paced.ok()); CHECK_EQ(ecs::try_get(leader->supply()->convoyLeader), leader); CHECK_EQ(ecs::try_get(follower->supply()->convoyLeader), leader); CHECK_EQ(leader->supply()->convoyIndex, 0u); CHECK_EQ(follower->supply()->convoyIndex, 1u); CHECK(leader->supply()->convoyWaiting); const eve::SimulationStep movement{eve::SimulationTick{1}, eve::Duration::fromSeconds(1.0).expect("convoy movement dt")}; REQUIRE(eve::rts::MotionSystem::step(movement).ok()); CHECK(std::abs(leader->motion()->x - 8.0f) < 1e-5f); CHECK(follower->motion()->x > 1.9f); follower->motion()->x = 5.0f; REQUIRE(eve::rts::SupplyConvoySystem::step().ok()); CHECK(!leader->supply()->convoyWaiting); REQUIRE(eve::rts::MotionSystem::step(movement).ok()); CHECK(leader->motion()->x > 8.9f); relay->release(); follower->release(); leader->release(); faction->release(); } TEST_CASE("rts.buildingAmmoProductionPurchasesFiniteStockAndSnapshotsFractionalProgress") { eve::rts::RTS module; auto buildingResult = module.newBuilding( subject("00000000-0000-7000-8000-000000000010")); REQUIRE(buildingResult.ok()); Building* building = std::move(buildingResult).takeValue(); building->supply()->capacity = 3.0f; building->supply()->productionResource = "minerals"; building->supply()->productionCostPerRound = 2; building->supply()->productionRate = 2.0f; std::int64_t bank = 5; eve::rts::AmmoProductionPurchase purchase = [&](Building& producer, std::string_view resource, std::int64_t unitCost, std::size_t requested) -> eve::Result { CHECK_EQ(&producer, building); CHECK_EQ(resource, "minerals"); const auto affordable = static_cast(bank / unitCost); const auto bought = std::min(requested, affordable); bank -= static_cast(bought) * unitCost; return eve::Result::success(bought); }; auto partial = eve::rts::SupplySystem::step( {eve::SimulationTick{1}, eve::Duration::fromSeconds(0.25).expect("ammo partial")}, purchase); REQUIRE(partial.ok()); CHECK_EQ(building->supply()->stock, 0.0f); CHECK_EQ(building->supply()->productionProgress, 0.5f); auto snapshot = module.snapshotState(); REQUIRE(snapshot.ok()); building->supply()->productionProgress = 0.0f; REQUIRE(module.restoreState(snapshot.value()).ok()); CHECK_EQ(building->supply()->productionProgress, 0.5f); std::vector events; auto produced = eve::rts::SupplySystem::step( {eve::SimulationTick{2}, eve::Duration::fromSeconds(1.0).expect("ammo production")}, purchase, nullptr, {}, [&](const eve::rts::LifecycleEvent& event, eve::SimulationTick tick) { CHECK_EQ(tick.value(), 2u); events.push_back(event); }); REQUIRE(produced.ok()); CHECK_EQ(produced.value(), 2u); CHECK_EQ(building->supply()->stock, 2.0f); CHECK_EQ(building->supply()->productionProgress, 0.5f); CHECK_EQ(bank, 1); REQUIRE_EQ(events.size(), 1u); CHECK_EQ(static_cast(events[0].kind), static_cast(eve::rts::LifecycleEventKind::AmmoProduced)); CHECK_EQ(events[0].source, building->identity()->subject); CHECK_EQ(events[0].detail, "minerals"); CHECK_EQ(events[0].value, 2.0); auto unaffordable = eve::rts::SupplySystem::step( {eve::SimulationTick{3}, eve::Duration::fromSeconds(1.0).expect("ammo insufficient")}, purchase); REQUIRE(unaffordable.ok()); CHECK_EQ(building->supply()->stock, 2.0f); CHECK_EQ(building->supply()->productionProgress, 1.0f); CHECK_EQ(bank, 1); } TEST_CASE("rts.buildingProductionAndModuleFactoryCompose") { eve::rts::RTS module; auto unitResult = module.newUnit(subject("00000000-0000-7000-8000-000000000011")); REQUIRE(unitResult.ok()); Unit* unit = std::move(unitResult).takeValue(); CHECK_EQ(module.unitCount(), 1u); auto buildingResult = module.newBuilding(subject("00000000-0000-7000-8000-000000000012")); REQUIRE(buildingResult.ok()); Building* building = std::move(buildingResult).takeValue(); CHECK_EQ(module.buildingCount(), 1u); auto duration = eve::Duration::fromSeconds(1.0); REQUIRE(duration.ok()); auto task = building->production()->values.enqueue("faction", "train", "worker", std::move(duration).takeValue()); REQUIRE(task.ok()); std::move(task).takeValue(); eve::action::ActionRuntime runtime; eve::rts::ActionAdapter adapter(runtime); const eve::SimulationStep step{eve::SimulationTick{1}, eve::Duration::fromNanoseconds(500000000)}; auto processed = module.step(step, adapter); REQUIRE(processed.ok()); CHECK(processed.value() >= 2u); CHECK_EQ(building->production()->values.taskCount(), 1u); (void)unit; } TEST_CASE("rts.moduleResolvesStableSubjectsAndRejectsCrossRootDuplicates") { eve::rts::RTS module; REQUIRE(module.configureScriptWorld(16, 16, 1.0f).ok()); REQUIRE(module.setScriptNavigationBlocked(8, 8, true).ok()); REQUIRE(module.setScriptNavigationCost(3, 3, 0.5f).ok()); const auto stable = subject("00000000-0000-7000-8000-000000000071"); const auto factionSubject = subject("00000000-0000-7000-8000-000000000072"); auto factionResult = module.newFaction(factionSubject); REQUIRE(factionResult.ok()); Faction* faction = std::move(factionResult).takeValue(); REQUIRE(module.addScriptResource(*faction, "minerals", 300).ok()); auto minerals = module.scriptResource(*faction, "minerals"); REQUIRE(minerals.ok()); CHECK_EQ(minerals.value(), 300); CHECK_EQ(module.findFaction(factionSubject), faction); CHECK_EQ(module.findFaction(stable), nullptr); auto unitResult = module.newFactionUnit(*faction, stable); REQUIRE(unitResult.ok()); Unit* unit = std::move(unitResult).takeValue(); CHECK_EQ(faction->members()->units.size(), 1u); CHECK_EQ(ecs::try_get(faction->members()->units.front()), unit); CHECK(unit->crowd()->link.isBound()); CHECK(unit->sensing()->link.isBound()); CHECK_EQ(module.findUnit(stable), unit); CHECK_EQ(module.findBuilding(stable), nullptr); auto duplicate = module.newBuilding(stable); CHECK(!duplicate.ok()); CHECK_EQ(module.unitCount(), 1u); CHECK_EQ(module.buildingCount(), 0u); eve::rts::CommandSpec move; move.kind = OrderKind::Move; move.target = {4.0f, 7.0f}; const std::array selected{stable}; auto commanded = module.commandUnits(selected, move, {eve::rts::FormationKind::Grid, 1.0f, 0}); REQUIRE(commanded.ok()); CHECK_EQ(commanded.value().accepted, 1u); auto active = unit->orders()->values.current(); REQUIRE(active.ok()); CHECK_EQ(active.value().target.x, 4.0f); CHECK_EQ(active.value().target.y, 7.0f); auto stepped = module.stepScript(0.5); REQUIRE(stepped.ok()); CHECK(unit->motion()->x > 0.0f); CHECK(unit->motion()->y > 0.0f); const std::array missing{subject("00000000-0000-7000-8000-000000000073")}; auto rejected = module.commandUnits(missing, move); CHECK(!rejected.ok()); CHECK_EQ(unit->orders()->values.orderCount(), 1u); auto inspected = module.inspectState().toJson(); REQUIRE(inspected.ok()); CHECK(inspected.value().find(stable.format()) != std::string::npos); CHECK(inspected.value().find(factionSubject.format()) != std::string::npos); } TEST_CASE("rts.scriptProfileMaterializesCanonicalContentAndAutomaticFire") { eve::rts::RTS module; REQUIRE(module.configureScriptWorld(20, 20, 1.0f).ok()); auto loaded = module.loadScriptContent(R"JSON({ "weapons":[{"id":"rifle","damageType":"normal","damage":8,"range":6,"cooldown":0.1, "projectileSpeed":0,"magazineSize":8,"reloadTime":1}], "units":[ {"id":"marine","role":"soldier","weaponType":"rifle","health":80,"speed":3, "radius":0.35,"attackRange":6,"sightRange":8,"targetTags":["biological"]}, {"id":"worker","role":"worker","health":50,"speed":2,"radius":0.3,"sightRange":5}], "buildings":[{"id":"command_center","health":900,"radius":1.2,"dropoff":true, "powerProvided":10,"buildInfluenceRadius":8}] })JSON"); REQUIRE(loaded.ok()); auto unitId = eve::LogicalId::parse("unit:marine"); auto workerId = eve::LogicalId::parse("unit:worker"); auto buildingId = eve::LogicalId::parse("building:command_center"); REQUIRE(unitId.has_value()); REQUIRE(workerId.has_value()); REQUIRE(buildingId.has_value()); auto firstFaction = module.newFaction(subject("00000000-0000-7000-8000-000000000074")); auto secondFaction = module.newFaction(subject("00000000-0000-7000-8000-000000000075")); REQUIRE(firstFaction.ok()); REQUIRE(secondFaction.ok()); Faction* first = std::move(firstFaction).takeValue(); Faction* second = std::move(secondFaction).takeValue(); auto attackerResult = module.newFactionUnit( *first, subject("00000000-0000-7000-8000-000000000076"), *unitId); auto targetResult = module.newFactionUnit( *second, subject("00000000-0000-7000-8000-000000000077"), *unitId); auto workerResult = module.newFactionUnit( *first, subject("00000000-0000-7000-8000-000000000078"), *workerId); auto buildingResult = module.newFactionBuilding( *first, subject("00000000-0000-7000-8000-000000000079"), *buildingId); REQUIRE(attackerResult.ok()); REQUIRE(targetResult.ok()); REQUIRE(workerResult.ok()); REQUIRE(buildingResult.ok()); Unit* attacker = std::move(attackerResult).takeValue(); Unit* target = std::move(targetResult).takeValue(); Unit* worker = std::move(workerResult).takeValue(); Building* building = std::move(buildingResult).takeValue(); attacker->motion()->x = 2.0f; attacker->motion()->y = 2.0f; target->motion()->x = 5.0f; target->motion()->y = 2.0f; CHECK_EQ(attacker->durability()->state.maxHealth, 80.0); CHECK_EQ(attacker->motion()->speed, 3.0f); CHECK(attacker->weapon()->link.isBound()); CHECK(worker->worker()->autoAssign); CHECK_EQ(worker->worker()->capacity, 10.0f); CHECK_EQ(building->integrity()->state.maxHealth, 900.0); CHECK_EQ(building->infrastructure()->powerProduced, 10.0f); REQUIRE_EQ(building->dropoff()->acceptedResources.size(), 1u); CHECK_EQ(building->dropoff()->acceptedResources.front(), "minerals"); const double before = target->durability()->state.health; for (int index = 0; index < 4; ++index) REQUIRE(module.stepScript(0.1).ok()); CHECK(target->durability()->state.health < before); const auto frameProjection = module.inspectFrameEvents(); const auto* events = frameProjection.getIf(); REQUIRE(events != nullptr); bool observedFire = false; bool observedDamage = false; for (const auto& event : *events) { const auto* object = event.getIf(); if (object == nullptr) continue; const auto found = object->find("type"); const auto* type = found == object->end() ? nullptr : found->second.getIf(); if (type != nullptr && *type == "weapon_fired") observedFire = true; if (type != nullptr && *type == "damage") observedDamage = true; } CHECK(observedFire); CHECK(observedDamage); } TEST_CASE("rts.lifecycleTransitionsProjectThroughFrameEventsAndClearNextStep") { class PendingExecutor final : public eve::rts::IRTSActionExecutor { public: eve::Result execute( Unit&, const eve::rts::OrderRecord&, const eve::SimulationStep&) override { return eve::Result::success( {eve::rts::ActionDisposition::Pending}); } } executor; eve::rts::RTS module; auto created = module.newUnit(subject("00000000-0000-7000-8000-00000000ec01")); REQUIRE(created.ok()); Unit* unit = std::move(created).takeValue(); unit->shield()->capacity = 10.0f; unit->shield()->value = 9.0f; unit->shield()->regenRate = 2.0f; REQUIRE(module.step({eve::SimulationTick{7}, eve::Duration::fromSeconds(1.0).expect("lifecycle projection dt")}, executor).ok()); const auto frameEvents = module.inspectFrameEvents(); const auto* events = frameEvents.getIf(); REQUIRE(events != nullptr); REQUIRE_EQ(events->size(), 1u); const auto* projected = events->front().getIf(); REQUIRE(projected != nullptr); CHECK_EQ(*projected->at("type").getIf(), "shield_recharged"); CHECK_EQ(*projected->at("source").getIf(), unit->identity()->subject.format()); CHECK_EQ(*projected->at("tick").getIf(), std::int64_t{7}); REQUIRE(module.step({eve::SimulationTick{8}, eve::Duration::fromSeconds(0.1).expect("lifecycle clear dt")}, executor).ok()); const auto clearedEvents = module.inspectFrameEvents(); const auto* cleared = clearedEvents.getIf(); REQUIRE(cleared != nullptr); CHECK(cleared->empty()); } TEST_CASE("rts.scriptGridBlocksHitscanAndNewObstaclesInterceptProjectiles") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS module; REQUIRE(module.configureScriptWorld(12, 6, 1.0f).ok()); REQUIRE(module.loadScriptContent(R"JSON({ "weapons":[ {"id":"beam","damageType":"normal","damage":10,"range":10,"cooldown":0.1, "projectileSpeed":0,"blockedByObstacles":true}, {"id":"shell","damageType":"normal","damage":10,"range":10,"cooldown":10, "projectileSpeed":4,"blockedByObstacles":true}], "units":[ {"id":"beam_unit","weaponType":"beam","health":50,"speed":0,"radius":0.3, "attackRange":10,"sightRange":12}, {"id":"shell_unit","weaponType":"shell","health":50,"speed":0,"radius":0.3, "attackRange":10,"sightRange":12}, {"id":"target","health":50,"speed":0,"radius":0.3,"sightRange":1}] })JSON").ok()); auto beamType = eve::LogicalId::parse("unit:beam_unit"); auto shellType = eve::LogicalId::parse("unit:shell_unit"); auto targetType = eve::LogicalId::parse("unit:target"); REQUIRE(beamType.has_value()); REQUIRE(shellType.has_value()); REQUIRE(targetType.has_value()); Faction* blue = module.newFaction(subject("00000000-0000-7000-8000-00000000e901")).value(); Faction* red = module.newFaction(subject("00000000-0000-7000-8000-00000000e902")).value(); Unit* beam = module.newFactionUnit( *blue, subject("00000000-0000-7000-8000-00000000e903"), *beamType).value(); Unit* beamTarget = module.newFactionUnit( *red, subject("00000000-0000-7000-8000-00000000e904"), *targetType).value(); beam->motion()->x = 1.5f; beam->motion()->y = 1.5f; beamTarget->motion()->x = 7.5f; beamTarget->motion()->y = 1.5f; REQUIRE(module.setScriptNavigationBlocked(4, 1, true).ok()); REQUIRE(module.stepScript(0.2).ok()); CHECK_EQ(beamTarget->durability()->state.health, 50.0); REQUIRE(module.setScriptNavigationBlocked(4, 1, false).ok()); REQUIRE(module.stepScript(0.2).ok()); CHECK(beamTarget->durability()->state.health < 50.0); Unit* shell = module.newFactionUnit( *blue, subject("00000000-0000-7000-8000-00000000e905"), *shellType).value(); Unit* shellTarget = module.newFactionUnit( *red, subject("00000000-0000-7000-8000-00000000e906"), *targetType).value(); shell->motion()->x = 1.5f; shell->motion()->y = 4.5f; shellTarget->motion()->x = 7.5f; shellTarget->motion()->y = 4.5f; REQUIRE(module.stepScript(0.1).ok()); REQUIRE(module.setScriptTerrainElevation(4, 4, 2.0f).ok()); REQUIRE(module.stepScript(1.0).ok()); REQUIRE(module.stepScript(1.0).ok()); CHECK_EQ(shellTarget->durability()->state.health, 50.0); } TEST_CASE("rts.scriptTerrainHeightDrivesFogFireLinesAndCheckpoints") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS module; REQUIRE(module.configureScriptWorld(12, 5, 1.0f).ok()); REQUIRE(module.loadScriptContent(R"JSON({ "weapons":[{"id":"rifle","damageType":"normal","damage":10,"range":10,"cooldown":0.1, "projectileSpeed":0,"blockedByObstacles":true}], "units":[ {"id":"shooter","weaponType":"rifle","health":50,"speed":0,"radius":0.3, "attackRange":10,"sightRange":12,"firingHeight":1,"targetHeight":1}, {"id":"target","health":50,"speed":0,"radius":0.3,"sightRange":1,"targetHeight":1}] })JSON").ok()); const auto shooterType = eve::LogicalId::parse("unit:shooter"); const auto targetType = eve::LogicalId::parse("unit:target"); REQUIRE(shooterType.has_value()); REQUIRE(targetType.has_value()); Faction* blue = module.newFaction(subject("00000000-0000-7000-8000-00000000e801")).value(); Faction* red = module.newFaction(subject("00000000-0000-7000-8000-00000000e802")).value(); Unit* shooter = module.newFactionUnit( *blue, subject("00000000-0000-7000-8000-00000000e803"), *shooterType).value(); Unit* target = module.newFactionUnit( *red, subject("00000000-0000-7000-8000-00000000e804"), *targetType).value(); shooter->motion()->x = 1.5f; shooter->motion()->y = 2.5f; target->motion()->x = 7.5f; target->motion()->y = 2.5f; CHECK_EQ(shooter->combat()->firingHeight, 1.0f); CHECK_EQ(target->combat()->targetHeight, 1.0f); REQUIRE(module.setScriptTerrainElevation(4, 2, 2.0f).ok()); REQUIRE(module.captureScriptCheckpoint("ridge").ok()); REQUIRE(module.stepScript(0.2).ok()); CHECK_EQ(target->durability()->state.health, 50.0); CHECK(!module.scriptCellVisible(*blue, 7, 2).value()); REQUIRE(module.setScriptTerrainElevation(1, 2, 3.0f).ok()); REQUIRE(module.stepScript(0.2).ok()); CHECK(module.scriptCellVisible(*blue, 7, 2).value()); CHECK(target->durability()->state.health < 50.0); REQUIRE(module.setScriptTerrainElevation(4, 2, 0.0f).ok()); REQUIRE(module.restoreScriptCheckpoint("ridge").ok()); CHECK_EQ(module.scriptTerrainElevation(4, 2).value(), 2.0f); CHECK_EQ(module.scriptTerrainElevation(1, 2).value(), 0.0f); } TEST_CASE("rts.completedProductionBoardsDispatchesAndUnloadsTransportReinforcements") { ecs::Table world; ecs::ScopedTable guard(world); Faction* faction = Faction::createFaction(); Building* factory = Building::createBuilding(); Unit* transport = Unit::createUnit(); auto factoryFaction = eve::rts::FactionLink::bind(ecs::handle_of(faction)); auto transportFaction = eve::rts::FactionLink::bind(ecs::handle_of(faction)); REQUIRE(factoryFaction.ok()); REQUIRE(transportFaction.ok()); factory->faction()->link = std::move(factoryFaction).takeValue(); transport->faction()->link = std::move(transportFaction).takeValue(); transport->containment()->capacity = 2; factory->rally()->enabled = true; factory->rally()->command.kind = OrderKind::AttackMove; factory->rally()->command.target = {20.0f, 4.0f}; factory->rally()->combatGroup = 77; factory->rally()->transport = ecs::handle_of(transport); const auto transportHandle = ecs::handle_of(transport); factory->rally()->minimumTransportLoad = 2; const auto duration = eve::Duration::fromSeconds(1.0).expect("reinforcement duration"); REQUIRE(factory->production()->values.enqueue("faction", "unit", "marine", duration).ok()); REQUIRE(factory->production()->values.enqueue("faction", "unit", "marine", duration).ok()); std::vector spawned; eve::rts::ProductionSpawn spawn = [&](Building&, const eve::production::ProductionTask&) { Unit* unit = Unit::createUnit(); auto link = eve::rts::FactionLink::bind(ecs::handle_of(faction)); if (!link) return eve::Result::failure(link.status()); unit->faction()->link = std::move(link).takeValue(); spawned.push_back(ecs::handle_of(unit)); return eve::Result::success(unit); }; auto first = eve::rts::BuildingProductionSystem::step( {eve::SimulationTick{1}, duration}, spawn); REQUIRE(first.ok()); auto second = eve::rts::BuildingProductionSystem::step( {eve::SimulationTick{2}, duration}, spawn); REQUIRE(second.ok()); REQUIRE_EQ(spawned.size(), 2u); transport = dynamic_cast(ecs::try_get(transportHandle)); REQUIRE(transport != nullptr); CHECK_EQ(transport->containment()->occupants.size(), 2u); auto dispatched = eve::rts::ReinforcementSystem::step(); REQUIRE(dispatched.ok()); CHECK(factory->rally()->transportActive); transport->motion()->arrived = false; auto travelling = eve::rts::ReinforcementSystem::step(); REQUIRE(travelling.ok()); CHECK_EQ(transport->containment()->occupants.size(), 2u); transport->motion()->x = 20.0f; transport->motion()->y = 4.0f; transport->motion()->arrived = true; auto arrived = eve::rts::ReinforcementSystem::step(); REQUIRE(arrived.ok()); CHECK(!factory->rally()->transportActive); CHECK(transport->containment()->occupants.empty()); for (const auto& handle : spawned) { Unit* unit = dynamic_cast(ecs::try_get(handle)); REQUIRE(unit != nullptr); CHECK(!unit->containment()->container.isBound()); CHECK_EQ(unit->tactics()->combatGroup, 77u); CHECK_EQ(unit->motion()->x, 20.0f); auto order = unit->orders()->values.current(); REQUIRE(order.ok()); CHECK_EQ(static_cast(order.value().kind), static_cast(OrderKind::AttackMove)); } } TEST_CASE("rts.factionResourceFloorsProtectHighPriorityProductionAcrossFactories") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS rts; auto factionResult = rts.newFaction(subject("00000000-0000-7000-8000-0000000004a1")); auto barracksResult = rts.newBuilding(subject("00000000-0000-7000-8000-0000000004a2")); auto factoryResult = rts.newBuilding(subject("00000000-0000-7000-8000-0000000004a3")); REQUIRE(factionResult.ok()); REQUIRE(barracksResult.ok()); REQUIRE(factoryResult.ok()); Faction* faction = std::move(factionResult).takeValue(); Building* barracks = std::move(barracksResult).takeValue(); Building* factory = std::move(factoryResult).takeValue(); for (Building* building : {barracks, factory}) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(faction)); REQUIRE(link.ok()); building->faction()->link = std::move(link).takeValue(); } REQUIRE(rts.setProductionResourceReserve(*faction, "minerals", 100, 10).ok()); REQUIRE(rts.setProductionResourceReserve(*faction, "gas", 25, 10).ok()); eve::economy::EconomyLedger ledger; REQUIRE_EQ(ledger.credit("minerals", 150), 150); REQUIRE_EQ(ledger.credit("gas", 25), 25); eve::rts::RTSEconomyAdapter economy(ledger); eve::action::ActionRuntime action; const auto duration = eve::Duration::fromSeconds(1.0).expect("resource floor production duration"); auto marineCost = eve::resource::CostSpec::single("minerals", 50); REQUIRE(marineCost.ok()); auto firstMarine = rts.build(*barracks, action, economy.account(), std::move(marineCost).takeValue(), "marine", duration, "unit", 1); REQUIRE(firstMarine.ok()); CHECK_EQ(ledger.get("minerals"), 100); marineCost = eve::resource::CostSpec::single("minerals", 50); REQUIRE(marineCost.ok()); auto blockedMarine = rts.build(*barracks, action, economy.account(), std::move(marineCost).takeValue(), "marine", duration, "unit", 1); CHECK(!blockedMarine.ok()); CHECK_EQ(ledger.get("minerals"), 100); auto tankCost = eve::resource::CostSpec::from({{"minerals", 100}, {"gas", 25}}); REQUIRE(tankCost.ok()); auto tank = rts.build(*factory, action, economy.account(), std::move(tankCost).takeValue(), "tank", duration, "unit", 10); REQUIRE(tank.ok()); CHECK_EQ(ledger.get("minerals"), 0); CHECK_EQ(ledger.get("gas"), 0); auto snapshot = rts.snapshotState(); REQUIRE(snapshot.ok()); REQUIRE_EQ(snapshot.value().factions.size(), 1u); CHECK_EQ(snapshot.value().factions[0].productionPolicy.resourceReserves.at("minerals").amount, 100); auto canonical = rts.canonicalStateJson(); REQUIRE(canonical.ok()); CHECK(canonical.value().find("productionPolicy") != std::string::npos); REQUIRE(rts.setProductionResourceReserve(*faction, "minerals", 0, 10).ok()); CHECK(!faction->productionPolicy()->resourceReserves.contains("minerals")); } TEST_CASE("rts.sharedReinforcementCapReservesLastSlotByTypePriority") { ecs::Table world; ecs::ScopedTable guard(world); Faction* faction = Faction::createFaction(); Building* infantryFactory = Building::createBuilding( subject("00000000-0000-7000-8000-0000000003f1")); Building* armorFactory = Building::createBuilding( subject("00000000-0000-7000-8000-0000000003f2")); for (Building* factory : {infantryFactory, armorFactory}) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(faction)); REQUIRE(link.ok()); factory->faction()->link = std::move(link).takeValue(); factory->rally()->enabled = true; factory->rally()->combatGroup = 91; factory->rally()->reinforcementLimit = 1; factory->rally()->reinforcementTypePriorities["rts:marine"] = 1; factory->rally()->reinforcementTypePriorities["rts:tank"] = 10; } const auto duration = eve::Duration::fromSeconds(5.0).expect("reinforcement policy duration"); auto marineTask = infantryFactory->production()->values.enqueue( "faction", "unit", "rts:marine", duration); auto tankTask = armorFactory->production()->values.enqueue( "faction", "unit", "rts:tank", duration); REQUIRE(marineTask.ok()); REQUIRE(tankTask.ok()); const std::string marineId = marineTask.value(); const std::string tankId = tankTask.value(); REQUIRE(eve::rts::ReinforcementProductionPolicySystem::step().ok()); REQUIRE(infantryFactory->production()->values.find(marineId)); REQUIRE(armorFactory->production()->values.find(tankId)); CHECK_EQ(static_cast(infantryFactory->production()->values.find(marineId)->get().state), static_cast(eve::production::TaskState::Paused)); CHECK_NE(static_cast(armorFactory->production()->values.find(tankId)->get().state), static_cast(eve::production::TaskState::Paused)); CHECK(infantryFactory->rally()->reinforcementCapped); const auto tankDefinition = eve::LogicalId::parse("rts:tank"); REQUIRE(tankDefinition.has_value()); Unit* existing = Unit::createUnit({}, *tankDefinition); auto existingFaction = eve::rts::FactionLink::bind(ecs::handle_of(faction)); REQUIRE(existingFaction.ok()); existing->faction()->link = std::move(existingFaction).takeValue(); existing->tactics()->combatGroup = 91; REQUIRE(eve::rts::ReinforcementProductionPolicySystem::step().ok()); CHECK_EQ(static_cast(armorFactory->production()->values.find(tankId)->get().state), static_cast(eve::production::TaskState::Paused)); existing->durability()->alive = false; REQUIRE(eve::rts::ReinforcementProductionPolicySystem::step().ok()); CHECK_NE(static_cast(armorFactory->production()->values.find(tankId)->get().state), static_cast(eve::production::TaskState::Paused)); CHECK(armorFactory->rally()->reinforcementPolicyPausedTask.empty()); existing->release(); armorFactory->release(); infantryFactory->release(); faction->release(); } TEST_CASE("rts.reinforcementRequestUsesDeterministicFallbackChain") { ecs::Table world; ecs::ScopedTable guard(world); Building* factory = Building::createBuilding(); factory->rally()->reinforcementFallbacks["rts:tank"] = "rts:marine"; std::vector attempts; eve::rts::ReinforcementEnqueue enqueue = [&](Building& producer, std::string_view product) { CHECK_EQ(&producer, factory); attempts.emplace_back(product); if (product == "rts:tank") return eve::Result::failure(eve::Diagnostic::error( eve::DiagnosticCode::Conflict, "tank unavailable", "product")); return producer.production()->values.enqueue("faction", "unit", product, eve::Duration::fromSeconds(1.0).expect("fallback duration")); }; auto requested = eve::rts::ReinforcementProductionPolicySystem::request( *factory, "rts:tank", enqueue); REQUIRE(requested.ok()); CHECK_EQ(requested.value().requestedProduct, "rts:tank"); CHECK_EQ(requested.value().queuedProduct, "rts:marine"); REQUIRE_EQ(attempts.size(), 2u); CHECK_EQ(attempts[0], "rts:tank"); CHECK_EQ(attempts[1], "rts:marine"); factory->rally()->reinforcementFallbacks["rts:marine"] = "rts:tank"; auto cyclic = eve::rts::ReinforcementProductionPolicySystem::request( *factory, "rts:tank", [&](Building&, std::string_view) { return eve::Result::failure(eve::Diagnostic::error( eve::DiagnosticCode::Conflict, "unavailable", "product")); }); CHECK(!cyclic.ok()); CHECK_EQ(cyclic.code(), eve::StatusCode::Rejected); factory->release(); } TEST_CASE("rts.cappedReinforcementUsesInjectedAtomicCancelRefundBoundary") { ecs::Table world; ecs::ScopedTable guard(world); Faction* faction = Faction::createFaction(); Building* factory = Building::createBuilding(); Unit* existing = Unit::createUnit(); auto buildingFaction = eve::rts::FactionLink::bind(ecs::handle_of(faction)); auto unitFaction = eve::rts::FactionLink::bind(ecs::handle_of(faction)); REQUIRE(buildingFaction.ok()); REQUIRE(unitFaction.ok()); factory->faction()->link = std::move(buildingFaction).takeValue(); existing->faction()->link = std::move(unitFaction).takeValue(); factory->rally()->enabled = true; factory->rally()->combatGroup = 92; factory->rally()->reinforcementLimit = 1; factory->rally()->reinforcementAutoCancelDelay = 1.0f; existing->tactics()->combatGroup = 92; auto task = factory->production()->values.enqueue("faction", "unit", "rts:marine", eve::Duration::fromSeconds(5.0).expect("auto cancel duration")); REQUIRE(task.ok()); const std::string taskId = task.value(); bool refunded = false; eve::rts::ReinforcementCancel cancel = [&](Building& producer, std::string_view id) { CHECK_EQ(&producer, factory); CHECK_EQ(id, taskId); auto cancelled = producer.production()->values.cancel(id, "reinforcement capped"); if (cancelled) refunded = true; return cancelled; }; const eve::SimulationStep half{eve::SimulationTick{1}, eve::Duration::fromSeconds(0.5).expect("half capped delay")}; REQUIRE(eve::rts::ReinforcementProductionPolicySystem::step(half, cancel).ok()); CHECK(!refunded); const eve::SimulationStep secondHalf{eve::SimulationTick{2}, half.delta}; REQUIRE(eve::rts::ReinforcementProductionPolicySystem::step(secondHalf, cancel).ok()); CHECK(refunded); REQUIRE(factory->production()->values.find(taskId)); CHECK_EQ(static_cast(factory->production()->values.find(taskId)->get().state), static_cast(eve::production::TaskState::Cancelled)); CHECK(!factory->rally()->reinforcementCapped); existing->release(); factory->release(); faction->release(); } TEST_CASE("rts.productionBlockedExitWaitsAndSpawnsAtFirstAvailablePosition") { ecs::Table world; ecs::ScopedTable guard(world); Building* factory = Building::createBuilding(); factory->placement()->worldX = 4.0f; factory->placement()->worldY = 5.0f; const auto duration = eve::Duration::fromSeconds(1.0).expect("blocked exit duration"); auto queued = factory->production()->values.enqueue("faction", "unit", "marine", duration); REQUIRE(queued.ok()); const std::string taskId = std::move(queued).takeValue(); int probes = 0; eve::rts::ProductionSpawnPosition position = [&](Building&, const eve::production::ProductionTask&) { ++probes; if (probes == 1) return eve::Result>::success(std::nullopt); return eve::Result>::success( eve::rts::WorldPosition{8.0f, 9.0f}); }; std::vector spawned; eve::rts::ProductionSpawn spawn = [&](Building&, const eve::production::ProductionTask&) { Unit* unit = Unit::createUnit(); spawned.push_back(ecs::handle_of(unit)); return eve::Result::success(unit); }; std::vector productionEvents; auto collectProduction = [&](const eve::rts::LifecycleEvent& event, eve::SimulationTick) { productionEvents.push_back(event); }; auto blocked = eve::rts::BuildingProductionSystem::step( {eve::SimulationTick{1}, duration}, spawn, position, collectProduction); REQUIRE(blocked.ok()); CHECK(spawned.empty()); CHECK(factory->rally()->productionSpawnBlocked); CHECK_EQ(factory->rally()->blockedProductionTask, taskId); CHECK(factory->rally()->settledProductionTasks.empty()); REQUIRE_EQ(productionEvents.size(), 1u); CHECK_EQ(static_cast(productionEvents[0].kind), static_cast(eve::rts::LifecycleEventKind::ProductionSpawnBlocked)); auto cleared = eve::rts::BuildingProductionSystem::step( {eve::SimulationTick{2}, duration}, spawn, position, collectProduction); REQUIRE(cleared.ok()); REQUIRE_EQ(spawned.size(), 1u); auto* unit = dynamic_cast(ecs::try_get(spawned.front())); REQUIRE(unit != nullptr); CHECK_EQ(unit->motion()->x, 8.0f); CHECK_EQ(unit->motion()->y, 9.0f); CHECK(!factory->rally()->productionSpawnBlocked); CHECK(factory->rally()->blockedProductionTask.empty()); CHECK_EQ(factory->rally()->settledProductionTasks.size(), 1u); REQUIRE_EQ(productionEvents.size(), 3u); CHECK_EQ(static_cast(productionEvents[1].kind), static_cast(eve::rts::LifecycleEventKind::ProductionSpawnCleared)); CHECK_EQ(static_cast(productionEvents[2].kind), static_cast(eve::rts::LifecycleEventKind::UnitProduced)); } TEST_CASE("rts.orderAndProductionAdaptersRoundTripCanonicalQueueSnapshots") { ecs::Table world; ecs::ScopedTable guard(world); Unit* target = Unit::createUnit(); eve::rts::OrderComponent orders; CommandSpec move; move.kind = OrderKind::SuppressArea; move.target = {7.0f, 9.0f}; move.secondaryTarget = {11.0f, 13.0f}; move.radius = 2.5f; move.append = true; move.targetEntity = ecs::handle_of(target); REQUIRE(orders.enqueue(move).ok()); auto orderState = orders.snapshotState(); REQUIRE(orderState.ok()); eve::rts::OrderComponent restoredOrders; REQUIRE(restoredOrders.restoreState(orderState.value()).ok()); auto current = restoredOrders.current(); REQUIRE(current.ok()); CHECK_EQ(static_cast(current.value().kind), static_cast(OrderKind::SuppressArea)); CHECK_EQ(current.value().target.x, 7.0f); CHECK_EQ(current.value().target.y, 9.0f); CHECK_EQ(current.value().secondaryTarget.x, 11.0f); CHECK_EQ(current.value().secondaryTarget.y, 13.0f); CHECK_EQ(current.value().radius, 2.5f); CHECK(current.value().append); CHECK_EQ(ecs::try_get(current.value().targetEntity), target); eve::rts::ProductionComponent production; const auto duration = eve::Duration::fromSeconds(2.0).expect("snapshot production duration"); REQUIRE(production.enqueue("faction", "unit", "marine", duration).ok()); auto productionJson = production.snapshot(); REQUIRE(productionJson.ok()); eve::rts::ProductionComponent restoredProduction; REQUIRE(restoredProduction.restore(productionJson.value()).ok()); CHECK_EQ(restoredProduction.taskCount(), 1u); const auto before = restoredProduction.snapshot(); REQUIRE(before.ok()); auto rejected = restoredProduction.restore("{not-json"); CHECK(!rejected.ok()); auto after = restoredProduction.snapshot(); REQUIRE(after.ok()); CHECK_EQ(after.value(), before.value()); target->release(); } TEST_CASE("rts.rootSnapshotRestoresStableRelationshipsAndRejectsTopologyMismatch") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS module; auto factionResult = module.newFaction(subject("00000000-0000-7000-8000-000000000081")); auto unitResult = module.newUnit(subject("00000000-0000-7000-8000-000000000082")); auto buildingResult = module.newBuilding(subject("00000000-0000-7000-8000-000000000083")); auto nodeResult = module.newResourceNode(subject("00000000-0000-7000-8000-000000000084"), "minerals", 500.0f, {8.0f, 6.0f}, 2); auto playerResult = module.newPlayer(subject("00000000-0000-7000-8000-000000000085")); auto matchResult = module.newMatch(subject("00000000-0000-7000-8000-000000000086")); REQUIRE(factionResult.ok()); REQUIRE(unitResult.ok()); REQUIRE(buildingResult.ok()); REQUIRE(nodeResult.ok()); REQUIRE(playerResult.ok()); REQUIRE(matchResult.ok()); Faction* faction = factionResult.value(); Unit* unit = unitResult.value(); Building* building = buildingResult.value(); ResourceNode* node = nodeResult.value(); Player* player = playerResult.value(); Match* match = matchResult.value(); auto unitFaction = eve::rts::FactionLink::bind(ecs::handle_of(faction)); auto buildingFaction = eve::rts::FactionLink::bind(ecs::handle_of(faction)); auto nodeLink = eve::rts::ResourceNodeLink::bind(ecs::handle_of(node)); auto dropoffLink = eve::rts::BuildingLink::bind(ecs::handle_of(building)); REQUIRE(unitFaction.ok()); REQUIRE(buildingFaction.ok()); REQUIRE(nodeLink.ok()); REQUIRE(dropoffLink.ok()); unit->faction()->link = std::move(unitFaction).takeValue(); building->faction()->link = std::move(buildingFaction).takeValue(); unit->worker()->resourceNode = std::move(nodeLink).takeValue(); unit->worker()->dropoff = std::move(dropoffLink).takeValue(); unit->worker()->cargo = 17.0f; unit->motion()->x = 3.0f; unit->motion()->y = 4.0f; unit->tactics()->coordinatedVolleyInterval = 0.75f; unit->tactics()->volleyReleaseRemaining = 0.25f; unit->tactics()->volleyHolding = true; unit->artillery()->observedFireSpotter = ecs::handle_of(unit); unit->artillery()->usingObservedFire = true; REQUIRE(module.setUnitAttribute(*unit, "armor", 7.0).ok()); REQUIRE(unit->tags()->values.add("unit.worker").ok()); REQUIRE(building->tags()->values.add("building.dropoff").ok()); eve::rts::RTSEffectDefinition effect; effect.id = "snapshot-morale"; effect.source = "test"; effect.duration = 5.0; REQUIRE(module.applyEffect(*unit, effect).ok()); building->construction()->builders.push_back(ecs::handle_of(unit)); building->rally()->enabled = true; building->rally()->productionSpawnBlocked = true; building->rally()->blockedProductionTask = "task-7"; building->combat()->airDefenseNetworkRange = 9.0f; building->combat()->airDefenseNetworkRoot = ecs::handle_of(building); building->combat()->airDefenseNetworkSize = 3; building->rally()->command.kind = OrderKind::Escort; building->rally()->command.targetEntity = ecs::handle_of(unit); node->harvest()->workers.push_back(ecs::handle_of(unit)); faction->members()->units.push_back(ecs::handle_of(unit)); faction->members()->buildings.push_back(ecs::handle_of(building)); faction->workforce()->autoConstruction = true; faction->intel()->enabled = true; player->selection()->units.push_back(ecs::handle_of(unit)); player->selection()->buildings.push_back(ecs::handle_of(building)); Match::Participants::Entry participant; auto participantLink = eve::rts::FactionLink::bind(ecs::handle_of(faction)); REQUIRE(participantLink.ok()); participant.faction = std::move(participantLink).takeValue(); participant.team = 3; match->participants()->entries.push_back(std::move(participant)); CommandSpec attack; attack.kind = OrderKind::Attack; attack.target = {8.0f, 6.0f}; attack.targetEntity = ecs::handle_of(building); REQUIRE(unit->orders()->values.enqueue(attack).ok()); auto duration = eve::Duration::fromSeconds(3.0).expect("snapshot queue duration"); REQUIRE(building->production()->values.enqueue("faction", "unit", "marine", duration).ok()); auto snapshot = module.snapshotState(); REQUIRE(snapshot.ok()); unit->motion()->x = 99.0f; unit->worker()->cargo = 0.0f; unit->tactics()->coordinatedVolleyInterval = 0.0f; unit->tactics()->volleyReleaseRemaining = 0.0f; unit->tactics()->volleyHolding = false; unit->artillery()->observedFireSpotter = {}; unit->artillery()->usingObservedFire = false; REQUIRE(module.setUnitAttribute(*unit, "armor", 1.0).ok()); REQUIRE(unit->tags()->values.remove("unit.worker").ok()); REQUIRE(building->tags()->values.remove("building.dropoff").ok()); building->construction()->builders.clear(); node->stock()->remaining = 1.0f; building->rally()->command.targetEntity = {}; building->rally()->productionSpawnBlocked = false; building->rally()->blockedProductionTask.clear(); building->combat()->airDefenseNetworkRange = 0.0f; building->combat()->airDefenseNetworkRoot = {}; building->combat()->airDefenseNetworkSize = 0; faction->members()->units.clear(); faction->workforce()->autoConstruction = false; faction->intel()->enabled = false; player->selection()->units.clear(); match->participants()->entries.clear(); REQUIRE(module.restoreState(snapshot.value()).ok()); CHECK_EQ(unit->motion()->x, 3.0f); CHECK_EQ(unit->worker()->cargo, 17.0f); CHECK_EQ(unit->tactics()->coordinatedVolleyInterval, 0.75f); CHECK_EQ(unit->tactics()->volleyReleaseRemaining, 0.25f); CHECK(unit->tactics()->volleyHolding); CHECK(faction->intel()->enabled); CHECK_EQ(ecs::try_get(unit->artillery()->observedFireSpotter), unit); CHECK(unit->artillery()->usingObservedFire); auto armor = module.readUnitAttribute(*unit, "armor"); REQUIRE(armor.ok()); CHECK_EQ(armor.value(), 7.0); CHECK(unit->tags()->values.contains("unit.worker")); CHECK(building->tags()->values.contains("building.dropoff")); CHECK_EQ(unit->effects()->values.count(), 1u); CHECK_EQ(unit->worker()->resourceNode.resolve(), node); CHECK_EQ(building->construction()->builders.size(), 1u); CHECK_EQ(ecs::try_get(building->rally()->command.targetEntity), unit); CHECK(building->rally()->productionSpawnBlocked); CHECK_EQ(building->rally()->blockedProductionTask, "task-7"); CHECK_EQ(building->combat()->airDefenseNetworkRange, 9.0f); CHECK_EQ(ecs::try_get(building->combat()->airDefenseNetworkRoot), building); CHECK_EQ(building->combat()->airDefenseNetworkSize, 3u); CHECK_EQ(node->stock()->remaining, 500.0f); auto restoredOrder = unit->orders()->values.current(); REQUIRE(restoredOrder.ok()); CHECK_EQ(ecs::try_get(restoredOrder.value().targetEntity), building); CHECK_EQ(building->production()->values.taskCount(), 1u); CHECK_EQ(faction->members()->units.size(), 1u); CHECK(faction->workforce()->autoConstruction); CHECK_EQ(player->selection()->units.size(), 1u); REQUIRE_EQ(match->participants()->entries.size(), 1u); CHECK_EQ(match->participants()->entries.front().faction.resolve(), faction); CHECK_EQ(match->participants()->entries.front().team, 3); auto invalid = snapshot.value(); invalid.resourceNodes.clear(); unit->motion()->x = 42.0f; auto rejected = module.restoreState(invalid); CHECK(!rejected.ok()); CHECK_EQ(unit->motion()->x, 42.0f); invalid = snapshot.value(); invalid.units.front().worker.resourceNode = building->identity()->subject; rejected = module.restoreState(invalid); CHECK(!rejected.ok()); CHECK_EQ(unit->motion()->x, 42.0f); } TEST_CASE("rts.workforcePolicyAssignsNearestBuildersAndRepairersWhileKeepingReserve") { ecs::Table world; ecs::ScopedTable guard(world); Faction* faction = Faction::createFaction(); faction->workforce()->autoConstruction = true; faction->workforce()->autoRepair = true; faction->workforce()->maxBuildersPerSite = 2; faction->workforce()->maxRepairersPerBuilding = 2; faction->workforce()->reserveWorkers = 1; Building* site = Building::createBuilding(); const auto siteHandle = ecs::handle_of(site); Building* damaged = Building::createBuilding(); site = dynamic_cast(ecs::try_get(siteHandle)); REQUIRE(site != nullptr); site->construction()->progress = 0.0f; site->placement()->worldX = 2.0f; damaged->construction()->progress = 1.0f; damaged->placement()->worldX = 20.0f; damaged->integrity()->state.health = 50.0; damaged->integrity()->state.maxHealth = 100.0; for (Building* building : {site, damaged}) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(faction)); REQUIRE(link.ok()); building->faction()->link = std::move(link).takeValue(); } std::vector workers; for (float x : {0.0f, 1.0f, 18.0f, 30.0f}) { Unit* worker = Unit::createUnit(); auto link = eve::rts::FactionLink::bind(ecs::handle_of(faction)); REQUIRE(link.ok()); worker->faction()->link = std::move(link).takeValue(); worker->motion()->x = x; worker->worker()->buildRate = 1.0f; worker->worker()->repairRate = 1.0f; workers.push_back(ecs::handle_of(worker)); } auto assigned = eve::rts::WorkforceAssignmentSystem::step(); REQUIRE(assigned.ok()); CHECK_EQ(assigned.value(), 3u); std::size_t builders = 0, repairers = 0, idle = 0; for (const auto& handle : workers) { auto* worker = dynamic_cast(ecs::try_get(handle)); REQUIRE(worker != nullptr); auto current = worker->orders()->values.current(); if (!current.ok()) { ++idle; continue; } if (current.value().kind == OrderKind::Build) ++builders; if (current.value().kind == OrderKind::Repair) ++repairers; } CHECK_EQ(builders, 2u); CHECK_EQ(repairers, 1u); CHECK_EQ(idle, 1u); } TEST_CASE("rts.navigationUsesCanonicalMapPathAndReportsUnreachableOnce") { ecs::Table world; ecs::ScopedTable guard(world); eve::map::Pathfinder pathfinder(5, 3); pathfinder.setDiagonal(false); pathfinder.setBlocked(1, 1, true); Unit* unit = Unit::createUnit(); unit->motion()->x = 0.0f; unit->motion()->y = 1.0f; unit->motion()->speed = 1.0f; CommandSpec move; move.kind = OrderKind::Move; move.target = {4.0f, 1.0f}; auto queued = unit->orders()->values.enqueue(move); REQUIRE(queued.ok()); std::move(queued).takeValue(); int unreachableCount = 0; auto planned = eve::rts::NavigationSystem::step(pathfinder, {}, [&](Unit& reported, const eve::rts::OrderRecord&) { CHECK(&reported == unit); ++unreachableCount; }); REQUIRE(planned.ok()); CHECK(!unit->navigation()->unreachable); REQUIRE(!unit->navigation()->waypoints.empty()); CHECK(std::abs(unit->navigation()->waypoints.front().y - 1.0f) > 0.5f); const eve::SimulationStep tick{eve::SimulationTick{1}, eve::Duration::fromSeconds(1.0).expect("navigation dt")}; auto moved = eve::rts::MotionSystem::step(tick); REQUIRE(moved.ok()); CHECK(std::abs(unit->motion()->y - 1.0f) > 0.5f); pathfinder.setBlocked(3, 1, true); pathfinder.setBlocked(4, 0, true); pathfinder.setBlocked(4, 2, true); auto blockedQueue = unit->orders()->values.replace(move); REQUIRE(blockedQueue.ok()); std::move(blockedQueue).takeValue(); auto blocked = eve::rts::NavigationSystem::step(pathfinder, {}, [&](Unit&, const eve::rts::OrderRecord&) { ++unreachableCount; }); REQUIRE(blocked.ok()); CHECK(unit->navigation()->unreachable); CHECK_EQ(unreachableCount, 1); auto repeated = eve::rts::NavigationSystem::step(pathfinder, {}, [&](Unit&, const eve::rts::OrderRecord&) { ++unreachableCount; }); REQUIRE(repeated.ok()); CHECK_EQ(unreachableCount, 1); unit->release(); } TEST_CASE("rts.trafficReservationsHonorPriorityAndRecoverAtNarrowCells") { ecs::Table world; ecs::ScopedTable guard(world); eve::map::Pathfinder pathfinder(3, 1); pathfinder.setDiagonal(false); Unit* low = Unit::createUnit(subject("00000000-0000-7000-8000-000000000202")); Unit* high = Unit::createUnit(subject("00000000-0000-7000-8000-000000000201")); low->motion()->x = 0.0f; high->motion()->x = 2.0f; low->navigation()->movementPriority = 1; high->navigation()->movementPriority = 9; CommandSpec lowMove; lowMove.kind = OrderKind::Move; lowMove.target = {2.0f, 0.0f}; CommandSpec highMove = lowMove; highMove.target = {0.0f, 0.0f}; REQUIRE(low->orders()->values.enqueue(lowMove).ok()); REQUIRE(high->orders()->values.enqueue(highMove).ok()); REQUIRE(eve::rts::NavigationSystem::step(pathfinder, {}).ok()); auto reserved = eve::rts::TrafficReservationSystem::step(pathfinder, {}); REQUIRE(reserved.ok()); CHECK(low->navigation()->trafficWaiting); CHECK(!high->navigation()->trafficWaiting); high->motion()->x = 1.0f; REQUIRE(eve::rts::NavigationSystem::step(pathfinder, {}).ok()); REQUIRE(eve::rts::TrafficReservationSystem::step(pathfinder, {}).ok()); CHECK(!low->navigation()->trafficWaiting); low->release(); high->release(); } TEST_CASE("rts.arrivedMovementOrdersAdvanceTheCanonicalMixedQueue") { ecs::Table world; ecs::ScopedTable guard(world); Unit* unit = Unit::createUnit(); unit->motion()->speed = 2.0f; CommandSpec first; first.kind = OrderKind::Move; first.target = {1.0f, 0.0f}; CommandSpec second; second.kind = OrderKind::AttackMove; second.target = {3.0f, 0.0f}; CommandSpec third; third.kind = OrderKind::Move; third.target = {4.0f, 0.0f}; REQUIRE(unit->orders()->values.enqueue(first).ok()); REQUIRE(unit->orders()->values.enqueue(second).ok()); REQUIRE(unit->orders()->values.enqueue(third).ok()); const eve::SimulationStep tick{eve::SimulationTick{1}, eve::Duration::fromSeconds(0.5).expect("movement queue dt")}; REQUIRE(eve::rts::MotionSystem::step(tick).ok()); CHECK(unit->motion()->arrived); auto settled = eve::rts::MovementOrderSystem::step(); REQUIRE(settled.ok()); CHECK_EQ(settled.value(), 1u); auto current = unit->orders()->values.current(); REQUIRE(current.ok()); CHECK_EQ(static_cast(current.value().kind), static_cast(OrderKind::AttackMove)); REQUIRE(eve::rts::MotionSystem::step(tick).ok()); CHECK(!unit->motion()->arrived); REQUIRE(eve::rts::MotionSystem::step(tick).ok()); CHECK(unit->motion()->arrived); REQUIRE(eve::rts::MovementOrderSystem::step().ok()); current = unit->orders()->values.current(); REQUIRE(current.ok()); CHECK_EQ(static_cast(current.value().kind), static_cast(OrderKind::Move)); unit->release(); } TEST_CASE("rts.attackMovePausesInWeaponRangeThenResumesAndGuardsDestination") { ecs::Table world; ecs::ScopedTable guard(world); Unit* attacker = Unit::createUnit(); Unit* enemy = Unit::createUnit(); attacker->motion()->speed = 5.0f; attacker->combat()->engagementRange = 3.0f; attacker->combat()->target = ecs::handle_of(enemy); enemy->motion()->x = 2.0f; CommandSpec command; command.kind = OrderKind::AttackMove; command.target = {5.0f, 0.0f}; REQUIRE(attacker->orders()->values.enqueue(command).ok()); const eve::SimulationStep tick{eve::SimulationTick{1}, eve::Duration::fromSeconds(1.0).expect("attack move dt")}; REQUIRE(eve::rts::MotionSystem::step(tick).ok()); CHECK(std::abs(attacker->motion()->x) < 1e-5f); attacker->combat()->target = {}; REQUIRE(eve::rts::MotionSystem::step(tick).ok()); CHECK(std::abs(attacker->motion()->x - 5.0f) < 1e-5f); REQUIRE(eve::rts::MovementOrderSystem::step().ok()); CHECK(!attacker->orders()->values.empty()); attacker->release(); enemy->release(); } TEST_CASE("rts.patrolPersistsAndAlternatesBetweenCommandOriginAndTarget") { ecs::Table world; ecs::ScopedTable guard(world); Unit* unit = Unit::createUnit(); unit->motion()->x = 1.0f; unit->motion()->speed = 2.0f; CommandSpec patrol; patrol.kind = OrderKind::Patrol; patrol.target = {3.0f, 0.0f}; REQUIRE(unit->orders()->values.enqueue(patrol).ok()); REQUIRE(eve::rts::PatrolSystem::step().ok()); CHECK(unit->navigation()->patrolInitialized); CHECK(unit->navigation()->patrolTowardTarget); CHECK(std::abs(unit->navigation()->patrolOrigin.x - 1.0f) < 1e-5f); const eve::SimulationStep tick{eve::SimulationTick{1}, eve::Duration::fromSeconds(1.0).expect("patrol dt")}; REQUIRE(eve::rts::MotionSystem::step(tick).ok()); CHECK(std::abs(unit->motion()->x - 3.0f) < 1e-5f); REQUIRE(eve::rts::PatrolSystem::step().ok()); CHECK(!unit->navigation()->patrolTowardTarget); REQUIRE(eve::rts::MotionSystem::step(tick).ok()); CHECK(std::abs(unit->motion()->x - 1.0f) < 1e-5f); CHECK(!unit->orders()->values.empty()); unit->release(); } TEST_CASE("rts.stopSettlesImmediatelyAndHoldAnchorsAutomaticCombat") { ecs::Table world; ecs::ScopedTable guard(world); Unit* unit = Unit::createUnit(); unit->motion()->x = 4.0f; unit->motion()->y = 2.0f; unit->combat()->target = ecs::handle_of(unit); CommandSpec hold; hold.kind = OrderKind::HoldPosition; REQUIRE(unit->orders()->values.replace(hold).ok()); REQUIRE(eve::rts::CommandStateSystem::step().ok()); CHECK(unit->combat()->holdPosition); CHECK(unit->combat()->guardSet); CHECK(std::abs(unit->combat()->guardX - 4.0f) < 1e-5f); CommandSpec stop; stop.kind = OrderKind::Stop; REQUIRE(unit->orders()->values.replace(stop).ok()); auto stopped = eve::rts::CommandStateSystem::step(); REQUIRE(stopped.ok()); CHECK_EQ(stopped.value(), 1u); CHECK(unit->orders()->values.empty()); CHECK(!unit->combat()->holdPosition); CHECK(!unit->combat()->guardSet); CHECK(unit->combat()->target.table == nullptr); unit->release(); } TEST_CASE("rts.fogUsesCanonicalFovForTeamSharingAndLastKnownContacts") { ecs::Table world; ecs::ScopedTable guard(world); Faction* alpha = Faction::createFaction(subject("00000000-0000-7000-8000-000000000101")); Faction* ally = Faction::createFaction(subject("00000000-0000-7000-8000-000000000102")); Faction* enemy = Faction::createFaction(subject("00000000-0000-7000-8000-000000000103")); Unit* scout = Unit::createUnit(subject("00000000-0000-7000-8000-000000000104")); Unit* target = Unit::createUnit(subject("00000000-0000-7000-8000-000000000105")); auto alphaLink = eve::rts::FactionLink::bind(ecs::handle_of(alpha)); auto enemyLink = eve::rts::FactionLink::bind(ecs::handle_of(enemy)); REQUIRE(alphaLink.ok()); REQUIRE(enemyLink.ok()); scout->faction()->link = std::move(alphaLink).takeValue(); target->faction()->link = std::move(enemyLink).takeValue(); scout->motion()->x = 1.0f; scout->motion()->y = 1.0f; scout->vision()->sightRange = 5.0f; target->motion()->x = 3.0f; target->motion()->y = 1.0f; eve::map::Fov shared(10, 10); eve::map::Fov hostile(10, 10); eve::rts::FogOfWarSystem::State fog; const auto provider = [&](Faction& faction) -> eve::map::Fov* { return &faction == enemy ? &hostile : &shared; }; const eve::SimulationStep first{eve::SimulationTick{1}, eve::Duration::fromSeconds(0.5).expect("fog dt")}; auto revealed = eve::rts::FogOfWarSystem::step(first, {}, fog, provider); REQUIRE(revealed.ok()); CHECK(shared.isVisible(3, 1)); const auto* alphaContact = eve::rts::FogOfWarSystem::contact(*alpha, target->identity()->subject); const auto* allyContact = eve::rts::FogOfWarSystem::contact(*ally, target->identity()->subject); REQUIRE(alphaContact != nullptr); REQUIRE(allyContact != nullptr); CHECK(alphaContact->visible); CHECK(allyContact->visible); target->motion()->x = 9.0f; target->motion()->y = 9.0f; const eve::SimulationStep second{eve::SimulationTick{2}, eve::Duration::fromSeconds(1.0).expect("fog age")}; auto hidden = eve::rts::FogOfWarSystem::step(second, {}, fog, provider); REQUIRE(hidden.ok()); CHECK(shared.isExplored(3, 1)); alphaContact = eve::rts::FogOfWarSystem::contact(*alpha, target->identity()->subject); REQUIRE(alphaContact != nullptr); CHECK(!alphaContact->visible); CHECK(std::abs(alphaContact->ageSeconds - 1.0) < 1e-6); CHECK(std::abs(alphaContact->position.x - 3.0f) < 1e-5f); eve::rts::FogOfWarSystem::clear(fog); target->release(); scout->release(); enemy->release(); ally->release(); alpha->release(); } TEST_CASE("rts.radarCreatesQuantizedUntargetableContactsAndJammingStopsRefresh") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(subject("00000000-0000-7000-8000-000000000106")); Faction* red = Faction::createFaction(subject("00000000-0000-7000-8000-000000000107")); Unit* radar = Unit::createUnit(subject("00000000-0000-7000-8000-000000000108")); Unit* target = Unit::createUnit(subject("00000000-0000-7000-8000-000000000109")); Unit* jammer = Unit::createUnit(subject("00000000-0000-7000-8000-000000000110")); auto blueLink = eve::rts::FactionLink::bind(ecs::handle_of(blue)); auto targetLink = eve::rts::FactionLink::bind(ecs::handle_of(red)); auto jammerLink = eve::rts::FactionLink::bind(ecs::handle_of(red)); REQUIRE(blueLink.ok()); REQUIRE(targetLink.ok()); REQUIRE(jammerLink.ok()); radar->faction()->link = std::move(blueLink).takeValue(); target->faction()->link = std::move(targetLink).takeValue(); jammer->faction()->link = std::move(jammerLink).takeValue(); radar->vision()->radarRange = 20.0f; radar->vision()->radarResolution = 2.0f; target->motion()->x = 5.2f; target->motion()->y = 3.1f; jammer->motion()->x = 30.0f; eve::map::Fov blueFov(40, 40), redFov(40, 40); eve::rts::FogOfWarSystem::State fog; const auto provider = [&](Faction& faction) { return &faction == blue ? &blueFov : &redFov; }; const eve::SimulationStep first{eve::SimulationTick{1}, eve::Duration::fromSeconds(0.5).expect("radar dt")}; REQUIRE(eve::rts::FogOfWarSystem::step(first, {}, fog, provider).ok()); const auto* contact = eve::rts::FogOfWarSystem::contact(*blue, target->identity()->subject); REQUIRE(contact != nullptr); CHECK_EQ(contact->kind, "radar_unit"); CHECK(!contact->visible); CHECK(!contact->detected); CHECK(std::abs(contact->position.x - 5.0f) < 1e-5f); CHECK(std::abs(contact->position.y - 3.0f) < 1e-5f); jammer->motion()->x = 5.2f; jammer->motion()->y = 3.1f; jammer->vision()->jammingRange = 3.0f; target->motion()->x = 9.0f; const eve::SimulationStep second{eve::SimulationTick{2}, eve::Duration::fromSeconds(1.0).expect("jam dt")}; REQUIRE(eve::rts::FogOfWarSystem::step(second, {}, fog, provider).ok()); contact = eve::rts::FogOfWarSystem::contact(*blue, target->identity()->subject); REQUIRE(contact != nullptr); CHECK(std::abs(contact->position.x - 5.0f) < 1e-5f); CHECK(std::abs(contact->ageSeconds - 1.0) < 1e-6); eve::rts::FogOfWarSystem::clear(fog); jammer->release(); target->release(); radar->release(); red->release(); blue->release(); } TEST_CASE("rts.contentPackPublishesAtomicallyToCanonicalDefinitions") { eve::definitions::DefinitionRegistry registry; const std::string pack = R"JSON({ "weapons":[{"id":"rifle","damage":8,"range":5,"cooldown":0.5,"magazineSize":6,"reloadTime":2, "falloffStart":2,"minimumDamageFactor":0.4,"splashMinimumDamageFactor":0.2, "accuracy":0.75,"scatterRadius":1.5, "targetsGround":false,"targetsAir":true,"requiredTargetTags":["armored"], "preferredTargetTags":["biological"],"preferredTargetBonus":3.5, "excludedTargetTags":["cloaked"],"friendlyFire":true,"blockedByObstacles":true}], "buildings":[{"id":"barracks","health":700}], "units":[{"id":"marine","weaponType":"rifle","producer":"barracks","health":80}], "upgrades":[{"id":"weapons_1","producer":"barracks","targetUnit":"marine","attackMultiplier":1.2}] })JSON"; auto loaded = eve::rts::RTSContentLoader::load(registry, pack); REQUIRE(loaded.ok()); CHECK_EQ(loaded.value().inserted, 4u); CHECK_EQ(registry.countType("unit"), 1); CHECK_EQ(registry.countType("building"), 1); auto weapon = registry.resolve("weapon", "rifle"); REQUIRE(weapon.ok()); auto typed = eve::weapon::parseWeaponDefinition(weapon.value().get()); REQUIRE(typed.ok()); CHECK_EQ(typed.value().magSize, 6); CHECK(!typed.value().targetsGround); CHECK(typed.value().targetsAir); CHECK(typed.value().friendlyFire); CHECK(typed.value().blockedByObstacles); CHECK_EQ(typed.value().falloffStart, 2.0f); CHECK_EQ(typed.value().minimumDamageFactor, 0.4f); CHECK_EQ(typed.value().splashMinimumDamageFactor, 0.2f); CHECK_EQ(typed.value().accuracy, 0.75f); CHECK_EQ(typed.value().scatterRadius, 1.5f); REQUIRE_EQ(typed.value().requiredTargetTags.size(), 1u); CHECK_EQ(typed.value().requiredTargetTags.front(), "armored"); REQUIRE_EQ(typed.value().excludedTargetTags.size(), 1u); CHECK_EQ(typed.value().excludedTargetTags.front(), "cloaked"); REQUIRE_EQ(typed.value().preferredTargetTags.size(), 1u); CHECK_EQ(typed.value().preferredTargetTags.front(), "biological"); CHECK_EQ(typed.value().preferredTargetBonus, 3.5f); CHECK(std::abs(typed.value().projectile.speed) < 1e-5f); const std::string before = registry.snapshotJson(); auto rejected = eve::rts::RTSContentLoader::load( registry, R"JSON({"units":[{"id":"broken","weaponType":"missing"}]})JSON"); CHECK(!rejected.ok()); rejected.ignore("expected atomic RTS content rejection"); CHECK_EQ(registry.snapshotJson(), before); auto replaced = eve::rts::RTSContentLoader::load(registry, pack); REQUIRE(replaced.ok()); CHECK_EQ(replaced.value().replaced, 4u); } TEST_CASE("rts.completedCanonicalResearchAppliesToExistingAndFutureUnitsOnce") { ecs::Table world; ecs::ScopedTable guard(world); eve::definitions::DefinitionRegistry registry; auto content = eve::rts::RTSContentLoader::load(registry, R"JSON({ "units":[{"id":"marine"}], "buildings":[{"id":"lab"}], "upgrades":[{"id":"veteran_training","producer":"lab","targetUnit":"marine", "attackMultiplier":1.5,"healthMultiplier":1.25,"speedMultiplier":1.2,"gatherMultiplier":2.0, "shieldMultiplier":1.5,"shieldRegenMultiplier":1.25}] })JSON"); REQUIRE(content.ok()); auto marineId = eve::LogicalId::fromParts("unit", "marine"); auto labId = eve::LogicalId::fromParts("building", "lab"); REQUIRE(marineId.has_value()); REQUIRE(labId.has_value()); Faction* faction = Faction::createFaction(); Unit* marine = Unit::createUnit({}, *marineId); Building* lab = Building::createBuilding({}, *labId); auto unitFaction = eve::rts::FactionLink::bind(ecs::handle_of(faction)); auto labFaction = eve::rts::FactionLink::bind(ecs::handle_of(faction)); REQUIRE(unitFaction.ok()); REQUIRE(labFaction.ok()); marine->faction()->link = std::move(unitFaction).takeValue(); lab->faction()->link = std::move(labFaction).takeValue(); marine->motion()->speed = 2.0f; marine->worker()->gatherRate = 3.0f; marine->durability()->state.health = 80.0; marine->durability()->state.maxHealth = 100.0; marine->shield()->value = 20.0f; marine->shield()->capacity = 40.0f; marine->shield()->regenRate = 4.0f; auto duration = eve::Duration::fromSeconds(1.0); REQUIRE(duration.ok()); auto research = lab->production()->values.enqueue("faction", "research", "veteran_training", duration.value()); REQUIRE(research.ok()); std::move(research).takeValue(); const eve::SimulationStep tick{eve::SimulationTick{1}, eve::Duration::fromSeconds(1.0).expect("research dt")}; auto advanced = eve::rts::BuildingProductionSystem::step(tick); REQUIRE(advanced.ok()); auto settled = eve::rts::TechnologySystem::step(registry); REQUIRE(settled.ok()); CHECK_EQ(faction->technology()->unlocked.size(), 1u); CHECK(std::abs(marine->combat()->upgradeDamageFactor - 1.5f) < 1e-5f); CHECK(std::abs(marine->motion()->speed - 2.4f) < 1e-5f); CHECK(std::abs(marine->worker()->gatherRate - 6.0f) < 1e-5f); CHECK(std::abs(marine->durability()->state.maxHealth - 125.0) < 1e-6); CHECK(std::abs(marine->durability()->state.health - 100.0) < 1e-6); CHECK_EQ(marine->shield()->capacity, 60.0f); CHECK_EQ(marine->shield()->value, 30.0f); CHECK_EQ(marine->shield()->regenRate, 5.0f); auto repeated = eve::rts::TechnologySystem::step(registry); REQUIRE(repeated.ok()); CHECK(std::abs(marine->motion()->speed - 2.4f) < 1e-5f); Unit* reinforcement = Unit::createUnit({}, *marineId); auto reinforcementFaction = eve::rts::FactionLink::bind(ecs::handle_of(faction)); REQUIRE(reinforcementFaction.ok()); reinforcement->faction()->link = std::move(reinforcementFaction).takeValue(); reinforcement->motion()->speed = 3.0f; auto futureApplied = eve::rts::TechnologySystem::step(registry); REQUIRE(futureApplied.ok()); CHECK(std::abs(reinforcement->motion()->speed - 3.6f) < 1e-5f); reinforcement->release(); lab->release(); marine->release(); faction->release(); } TEST_CASE("rts.matchHeadquartersResourceVictoryAndTeamSurrender") { ecs::Table world; ecs::ScopedTable guard(world); auto hqId = eve::LogicalId::fromParts("building", "hq"); REQUIRE(hqId.has_value()); Faction* alpha = Faction::createFaction(subject("00000000-0000-7000-8000-000000000201")); Faction* beta = Faction::createFaction(subject("00000000-0000-7000-8000-000000000202")); Match* headquarters = Match::createMatch(subject("00000000-0000-7000-8000-000000000203")); headquarters->rules()->rule = eve::rts::VictoryRule::DestroyHeadquarters; headquarters->rules()->archetype = "hq"; REQUIRE(eve::rts::MatchSystem::addParticipant(*headquarters, *alpha, 10).ok()); REQUIRE(eve::rts::MatchSystem::addParticipant(*headquarters, *beta, 20).ok()); Building* alphaHq = Building::createBuilding({}, *hqId); Building* betaHq = Building::createBuilding({}, *hqId); auto alphaOwner = eve::rts::FactionLink::bind(ecs::handle_of(alpha)); auto betaOwner = eve::rts::FactionLink::bind(ecs::handle_of(beta)); REQUIRE(alphaOwner.ok()); REQUIRE(betaOwner.ok()); alphaHq->faction()->link = std::move(alphaOwner).takeValue(); betaHq->faction()->link = std::move(betaOwner).takeValue(); alphaHq->integrity()->alive = true; alphaHq->integrity()->state.health = 100.0; betaHq->integrity()->alive = true; betaHq->integrity()->state.health = 100.0; REQUIRE(eve::rts::MatchSystem::start(*headquarters).ok()); auto ongoing = eve::rts::MatchSystem::step(*headquarters); REQUIRE(ongoing.ok()); CHECK_EQ(static_cast(headquarters->state()->phase), static_cast(eve::rts::MatchPhase::Running)); betaHq->integrity()->alive = false; betaHq->integrity()->state.health = 0.0; auto won = eve::rts::MatchSystem::step(*headquarters); REQUIRE(won.ok()); CHECK_EQ(static_cast(headquarters->state()->phase), static_cast(eve::rts::MatchPhase::Finished)); CHECK_EQ(headquarters->state()->winningTeam, 10); Match* resource = Match::createMatch(subject("00000000-0000-7000-8000-000000000204")); resource->rules()->rule = eve::rts::VictoryRule::ResourceTarget; resource->rules()->archetype = "ore"; resource->rules()->targetValue = 500.0; REQUIRE(eve::rts::MatchSystem::addParticipant(*resource, *alpha, 10).ok()); REQUIRE(eve::rts::MatchSystem::addParticipant(*resource, *beta, 20).ok()); REQUIRE(eve::rts::MatchSystem::start(*resource).ok()); auto resourceWin = eve::rts::MatchSystem::step(*resource, [&](Faction& faction, std::string_view kind) { CHECK_EQ(kind, "ore"); return eve::Result::success(&faction == beta ? 500.0 : 100.0); }); REQUIRE(resourceWin.ok()); CHECK_EQ(resource->state()->winningTeam, 20); Match* surrender = Match::createMatch(subject("00000000-0000-7000-8000-000000000205")); REQUIRE(eve::rts::MatchSystem::addParticipant(*surrender, *alpha, 1).ok()); REQUIRE(eve::rts::MatchSystem::addParticipant(*surrender, *beta, 2).ok()); REQUIRE(eve::rts::MatchSystem::start(*surrender).ok()); REQUIRE(eve::rts::MatchSystem::surrender(*surrender, *beta).ok()); CHECK_EQ(surrender->state()->winningTeam, 1); CHECK(surrender->participants()->entries[1].surrendered); surrender->release(); resource->release(); headquarters->release(); betaHq->release(); alphaHq->release(); beta->release(); alpha->release(); } TEST_CASE("rts.matchTeamsDriveAbilityRelationsAndAutomaticCombatAlliances") { ecs::Table world; ecs::ScopedTable guard(world); Faction* alpha = Faction::createFaction(subject("00000000-0000-7000-8000-00000000d101")); Faction* bravo = Faction::createFaction(subject("00000000-0000-7000-8000-00000000d102")); Faction* red = Faction::createFaction(subject("00000000-0000-7000-8000-00000000d103")); Match* match = Match::createMatch(subject("00000000-0000-7000-8000-00000000d104")); REQUIRE(eve::rts::MatchSystem::addParticipant(*match, *alpha, 1).ok()); REQUIRE(eve::rts::MatchSystem::addParticipant(*match, *bravo, 1).ok()); REQUIRE(eve::rts::MatchSystem::addParticipant(*match, *red, 2).ok()); CHECK(eve::rts::FactionRelationSystem::isAllied(alpha, bravo)); CHECK(!eve::rts::FactionRelationSystem::isAllied(alpha, red)); Unit* caster = Unit::createUnit(subject("00000000-0000-7000-8000-00000000d105")); Unit* teammate = Unit::createUnit(subject("00000000-0000-7000-8000-00000000d106")); auto bind = [&](Unit& unit, Faction& faction) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(&faction)); REQUIRE(link.ok()); unit.faction()->link = std::move(link).takeValue(); unit.durability()->state.health = unit.durability()->state.maxHealth = 10.0; }; bind(*caster, *alpha); bind(*teammate, *bravo); teammate->motion()->x = 2.0f; eve::combat::DamageRuntime damage; eve::rts::AbilitySpec hostile; hostile.id = "hostile"; hostile.target = eve::rts::AbilityTarget::Enemy; hostile.range = 4.0f; hostile.damage = 2.0f; CHECK(!eve::rts::AbilitySystem::cast( *caster, hostile, ecs::handle_of(teammate), {}, damage).ok()); eve::weapon::WeaponEntity* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = "team-rifle"; definition.kind = eve::weapon::WeaponKind::Ranged; definition.damage = 3.0f; definition.damageType = "damage.physical"; definition.range = 5.0f; definition.cooldown = 0.1f; definition.magSize = 4; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); weapon->state()->stages = &weapon->definition()->def->stages; weapon->state()->resource.kind = eve::weapon::ResourceKind::Ammo; weapon->state()->resource.value = weapon->state()->resource.max = 4.0f; auto weaponLink = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(weaponLink.ok()); caster->weapon()->link = std::move(weaponLink).takeValue(); caster->combat()->acquisitionRange = 5.0f; eve::sensing::SensingWorld sensing; eve::rts::CombatFireSystem::State combatState; auto fired = eve::rts::CombatFireSystem::step( {eve::SimulationTick{1}, eve::Duration::fromSeconds(0.2).expect("team combat dt")}, combatState, sensing, damage); REQUIRE(fired.ok()); CHECK_EQ(fired.value(), 0u); CHECK_EQ(teammate->durability()->state.health, 10.0); eve::weapon::WeaponDefinition rocket; rocket.id = "team-rocket"; rocket.damage = 5.0f; rocket.damageType = "damage.explosive"; rocket.range = 5.0f; rocket.projectile.speed = 2.0f; eve::rts::RTSProjectileSystem projectiles; REQUIRE(projectiles.launch(caster->identity()->subject, caster->faction()->link.handle(), {}, ecs::handle_of(teammate), {2.0f, 0.0f}, rocket).ok()); auto impact = projectiles.step( {eve::SimulationTick{2}, eve::Duration::fromSeconds(1.0).expect("team projectile dt")}, damage); REQUIRE(impact.ok()); CHECK_EQ(impact.value(), 1u); CHECK_EQ(teammate->durability()->state.health, 10.0); caster->command()->range = 5.0f; caster->command()->capacity = 2; teammate->command()->requiresCommand = true; teammate->command()->cost = 1; REQUIRE(eve::rts::CommandNetworkSystem::step().ok()); CHECK(teammate->command()->inCommand); CHECK(ecs::try_get(teammate->command()->source) == caster); Unit* enemy = Unit::createUnit(subject("00000000-0000-7000-8000-00000000d108")); bind(*enemy, *red); enemy->motion()->x = 3.0f; alpha->intel()->enabled = true; CHECK(!eve::rts::FactionIntelSystem::isTargetable(alpha, enemy->identity()->subject)); alpha->intel()->contacts.push_back( {subject("ffffffff-ffff-7fff-bfff-ffffffffffff"), "remembered", {}, 0.0, false, false}); alpha->intel()->contacts.push_back( {enemy->identity()->subject, "unit", {3.0f, 0.0f}, 1.0, false, false}); CHECK(!eve::rts::AbilitySystem::cast( *caster, hostile, ecs::handle_of(enemy), {}, damage).ok()); alpha->intel()->contacts.back().visible = true; alpha->intel()->contacts.back().detected = true; REQUIRE(eve::rts::AbilitySystem::cast( *caster, hostile, ecs::handle_of(enemy), {}, damage).ok()); CHECK_EQ(enemy->durability()->state.health, 8.0); alpha->intel()->contacts.back().visible = false; alpha->intel()->contacts.back().detected = false; eve::rts::CommandSpec hiddenAttack; hiddenAttack.kind = eve::rts::OrderKind::Attack; hiddenAttack.targetEntity = ecs::handle_of(enemy); REQUIRE(caster->orders()->values.replace(hiddenAttack).ok()); auto hiddenFire = eve::rts::CombatFireSystem::step( {eve::SimulationTick{3}, eve::Duration::fromSeconds(0.2).expect("hidden target dt")}, combatState, sensing, damage); REQUIRE(hiddenFire.ok()); CHECK_EQ(hiddenFire.value(), 0u); CHECK_EQ(enemy->durability()->state.health, 8.0); Building* influence = Building::createBuilding( subject("00000000-0000-7000-8000-00000000d107")); auto influenceOwner = eve::rts::FactionLink::bind(ecs::handle_of(alpha)); REQUIRE(influenceOwner.ok()); influence->faction()->link = std::move(influenceOwner).takeValue(); influence->placement()->placed = true; influence->construction()->progress = 1.0f; influence->integrity()->alive = true; influence->infrastructure()->powered = true; influence->infrastructure()->buildInfluenceRadius = 5.0f; const auto outpost = eve::LogicalId::fromParts("building", "outpost"); REQUIRE(outpost.has_value()); CHECK(eve::rts::BuildInfluenceSystem::validate( *bravo, {2.0f, 0.0f}, *outpost, [](eve::rts::WorldPosition, eve::LogicalId) { return eve::Result::success(); }).ok()); influence->release(); enemy->release(); weapon->release(); teammate->release(); caster->release(); match->release(); red->release(); bravo->release(); alpha->release(); } TEST_CASE("rts.infrastructureAllocatesPowerByPriorityAndCreditsCanonicalIncome") { ecs::Table world; ecs::ScopedTable guard(world); Faction* faction = Faction::createFaction(subject("00000000-0000-7000-8000-000000000211")); Building* plant = Building::createBuilding(subject("00000000-0000-7000-8000-000000000212")); Building* refinery = Building::createBuilding(subject("00000000-0000-7000-8000-000000000213")); Building* radar = Building::createBuilding(subject("00000000-0000-7000-8000-000000000214")); for (Building* building : {plant, refinery, radar}) { auto owner = eve::rts::FactionLink::bind(ecs::handle_of(faction)); REQUIRE(owner.ok()); building->faction()->link = std::move(owner).takeValue(); building->integrity()->state.health = 100.0; } plant->infrastructure()->powerProduced = 5.0f; refinery->infrastructure()->powerConsumed = 4.0f; refinery->infrastructure()->powerPriority = 20; refinery->infrastructure()->incomeResource = "ore"; refinery->infrastructure()->incomeRate = 2.5f; radar->infrastructure()->powerConsumed = 3.0f; radar->infrastructure()->powerPriority = 10; std::int64_t credited = 0; const eve::SimulationStep step{eve::SimulationTick{1}, eve::Duration::fromSeconds(2.0).expect("income dt")}; auto advanced = eve::rts::InfrastructureSystem::step(step, [&](Building& source, const eve::resource::CostSpec& cost) { CHECK(&source == refinery); REQUIRE_EQ(cost.items().size(), 1u); CHECK_EQ(cost.items()[0].resource.value(), "ore"); credited += cost.items()[0].amount.value(); return eve::Result::success(eve::resource::Receipt{}); }); REQUIRE(advanced.ok()); CHECK(refinery->infrastructure()->powered); CHECK(!radar->infrastructure()->powered); CHECK_EQ(credited, 5); CHECK(std::abs(refinery->infrastructure()->incomeProgress) < 1e-6f); radar->release(); refinery->release(); plant->release(); faction->release(); } TEST_CASE("rts.commandNetworkUsesRelaysCapacityPriorityAndHostileJamming") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(subject("00000000-0000-7000-8000-000000000221")); Faction* red = Faction::createFaction(subject("00000000-0000-7000-8000-000000000222")); Building* hq = Building::createBuilding(subject("00000000-0000-7000-8000-000000000223")); Unit* relay = Unit::createUnit(subject("00000000-0000-7000-8000-000000000224")); Unit* high = Unit::createUnit(subject("00000000-0000-7000-8000-000000000225")); Unit* low = Unit::createUnit(subject("00000000-0000-7000-8000-000000000226")); Unit* jammer = Unit::createUnit(subject("00000000-0000-7000-8000-000000000227")); auto bindFaction = [&](auto& entity, Faction& faction) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(&faction)); REQUIRE(link.ok()); entity.faction()->link = std::move(link).takeValue(); }; bindFaction(*hq, *blue); bindFaction(*relay, *blue); bindFaction(*high, *blue); bindFaction(*low, *blue); bindFaction(*jammer, *red); hq->integrity()->state.health = hq->integrity()->state.maxHealth = 100.0; hq->command()->range = 10.0f; hq->command()->capacity = 1; relay->motion()->x = 8.0f; relay->command()->range = 12.0f; relay->command()->capacity = 1; relay->command()->cost = 1; relay->command()->priority = 100; relay->command()->relayRequiresUplink = true; relay->command()->requiresCommand = true; high->motion()->x = 16.0f; high->command()->requiresCommand = true; high->command()->priority = 10; low->motion()->x = 17.0f; low->motion()->speed = 2.0f; low->command()->requiresCommand = true; low->command()->priority = 1; low->command()->outOfCommandSpeedFactor = 0.5f; jammer->motion()->x = 40.0f; jammer->command()->jammingRange = 5.0f; auto connected = eve::rts::CommandNetworkSystem::step(); REQUIRE(connected.ok()); CHECK(relay->command()->relayActive); CHECK(high->command()->inCommand); CHECK(!low->command()->inCommand); CHECK_EQ(relay->command()->load, 1); jammer->motion()->x = 8.0f; auto jammed = eve::rts::CommandNetworkSystem::step(); REQUIRE(jammed.ok()); CHECK(relay->command()->jammed); CHECK(!relay->command()->relayActive); CHECK(!high->command()->inCommand); CommandSpec move; move.kind = OrderKind::Move; move.target = {27.0f, 0.0f}; REQUIRE(low->orders()->values.replace(move).ok()); auto moved = eve::rts::MotionSystem::step({eve::SimulationTick{1}, eve::Duration::fromSeconds(1.0).expect("command motion dt")}); REQUIRE(moved.ok()); CHECK(std::abs(low->motion()->x - 18.0f) < 1e-5f); jammer->release(); low->release(); high->release(); relay->release(); hq->release(); red->release(); blue->release(); } TEST_CASE("rts.abilitiesUseCanonicalDamageEconomyCooldownAndInterruptibleChannels") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(subject("00000000-0000-7000-8000-000000000231")); Faction* red = Faction::createFaction(subject("00000000-0000-7000-8000-000000000232")); Unit* caster = Unit::createUnit(subject("00000000-0000-7000-8000-000000000233")); Unit* target = Unit::createUnit(subject("00000000-0000-7000-8000-000000000234")); auto blueLink = eve::rts::FactionLink::bind(ecs::handle_of(blue)); auto redLink = eve::rts::FactionLink::bind(ecs::handle_of(red)); REQUIRE(blueLink.ok()); REQUIRE(redLink.ok()); caster->faction()->link = std::move(blueLink).takeValue(); target->faction()->link = std::move(redLink).takeValue(); caster->durability()->state.health = caster->durability()->state.maxHealth = 10.0; target->durability()->state.health = target->durability()->state.maxHealth = 10.0; target->motion()->x = 3.0f; target->shield()->capacity = target->shield()->value = 3.0f; target->shield()->regenDelay = 2.0f; eve::combat::DamageRuntime damage; eve::rts::AbilitySpec bolt; bolt.id = "arc-bolt"; bolt.target = eve::rts::AbilityTarget::Enemy; bolt.range = 5.0f; bolt.cooldown = 1.0f; bolt.resourceType = "energy"; bolt.resourceCost = 2; bolt.damage = 5.0f; std::int64_t debited = 0; auto cast = eve::rts::AbilitySystem::cast(*caster, bolt, ecs::handle_of(target), {}, damage, [&](Unit& source, const eve::resource::CostSpec& cost) { CHECK(&source == caster); debited += cost.items()[0].amount.value(); return eve::Result::success(eve::resource::Receipt{}); }); REQUIRE(cast.ok()); CHECK_EQ(debited, 2); CHECK(std::abs(target->shield()->value) < 1e-5f); CHECK(std::abs(target->durability()->state.health - 8.0) < 1e-5); auto cooling = eve::rts::AbilitySystem::cast(*caster, bolt, ecs::handle_of(target), {}, damage); CHECK(!cooling.ok()); eve::rts::AbilitySpec channel; channel.id = "repair-channel"; channel.target = eve::rts::AbilityTarget::Self; channel.range = 0.0f; channel.castTime = 1.0f; channel.healing = 4.0f; channel.interruptOnDamage = true; caster->durability()->state.health = 5.0; std::vector abilityEvents; auto collectAbility = [&](const eve::rts::LifecycleEvent& event, eve::SimulationTick) { abilityEvents.push_back(event); }; REQUIRE(eve::rts::AbilitySystem::cast(*caster, channel, {}, {}, damage, {}, {}, {}, collectAbility).ok()); caster->durability()->state.health = 4.0; auto interrupted = eve::rts::AbilitySystem::step({eve::SimulationTick{1}, eve::Duration::fromSeconds(1.0).expect("ability dt")}, damage, {}, collectAbility); REQUIRE(interrupted.ok()); CHECK(!caster->abilities()->channel.has_value()); CHECK(std::abs(caster->durability()->state.health - 4.0) < 1e-5); REQUIRE_EQ(abilityEvents.size(), 2u); CHECK_EQ(static_cast(abilityEvents[0].kind), static_cast(eve::rts::LifecycleEventKind::AbilityChannelStarted)); CHECK_EQ(static_cast(abilityEvents[1].kind), static_cast(eve::rts::LifecycleEventKind::AbilityInterrupted)); caster->veterancy()->veteranThreshold = 10.0f; caster->veterancy()->eliteThreshold = 20.0f; caster->veterancy()->veteranDamageFactor = 1.25f; caster->veterancy()->eliteDamageFactor = 1.5f; caster->veterancy()->veteranHealthFactor = 1.2f; caster->veterancy()->eliteHealthFactor = 1.5f; target->durability()->alive = true; target->durability()->state.health = 2.0; target->durability()->state.maxHealth = 10.0; target->shield()->value = 0.0f; eve::rts::AbilitySpec finisher; finisher.id = "finisher"; finisher.target = eve::rts::AbilityTarget::Enemy; finisher.range = 5.0f; finisher.damage = 2.0f; REQUIRE(eve::rts::AbilitySystem::cast( *caster, finisher, ecs::handle_of(target), {}, damage).ok()); CHECK(!target->durability()->alive); CHECK_EQ(caster->veterancy()->experience, 10.0f); CHECK_EQ(caster->veterancy()->level, 1); CHECK_EQ(caster->combat()->upgradeDamageFactor, 1.25f); CHECK_EQ(caster->durability()->state.maxHealth, 12.0); target->release(); caster->release(); red->release(); blue->release(); } TEST_CASE("rts.projectilesUseCanonicalTrajectorySweptImpactAndHostileSplashDamage") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(subject("00000000-0000-7000-8000-000000000241")); Faction* red = Faction::createFaction(subject("00000000-0000-7000-8000-000000000242")); Unit* source = Unit::createUnit(subject("00000000-0000-7000-8000-000000000243")); Unit* target = Unit::createUnit(subject("00000000-0000-7000-8000-000000000244")); Unit* splash = Unit::createUnit(subject("00000000-0000-7000-8000-000000000245")); Unit* ally = Unit::createUnit(subject("00000000-0000-7000-8000-000000000246")); auto bind = [&](Unit& unit, Faction& faction) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(&faction)); REQUIRE(link.ok()); unit.faction()->link = std::move(link).takeValue(); unit.durability()->state.health = unit.durability()->state.maxHealth = 10.0; }; bind(*source, *blue); bind(*ally, *blue); bind(*target, *red); bind(*splash, *red); target->motion()->x = 4.0f; splash->motion()->x = 4.5f; ally->motion()->x = 4.5f; target->shield()->capacity = target->shield()->value = 2.0f; eve::weapon::WeaponDefinition definition; definition.id = "rts-rocket"; definition.damage = 6.0f; definition.damageType = "damage.explosive"; definition.range = 10.0f; definition.projectile.speed = 4.0f; definition.projectile.aoe = 2.0f; eve::rts::RTSProjectileSystem projectiles; REQUIRE(projectiles.launch(source->identity()->subject, source->faction()->link.handle(), {}, ecs::handle_of(target), {4.0f, 0.0f}, definition).ok()); eve::combat::DamageRuntime damage; auto flying = projectiles.step({eve::SimulationTick{1}, eve::Duration::fromSeconds(0.5).expect("projectile dt")}, damage); REQUIRE(flying.ok()); CHECK_EQ(flying.value(), 0u); CHECK_EQ(projectiles.activeCount(), 1u); const auto inFlight = projectiles.snapshot(); eve::rts::RTSProjectileSystem restoredProjectiles; REQUIRE(restoredProjectiles.restore(inFlight, [&](eve::SubjectRef stable) -> ecs::Entity* { for (Unit* unit : {source, target, splash, ally}) if (unit->identity()->subject == stable) return unit; if (blue->identity()->subject == stable) return blue; if (red->identity()->subject == stable) return red; return nullptr; }).ok()); CHECK_EQ(restoredProjectiles.activeCount(), 1u); auto impacted = restoredProjectiles.step({eve::SimulationTick{2}, eve::Duration::fromSeconds(0.5).expect("projectile dt")}, damage); REQUIRE(impacted.ok()); CHECK_EQ(impacted.value(), 1u); CHECK_EQ(restoredProjectiles.activeCount(), 0u); CHECK(std::abs(target->shield()->value) < 1e-5f); CHECK(std::abs(target->durability()->state.health - 6.0) < 1e-5); CHECK(std::abs(splash->durability()->state.health - 5.5) < 1e-5); CHECK(std::abs(ally->durability()->state.health - 10.0) < 1e-5); Unit* blocker = Unit::createUnit(subject("00000000-0000-7000-8000-000000000247")); bind(*blocker, *red); blocker->motion()->x = 1.5f; target->durability()->state.health = 10.0; definition.projectile.aoe = 0.0f; definition.blockedByObstacles = true; REQUIRE(restoredProjectiles.launch(source->identity()->subject, source->faction()->link.handle(), {}, ecs::handle_of(target), {4.0f, 0.0f}, definition).ok()); bool queriedCollision = false; auto blocked = restoredProjectiles.step({eve::SimulationTick{3}, eve::Duration::fromSeconds(0.5).expect("blocked projectile dt")}, damage, [&](eve::rts::WorldPosition from, float fromHeight, eve::rts::WorldPosition to, float toHeight, eve::SubjectRef projectileSource, ecs::EntityHandle intended) { queriedCollision = true; CHECK(from.x <= 1e-5f); CHECK(to.x >= 1.9f); CHECK(std::abs(fromHeight) < 1e-5f); CHECK(std::abs(toHeight) < 1e-5f); CHECK(projectileSource == source->identity()->subject); CHECK(ecs::try_get(intended) == target); return eve::Result>::success( eve::rts::ProjectileCollision{{1.5f, 0.0f}, ecs::handle_of(blocker)}); }); REQUIRE(blocked.ok()); CHECK(queriedCollision); CHECK_EQ(blocked.value(), 1u); CHECK_EQ(restoredProjectiles.activeCount(), 0u); CHECK(std::abs(blocker->durability()->state.health - 4.0) < 1e-5); CHECK(std::abs(target->durability()->state.health - 10.0) < 1e-5); source->veterancy()->veteranThreshold = 10.0f; source->veterancy()->eliteThreshold = 20.0f; source->veterancy()->veteranDamageFactor = 1.25f; source->veterancy()->eliteDamageFactor = 1.5f; source->veterancy()->veteranHealthFactor = 1.2f; source->veterancy()->eliteHealthFactor = 1.5f; target->durability()->alive = true; target->durability()->state.health = target->durability()->state.maxHealth = 10.0; definition.damage = 12.0f; definition.blockedByObstacles = false; REQUIRE(restoredProjectiles.launch(source->identity()->subject, source->faction()->link.handle(), {}, ecs::handle_of(target), {4.0f, 0.0f}, definition).ok()); auto lethal = restoredProjectiles.step({eve::SimulationTick{4}, eve::Duration::fromSeconds(1.0).expect("lethal projectile dt")}, damage); REQUIRE(lethal.ok()); CHECK(!target->durability()->alive); CHECK_EQ(source->veterancy()->level, 1); CHECK_EQ(source->veterancy()->experience, 10.0f); CHECK_EQ(source->combat()->upgradeDamageFactor, 1.25f); CHECK_EQ(source->durability()->state.maxHealth, 12.0); blocker->release(); ally->release(); splash->release(); target->release(); source->release(); red->release(); blue->release(); } TEST_CASE("rts.ballisticProjectileUsesAbsoluteHeightsAndSnapshotsThreeDimensionalFlight") { ecs::Table world; ecs::ScopedTable guard(world); Faction* faction = Faction::createFaction(subject("00000000-0000-7000-8000-00000000e701")); Unit* source = Unit::createUnit(subject("00000000-0000-7000-8000-00000000e702")); auto factionLink = eve::rts::FactionLink::bind(ecs::handle_of(faction)); REQUIRE(factionLink.ok()); source->faction()->link = std::move(factionLink).takeValue(); eve::weapon::WeaponDefinition definition; definition.id = "height-mortar"; definition.damage = 1.0f; definition.range = 10.0f; definition.projectile.speed = 10.0f; definition.projectile.gravity = 4.0f; definition.blockedByObstacles = true; eve::rts::RTSProjectileSystem projectiles; REQUIRE(projectiles.launch(source->identity()->subject, source->faction()->link.handle(), {}, {}, {6.0f, 0.0f}, definition, 1.0, {}, 1.0f, 1.0f).ok()); auto launched = projectiles.snapshot(); REQUIRE(!launched.runtime.slots.empty()); REQUIRE(launched.runtime.slots[0].state.has_value()); CHECK_EQ(launched.runtime.slots[0].state->position.y, 1.0); CHECK(launched.runtime.slots[0].state->velocity.y > 0.0); eve::combat::DamageRuntime damage; bool sawThreeDimensionalSweep = false; auto advanced = projectiles.step( {eve::SimulationTick{1}, eve::Duration::fromSeconds(0.2).expect("ballistic height dt")}, damage, [&](eve::rts::WorldPosition, float fromHeight, eve::rts::WorldPosition, float toHeight, eve::SubjectRef, ecs::EntityHandle) { sawThreeDimensionalSweep = true; CHECK(fromHeight >= 1.0f); CHECK(toHeight > fromHeight); return eve::Result>::success(std::nullopt); }); REQUIRE(advanced.ok()); CHECK(sawThreeDimensionalSweep); const auto inFlight = projectiles.snapshot(); REQUIRE(inFlight.runtime.slots[0].state.has_value()); CHECK(inFlight.runtime.slots[0].state->position.y > 1.0); eve::rts::RTSProjectileSystem restored; REQUIRE(restored.restore(inFlight, [&](eve::SubjectRef stable) -> ecs::Entity* { if (stable == source->identity()->subject) return source; if (stable == faction->identity()->subject) return faction; return nullptr; }).ok()); CHECK(restored.snapshot().runtime.slots[0].state->position == inFlight.runtime.slots[0].state->position); auto completed = restored.step( {eve::SimulationTick{2}, eve::Duration::fromSeconds(0.5).expect("ballistic impact dt")}, damage); REQUIRE(completed.ok()); CHECK_EQ(completed.value(), std::size_t{1}); CHECK_EQ(restored.activeCount(), std::size_t{0}); source->release(); faction->release(); } TEST_CASE("rts.indirectFireRequiresFriendlyObserverAndFreezesAimAfterContactLoss") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(subject("00000000-0000-7000-8000-0000000003a1")); Faction* red = Faction::createFaction(subject("00000000-0000-7000-8000-0000000003a2")); Unit* gun = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003a3")); Unit* observer = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003a4")); Unit* target = Unit::createUnit(subject("00000000-0000-7000-8000-0000000003a5")); auto bind = [&](Unit& unit, Faction& faction) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(&faction)); REQUIRE(link.ok()); unit.faction()->link = std::move(link).takeValue(); }; bind(*gun, *blue); bind(*observer, *blue); bind(*target, *red); gun->vision()->sightRange = 3.0f; gun->combat()->acquisitionRange = 15.0f; observer->motion()->x = 10.0f; observer->vision()->sightRange = 4.0f; target->motion()->x = 12.0f; target->durability()->state.health = target->durability()->state.maxHealth = 100.0; auto* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = "observed-howitzer"; definition.kind = eve::weapon::WeaponKind::Ranged; definition.logic = "rts-indirect"; definition.damage = 10.0f; definition.range = 15.0f; definition.cooldown = 0.1f; definition.magSize = 4; definition.projectile.speed = 2.0f; definition.projectile.gravity = 1.0f; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); weapon->state()->stages = &weapon->definition()->def->stages; weapon->state()->resource.kind = eve::weapon::ResourceKind::Ammo; weapon->state()->resource.value = 4.0f; weapon->state()->resource.max = 4.0f; auto weaponLink = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(weaponLink.ok()); gun->weapon()->link = std::move(weaponLink).takeValue(); eve::sensing::SensingWorld sensing; eve::combat::DamageRuntime damage; eve::rts::CombatFireSystem::State combatState; eve::rts::RTSProjectileSystem projectiles; auto step = eve::SimulationStep{eve::SimulationTick{1}, eve::Duration::fromSeconds(0.1).expect("observed fire step")}; auto fired = eve::rts::CombatFireSystem::step(step, combatState, sensing, damage, &projectiles); REQUIRE(fired.ok()); CHECK_EQ(ecs::try_get(gun->combat()->target), target); CHECK_EQ(fired.value(), 1u); CHECK(gun->artillery()->usingObservedFire); CHECK_EQ(ecs::try_get(gun->artillery()->observedFireSpotter), observer); auto inFlight = projectiles.snapshot(); REQUIRE_EQ(inFlight.payloads.size(), 1u); CHECK_EQ(inFlight.payloads.front().target, target->identity()->subject); CHECK_EQ(inFlight.payloads.front().observer, observer->identity()->subject); eve::rts::RTSProjectileSystem restoredProjectiles; REQUIRE(restoredProjectiles.restore(inFlight, [&](eve::SubjectRef stable) -> ecs::Entity* { for (Unit* unit : {gun, observer, target}) if (unit->identity()->subject == stable) return unit; if (blue->identity()->subject == stable) return blue; if (red->identity()->subject == stable) return red; return nullptr; }).ok()); observer->motion()->x = 30.0f; target->motion()->x = 14.0f; auto advanced = restoredProjectiles.step(step, damage); REQUIRE(advanced.ok()); inFlight = restoredProjectiles.snapshot(); REQUIRE_EQ(inFlight.payloads.size(), 1u); CHECK(!inFlight.payloads.front().target.isValid()); CHECK(!inFlight.payloads.front().observer.isValid()); CHECK(std::abs(inFlight.payloads.front().targetPoint.x - 12.0f) < 1e-5f); gun->combat()->target = {}; step.tick = eve::SimulationTick{2}; auto blind = eve::rts::CombatFireSystem::step(step, combatState, sensing, damage, &projectiles); REQUIRE(blind.ok()); CHECK_EQ(blind.value(), 0u); CHECK_EQ(ecs::try_get(gun->combat()->target), nullptr); weapon->release(); gun->release(); observer->release(); target->release(); blue->release(); red->release(); } TEST_CASE("rts.fireSupportAssignsSuppressionAndCounterBatteryUsesRecentExposure") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(subject("00000000-0000-7000-8000-000000000251")); Faction* red = Faction::createFaction(subject("00000000-0000-7000-8000-000000000252")); Unit* requester = Unit::createUnit(subject("00000000-0000-7000-8000-000000000253")); Unit* battery = Unit::createUnit(subject("00000000-0000-7000-8000-000000000254")); Unit* hostile = Unit::createUnit(subject("00000000-0000-7000-8000-000000000255")); auto bind = [&](Unit& unit, Faction& faction) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(&faction)); REQUIRE(link.ok()); unit.faction()->link = std::move(link).takeValue(); unit.durability()->state.health = unit.durability()->state.maxHealth = 10.0; }; bind(*requester, *blue); bind(*battery, *blue); bind(*hostile, *red); hostile->motion()->x = 8.0f; eve::weapon::WeaponDefinition indirect; indirect.id = "rts-howitzer-support"; indirect.range = 20.0f; indirect.projectile.speed = 10.0f; indirect.projectile.gravity = 1.0f; auto makeWeapon = [&]() { auto* weapon = eve::weapon::WeaponEntity::createWeapon(); weapon->definition()->owned = std::make_shared(indirect); weapon->definition()->def = weapon->definition()->owned.get(); weapon->state()->stages = &weapon->definition()->def->stages; return weapon; }; auto* batteryWeapon = makeWeapon(); auto* hostileWeapon = makeWeapon(); auto batteryLink = eve::rts::WeaponLink::bind(ecs::handle_of(batteryWeapon)); auto hostileLink = eve::rts::WeaponLink::bind(ecs::handle_of(hostileWeapon)); REQUIRE(batteryLink.ok()); REQUIRE(hostileLink.ok()); battery->weapon()->link = std::move(batteryLink).takeValue(); hostile->weapon()->link = std::move(hostileLink).takeValue(); auto assigned = eve::rts::FireSupportSystem::request(*requester, {10.0f, 0.0f}, 2.0f, 3, 1); REQUIRE(assigned.ok()); CHECK_EQ(assigned.value(), 1u); auto supportOrder = battery->orders()->values.current(); REQUIRE(supportOrder.ok()); CHECK_EQ(static_cast(supportOrder.value().kind), static_cast(OrderKind::SuppressArea)); CHECK_EQ(battery->artillery()->suppressionShotsRemaining, 3); CHECK(ecs::try_get(battery->artillery()->fireSupportRequester) == requester); auto cancelled = eve::rts::FireSupportSystem::cancel(*requester); REQUIRE(cancelled.ok()); CHECK_EQ(cancelled.value(), 1u); battery->artillery()->autoCounterBattery = true; battery->artillery()->counterBatteryWindowTicks = 5; hostile->artillery()->lastFireTick = eve::SimulationTick{10}; hostile->artillery()->lastFirePosition = {8.0f, 0.0f}; auto countered = eve::rts::FireSupportSystem::step({eve::SimulationTick{12}, eve::Duration::zero()}); REQUIRE(countered.ok()); CHECK_EQ(countered.value(), 1u); auto counterOrder = battery->orders()->values.current(); REQUIRE(counterOrder.ok()); CHECK_EQ(static_cast(counterOrder.value().kind), static_cast(OrderKind::AttackGround)); CHECK(std::abs(counterOrder.value().target.x - 8.0f) < 1e-5f); hostileWeapon->release(); batteryWeapon->release(); hostile->release(); battery->release(); requester->release(); red->release(); blue->release(); } TEST_CASE("rts.artilleryRelocationChoosesReachableLowThreatFreshPosition") { ecs::Table world; ecs::ScopedTable guard(world); Faction* blue = Faction::createFaction(subject("00000000-0000-7000-8000-000000000291")); Faction* red = Faction::createFaction(subject("00000000-0000-7000-8000-000000000292")); Unit* gun = Unit::createUnit(subject("00000000-0000-7000-8000-000000000293")); auto bind = [&](Unit& unit, Faction& faction) { auto link = eve::rts::FactionLink::bind(ecs::handle_of(&faction)); REQUIRE(link.ok()); unit.faction()->link = std::move(link).takeValue(); unit.durability()->state.health = unit.durability()->state.maxHealth = 10.0; }; bind(*gun, *blue); gun->motion()->x = 10.0f; gun->motion()->y = 10.0f; eve::map::Pathfinder pathfinder(32, 32); eve::rts::NavigationGrid grid; auto initial = eve::rts::ArtilleryRelocationSystem::select(*gun, {20.0f, 10.0f}, 4.0f, 20.0f, pathfinder, grid); REQUIRE(initial.ok()); const WorldPosition departed = initial.value().target; gun->artillery()->departedPosition = departed; gun->artillery()->hasDepartedPosition = true; std::vector threats; std::vector weapons; for (const auto& position : std::array{{{9.03f, 13.88f}, {9.03f, 6.12f}}}) { Unit* threat = Unit::createUnit(); bind(*threat, *red); threat->motion()->x = position.x; threat->motion()->y = position.y; auto* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = "relocation-threat"; definition.damage = 10.0f; definition.range = 1.25f; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); auto weaponLink = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(weaponLink.ok()); threat->weapon()->link = std::move(weaponLink).takeValue(); threats.push_back(threat); weapons.push_back(weapon); } auto selected = eve::rts::ArtilleryRelocationSystem::select(*gun, {20.0f, 10.0f}, 4.0f, 20.0f, pathfinder, grid); REQUIRE(selected.ok()); CHECK(selected.value().avoidedThreat); CHECK(selected.value().avoidedDepartedPosition); CHECK(std::hypot(selected.value().target.x - departed.x, selected.value().target.y - departed.y) > 1.0f); CHECK(pathfinder.isWalkable(static_cast(std::lround(selected.value().target.x)), static_cast(std::lround(selected.value().target.y)))); for (auto* weapon : weapons) weapon->release(); for (Unit* threat : threats) threat->release(); gun->release(); red->release(); blue->release(); } TEST_CASE("rts.rebuildSnapshotMaterializesTopologyAndRollsBackInvalidRelationships") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS source; auto factionResult = source.newFaction(subject("00000000-0000-7000-8000-000000000261")); auto unitResult = source.newUnit(subject("00000000-0000-7000-8000-000000000262")); auto nodeResult = source.newResourceNode(subject("00000000-0000-7000-8000-000000000263"), "ore", 80.0f, {4.0f, 5.0f}, 2); REQUIRE(factionResult.ok()); REQUIRE(unitResult.ok()); REQUIRE(nodeResult.ok()); auto* faction = factionResult.value(); auto* unit = unitResult.value(); auto* node = nodeResult.value(); unit->identity()->displayName = "Harvester"; unit->motion()->x = 2.0f; auto factionLink = eve::rts::FactionLink::bind(ecs::handle_of(faction)); auto nodeLink = eve::rts::ResourceNodeLink::bind(ecs::handle_of(node)); REQUIRE(factionLink.ok()); REQUIRE(nodeLink.ok()); unit->faction()->link = std::move(factionLink).takeValue(); unit->worker()->resourceNode = std::move(nodeLink).takeValue(); faction->members()->units.push_back(ecs::handle_of(unit)); node->harvest()->workers.push_back(ecs::handle_of(unit)); auto captured = source.snapshotState(); REQUIRE(captured.ok()); eve::rts::RTS rebuilt; REQUIRE(rebuilt.rebuildState(captured.value()).ok()); CHECK_EQ(rebuilt.unitCount(), 1u); CHECK_EQ(rebuilt.factionCount(), 1u); CHECK_EQ(rebuilt.resourceNodeCount(), 1u); auto* restoredUnit = rebuilt.findUnit(unit->identity()->subject); REQUIRE(restoredUnit != nullptr); CHECK_EQ(restoredUnit->identity()->displayName, "Harvester"); CHECK(std::abs(restoredUnit->motion()->x - 2.0f) < 1e-5f); CHECK(restoredUnit->faction()->link.resolve() != nullptr); CHECK(restoredUnit->worker()->resourceNode.resolve() != nullptr); eve::rts::RTS invalidTarget; auto invalid = captured.value(); invalid.units.front().faction = subject("00000000-0000-7000-8000-000000000269"); auto rejected = invalidTarget.rebuildState(invalid); CHECK(!rejected.ok()); CHECK_EQ(invalidTarget.unitCount(), 0u); CHECK_EQ(invalidTarget.factionCount(), 0u); CHECK_EQ(invalidTarget.resourceNodeCount(), 0u); } TEST_CASE("rts.commandLogRoundTripsStableSubjectsAndAppliesAtExactTick") { ecs::Table world; ecs::ScopedTable guard(world); const auto firstSubject = subject("00000000-0000-7000-8000-000000000271"); const auto secondSubject = subject("00000000-0000-7000-8000-000000000272"); const auto targetSubject = subject("00000000-0000-7000-8000-000000000273"); eve::rts::RTS source; REQUIRE(source.newUnit(firstSubject).ok()); REQUIRE(source.newUnit(secondSubject).ok()); REQUIRE(source.newBuilding(targetSubject).ok()); eve::rts::RTSReplayCommand command; command.tick = eve::SimulationTick{7}; command.units = {secondSubject, firstSubject, secondSubject}; command.command.kind = OrderKind::Attack; command.command.target = {9.0f, 4.0f}; command.command.targetEntity = ecs::handle_of(source.findBuilding(targetSubject)); command.targetEntity = targetSubject; command.formation = {eve::rts::FormationKind::Line, 2.0f, 0}; eve::rts::RTSCommandLog recorded; REQUIRE(recorded.queue(command, eve::SimulationTick{3}).ok()); CHECK_EQ(recorded.size(), 1u); const std::string text = recorded.exportText(); CHECK(text.starts_with("EVERTS_COMMANDS 1\n")); eve::rts::RTS replay; REQUIRE(replay.newUnit(firstSubject).ok()); REQUIRE(replay.newUnit(secondSubject).ok()); REQUIRE(replay.newBuilding(targetSubject).ok()); eve::rts::RTSCommandLog imported; REQUIRE(imported.importText(text, eve::SimulationTick{3}).ok()); CHECK_EQ(imported.exportText(), text); auto early = imported.apply(eve::SimulationTick{6}, replay); REQUIRE(early.ok()); CHECK_EQ(early.value(), 0u); auto applied = imported.apply(eve::SimulationTick{7}, replay); REQUIRE(applied.ok()); CHECK_EQ(applied.value(), 2u); for (eve::SubjectRef unitSubject : {firstSubject, secondSubject}) { auto current = replay.findUnit(unitSubject)->orders()->values.current(); REQUIRE(current.ok()); CHECK_EQ(static_cast(current.value().kind), static_cast(OrderKind::Attack)); CHECK(ecs::try_get(current.value().targetEntity) == replay.findBuilding(targetSubject)); } CHECK(!imported.importText("EVERTS_COMMANDS 99\n", eve::SimulationTick{}).ok()); CHECK(!imported.importText(text, eve::SimulationTick{8}).ok()); CHECK_EQ(imported.size(), 1u); } TEST_CASE("rts.genericReplayCoversQueuedMovementGroundFireAndControlOrdersAtExactTicks") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS module; const auto unitSubject = subject("00000000-0000-7000-8000-00000000d191"); const auto buildingSubject = subject("00000000-0000-7000-8000-00000000d192"); const auto transportSubject = subject("00000000-0000-7000-8000-00000000d193"); auto* unit = module.newUnit(unitSubject).value(); module.newBuilding(buildingSubject).value(); module.newUnit(transportSubject).value(); eve::rts::RTSCommandLog recorded; const auto queue = [&](std::uint64_t tick, OrderKind kind, eve::rts::WorldPosition point = {}, bool append = false, eve::SubjectRef target = {}, float spacing = 0.0f) { eve::rts::RTSReplayCommand replay; replay.tick = eve::SimulationTick{tick}; replay.units = {unitSubject}; replay.command.kind = kind; replay.command.target = point; replay.command.append = append; replay.targetEntity = target; if (spacing > 0.0f) { replay.formation.kind = FormationKind::Grid; replay.formation.spacing = spacing; } return recorded.queue(std::move(replay)); }; REQUIRE(queue(2, OrderKind::Move, {3.0f, 4.0f}, false, {}, 1.25f).ok()); REQUIRE(queue(2, OrderKind::AttackMove, {6.0f, 7.0f}, true, {}, 1.5f).ok()); REQUIRE(queue(3, OrderKind::AttackGround, {8.0f, 9.0f}, true).ok()); REQUIRE(queue(4, OrderKind::Stop).ok()); REQUIRE(queue(5, OrderKind::HoldPosition).ok()); REQUIRE(queue(6, OrderKind::Patrol, {2.0f, 5.0f}).ok()); REQUIRE(queue(7, OrderKind::Repair, {}, false, buildingSubject).ok()); REQUIRE(queue(8, OrderKind::Capture, {}, false, buildingSubject).ok()); REQUIRE(queue(9, OrderKind::Garrison, {}, false, buildingSubject).ok()); REQUIRE(queue(10, OrderKind::BoardTransport, {}, false, transportSubject).ok()); const std::string text = recorded.exportText(); CHECK(text.starts_with("EVERTS_COMMANDS 1\n")); eve::rts::RTSCommandLog replay; REQUIRE(replay.importText(text).ok()); CHECK_EQ(replay.exportText(), text); auto first = replay.apply(eve::SimulationTick{2}, module); REQUIRE(first.ok()); CHECK_EQ(first.value(), std::size_t{2}); CHECK_EQ(unit->orders()->values.orderCount(), std::size_t{2}); auto ground = replay.apply(eve::SimulationTick{3}, module); REQUIRE(ground.ok()); CHECK_EQ(unit->orders()->values.orderCount(), std::size_t{3}); for (std::uint64_t tick = 4; tick <= 10; ++tick) { auto applied = replay.apply(eve::SimulationTick{tick}, module); REQUIRE(applied.ok()); CHECK_EQ(applied.value(), std::size_t{1}); CHECK_EQ(unit->orders()->values.orderCount(), std::size_t{1}); } auto current = unit->orders()->values.current(); REQUIRE(current.ok()); CHECK_EQ(static_cast(current.value().kind), static_cast(OrderKind::BoardTransport)); CHECK(ecs::try_get(current.value().targetEntity) == module.findUnit(transportSubject)); } TEST_CASE("rts.fireSupportFacadeRoundTripsAndReplaysRequestAndCancellationAtExactTicks") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS module; const auto factionSubject = subject("00000000-0000-7000-8000-00000000d201"); const auto requesterSubject = subject("00000000-0000-7000-8000-00000000d202"); const auto batterySubject = subject("00000000-0000-7000-8000-00000000d203"); auto faction = module.newFaction(factionSubject); REQUIRE(faction.ok()); auto requester = module.newFactionUnit(*faction.value(), requesterSubject); auto battery = module.newFactionUnit(*faction.value(), batterySubject); REQUIRE(requester.ok()); REQUIRE(battery.ok()); requester.value()->motion()->x = 0.0f; battery.value()->motion()->x = 2.0f; auto* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition indirect; indirect.id = "replay-howitzer"; indirect.range = 30.0f; indirect.projectile.speed = 12.0f; indirect.projectile.gravity = 1.0f; weapon->definition()->owned = std::make_shared(indirect); weapon->definition()->def = weapon->definition()->owned.get(); auto weaponLink = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(weaponLink.ok()); battery.value()->weapon()->link = std::move(weaponLink).takeValue(); eve::rts::RTSReplayCommand request; request.tick = eve::SimulationTick{4}; request.operation = eve::rts::RTSReplayOperation::RequestFireSupport; request.producer = requesterSubject; request.point = {12.0f, 1.0f}; request.command.radius = 2.5f; request.priority = 4; request.limit = 1; eve::rts::RTSReplayCommand cancel; cancel.tick = eve::SimulationTick{5}; cancel.operation = eve::rts::RTSReplayOperation::CancelFireSupport; cancel.producer = requesterSubject; eve::rts::RTSCommandLog recorded; REQUIRE(recorded.queue(request).ok()); REQUIRE(recorded.queue(cancel).ok()); const std::string text = recorded.exportText(); CHECK(text.starts_with("EVERTS_COMMANDS 3\n")); eve::rts::RTSCommandLog replay; REQUIRE(replay.importText(text).ok()); CHECK_EQ(replay.exportText(), text); auto early = replay.apply(eve::SimulationTick{3}, module); REQUIRE(early.ok()); CHECK_EQ(early.value(), 0u); auto assigned = replay.apply(eve::SimulationTick{4}, module); REQUIRE(assigned.ok()); CHECK_EQ(assigned.value(), 1u); auto order = battery.value()->orders()->values.current(); REQUIRE(order.ok()); CHECK_EQ(static_cast(order.value().kind), static_cast(OrderKind::SuppressArea)); CHECK_EQ(battery.value()->artillery()->suppressionShotsRemaining, 4); CHECK_EQ(ecs::try_get(battery.value()->artillery()->fireSupportRequester), requester.value()); auto cancelled = replay.apply(eve::SimulationTick{5}, module); REQUIRE(cancelled.ok()); CHECK_EQ(cancelled.value(), 1u); CHECK(ecs::try_get(battery.value()->artillery()->fireSupportRequester) == nullptr); weapon->release(); } TEST_CASE("rts.suppressionAndEscortFacadesRoundTripDedicatedTacticalState") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS module; const auto factionSubject = subject("00000000-0000-7000-8000-00000000d211"); const auto firstSubject = subject("00000000-0000-7000-8000-00000000d212"); const auto secondSubject = subject("00000000-0000-7000-8000-00000000d213"); const auto protectedSubject = subject("00000000-0000-7000-8000-00000000d214"); auto* faction = module.newFaction(factionSubject).value(); auto* first = module.newFactionUnit(*faction, firstSubject).value(); auto* second = module.newFactionUnit(*faction, secondSubject).value(); auto* protectedUnit = module.newFactionUnit(*faction, protectedSubject).value(); protectedUnit->motion()->x = 10.0f; protectedUnit->motion()->y = 8.0f; auto* weapon = eve::weapon::WeaponEntity::createWeapon(); auto firstWeapon = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); auto secondWeapon = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(firstWeapon.ok()); REQUIRE(secondWeapon.ok()); first->weapon()->link = std::move(firstWeapon).takeValue(); second->weapon()->link = std::move(secondWeapon).takeValue(); eve::rts::RTSReplayCommand suppress; suppress.tick = eve::SimulationTick{4}; suppress.operation = eve::rts::RTSReplayOperation::SuppressArea; suppress.units = {secondSubject, firstSubject}; suppress.command.kind = OrderKind::SuppressArea; suppress.command.target = {12.0f, 3.0f}; suppress.command.secondaryTarget = {18.0f, 7.0f}; suppress.command.radius = 2.0f; suppress.priority = 3; eve::rts::RTSReplayCommand escort; escort.tick = eve::SimulationTick{5}; escort.operation = eve::rts::RTSReplayOperation::Escort; escort.units = {secondSubject, firstSubject}; escort.targetEntity = protectedSubject; escort.command.kind = OrderKind::Escort; escort.command.radius = 6.0f; escort.formation.spacing = 1.5f; eve::rts::RTSCommandLog recorded; REQUIRE(recorded.queue(suppress).ok()); REQUIRE(recorded.queue(escort).ok()); const std::string text = recorded.exportText(); CHECK(text.starts_with("EVERTS_COMMANDS 2\n")); eve::rts::RTSCommandLog replay; REQUIRE(replay.importText(text).ok()); CHECK_EQ(replay.exportText(), text); auto suppressed = replay.apply(eve::SimulationTick{4}, module); REQUIRE(suppressed.ok()); CHECK_EQ(suppressed.value(), std::size_t{2}); auto suppressionOrder = first->orders()->values.current(); REQUIRE(suppressionOrder.ok()); CHECK_EQ(static_cast(suppressionOrder.value().kind), static_cast(OrderKind::SuppressArea)); CHECK_EQ(first->artillery()->suppressionShotsRemaining, 3); CHECK_EQ(second->artillery()->suppressionShotsRemaining, 3); CHECK(std::abs(suppressionOrder.value().secondaryTarget.x - 18.0f) < 1e-5f); auto escorted = replay.apply(eve::SimulationTick{5}, module); REQUIRE(escorted.ok()); CHECK_EQ(escorted.value(), std::size_t{2}); auto escortOrder = first->orders()->values.current(); REQUIRE(escortOrder.ok()); CHECK_EQ(static_cast(escortOrder.value().kind), static_cast(OrderKind::Escort)); CHECK(ecs::try_get(escortOrder.value().targetEntity) == protectedUnit); CHECK(first->tactics()->escortOffsetX > 0.0f); CHECK(second->tactics()->escortOffsetX < 0.0f); CHECK(std::abs(first->tactics()->protectionRange - 6.0f) < 1e-5f); CHECK(std::abs(first->combat()->leashRange - 6.0f) < 1e-5f); weapon->release(); } TEST_CASE("rts.commandLogReplaysConstructionProductionResearchAndAbilitiesThroughCanonicalFacades") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS rts; REQUIRE(rts.configureScriptWorld(16, 16, 1.0f).ok()); REQUIRE(rts.loadScriptContent(R"({ "units":[ {"id":"worker","role":"worker","health":60,"speed":3,"radius":0.3,"buildRate":1}, {"id":"marine","producer":"barracks","costResource":"minerals","cost":50, "buildTime":1,"health":80,"speed":3,"radius":0.3}], "buildings":[ {"id":"barracks","costResource":"minerals","cost":150,"buildTime":2,"health":500}], "upgrades":[ {"id":"weapons_1","producer":"barracks","targetUnit":"marine", "costResource":"minerals","cost":100,"researchTime":2,"attackMultiplier":1.2}], "abilities":[ {"id":"grenade","casterUnit":"marine","targetType":"point","range":6,"radius":1, "cooldown":2,"damage":10,"castTime":0.5,"resourceType":"minerals","resourceCost":10}] })").ok()); const auto factionSubject = subject("00000000-0000-7000-8000-000000000291"); const auto builderSubject = subject("00000000-0000-7000-8000-000000000292"); const auto casterSubject = subject("00000000-0000-7000-8000-000000000293"); const auto producerSubject = subject("00000000-0000-7000-8000-000000000294"); const auto buildingSubject = subject("00000000-0000-7000-8000-000000000295"); const auto producedSubject = subject("00000000-0000-7000-8000-000000000296"); const auto workerId = eve::LogicalId::parse("unit:worker"); const auto marineId = eve::LogicalId::parse("unit:marine"); const auto barracksId = eve::LogicalId::parse("building:barracks"); REQUIRE(workerId.has_value()); REQUIRE(marineId.has_value()); REQUIRE(barracksId.has_value()); auto* faction = rts.newFaction(factionSubject).value(); REQUIRE(rts.newFactionUnit(*faction, builderSubject, *workerId).ok()); auto* caster = rts.newFactionUnit(*faction, casterSubject, *marineId).value(); caster->motion()->x = 1.0f; caster->motion()->y = 1.0f; REQUIRE(rts.newFactionBuilding(*faction, producerSubject, *barracksId).ok()); REQUIRE(rts.addScriptResource(*faction, "minerals", 500).ok()); eve::rts::RTSCommandLog recorded; eve::rts::RTSReplayCommand construction; construction.tick = eve::SimulationTick{2}; construction.operation = eve::rts::RTSReplayOperation::Construction; construction.units = {builderSubject}; construction.faction = factionSubject; construction.resultSubject = buildingSubject; construction.definition = *barracksId; construction.point = {4.0f, 5.0f}; REQUIRE(recorded.queue(construction).ok()); eve::rts::RTSReplayCommand production; production.tick = eve::SimulationTick{2}; production.operation = eve::rts::RTSReplayOperation::Production; production.producer = producerSubject; production.resultSubject = producedSubject; production.definition = *marineId; production.priority = 3; REQUIRE(recorded.queue(production).ok()); eve::rts::RTSReplayCommand research; research.tick = eve::SimulationTick{2}; research.operation = eve::rts::RTSReplayOperation::Research; research.producer = producerSubject; research.value = "weapons_1"; REQUIRE(recorded.queue(research).ok()); eve::rts::RTSReplayCommand ability; ability.tick = eve::SimulationTick{2}; ability.operation = eve::rts::RTSReplayOperation::Ability; ability.units = {casterSubject}; ability.value = "grenade"; ability.point = {2.0f, 1.0f}; REQUIRE(recorded.queue(ability).ok()); eve::rts::RTSReplayCommand cancelProduction; cancelProduction.tick = eve::SimulationTick{3}; cancelProduction.operation = eve::rts::RTSReplayOperation::CancelProduction; cancelProduction.producer = producerSubject; cancelProduction.priority = -1; REQUIRE(recorded.queue(cancelProduction).ok()); eve::rts::RTSReplayCommand cancelAbility; cancelAbility.tick = eve::SimulationTick{3}; cancelAbility.operation = eve::rts::RTSReplayOperation::CancelAbility; cancelAbility.units = {casterSubject}; REQUIRE(recorded.queue(cancelAbility).ok()); const std::string text = recorded.exportText(); CHECK(text.starts_with("EVERTS_COMMANDS 2\n")); eve::rts::RTSCommandLog imported; REQUIRE(imported.importText(text).ok()); CHECK_EQ(imported.exportText(), text); REQUIRE(imported.apply(eve::SimulationTick{1}, rts).ok()); auto applied = imported.apply(eve::SimulationTick{2}, rts); REQUIRE(applied.ok()); CHECK_EQ(applied.value(), std::size_t{4}); CHECK(rts.findBuilding(buildingSubject) != nullptr); CHECK_EQ(rts.findBuilding(producerSubject)->production()->values.taskCount(), std::size_t{2}); CHECK(caster->abilities()->channel.has_value()); CHECK_EQ(rts.scriptResource(*faction, "minerals").value(), 190); auto cancelled = imported.apply(eve::SimulationTick{3}, rts); REQUIRE(cancelled.ok()); CHECK_EQ(cancelled.value(), std::size_t{2}); CHECK(!caster->abilities()->channel.has_value()); CHECK_EQ(rts.scriptResource(*faction, "minerals").value(), 290); std::string replayCanonical; { ecs::Table replayTable; ecs::ScopedTable replayGuard(replayTable); eve::rts::RTS replayWorld; REQUIRE(replayWorld.configureScriptWorld(16, 16, 1.0f).ok()); REQUIRE(replayWorld.loadScriptContent(R"({ "units":[ {"id":"worker","role":"worker","health":60,"speed":3,"radius":0.3,"buildRate":1}, {"id":"marine","producer":"barracks","costResource":"minerals","cost":50, "buildTime":1,"health":80,"speed":3,"radius":0.3}], "buildings":[ {"id":"barracks","costResource":"minerals","cost":150,"buildTime":2,"health":500}], "upgrades":[ {"id":"weapons_1","producer":"barracks","targetUnit":"marine", "costResource":"minerals","cost":100,"researchTime":2,"attackMultiplier":1.2}], "abilities":[ {"id":"grenade","casterUnit":"marine","targetType":"point","range":6,"radius":1, "cooldown":2,"damage":10,"castTime":0.5,"resourceType":"minerals","resourceCost":10}] })").ok()); auto* replayFaction = replayWorld.newFaction(factionSubject).value(); REQUIRE(replayWorld.newFactionUnit(*replayFaction, builderSubject, *workerId).ok()); auto* replayCaster = replayWorld.newFactionUnit(*replayFaction, casterSubject, *marineId).value(); replayCaster->motion()->x = 1.0f; replayCaster->motion()->y = 1.0f; REQUIRE(replayWorld.newFactionBuilding(*replayFaction, producerSubject, *barracksId).ok()); REQUIRE(replayWorld.addScriptResource(*replayFaction, "minerals", 500).ok()); eve::rts::RTSCommandLog replayLog; REQUIRE(replayLog.importText(text).ok()); REQUIRE(replayLog.apply(eve::SimulationTick{2}, replayWorld).ok()); REQUIRE(replayLog.apply(eve::SimulationTick{3}, replayWorld).ok()); CHECK_EQ(replayWorld.scriptResource(*replayFaction, "minerals").value(), 290); auto canonical = replayWorld.canonicalStateJson(); REQUIRE(canonical.ok()); replayCanonical = canonical.value(); } auto sourceCanonical = rts.canonicalStateJson(); REQUIRE(sourceCanonical.ok()); CHECK_EQ(replayCanonical, sourceCanonical.value()); } TEST_CASE("rts.rallyFacadeConfiguresLinkedReinforcementPolicyAndExclusiveTransport") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS rts; REQUIRE(rts.configureScriptWorld(16, 16, 1.0f).ok()); REQUIRE(rts.loadScriptContent(R"({ "units":[{"id":"marine","health":80,"speed":3,"radius":0.3}, {"id":"medic","health":60,"speed":3,"radius":0.3}, {"id":"apc","health":200,"speed":4,"radius":0.8}], "buildings":[{"id":"barracks","health":500,"buildTime":2}] })").ok()); auto* faction = rts.newFaction(subject("00000000-0000-7000-8000-0000000002a1")).value(); const auto barracksId = eve::LogicalId::parse("building:barracks"); const auto apcId = eve::LogicalId::parse("unit:apc"); REQUIRE(barracksId.has_value()); REQUIRE(apcId.has_value()); auto* first = rts.newFactionBuilding( *faction, subject("00000000-0000-7000-8000-0000000002a2"), *barracksId).value(); auto* second = rts.newFactionBuilding( *faction, subject("00000000-0000-7000-8000-0000000002a3"), *barracksId).value(); auto* third = rts.newFactionBuilding( *faction, subject("00000000-0000-7000-8000-0000000002a4"), *barracksId).value(); auto* transport = rts.newFactionUnit( *faction, subject("00000000-0000-7000-8000-0000000002a5"), *apcId).value(); transport->containment()->capacity = 4; CommandSpec rally; rally.kind = OrderKind::AttackMove; rally.target = {12.0f, 6.0f}; REQUIRE(rts.setBuildingRally(*first, rally, true).ok()); REQUIRE(rts.linkBuildingRally(*second, *first).ok()); CHECK(first->rally()->combatGroup != 0); CHECK_EQ(second->rally()->combatGroup, first->rally()->combatGroup); REQUIRE(rts.setReinforcementLimit(*first, 8).ok()); REQUIRE(rts.setReinforcementTypeLimit(*first, "marine", 5).ok()); REQUIRE(rts.setReinforcementTypePriority(*second, "medic", 3).ok()); REQUIRE(rts.setReinforcementFallback(*first, "medic", "marine").ok()); CHECK(!rts.setReinforcementFallback(*first, "marine", "medic").ok()); REQUIRE(rts.setReinforcementAutoCancel(*first, 2.5f).ok()); for (auto* building : {first, second}) { CHECK_EQ(building->rally()->reinforcementLimit, std::size_t{8}); CHECK_EQ(building->rally()->reinforcementTypeLimits.at("marine"), std::size_t{5}); CHECK_EQ(building->rally()->reinforcementTypePriorities.at("medic"), 3); CHECK_EQ(building->rally()->reinforcementFallbacks.at("medic"), "marine"); CHECK_EQ(building->rally()->reinforcementAutoCancelDelay, 2.5f); } REQUIRE(rts.setReinforcementTransport(*first, transport, 2).ok()); CHECK(!rts.setReinforcementTransport(*third, transport, 2).ok()); REQUIRE(rts.setReinforcementTransport(*first, nullptr).ok()); REQUIRE(rts.setReinforcementTransport(*third, transport, 2).ok()); REQUIRE(rts.clearBuildingRally(*second).ok()); CHECK(!second->rally()->enabled); CHECK_EQ(second->rally()->combatGroup, std::uint64_t{0}); } TEST_CASE("rts.lockstepAppliesCommandsBeforeExactFixedSimulationTicks") { ecs::Table world; ecs::ScopedTable guard(world); const auto unitSubject = subject("00000000-0000-7000-8000-000000000281"); eve::rts::RTS simulation; REQUIRE(simulation.newUnit(unitSubject).ok()); eve::rts::RTSReplayCommand command; command.tick = eve::SimulationTick{2}; command.units = {unitSubject}; command.command.kind = OrderKind::Move; command.command.target = {6.0f, 3.0f}; command.formation.spacing = 1.0f; eve::rts::RTSLockstep lockstep; auto tenth = eve::Duration::fromSeconds(0.1); REQUIRE(tenth.ok()); REQUIRE(lockstep.setFixedStep(tenth.value()).ok()); REQUIRE(lockstep.queue(command).ok()); PendingRTSExecutor executor; auto first = lockstep.step(simulation, executor); REQUIRE(first.ok()); CHECK_EQ(lockstep.currentTick().value(), 1u); CHECK(!simulation.findUnit(unitSubject)->orders()->values.current().ok()); auto second = lockstep.step(simulation, executor); REQUIRE(second.ok()); CHECK_EQ(lockstep.currentTick().value(), 2u); auto current = simulation.findUnit(unitSubject)->orders()->values.current(); REQUIRE(current.ok()); CHECK_EQ(static_cast(current.value().kind), static_cast(OrderKind::Move)); CHECK(std::abs(current.value().target.x - 6.0f) < 1e-5f); CHECK(std::abs(current.value().target.y - 3.0f) < 1e-5f); CHECK(!lockstep.setFixedStep(eve::Duration::zero()).ok()); lockstep.reset(eve::SimulationTick{9}); CHECK_EQ(lockstep.currentTick().value(), 9u); CHECK_EQ(lockstep.commands().size(), 0u); } TEST_CASE("rts.canonicalStateAndInjectedHashIgnoreCreationOrderAndDetectMutation") { ecs::Table world; ecs::ScopedTable guard(world); const auto first = subject("00000000-0000-7000-8000-000000000291"); const auto second = subject("00000000-0000-7000-8000-000000000292"); const auto base = subject("00000000-0000-7000-8000-000000000293"); eve::rts::RTS left; eve::rts::RTS right; REQUIRE(left.newUnit(first).ok()); REQUIRE(left.newUnit(second).ok()); REQUIRE(left.newBuilding(base).ok()); REQUIRE(right.newBuilding(base).ok()); REQUIRE(right.newUnit(second).ok()); REQUIRE(right.newUnit(first).ok()); left.findBuilding(base)->infrastructure()->powerProduced = 5.0f; right.findBuilding(base)->infrastructure()->powerProduced = 5.0f; left.findUnit(first)->motion()->x = 3.0f; right.findUnit(first)->motion()->x = 3.0f; REQUIRE(left.findUnit(first)->tags()->values.add("unit.scout").ok()); REQUIRE(right.findUnit(first)->tags()->values.add("unit.scout").ok()); auto leftJson = left.canonicalStateJson(); auto rightJson = right.canonicalStateJson(); REQUIRE(leftJson.ok()); REQUIRE(rightJson.ok()); CHECK_EQ(leftJson.value(), rightJson.value()); CHECK(eve::Value::fromJson(leftJson.value()).ok()); std::string hashedInput; auto digest = eve::ContentId::parse("00000000-0000-7000-8000-000000000299"); REQUIRE(digest.has_value()); eve::SnapshotHashProvider provider = [&](std::string_view input) { hashedInput = input; return eve::Result::success(*digest); }; auto hash = left.stateHash(provider); REQUIRE(hash.ok()); CHECK_EQ(hash.value(), *digest); CHECK_EQ(hashedInput, leftJson.value()); right.findUnit(first)->worker()->cargo = 1.0f; right.findBuilding(base)->infrastructure()->powerProduced = 6.0f; auto changed = right.canonicalStateJson(); REQUIRE(changed.ok()); CHECK_NE(changed.value(), leftJson.value()); CHECK(!left.stateHash({}).ok()); } TEST_CASE("rts.buildInfluenceComposesPoweredOwnershipWithCanonicalPlacementProvider") { ecs::Table world; ecs::ScopedTable guard(world); auto factionHandle = ecs::handle_of(Faction::createFaction()); auto hostileHandle = ecs::handle_of(Faction::createFaction()); auto sourceHandle = ecs::handle_of(Building::createBuilding()); auto* faction = dynamic_cast(ecs::try_get(factionHandle)); auto* hostile = dynamic_cast(ecs::try_get(hostileHandle)); auto* source = dynamic_cast(ecs::try_get(sourceHandle)); auto owner = eve::rts::FactionLink::bind(factionHandle); REQUIRE(owner.ok()); source->faction()->link = std::move(owner).takeValue(); source->placement()->placed = true; source->placement()->worldX = 4.0f; source->placement()->worldY = 3.0f; source->infrastructure()->buildInfluenceRadius = 5.0f; source->infrastructure()->powered = false; auto definition = eve::LogicalId::parse("rts:factory"); REQUIRE(definition.has_value()); int providerCalls = 0; eve::rts::PlacementValidation provider = [&](WorldPosition point, eve::LogicalId id) { ++providerCalls; CHECK_EQ(id, *definition); if (point.x == 7.0f) return eve::Result::failure(eve::Diagnostic::error( eve::DiagnosticCode::Conflict, "occupied", "placement.occupancy")); return eve::Result::success(); }; CHECK(!eve::rts::BuildInfluenceSystem::validate(*faction, {5.0f, 3.0f}, *definition, provider).ok()); CHECK_EQ(providerCalls, 0); source->infrastructure()->powered = true; CHECK(!eve::rts::BuildInfluenceSystem::validate(*hostile, {5.0f, 3.0f}, *definition, provider).ok()); CHECK_EQ(providerCalls, 0); REQUIRE(eve::rts::BuildInfluenceSystem::validate(*faction, {5.0f, 3.0f}, *definition, provider).ok()); CHECK_EQ(providerCalls, 1); CHECK(!eve::rts::BuildInfluenceSystem::validate(*faction, {7.0f, 3.0f}, *definition, provider).ok()); CHECK_EQ(providerCalls, 2); REQUIRE(eve::rts::BuildInfluenceSystem::validate(*hostile, {20.0f, 20.0f}, *definition, provider, false).ok()); CHECK_EQ(providerCalls, 3); source->release(); hostile->release(); faction->release(); } TEST_CASE("rts.automaticTargetPoliciesAndWeaponPreferencesOverrideDistanceButExplicitAttackStillWins") { ecs::Table world; ecs::ScopedTable guard(world); auto blueHandle = ecs::handle_of(Faction::createFaction( subject("00000000-0000-7000-8000-0000000002a4"))); auto redHandle = ecs::handle_of(Faction::createFaction( subject("00000000-0000-7000-8000-0000000002a5"))); auto workerDefinition = eve::LogicalId::parse("rts:worker"); auto tankDefinition = eve::LogicalId::parse("rts:tank"); REQUIRE(workerDefinition.has_value()); REQUIRE(tankDefinition.has_value()); auto attackerHandle = ecs::handle_of(Unit::createUnit( subject("00000000-0000-7000-8000-0000000002a1"), *workerDefinition)); auto closeHandle = ecs::handle_of(Unit::createUnit( subject("00000000-0000-7000-8000-0000000002a2"), *workerDefinition)); auto valuableHandle = ecs::handle_of(Unit::createUnit( subject("00000000-0000-7000-8000-0000000002a3"), *tankDefinition)); auto* blue = dynamic_cast(ecs::try_get(blueHandle)); auto* red = dynamic_cast(ecs::try_get(redHandle)); auto* attacker = dynamic_cast(ecs::try_get(attackerHandle)); auto* close = dynamic_cast(ecs::try_get(closeHandle)); auto* valuable = dynamic_cast(ecs::try_get(valuableHandle)); auto bindFaction = [&](Unit& unit, ecs::EntityHandle faction) { auto link = eve::rts::FactionLink::bind(faction); REQUIRE(link.ok()); unit.faction()->link = std::move(link).takeValue(); }; bindFaction(*attacker, blueHandle); bindFaction(*close, redHandle); bindFaction(*valuable, redHandle); attacker->combat()->acquisitionRange = 10.0f; attacker->combat()->targetPriorities[tankDefinition->format()] = 5.0f; close->motion()->x = 2.0f; valuable->motion()->x = 6.0f; close->durability()->state.health = close->durability()->state.maxHealth = 100.0; valuable->durability()->state.health = valuable->durability()->state.maxHealth = 100.0; auto* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = "priority-rifle"; definition.damage = 1.0f; definition.range = 10.0f; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); weapon->state()->stages = &weapon->definition()->def->stages; auto weaponLink = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(weaponLink.ok()); attacker->weapon()->link = std::move(weaponLink).takeValue(); eve::sensing::SensingWorld sensing; eve::combat::DamageRuntime damage; eve::rts::CombatFireSystem::State state; auto step = eve::SimulationStep{eve::SimulationTick{1}, eve::Duration::fromSeconds(0.1).expect("priority combat step")}; REQUIRE(eve::rts::CombatFireSystem::step(step, state, sensing, damage).ok()); CHECK_EQ(ecs::try_get(attacker->combat()->target), valuable); REQUIRE(valuable->tags()->values.add("armored").ok()); attacker->combat()->targetPriorities.clear(); attacker->combat()->target = {}; definition.preferredTargetTags = {"armored"}; definition.preferredTargetBonus = 4.0f; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); weapon->state()->stages = &weapon->definition()->def->stages; step.tick = eve::SimulationTick{2}; REQUIRE(eve::rts::CombatFireSystem::step(step, state, sensing, damage).ok()); CHECK_EQ(ecs::try_get(attacker->combat()->target), valuable); CommandSpec explicitAttack; explicitAttack.kind = OrderKind::Attack; explicitAttack.targetEntity = closeHandle; REQUIRE(attacker->orders()->values.replace(explicitAttack).ok()); step.tick = eve::SimulationTick{3}; REQUIRE(eve::rts::CombatFireSystem::step(step, state, sensing, damage).ok()); CHECK_EQ(ecs::try_get(attacker->combat()->target), close); weapon->release(); valuable->release(); close->release(); attacker->release(); red->release(); blue->release(); } TEST_CASE("rts.weaponTargetDomainAndTagsFilterAutomaticAndExplicitAttacks") { ecs::Table world; ecs::ScopedTable guard(world); auto blueHandle = ecs::handle_of(Faction::createFaction( subject("00000000-0000-7000-8000-0000000002c1"))); auto redHandle = ecs::handle_of(Faction::createFaction( subject("00000000-0000-7000-8000-0000000002c2"))); auto attackerHandle = ecs::handle_of(Unit::createUnit( subject("00000000-0000-7000-8000-0000000002c3"))); auto groundHandle = ecs::handle_of(Unit::createUnit( subject("00000000-0000-7000-8000-0000000002c4"))); auto airHandle = ecs::handle_of(Unit::createUnit( subject("00000000-0000-7000-8000-0000000002c5"))); auto* attacker = dynamic_cast(ecs::try_get(attackerHandle)); auto* ground = dynamic_cast(ecs::try_get(groundHandle)); auto* air = dynamic_cast(ecs::try_get(airHandle)); auto bind = [&](Unit& unit, ecs::EntityHandle faction) { auto link = eve::rts::FactionLink::bind(faction); REQUIRE(link.ok()); unit.faction()->link = std::move(link).takeValue(); }; bind(*attacker, blueHandle); bind(*ground, redHandle); bind(*air, redHandle); attacker->combat()->acquisitionRange = 10.0f; ground->motion()->x = 2.0f; air->motion()->x = 5.0f; air->motion()->airborne = true; REQUIRE(air->tags()->values.add("armored").ok()); ground->durability()->state.health = ground->durability()->state.maxHealth = 100.0; air->durability()->state.health = air->durability()->state.maxHealth = 100.0; auto* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = "anti-air"; definition.damage = 0.0f; definition.range = 10.0f; definition.targetsGround = false; definition.targetsAir = true; definition.requiredTargetTags = {"armored"}; definition.excludedTargetTags = {"cloaked"}; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); weapon->state()->stages = &weapon->definition()->def->stages; auto weaponLink = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(weaponLink.ok()); attacker->weapon()->link = std::move(weaponLink).takeValue(); eve::sensing::SensingWorld sensing; eve::combat::DamageRuntime damage; eve::rts::CombatFireSystem::State state; auto step = eve::SimulationStep{eve::SimulationTick{1}, eve::Duration::fromSeconds(0.1).expect("target filter step")}; REQUIRE(eve::rts::CombatFireSystem::step(step, state, sensing, damage).ok()); CHECK_EQ(ecs::try_get(attacker->combat()->target), air); CommandSpec explicitGround; explicitGround.kind = OrderKind::Attack; explicitGround.targetEntity = groundHandle; REQUIRE(attacker->orders()->values.replace(explicitGround).ok()); step.tick = eve::SimulationTick{2}; REQUIRE(eve::rts::CombatFireSystem::step(step, state, sensing, damage).ok()); CHECK_EQ(ecs::try_get(attacker->combat()->target), nullptr); CHECK(attacker->orders()->values.empty()); REQUIRE(air->tags()->values.add("cloaked").ok()); step.tick = eve::SimulationTick{3}; REQUIRE(eve::rts::CombatFireSystem::step(step, state, sensing, damage).ok()); CHECK_EQ(ecs::try_get(attacker->combat()->target), nullptr); weapon->release(); dynamic_cast(ecs::try_get(airHandle))->release(); dynamic_cast(ecs::try_get(groundHandle))->release(); attacker->release(); dynamic_cast(ecs::try_get(redHandle))->release(); dynamic_cast(ecs::try_get(blueHandle))->release(); } TEST_CASE("rts.weaponRangeFalloffScalesHitscanDamageAtConfiguredRange") { ecs::Table world; ecs::ScopedTable guard(world); auto blueHandle = ecs::handle_of(Faction::createFaction( subject("00000000-0000-7000-8000-0000000002d1"))); auto redHandle = ecs::handle_of(Faction::createFaction( subject("00000000-0000-7000-8000-0000000002d2"))); auto attackerHandle = ecs::handle_of(Unit::createUnit( subject("00000000-0000-7000-8000-0000000002d3"))); auto targetHandle = ecs::handle_of(Unit::createUnit( subject("00000000-0000-7000-8000-0000000002d4"))); auto* attacker = dynamic_cast(ecs::try_get(attackerHandle)); auto* target = dynamic_cast(ecs::try_get(targetHandle)); auto blueLink = eve::rts::FactionLink::bind(blueHandle); auto redLink = eve::rts::FactionLink::bind(redHandle); REQUIRE(blueLink.ok()); REQUIRE(redLink.ok()); attacker->faction()->link = std::move(blueLink).takeValue(); target->faction()->link = std::move(redLink).takeValue(); attacker->combat()->acquisitionRange = 10.0f; target->motion()->x = 10.0f; target->durability()->state.health = target->durability()->state.maxHealth = 100.0; auto* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = "falloff-rifle"; definition.damage = 20.0f; definition.range = 10.0f; definition.falloffStart = 0.0f; definition.minimumDamageFactor = 0.5f; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); weapon->state()->stages = &weapon->definition()->def->stages; auto weaponLink = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(weaponLink.ok()); attacker->weapon()->link = std::move(weaponLink).takeValue(); eve::sensing::SensingWorld sensing; eve::combat::DamageRuntime damage; eve::rts::CombatFireSystem::State state; auto fired = eve::rts::CombatFireSystem::step( {eve::SimulationTick{1}, eve::Duration::fromSeconds(0.1).expect("falloff shot")}, state, sensing, damage); REQUIRE(fired.ok()); CHECK_EQ(fired.value(), 1u); CHECK(std::abs(target->durability()->state.health - 90.0) < 1e-5); weapon->release(); target->release(); attacker->release(); dynamic_cast(ecs::try_get(redHandle))->release(); dynamic_cast(ecs::try_get(blueHandle))->release(); } TEST_CASE("rts.combatWaitsForTurretToTraverseBeforeFiring") { ecs::Table world; ecs::ScopedTable guard(world); auto blueHandle = ecs::handle_of(Faction::createFaction( subject("00000000-0000-7000-8000-0000000002b1"))); auto redHandle = ecs::handle_of(Faction::createFaction( subject("00000000-0000-7000-8000-0000000002b2"))); auto attackerHandle = ecs::handle_of(Unit::createUnit( subject("00000000-0000-7000-8000-0000000002b3"))); auto targetHandle = ecs::handle_of(Unit::createUnit( subject("00000000-0000-7000-8000-0000000002b4"))); auto* blue = dynamic_cast(ecs::try_get(blueHandle)); auto* red = dynamic_cast(ecs::try_get(redHandle)); auto* attacker = dynamic_cast(ecs::try_get(attackerHandle)); auto* target = dynamic_cast(ecs::try_get(targetHandle)); auto blueLink = eve::rts::FactionLink::bind(blueHandle); auto redLink = eve::rts::FactionLink::bind(redHandle); REQUIRE(blueLink.ok()); REQUIRE(redLink.ok()); attacker->faction()->link = std::move(blueLink).takeValue(); target->faction()->link = std::move(redLink).takeValue(); attacker->combat()->acquisitionRange = 10.0f; attacker->combat()->turnRateDegrees = 90.0f; attacker->combat()->aimToleranceDegrees = 1.0f; target->motion()->x = -5.0f; target->durability()->state.health = target->durability()->state.maxHealth = 100.0; auto* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = "traversing-rifle"; definition.damage = 10.0f; definition.range = 10.0f; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); weapon->state()->stages = &weapon->definition()->def->stages; auto weaponLink = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(weaponLink.ok()); attacker->weapon()->link = std::move(weaponLink).takeValue(); eve::sensing::SensingWorld sensing; eve::combat::DamageRuntime damage; eve::rts::CombatFireSystem::State state; auto step = eve::SimulationStep{eve::SimulationTick{1}, eve::Duration::fromSeconds(1.0).expect("turret traversal step")}; auto first = eve::rts::CombatFireSystem::step(step, state, sensing, damage); REQUIRE(first.ok()); CHECK_EQ(first.value(), 0u); CHECK(std::abs(std::abs(weapon->aim()->yaw) - 90.0f) < 1e-5f); CHECK(std::abs(target->durability()->state.health - 100.0) < 1e-5); step.tick = eve::SimulationTick{2}; auto second = eve::rts::CombatFireSystem::step(step, state, sensing, damage); REQUIRE(second.ok()); CHECK_EQ(second.value(), 1u); CHECK(std::abs(target->durability()->state.health - 90.0) < 1e-5); weapon->release(); target->release(); attacker->release(); red->release(); blue->release(); } TEST_CASE("rts.facadeMigratesCombatStanceAndMovementPriorityIntoSnapshots") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS rts; const auto firstSubject = subject("00000000-0000-7000-8000-0000000002c1"); const auto secondSubject = subject("00000000-0000-7000-8000-0000000002c2"); auto firstCreated = rts.newUnit(firstSubject); auto secondCreated = rts.newUnit(secondSubject); REQUIRE(firstCreated.ok()); REQUIRE(secondCreated.ok()); auto* first = firstCreated.value(); auto* second = secondCreated.value(); first->motion()->x = 3.0f; first->motion()->y = 4.0f; second->motion()->x = 7.0f; second->motion()->y = 8.0f; const std::vector selection{firstSubject, secondSubject}; REQUIRE(rts.setUnitStance(selection, eve::rts::CombatStance::Passive, 6.0f).ok()); REQUIRE(rts.setUnitMovementPriority(selection, 250).ok()); CHECK_EQ(static_cast(first->combat()->stance), static_cast(eve::rts::CombatStance::Passive)); CHECK_EQ(first->combat()->leashRange, 6.0f); CHECK_EQ(first->combat()->guardX, 3.0f); CHECK_EQ(first->combat()->guardY, 4.0f); CHECK_EQ(second->navigation()->movementPriority, 100); const std::vector invalid{firstSubject, subject("00000000-0000-7000-8000-0000000002cf")}; auto rejected = rts.setUnitMovementPriority(invalid, -50); CHECK(!rejected.ok()); CHECK_EQ(first->navigation()->movementPriority, 100); auto snapshot = rts.snapshotState(); REQUIRE(snapshot.ok()); CHECK_EQ(static_cast(snapshot.value().units.front().combat.stance), static_cast(eve::rts::CombatStance::Passive)); auto canonical = rts.canonicalStateJson(); REQUIRE(canonical.ok()); CHECK(canonical.value().find("\"stance\":\"passive\"") != std::string::npos); } TEST_CASE("rts.facadeMigratesMobileSupplyReserveAmmoAndAutoResupplyControls") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS rts; auto unitCreated = rts.newUnit(subject("00000000-0000-7000-8000-0000000002d1")); auto buildingCreated = rts.newBuilding(subject("00000000-0000-7000-8000-0000000002d2")); REQUIRE(unitCreated.ok()); REQUIRE(buildingCreated.ok()); auto* unit = unitCreated.value(); auto* building = buildingCreated.value(); unit->supply()->capacity = 40.0f; building->supply()->capacity = 120.0f; auto unitSupply = rts.addUnitAmmoSupply(*unit, 55.0f); auto buildingSupply = rts.addBuildingAmmoSupply(*building, 75.0f); REQUIRE(unitSupply.ok()); REQUIRE(buildingSupply.ok()); CHECK_EQ(unitSupply.value(), 40.0f); CHECK_EQ(buildingSupply.value(), 75.0f); REQUIRE(rts.addUnitAmmoSupply(*unit, -100.0f).ok()); CHECK_EQ(unit->supply()->stock, 0.0f); CHECK(!rts.addBuildingAmmoSupply(*building, std::numeric_limits::quiet_NaN()).ok()); CHECK_EQ(building->supply()->stock, 75.0f); auto* weapon = eve::weapon::WeaponEntity::createWeapon(); eve::weapon::WeaponDefinition definition; definition.id = "supply-test-rifle"; definition.reserveSize = 90; weapon->definition()->owned = std::make_shared(definition); weapon->definition()->def = weapon->definition()->owned.get(); weapon->state()->resource.kind = eve::weapon::ResourceKind::Ammo; weapon->state()->resource.reserve = 20; auto weaponLink = eve::rts::WeaponLink::bind(ecs::handle_of(weapon)); REQUIRE(weaponLink.ok()); unit->weapon()->link = std::move(weaponLink).takeValue(); auto reserve = rts.addUnitReserveAmmo(*unit, 100); REQUIRE(reserve.ok()); CHECK_EQ(reserve.value(), 90); reserve = rts.addUnitReserveAmmo(*unit, -200); REQUIRE(reserve.ok()); CHECK_EQ(reserve.value(), 0); eve::rts::CommandSpec mission; mission.kind = eve::rts::OrderKind::SupplyRelay; REQUIRE(unit->orders()->values.replace(mission).ok()); unit->supply()->autoDispatch = true; unit->supply()->assignedTarget = ecs::handle_of(building); unit->supply()->reservedStock = 12.0f; unit->supply()->returning = true; unit->supply()->rendezvousActive = true; unit->navigation()->waypoints.push_back({3.0f, 4.0f}); unit->navigation()->plannedOrderId = "supply-plan"; unit->motion()->arrived = false; REQUIRE(rts.setUnitAutoResupply(*unit, false).ok()); CHECK(!unit->supply()->autoDispatch); CHECK(ecs::try_get(unit->supply()->assignedTarget) == nullptr); CHECK_EQ(unit->supply()->reservedStock, 0.0f); CHECK(!unit->supply()->returning); CHECK(!unit->supply()->rendezvousActive); CHECK(unit->navigation()->waypoints.empty()); CHECK(unit->navigation()->plannedOrderId.empty()); CHECK(unit->motion()->arrived); const auto inspected = rts.inspectState().toJson(); REQUIRE(inspected.ok()); CHECK(inspected.value().find("\"reserveAmmo\":0") != std::string::npos); CHECK(inspected.value().find("\"supplyCapacity\":40") != std::string::npos); auto snapshot = rts.snapshotState(); REQUIRE(snapshot.ok()); CHECK_EQ(snapshot.value().units.front().supply.capacity, 40.0f); CHECK_EQ(snapshot.value().buildings.front().supply.stock, 75.0f); unit->weapon()->link = {}; weapon->release(); } TEST_CASE("rts.facadeExposesManualBuildersAndFactionWorkforceAutomation") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS rts; const auto factionSubject = subject("00000000-0000-7000-8000-0000000002e1"); auto factionCreated = rts.newFaction(factionSubject); REQUIRE(factionCreated.ok()); auto* faction = factionCreated.value(); const auto siteSubject = subject("00000000-0000-7000-8000-0000000002e2"); auto siteCreated = rts.newFactionBuilding(*faction, siteSubject); REQUIRE(siteCreated.ok()); auto* site = siteCreated.value(); site->construction()->progress = 0.25f; site->placement()->worldX = 8.0f; site->placement()->worldY = 2.0f; std::vector workers; for (int index = 0; index < 3; ++index) { const auto workerId = "00000000-0000-7000-8000-0000000002e" + std::to_string(3 + index); auto workerCreated = rts.newFactionUnit(*faction, subject(workerId.c_str())); REQUIRE(workerCreated.ok()); workerCreated.value()->worker()->buildRate = 1.0f; workerCreated.value()->motion()->x = static_cast(index * 3); workers.push_back(workerCreated.value()); } auto manual = rts.assignBuilder(*workers.front(), *site); REQUIRE(manual.ok()); auto current = workers.front()->orders()->values.current(); REQUIRE(current.ok()); CHECK_EQ(static_cast(current.value().kind), static_cast(eve::rts::OrderKind::Build)); CHECK_EQ(current.value().targetEntity.id, site->identity()->self.id); REQUIRE(rts.configureWorkforce(*faction, true, 2, true, 3, 1).ok()); auto assigned = eve::rts::WorkforceAssignmentSystem::step(); REQUIRE(assigned.ok()); CHECK_EQ(assigned.value(), 1u); std::size_t buildingWorkers = 0; std::size_t idleWorkers = 0; for (auto* worker : workers) { auto order = worker->orders()->values.current(); if (!order.ok()) ++idleWorkers; else if (order.value().kind == eve::rts::OrderKind::Build) ++buildingWorkers; } CHECK_EQ(buildingWorkers, 2u); CHECK_EQ(idleWorkers, 1u); auto rejected = rts.configureWorkforce(*faction, false, 0, false, 2, 0); CHECK(!rejected.ok()); CHECK(faction->workforce()->autoConstruction); CHECK_EQ(faction->workforce()->maxBuildersPerSite, 2u); auto snapshot = rts.snapshotState(); REQUIRE(snapshot.ok()); CHECK(snapshot.value().factions.front().workforce.autoConstruction); CHECK_EQ(snapshot.value().factions.front().workforce.reserveWorkers, 1u); auto inspected = rts.inspectState().toJson(); REQUIRE(inspected.ok()); CHECK(inspected.value().find("\"autoConstruction\":true") != std::string::npos); CHECK(inspected.value().find("\"reserveWorkers\":1") != std::string::npos); } TEST_CASE("rts.facadeHealsCombatRootsAndAppliesCanonicalStatusDefinitions") { ecs::Table world; ecs::ScopedTable guard(world); eve::rts::RTS rts; eve::definitions::DefinitionRegistry definitions; const std::string content = R"json({ "statusEffects":[{ "id":"field_repair","duration":6,"speedMultiplier":0.8, "damageMultiplier":1.1,"incomingDamageMultiplier":0.9,"healingPerSecond":3 }] })json"; REQUIRE(rts.loadContent(definitions, content).ok()); const auto sourceSubject = subject("00000000-0000-7000-8000-0000000002f1"); const auto targetSubject = subject("00000000-0000-7000-8000-0000000002f2"); const auto buildingSubject = subject("00000000-0000-7000-8000-0000000002f3"); auto sourceCreated = rts.newUnit(sourceSubject); auto targetCreated = rts.newUnit(targetSubject); auto buildingCreated = rts.newBuilding(buildingSubject); REQUIRE(sourceCreated.ok()); REQUIRE(targetCreated.ok()); REQUIRE(buildingCreated.ok()); auto* target = targetCreated.value(); auto* building = buildingCreated.value(); target->durability()->state.health = 35.0; target->durability()->state.maxHealth = 50.0; building->integrity()->state.health = 70.0; building->integrity()->state.maxHealth = 100.0; auto unitHealing = rts.heal(sourceSubject, targetSubject, 100.0); auto buildingHealing = rts.heal(sourceSubject, buildingSubject, 12.0); REQUIRE(unitHealing.ok()); REQUIRE(buildingHealing.ok()); CHECK_EQ(unitHealing.value(), 15.0); CHECK_EQ(buildingHealing.value(), 12.0); CHECK_EQ(target->durability()->state.health, 50.0); CHECK_EQ(building->integrity()->state.health, 82.0); CHECK(!rts.heal(sourceSubject, targetSubject, -1.0).ok()); CHECK_EQ(target->durability()->state.health, 50.0); auto applied = rts.applyStatusEffect(sourceSubject, targetSubject, "field_repair", 2.5); REQUIRE(applied.ok()); CHECK(applied.value().isValid()); CHECK_EQ(target->effects()->values.count(), 1u); CHECK(std::abs(target->effects()->values.multiplier("speedMultiplier") - 0.8) < 1e-6); CHECK(std::abs(target->effects()->values.additive("healingPerSecond") - 3.0) < 1e-6); auto missing = rts.applyStatusEffect(sourceSubject, targetSubject, "missing", -1.0); CHECK(!missing.ok()); CHECK_EQ(target->effects()->values.count(), 1u); auto snapshot = rts.snapshotState(); REQUIRE(snapshot.ok()); CHECK_EQ(snapshot.value().units.back().effects.effects.effectCount(), 1); auto inspected = rts.inspectState().toJson(); REQUIRE(inspected.ok()); CHECK(inspected.value().find("\"activeEffects\":1") != std::string::npos); }