Files
EVEngine/test/rts_composition.cpp
T

662 lines
30 KiB
C++

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