#include "RtsCompositionFixtures.h" 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.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(); }