6762 lines
317 KiB
C++
6762 lines
317 KiB
C++
#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 <array>
|
|
#include <cmath>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <iostream>
|
|
#include <sstream>
|
|
#include <type_traits>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
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<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.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<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();
|
|
}
|
|
|
|
class PendingRTSExecutor final : public eve::rts::IRTSActionExecutor {
|
|
public:
|
|
eve::Result<eve::rts::ActionExecutionResult> execute(
|
|
Unit&, const eve::rts::OrderRecord&, const eve::SimulationStep&) override {
|
|
return eve::Result<eve::rts::ActionExecutionResult>::success(
|
|
{eve::rts::ActionDisposition::Pending});
|
|
}
|
|
};
|
|
|
|
class FireControlArmorRule final : public eve::combat::IDamageRule {
|
|
public:
|
|
eve::SubjectRef armored;
|
|
eve::SubjectRef light;
|
|
|
|
eve::Result<eve::combat::DamageAmounts> 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<eve::combat::DamageAmounts>::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<ecs::Entity, Unit>);
|
|
static_assert(std::is_base_of_v<ecs::Entity, Building>);
|
|
static_assert(std::is_base_of_v<ecs::Entity, Player>);
|
|
static_assert(std::is_base_of_v<ecs::Entity, Faction>);
|
|
static_assert(!std::is_base_of_v<Unit, Building>);
|
|
static_assert(!std::is_base_of_v<Building, Unit>);
|
|
static_assert(!std::is_base_of_v<Player, Faction>);
|
|
|
|
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<Unit, Unit::Identity, Unit::Motion, Unit::Orders>();
|
|
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<Building, Building::Identity, Building::Placement>();
|
|
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<Unit, Unit::Identity, Unit::Motion>();
|
|
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<Scout, Unit::Identity, Unit::Motion>();
|
|
for (auto it = scoutView.begin(); it != scoutView.end(); ++it) ++exactScouts;
|
|
CHECK_EQ(exactScouts, 1);
|
|
|
|
int unitOrderCount = 0;
|
|
auto orderView = ecs::View<Unit, Unit::Orders>();
|
|
for (auto it = orderView.begin(); it != orderView.end(); ++it) ++unitOrderCount;
|
|
CHECK_EQ(unitOrderCount, 2);
|
|
|
|
int buildingAsUnit = 0;
|
|
auto noBuildingView = ecs::View<Unit, Unit::Identity, Unit::Motion>();
|
|
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<Unit*> units;
|
|
std::vector<ecs::EntityHandle> 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<int>(order.kind), static_cast<int>(OrderKind::Move));
|
|
CHECK_EQ(order.formationSlot, static_cast<int>(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<ecs::EntityHandle, 1> 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<int>(order.kind), static_cast<int>(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<eve::resource::Receipt>::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<int>(current.value().kind), static_cast<int>(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<eve::SubjectRef> invalid{
|
|
worker->identity()->subject, nonWorker->identity()->subject};
|
|
auto rejected = rts.setWorkerAutoAssignment(invalid, true);
|
|
CHECK(!rejected.ok());
|
|
CHECK(!worker->worker()->autoAssign);
|
|
const std::vector<eve::SubjectRef> 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<eve::rts::LifecycleEvent> 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<int>(constructionEvents[0].kind),
|
|
static_cast<int>(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<eve::resource::Receipt>::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<eve::rts::LifecycleEvent> 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<int>(captureEvents[0].kind),
|
|
static_cast<int>(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<Unit*> 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<WorldPosition> 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<const eve::weapon::WeaponDefinition>(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<eve::rts::CombatFireEvent> 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<bool>::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<int>(blockedEvents[0].kind),
|
|
static_cast<int>(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<const eve::weapon::WeaponDefinition>(inaccurate);
|
|
weapon->definition()->def = weapon->definition()->owned.get();
|
|
weapon->state()->resource.value = 1.0f;
|
|
std::vector<eve::rts::CombatFireEvent> 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<int>(fireEvents[0].kind),
|
|
static_cast<int>(eve::rts::CombatFireEventKind::WeaponFired));
|
|
CHECK_EQ(static_cast<int>(fireEvents[1].kind),
|
|
static_cast<int>(eve::rts::CombatFireEventKind::ShotMissed));
|
|
CHECK_EQ(static_cast<int>(fireEvents[2].kind),
|
|
static_cast<int>(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<const eve::weapon::WeaponDefinition>(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<eve::rts::CombatFireEvent> 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<int>(reloadEvents[0].kind),
|
|
static_cast<int>(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<int>(reloadEvents[0].kind),
|
|
static_cast<int>(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<eve::rts::LifecycleEvent> 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<int>(shieldEvents[0].kind),
|
|
static_cast<int>(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<eve::rts::LifecycleEvent> 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<int>(moraleEvents[0].kind),
|
|
static_cast<int>(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<const eve::weapon::WeaponDefinition>(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<const eve::weapon::WeaponDefinition>(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<eve::weapon::WeaponEntity*> 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<const eve::weapon::WeaponDefinition>(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<Unit*>(ecs::try_get(protectedHandle));
|
|
auto* escort = dynamic_cast<Unit*>(ecs::try_get(escortHandle));
|
|
auto* shooterA = dynamic_cast<Unit*>(ecs::try_get(shooterAHandle));
|
|
auto* shooterB = dynamic_cast<Unit*>(ecs::try_get(shooterBHandle));
|
|
auto* enemyA = dynamic_cast<Unit*>(ecs::try_get(enemyAHandle));
|
|
auto* enemyB = dynamic_cast<Unit*>(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<const eve::weapon::WeaponDefinition>(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<const eve::weapon::WeaponDefinition>(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<const eve::weapon::WeaponDefinition>(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<eve::weapon::WeaponEntity*> 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<const eve::weapon::WeaponDefinition>(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<const eve::weapon::WeaponDefinition>(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<eve::weapon::WeaponEntity*> 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<const eve::weapon::WeaponDefinition>(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<Unit*> artillery;
|
|
std::vector<Unit*> 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<const eve::weapon::WeaponDefinition>(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<Unit*> 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<const eve::weapon::WeaponDefinition>(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<eve::weapon::WeaponEntity*> 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<const eve::weapon::WeaponDefinition>(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<void>::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<const eve::weapon::WeaponDefinition>(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<eve::rts::LifecycleEvent> 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<int>(events[0].kind), static_cast<int>(eve::rts::LifecycleEventKind::SupplyDispatched));
|
|
CHECK_EQ(static_cast<int>(events[1].kind), static_cast<int>(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<int>(events[0].kind), static_cast<int>(eve::rts::LifecycleEventKind::AmmoResupplied));
|
|
CHECK_EQ(static_cast<int>(events[1].kind), static_cast<int>(eve::rts::LifecycleEventKind::SupplyReturning));
|
|
auto returnOrder = supplier->orders()->values.current();
|
|
REQUIRE(returnOrder.ok());
|
|
CHECK_EQ(static_cast<int>(returnOrder.value().kind), static_cast<int>(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<eve::weapon::WeaponEntity*> 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<const eve::weapon::WeaponDefinition>(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<int>(active.value().kind), static_cast<int>(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<const eve::weapon::WeaponDefinition>(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<int>(order.value().kind), static_cast<int>(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<const eve::weapon::WeaponDefinition>(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<const eve::weapon::WeaponDefinition>(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<std::size_t> {
|
|
CHECK_EQ(&producer, building);
|
|
CHECK_EQ(resource, "minerals");
|
|
const auto affordable = static_cast<std::size_t>(bank / unitCost);
|
|
const auto bought = std::min(requested, affordable);
|
|
bank -= static_cast<std::int64_t>(bought) * unitCost;
|
|
return eve::Result<std::size_t>::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<eve::rts::LifecycleEvent> 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<int>(events[0].kind), static_cast<int>(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<eve::Value::Array>();
|
|
REQUIRE(events != nullptr);
|
|
bool observedFire = false;
|
|
bool observedDamage = false;
|
|
for (const auto& event : *events) {
|
|
const auto* object = event.getIf<eve::Value::Object>();
|
|
if (object == nullptr) continue;
|
|
const auto found = object->find("type");
|
|
const auto* type = found == object->end() ? nullptr : found->second.getIf<std::string>();
|
|
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<eve::rts::ActionExecutionResult> execute(
|
|
Unit&, const eve::rts::OrderRecord&, const eve::SimulationStep&) override {
|
|
return eve::Result<eve::rts::ActionExecutionResult>::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<eve::Value::Array>();
|
|
REQUIRE(events != nullptr);
|
|
REQUIRE_EQ(events->size(), 1u);
|
|
const auto* projected = events->front().getIf<eve::Value::Object>();
|
|
REQUIRE(projected != nullptr);
|
|
CHECK_EQ(*projected->at("type").getIf<std::string>(), "shield_recharged");
|
|
CHECK_EQ(*projected->at("source").getIf<std::string>(), unit->identity()->subject.format());
|
|
CHECK_EQ(*projected->at("tick").getIf<std::int64_t>(), 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<eve::Value::Array>();
|
|
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<ecs::EntityHandle> 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<Unit*>::failure(link.status());
|
|
unit->faction()->link = std::move(link).takeValue();
|
|
spawned.push_back(ecs::handle_of(unit));
|
|
return eve::Result<Unit*>::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<Unit*>(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<Unit*>(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<int>(order.value().kind), static_cast<int>(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<int>(infantryFactory->production()->values.find(marineId)->get().state),
|
|
static_cast<int>(eve::production::TaskState::Paused));
|
|
CHECK_NE(static_cast<int>(armorFactory->production()->values.find(tankId)->get().state),
|
|
static_cast<int>(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<int>(armorFactory->production()->values.find(tankId)->get().state),
|
|
static_cast<int>(eve::production::TaskState::Paused));
|
|
|
|
existing->durability()->alive = false;
|
|
REQUIRE(eve::rts::ReinforcementProductionPolicySystem::step().ok());
|
|
CHECK_NE(static_cast<int>(armorFactory->production()->values.find(tankId)->get().state),
|
|
static_cast<int>(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<std::string> 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<std::string>::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<std::string>::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<int>(factory->production()->values.find(taskId)->get().state),
|
|
static_cast<int>(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<std::optional<eve::rts::WorldPosition>>::success(std::nullopt);
|
|
return eve::Result<std::optional<eve::rts::WorldPosition>>::success(
|
|
eve::rts::WorldPosition{8.0f, 9.0f});
|
|
};
|
|
std::vector<ecs::EntityHandle> spawned;
|
|
eve::rts::ProductionSpawn spawn = [&](Building&, const eve::production::ProductionTask&) {
|
|
Unit* unit = Unit::createUnit();
|
|
spawned.push_back(ecs::handle_of(unit));
|
|
return eve::Result<Unit*>::success(unit);
|
|
};
|
|
|
|
std::vector<eve::rts::LifecycleEvent> 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<int>(productionEvents[0].kind),
|
|
static_cast<int>(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<Unit*>(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<int>(productionEvents[1].kind),
|
|
static_cast<int>(eve::rts::LifecycleEventKind::ProductionSpawnCleared));
|
|
CHECK_EQ(static_cast<int>(productionEvents[2].kind),
|
|
static_cast<int>(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<int>(current.value().kind), static_cast<int>(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<Building*>(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<ecs::EntityHandle> 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<Unit*>(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<int>(current.value().kind), static_cast<int>(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<int>(current.value().kind), static_cast<int>(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<int>(headquarters->state()->phase),
|
|
static_cast<int>(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<int>(headquarters->state()->phase),
|
|
static_cast<int>(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<double>::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<const eve::weapon::WeaponDefinition>(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<void>::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<eve::resource::Receipt>::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<eve::resource::Receipt>::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<eve::rts::LifecycleEvent> 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<int>(abilityEvents[0].kind),
|
|
static_cast<int>(eve::rts::LifecycleEventKind::AbilityChannelStarted));
|
|
CHECK_EQ(static_cast<int>(abilityEvents[1].kind),
|
|
static_cast<int>(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<std::optional<eve::rts::ProjectileCollision>>::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<std::optional<eve::rts::ProjectileCollision>>::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<const eve::weapon::WeaponDefinition>(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<const eve::weapon::WeaponDefinition>(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<int>(supportOrder.value().kind), static_cast<int>(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<int>(counterOrder.value().kind), static_cast<int>(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<Unit*> threats;
|
|
std::vector<eve::weapon::WeaponEntity*> weapons;
|
|
for (const auto& position : std::array<WorldPosition, 2>{{{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<const eve::weapon::WeaponDefinition>(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<int>(std::lround(selected.value().target.x)),
|
|
static_cast<int>(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<int>(current.value().kind), static_cast<int>(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<int>(current.value().kind), static_cast<int>(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<const eve::weapon::WeaponDefinition>(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<int>(order.value().kind), static_cast<int>(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<int>(suppressionOrder.value().kind), static_cast<int>(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<int>(escortOrder.value().kind), static_cast<int>(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<int>(current.value().kind), static_cast<int>(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<eve::ContentId>::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<Faction*>(ecs::try_get(factionHandle));
|
|
auto* hostile = dynamic_cast<Faction*>(ecs::try_get(hostileHandle));
|
|
auto* source = dynamic_cast<Building*>(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<void>::failure(eve::Diagnostic::error(
|
|
eve::DiagnosticCode::Conflict, "occupied", "placement.occupancy"));
|
|
return eve::Result<void>::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<Faction*>(ecs::try_get(blueHandle));
|
|
auto* red = dynamic_cast<Faction*>(ecs::try_get(redHandle));
|
|
auto* attacker = dynamic_cast<Unit*>(ecs::try_get(attackerHandle));
|
|
auto* close = dynamic_cast<Unit*>(ecs::try_get(closeHandle));
|
|
auto* valuable = dynamic_cast<Unit*>(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<const eve::weapon::WeaponDefinition>(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<const eve::weapon::WeaponDefinition>(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<Unit*>(ecs::try_get(attackerHandle));
|
|
auto* ground = dynamic_cast<Unit*>(ecs::try_get(groundHandle));
|
|
auto* air = dynamic_cast<Unit*>(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<const eve::weapon::WeaponDefinition>(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<Unit*>(ecs::try_get(airHandle))->release();
|
|
dynamic_cast<Unit*>(ecs::try_get(groundHandle))->release();
|
|
attacker->release();
|
|
dynamic_cast<Faction*>(ecs::try_get(redHandle))->release();
|
|
dynamic_cast<Faction*>(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<Unit*>(ecs::try_get(attackerHandle));
|
|
auto* target = dynamic_cast<Unit*>(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<const eve::weapon::WeaponDefinition>(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<Faction*>(ecs::try_get(redHandle))->release();
|
|
dynamic_cast<Faction*>(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<Faction*>(ecs::try_get(blueHandle));
|
|
auto* red = dynamic_cast<Faction*>(ecs::try_get(redHandle));
|
|
auto* attacker = dynamic_cast<Unit*>(ecs::try_get(attackerHandle));
|
|
auto* target = dynamic_cast<Unit*>(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<const eve::weapon::WeaponDefinition>(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<eve::SubjectRef> selection{firstSubject, secondSubject};
|
|
|
|
REQUIRE(rts.setUnitStance(selection, eve::rts::CombatStance::Passive, 6.0f).ok());
|
|
REQUIRE(rts.setUnitMovementPriority(selection, 250).ok());
|
|
CHECK_EQ(static_cast<int>(first->combat()->stance),
|
|
static_cast<int>(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<eve::SubjectRef> 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<int>(snapshot.value().units.front().combat.stance),
|
|
static_cast<int>(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<float>::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<const eve::weapon::WeaponDefinition>(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<eve::rts::Unit*> 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<float>(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<int>(current.value().kind), static_cast<int>(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);
|
|
}
|