Files

862 lines
43 KiB
C++

#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<const char*, 7> 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<const char*, 5> 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<int>(order.value().kind), static_cast<int>(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<eve::Value::Array>();
REQUIRE(damageEvents != nullptr);
REQUIRE_EQ(damageEvents->size(), 3);
const auto* damageEvent = (*damageEvents)[0].getIf<eve::Value::Object>();
REQUIRE(damageEvent != nullptr);
CHECK_EQ(*damageEvent->at("channel").getIf<std::string>(), "ability");
CHECK_EQ(*damageEvent->at("damageType").getIf<std::string>(), "normal");
CHECK_EQ(*damageEvent->at("appliedHealthDamage").getIf<double>(), 28.0);
CHECK_EQ(*damageEvent->at("target").getIf<std::string>(), target->identity()->subject.format());
const auto* castEvent = (*damageEvents)[1].getIf<eve::Value::Object>();
const auto* completedEvent = (*damageEvents)[2].getIf<eve::Value::Object>();
REQUIRE(castEvent != nullptr);
REQUIRE(completedEvent != nullptr);
CHECK_EQ(*castEvent->at("type").getIf<std::string>(), "ability_cast");
CHECK_EQ(*completedEvent->at("type").getIf<std::string>(), "ability_channel_completed");
REQUIRE(rts.stepScript(0.1).ok());
const auto clearedProjection = rts.inspectFrameEvents();
const auto* clearedEvents = clearedProjection.getIf<eve::Value::Array>();
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<eve::Value::Array>();
REQUIRE(expiredEvents != nullptr);
int expiredCount = 0;
for (const auto& event : *expiredEvents) {
const auto* object = event.getIf<eve::Value::Object>();
if (object != nullptr && *object->at("type").getIf<std::string>() == "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<eve::Value::Object>();
REQUIRE(visibleObject != nullptr);
CHECK(visibleObject->at("visible").getIf<bool>() != nullptr);
CHECK(*visibleObject->at("visible").getIf<bool>());
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<eve::Value::Object>();
REQUIRE(rememberedObject != nullptr);
CHECK(!*rememberedObject->at("visible").getIf<bool>());
CHECK(*rememberedObject->at("age").getIf<double>() >= 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<int>(economyMatch->state()->phase), static_cast<int>(eve::rts::MatchPhase::Finished));
CHECK_EQ(economyMatch->state()->winningTeam, 1);
auto projected = rts.inspectMatch(*economyMatch);
REQUIRE(projected.ok());
const auto* matchObject = projected.value().getIf<eve::Value::Object>();
REQUIRE(matchObject != nullptr);
CHECK_EQ(*matchObject->at("phase").getIf<std::string>(), "finished");
CHECK_EQ(*matchObject->at("winningTeam").getIf<std::int64_t>(), 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<int>(surrender->state()->phase), static_cast<int>(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();
}