implement waves

This commit is contained in:
2026-04-20 14:10:01 +02:00
parent 65de4ddc5c
commit 498b97db20
17 changed files with 1798 additions and 18 deletions

View File

@@ -12,7 +12,7 @@ Cross-references: `architecture.md` (design), `requirements.md` (REQ-* citations
| 4 | Buildings + placement + belt↔building transport | ✅ done | | 4 | Buildings + placement + belt↔building transport | ✅ done |
| 5 | Scrap + ships skeleton (data + spawning, no AI) | ✅ done | | 5 | Scrap + ships skeleton (data + spawning, no AI) | ✅ done |
| 6 | Ship behavior systems + movement arbitration | ✅ done | | 6 | Ship behavior systems + movement arbitration | ✅ done |
| 7 | Waves, threat accumulation, combat resolution, deaths & loot | | | 7 | Waves, threat accumulation, combat resolution, deaths & loot | ✅ done |
| 8 | UI layer (GameWorldView, visuals.toml, panels, build/demolish, speed controls) | ⬜ | | 8 | UI layer (GameWorldView, visuals.toml, panels, build/demolish, speed controls) | ⬜ |
Tick order reference (architecture.md §Tick Order): Tick order reference (architecture.md §Tick Order):

View File

@@ -52,6 +52,17 @@ struct ConstructionSite
Tick completesAt = 0; // 0 = queued but not yet started Tick completesAt = 0; // 0 = queued but not yet started
}; };
// Weapon state for stationary structures (defence stations).
// Distinct from Ship::Weapon; stations have no movement intent.
struct StationWeapon
{
float damage;
float range;
float fireRateHz;
float cooldownTicks;
std::optional<EntityId> currentTarget;
};
// A fully constructed, operational building. // A fully constructed, operational building.
struct Building struct Building
{ {
@@ -73,4 +84,7 @@ struct Building
std::vector<Port> outputPorts; std::vector<Port> outputPorts;
std::vector<Port> inputPorts; // perimeter tiles (minus output-port tiles), std::vector<Port> inputPorts; // perimeter tiles (minus output-port tiles),
// direction pointing INTO building // direction pointing INTO building
// Set only for defence stations; nullopt for all other building types.
std::optional<StationWeapon> weapon;
}; };

View File

@@ -718,3 +718,90 @@ void BuildingSystem::healBuilding(EntityId id, float amount)
} }
} }
} }
void BuildingSystem::damageBuilding(EntityId id, float amount)
{
for (Building& b : m_buildings)
{
if (b.id == id)
{
b.hp -= amount;
return;
}
}
}
EntityId BuildingSystem::placeImmediate(BuildingType type,
const std::vector<std::string>& surfaceMask,
QPoint anchor, Rotation rotation,
float hp, float maxHp)
{
const EntityId id = m_allocateId();
const ParsedSurfaceMask mask = parseSurfaceMask(surfaceMask, rotation);
Building building;
building.id = id;
building.anchor = anchor;
building.footprint = mask.footprint;
building.rotation = rotation;
building.type = type;
building.hp = hp;
building.maxHp = maxHp;
for (const QPoint& cell : mask.bodyCells)
{
const QPoint absCell = anchor + cell;
building.bodyCells.push_back(absCell);
m_tileOccupancy[{absCell.x(), absCell.y()}] = id;
}
for (const Port& port : mask.outputPorts)
{
Port absPort;
absPort.tile = anchor + port.tile;
absPort.direction = port.direction;
building.outputPorts.push_back(absPort);
}
building.inputPorts = computeInputPorts(building);
m_buildings.push_back(std::move(building));
return id;
}
bool BuildingSystem::removeBuilding(EntityId id)
{
for (std::vector<Building>::iterator it = m_buildings.begin();
it != m_buildings.end();
++it)
{
if (it->id == id)
{
for (const QPoint& cell : it->bodyCells)
{
m_tileOccupancy.erase({cell.x(), cell.y()});
}
m_buildings.erase(it);
return true;
}
}
return false;
}
void BuildingSystem::initStationWeapon(EntityId id, const StationWeapon& weapon)
{
for (Building& b : m_buildings)
{
if (b.id == id)
{
b.weapon = weapon;
return;
}
}
}
void BuildingSystem::forEachBuilding(std::function<void(Building&)> fn)
{
for (Building& b : m_buildings)
{
fn(b);
}
}

View File

@@ -71,6 +71,27 @@ public:
// Increase a building's HP by amount, clamped to maxHp. // Increase a building's HP by amount, clamped to maxHp.
void healBuilding(EntityId id, float amount); void healBuilding(EntityId id, float amount);
// Reduce a building's HP by amount; hp may go below 0 (step 9 processes deaths).
void damageBuilding(EntityId id, float amount);
// Bypass the construction queue and create a fully-operational Building
// immediately. Used for pre-placed structures (HQ, defence stations).
// surfaceMask comes from the relevant config struct.
EntityId placeImmediate(BuildingType type,
const std::vector<std::string>& surfaceMask,
QPoint anchor, Rotation rotation,
float hp, float maxHp);
// Remove an operational building by id without refund (used for deaths).
// Returns true if found and removed.
bool removeBuilding(EntityId id);
// Set the weapon component on an already-placed defence station.
void initStationWeapon(EntityId id, const StationWeapon& weapon);
// Mutable iteration over all operational buildings (used by CombatSystem).
void forEachBuilding(std::function<void(Building&)> fn);
private: private:
struct BeltEntry struct BeltEntry
{ {

View File

@@ -9,6 +9,8 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/Ship.h ${CMAKE_CURRENT_SOURCE_DIR}/Ship.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/ScrapSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/ScrapSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/CombatSystem.h
PARENT_SCOPE PARENT_SCOPE
) )
@@ -20,6 +22,8 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ScrapSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ScrapSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/CombatSystem.cpp
PARENT_SCOPE PARENT_SCOPE
) )

View File

@@ -0,0 +1,246 @@
#include "CombatSystem.h"
#include "BuildingSystem.h"
#include "BuildingType.h"
#include "Ship.h"
#include "ShipSystem.h"
CombatSystem::CombatSystem(const GameConfig& config)
: m_config(config)
{
}
void CombatSystem::tick(Tick currentTick,
ShipSystem& ships,
BuildingSystem& buildings,
std::vector<FireEvent>& outFireEvents)
{
// Ships: iterate and resolve weapon for each combat ship.
ships.forEach([&](Ship& ship)
{
resolveShipWeapon(ship, currentTick, ships, buildings, outFireEvents);
});
// Defence stations: acquire targets and fire.
buildings.forEachBuilding([&](Building& building)
{
if (building.type == BuildingType::PlayerDefenceStation ||
building.type == BuildingType::EnemyDefenceStation)
{
resolveStationWeapon(building, currentTick, ships, buildings, outFireEvents);
}
});
}
void CombatSystem::resolveShipWeapon(Ship& ship, Tick currentTick,
ShipSystem& ships,
BuildingSystem& buildings,
std::vector<FireEvent>& out)
{
if (!ship.weapon || !ship.threatResponse ||
!ship.threatResponse->currentTarget)
{
return;
}
Weapon& w = *ship.weapon;
// Decrement cooldown toward zero.
if (w.cooldownTicks > 0.0f)
{
w.cooldownTicks -= 1.0f;
}
if (w.cooldownTicks > 0.0f)
{
return;
}
const EntityId targetId = *ship.threatResponse->currentTarget;
const std::optional<QVector2D> tPos = targetPosition(targetId, ships, buildings);
if (!tPos)
{
ship.threatResponse->currentTarget = std::nullopt;
return;
}
const float dist = (ship.position - *tPos).length();
if (dist > w.range)
{
return;
}
// Apply damage to the correct pool.
if (ships.findShip(targetId))
{
ships.damageShip(targetId, w.damage);
}
else
{
buildings.damageBuilding(targetId, w.damage);
}
FireEvent evt;
evt.shooter = ship.id;
evt.target = targetId;
evt.emittedAt = currentTick;
out.push_back(evt);
w.cooldownTicks = static_cast<float>(kTickRateHz) / w.fireRateHz;
}
void CombatSystem::resolveStationWeapon(Building& station, Tick currentTick,
ShipSystem& ships,
BuildingSystem& buildings,
std::vector<FireEvent>& out)
{
if (!station.weapon)
{
return;
}
StationWeapon& w = *station.weapon;
const bool stationIsEnemy = (station.type == BuildingType::EnemyDefenceStation);
const QVector2D stationCenter(
station.anchor.x() + station.footprint.width() / 2.0f,
station.anchor.y() + station.footprint.height() / 2.0f);
// Validate or clear existing target.
if (w.currentTarget)
{
const std::optional<QVector2D> tPos =
targetPosition(*w.currentTarget, ships, buildings);
if (!tPos || (stationCenter - *tPos).length() > w.range)
{
w.currentTarget = std::nullopt;
}
}
// Acquire a new target if needed.
if (!w.currentTarget)
{
w.currentTarget = acquireStationTarget(station, stationIsEnemy,
ships, buildings);
}
if (!w.currentTarget)
{
return;
}
// Decrement cooldown.
if (w.cooldownTicks > 0.0f)
{
w.cooldownTicks -= 1.0f;
}
if (w.cooldownTicks > 0.0f)
{
return;
}
const EntityId targetId = *w.currentTarget;
const std::optional<QVector2D> tPos = targetPosition(targetId, ships, buildings);
if (!tPos)
{
w.currentTarget = std::nullopt;
return;
}
if ((stationCenter - *tPos).length() > w.range)
{
return;
}
if (ships.findShip(targetId))
{
ships.damageShip(targetId, w.damage);
}
else
{
buildings.damageBuilding(targetId, w.damage);
}
FireEvent evt;
evt.shooter = station.id;
evt.target = targetId;
evt.emittedAt = currentTick;
out.push_back(evt);
w.cooldownTicks = static_cast<float>(kTickRateHz) / w.fireRateHz;
}
std::optional<EntityId> CombatSystem::acquireStationTarget(
const Building& station, bool stationIsEnemy,
const ShipSystem& ships,
const BuildingSystem& buildings) const
{
const QVector2D stationCenter(
station.anchor.x() + station.footprint.width() / 2.0f,
station.anchor.y() + station.footprint.height() / 2.0f);
const float range = station.weapon->range;
std::optional<EntityId> best;
float bestDist = range;
// Scan ships for valid targets.
for (const Ship& candidate : ships.allShips())
{
const bool isValidTarget = stationIsEnemy ? !candidate.isEnemy
: candidate.isEnemy;
if (!isValidTarget)
{
continue;
}
const float dist = (candidate.position - stationCenter).length();
if (dist < bestDist)
{
bestDist = dist;
best = candidate.id;
}
}
// Enemy stations also target player buildings (HQ, PlayerDefenceStation).
if (stationIsEnemy)
{
for (const Building& b : buildings.allBuildings())
{
if (b.type != BuildingType::Hq &&
b.type != BuildingType::PlayerDefenceStation)
{
continue;
}
const QVector2D bCenter(b.anchor.x() + b.footprint.width() / 2.0f,
b.anchor.y() + b.footprint.height() / 2.0f);
const float dist = (bCenter - stationCenter).length();
if (dist < bestDist)
{
bestDist = dist;
best = b.id;
}
}
}
return best;
}
std::optional<QVector2D> CombatSystem::targetPosition(
EntityId id,
const ShipSystem& ships,
const BuildingSystem& buildings) const
{
const Ship* ship = ships.findShip(id);
if (ship)
{
return ship->position;
}
const Building* bld = buildings.findBuilding(id);
if (bld)
{
return QVector2D(bld->anchor.x() + bld->footprint.width() / 2.0f,
bld->anchor.y() + bld->footprint.height() / 2.0f);
}
return std::nullopt;
}

View File

@@ -0,0 +1,61 @@
#pragma once
#include <optional>
#include <vector>
#include <QVector2D>
#include "Building.h"
#include "EntityId.h"
#include "FireEvent.h"
#include "GameConfig.h"
#include "Ship.h"
#include "Tick.h"
class BuildingSystem;
class ShipSystem;
// Resolves all weapon fire for ships and defence stations (tick-order step 8).
// REQ-SHP-FIRING, REQ-DEF-PLAYER-FIRE, REQ-DEF-ENEMY-FIRE.
class CombatSystem
{
public:
explicit CombatSystem(const GameConfig& config);
// Advance weapon cooldowns, acquire targets for stations, fire when ready,
// apply damage, and append FireEvents. Damage is applied immediately via
// ShipSystem::damageShip and BuildingSystem::damageBuilding; step 9
// removes entities whose HP dropped to zero or below.
void tick(Tick currentTick,
ShipSystem& ships,
BuildingSystem& buildings,
std::vector<FireEvent>& outFireEvents);
private:
// Process one ship's weapon for this tick.
void resolveShipWeapon(Ship& ship, Tick currentTick,
ShipSystem& ships,
BuildingSystem& buildings,
std::vector<FireEvent>& out);
// Process one defence-station's weapon for this tick.
void resolveStationWeapon(Building& station, Tick currentTick,
ShipSystem& ships,
BuildingSystem& buildings,
std::vector<FireEvent>& out);
// Find the nearest valid target for a defence station within its range.
// Enemy stations target player ships + HQ + PlayerDefenceStation.
// Player stations target enemy ships only.
std::optional<EntityId> acquireStationTarget(
const Building& station, bool stationIsEnemy,
const ShipSystem& ships,
const BuildingSystem& buildings) const;
// Return the world position of the entity, or nullopt if it no longer exists.
std::optional<QVector2D> targetPosition(EntityId id,
const ShipSystem& ships,
const BuildingSystem& buildings) const;
const GameConfig& m_config;
};

View File

@@ -173,6 +173,19 @@ bool ShipSystem::healShip(EntityId id, float amount)
return false; return false;
} }
bool ShipSystem::damageShip(EntityId id, float amount)
{
for (Ship& s : m_ships)
{
if (s.id == id)
{
s.hp -= amount;
return true;
}
}
return false;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// clearMovementIntents // clearMovementIntents
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -47,6 +47,10 @@ public:
// -- Movement (tick-order step 10) --------------------------------------- // -- Movement (tick-order step 10) ---------------------------------------
void tickMovement(); void tickMovement();
// Reduce ship HP by amount. Does not remove the ship; step 9 handles death.
// Returns false if ship not found.
bool damageShip(EntityId id, float amount);
private: private:
const ShipDef* findShipDef(const std::string& blueprintId) const; const ShipDef* findShipDef(const std::string& blueprintId) const;

View File

@@ -1,8 +1,13 @@
#include "Simulation.h" #include "Simulation.h"
#include <cassert>
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "CombatSystem.h"
#include "ScrapSystem.h" #include "ScrapSystem.h"
#include "ShipSystem.h" #include "ShipSystem.h"
#include "SurfaceMask.h"
#include "WaveSystem.h"
Simulation::Simulation(const GameConfig& config, unsigned int seed) Simulation::Simulation(const GameConfig& config, unsigned int seed)
: m_config(config) : m_config(config)
@@ -10,8 +15,15 @@ Simulation::Simulation(const GameConfig& config, unsigned int seed)
, m_currentTick(0) , m_currentTick(0)
, m_nextId(1) , m_nextId(1)
, m_buildingBlocksStock(config.world.startingBuildingBlocks) , m_buildingBlocksStock(config.world.startingBuildingBlocks)
, m_gameOver(false)
, m_hqId(kInvalidEntityId)
, m_playerStation1Id(kInvalidEntityId)
, m_playerStation2Id(kInvalidEntityId)
, m_beltSystem(config.world.beltSpeedTilesPerSecond) , m_beltSystem(config.world.beltSpeedTilesPerSecond)
{ {
m_currentEnemyStationIds[0] = kInvalidEntityId;
m_currentEnemyStationIds[1] = kInvalidEntityId;
m_buildingSystem = std::make_unique<BuildingSystem>( m_buildingSystem = std::make_unique<BuildingSystem>(
config, config,
m_beltSystem, m_beltSystem,
@@ -20,35 +32,303 @@ Simulation::Simulation(const GameConfig& config, unsigned int seed)
m_rng); m_rng);
m_shipSystem = std::make_unique<ShipSystem>(config, [this]() { return allocateId(); }); m_shipSystem = std::make_unique<ShipSystem>(config, [this]() { return allocateId(); });
m_scrapSystem = std::make_unique<ScrapSystem>([this]() { return allocateId(); }); m_scrapSystem = std::make_unique<ScrapSystem>([this]() { return allocateId(); });
m_waveSystem = std::make_unique<WaveSystem>(config, m_rng);
m_combatSystem = std::make_unique<CombatSystem>(config);
// Initialize blueprint unlock state.
for (const ShipDef& def : config.ships.ships)
{
BlueprintState state;
state.unlocked = def.availableFromStart;
state.level = def.availableFromStart ? def.blueprint.playerProductionLevel : 0;
m_blueprintLevels[def.id] = state;
}
placeInitialStructures();
} }
Simulation::~Simulation() = default; Simulation::~Simulation() = default;
// ---------------------------------------------------------------------------
// tick
// ---------------------------------------------------------------------------
void Simulation::tick() void Simulation::tick()
{ {
m_buildingSystem->tickConstruction(m_currentTick); // Step 1: wave scheduler
m_buildingSystem->tickBeltPull(); // tick order step 3 m_waveSystem->tickWaveScheduler(m_currentTick, *m_shipSystem,
m_buildingSystem->tickProduction(m_currentTick); // step 4 m_config.world.heightTiles);
m_buildingSystem->tickBeltPush(); // step 5
m_beltSystem.tick(); // step 6
// Step 7: ship behavior systems (movement arbitration via intent priority). // Step 2: threat accumulation
m_waveSystem->tickThreatAccumulation(m_currentTick);
// Construction + production pipeline
m_buildingSystem->tickConstruction(m_currentTick);
m_buildingSystem->tickBeltPull(); // step 3
m_buildingSystem->tickProduction(m_currentTick); // step 4
m_buildingSystem->tickBeltPush(); // step 5
m_beltSystem.tick(); // step 6
// Step 7: ship behavior systems (movement arbitration via intent priority)
m_shipSystem->clearMovementIntents(); m_shipSystem->clearMovementIntents();
m_shipSystem->tickHomeReturn(); // priority 4 m_shipSystem->tickHomeReturn(); // priority 4
m_shipSystem->tickThreatResponse(*m_buildingSystem); // priority 3 m_shipSystem->tickThreatResponse(*m_buildingSystem); // priority 3
m_shipSystem->tickRepairBehavior(*m_buildingSystem); // priority 2 m_shipSystem->tickRepairBehavior(*m_buildingSystem); // priority 2
m_shipSystem->tickScrapCollector(*m_scrapSystem, *m_buildingSystem); // priority 1 m_shipSystem->tickScrapCollector(*m_scrapSystem, *m_buildingSystem); // priority 1
// Steps 8 & 9: combat resolution and deaths — added in implementation step 7. // Step 8: combat resolution
m_combatSystem->tick(m_currentTick, *m_shipSystem,
*m_buildingSystem, m_fireEvents);
// Step 10: advance ship positions from winning intents. // Step 9: deaths & loot
if (!m_gameOver)
{
tickDeathsAndLoot();
}
// Step 10: advance ship positions
m_shipSystem->tickMovement(); m_shipSystem->tickMovement();
m_scrapSystem->tickDespawn(m_currentTick); // step 11 // Step 11: scrap despawn
m_scrapSystem->tickDespawn(m_currentTick);
++m_currentTick; ++m_currentTick;
} }
// ---------------------------------------------------------------------------
// Pre-placement
// ---------------------------------------------------------------------------
void Simulation::placeInitialStructures()
{
// HQ — right edge of asteroid (rightmost asteroid tile is x = -1).
const ParsedSurfaceMask hqParsed =
parseSurfaceMask(m_config.stations.hq.surfaceMask, Rotation::East);
const int hqAnchorX = -hqParsed.footprint.width();
const int hqAnchorY =
(m_config.world.heightTiles - hqParsed.footprint.height()) / 2;
const float hqHp =
static_cast<float>(m_config.stations.hq.hpFormula.evaluate(0.0));
m_hqId = m_buildingSystem->placeImmediate(
BuildingType::Hq,
m_config.stations.hq.surfaceMask,
QPoint(hqAnchorX, hqAnchorY),
Rotation::East, hqHp, hqHp);
// Player defence stations — right edge of player buffer zone.
const ParsedSurfaceMask psParsed =
parseSurfaceMask(m_config.stations.playerStation.surfaceMask, Rotation::East);
const int psAnchorX =
m_config.world.regions.playerBufferWidth - psParsed.footprint.width();
const double psLevel = static_cast<double>(m_config.stations.playerStation.level);
const float psHp = static_cast<float>(
m_config.stations.playerStation.hpFormula.evaluate(psLevel));
StationWeapon psWeapon;
psWeapon.damage = static_cast<float>(
m_config.stations.playerStation.damageFormula.evaluate(psLevel));
psWeapon.range = static_cast<float>(
m_config.stations.playerStation.rangeFormula.evaluate(psLevel));
psWeapon.fireRateHz = static_cast<float>(
m_config.stations.playerStation.fireRateFormula.evaluate(psLevel));
psWeapon.cooldownTicks = 0.0f;
const int ps1Y = m_config.world.heightTiles / 4;
const int ps2Y = 3 * m_config.world.heightTiles / 4;
m_playerStation1Id = m_buildingSystem->placeImmediate(
BuildingType::PlayerDefenceStation,
m_config.stations.playerStation.surfaceMask,
QPoint(psAnchorX, ps1Y), Rotation::East, psHp, psHp);
m_buildingSystem->initStationWeapon(m_playerStation1Id, psWeapon);
m_playerStation2Id = m_buildingSystem->placeImmediate(
BuildingType::PlayerDefenceStation,
m_config.stations.playerStation.surfaceMask,
QPoint(psAnchorX, ps2Y), Rotation::East, psHp, psHp);
m_buildingSystem->initStationWeapon(m_playerStation2Id, psWeapon);
// Enemy defence stations — generation 0 (initial set).
placeEnemyStationSet(0);
}
void Simulation::placeEnemyStationSet(int generation)
{
const ParsedSurfaceMask esParsed =
parseSurfaceMask(m_config.stations.enemyStation.surfaceMask, Rotation::East);
// Right edge of contest zone, shifted right by (generation * pushExpandColumns).
const int rightEdgeX = m_config.world.regions.playerBufferWidth
+ m_config.world.regions.contestZoneWidth
+ generation * m_config.world.push.pushExpandColumns;
const int anchorX = rightEdgeX - esParsed.footprint.width();
const double genD = static_cast<double>(generation);
const float esHp = static_cast<float>(
m_config.stations.enemyStation.hpFormula.evaluate(genD));
StationWeapon esWeapon;
esWeapon.damage = static_cast<float>(
m_config.stations.enemyStation.damageFormula.evaluate(genD));
esWeapon.range = static_cast<float>(
m_config.stations.enemyStation.rangeFormula.evaluate(genD));
esWeapon.fireRateHz = static_cast<float>(
m_config.stations.enemyStation.fireRateFormula.evaluate(genD));
esWeapon.cooldownTicks = 0.0f;
const int y1 = m_config.world.heightTiles / 4;
const int y2 = 3 * m_config.world.heightTiles / 4;
const EntityId id1 = m_buildingSystem->placeImmediate(
BuildingType::EnemyDefenceStation,
m_config.stations.enemyStation.surfaceMask,
QPoint(anchorX, y1), Rotation::East, esHp, esHp);
m_buildingSystem->initStationWeapon(id1, esWeapon);
const EntityId id2 = m_buildingSystem->placeImmediate(
BuildingType::EnemyDefenceStation,
m_config.stations.enemyStation.surfaceMask,
QPoint(anchorX, y2), Rotation::East, esHp, esHp);
m_buildingSystem->initStationWeapon(id2, esWeapon);
m_currentEnemyStationIds[0] = id1;
m_currentEnemyStationIds[1] = id2;
}
// ---------------------------------------------------------------------------
// Deaths & loot (tick step 9)
// ---------------------------------------------------------------------------
void Simulation::tickDeathsAndLoot()
{
// --- Dead ships ---
std::vector<EntityId> deadShipIds;
m_shipSystem->forEach([&deadShipIds](Ship& s)
{
if (s.hp <= 0.0f)
{
deadShipIds.push_back(s.id);
}
});
for (EntityId deadId : deadShipIds)
{
const Ship* s = m_shipSystem->findShip(deadId);
if (!s)
{
continue;
}
// Look up scrap drop amount from config.
for (const ShipDef& def : m_config.ships.ships)
{
if (def.id == s->blueprintId && def.loot.scrapDrop > 0)
{
const Tick despawnAt = m_currentTick
+ secondsToTicks(m_config.world.scrapDespawnSeconds);
m_scrapSystem->spawn(s->position, def.loot.scrapDrop, despawnAt);
break;
}
}
m_shipSystem->despawn(deadId);
}
// --- Dead buildings (HQ, player/enemy defence stations) ---
std::vector<EntityId> deadBuildingIds;
for (const Building& b : m_buildingSystem->allBuildings())
{
if (b.hp <= 0.0f &&
(b.type == BuildingType::Hq ||
b.type == BuildingType::PlayerDefenceStation ||
b.type == BuildingType::EnemyDefenceStation))
{
deadBuildingIds.push_back(b.id);
}
}
for (EntityId deadId : deadBuildingIds)
{
const Building* b = m_buildingSystem->findBuilding(deadId);
if (!b)
{
continue;
}
if (b->type == BuildingType::Hq)
{
m_gameOver = true;
}
else
{
const QVector2D center(
b->anchor.x() + b->footprint.width() / 2.0f,
b->anchor.y() + b->footprint.height() / 2.0f);
const Tick despawnAt = m_currentTick
+ secondsToTicks(m_config.world.scrapDespawnSeconds);
int scrap = 0;
if (b->type == BuildingType::PlayerDefenceStation)
{
const double lv = static_cast<double>(
m_config.stations.playerStation.level);
scrap = static_cast<int>(
m_config.stations.playerStation.scrapDropFormula.evaluate(lv));
}
else if (b->type == BuildingType::EnemyDefenceStation)
{
const double genD = static_cast<double>(m_waveSystem->generation());
scrap = static_cast<int>(
m_config.stations.enemyStation.scrapDropFormula.evaluate(genD));
}
if (scrap > 0)
{
m_scrapSystem->spawn(center, scrap, despawnAt);
}
}
m_buildingSystem->removeBuilding(deadId);
}
// --- Push check: if both current enemy stations are gone, trigger push ---
const bool es0Gone =
(m_buildingSystem->findBuilding(m_currentEnemyStationIds[0]) == nullptr);
const bool es1Gone =
(m_buildingSystem->findBuilding(m_currentEnemyStationIds[1]) == nullptr);
if (es0Gone && es1Gone &&
m_currentEnemyStationIds[0] != kInvalidEntityId)
{
m_waveSystem->applyPush();
placeEnemyStationSet(m_waveSystem->generation());
awardBlueprintDrop();
}
}
void Simulation::awardBlueprintDrop()
{
std::vector<std::string> ids;
ids.reserve(m_config.ships.ships.size());
for (const ShipDef& def : m_config.ships.ships)
{
ids.push_back(def.id);
}
std::uniform_int_distribution<int> dist(0, static_cast<int>(ids.size()) - 1);
const std::string chosen = ids[static_cast<std::size_t>(dist(m_rng))];
BlueprintState& state = m_blueprintLevels.at(chosen);
const bool wasNew = !state.unlocked;
state.unlocked = true;
state.level += 1;
BlueprintDropEvent evt;
evt.blueprintId = chosen;
evt.newLevel = state.level;
evt.wasNewUnlock = wasNew;
m_blueprintDropEvents.push_back(evt);
}
// ---------------------------------------------------------------------------
// Drains
// ---------------------------------------------------------------------------
std::vector<FireEvent> Simulation::drainFireEvents() std::vector<FireEvent> Simulation::drainFireEvents()
{ {
std::vector<FireEvent> result; std::vector<FireEvent> result;
@@ -63,6 +343,10 @@ std::vector<BlueprintDropEvent> Simulation::drainBlueprintDropEvents()
return result; return result;
} }
// ---------------------------------------------------------------------------
// Accessors
// ---------------------------------------------------------------------------
Tick Simulation::currentTick() const Tick Simulation::currentTick() const
{ {
return m_currentTick; return m_currentTick;
@@ -73,6 +357,38 @@ int Simulation::buildingBlocksStock() const
return m_buildingBlocksStock; return m_buildingBlocksStock;
} }
bool Simulation::isGameOver() const
{
return m_gameOver;
}
double Simulation::threatLevel() const
{
return m_waveSystem->threatLevel();
}
int Simulation::blueprintLevel(const std::string& shipId) const
{
const std::map<std::string, BlueprintState>::const_iterator it =
m_blueprintLevels.find(shipId);
if (it == m_blueprintLevels.end())
{
return 0;
}
return it->second.level;
}
bool Simulation::isBlueprintUnlocked(const std::string& shipId) const
{
const std::map<std::string, BlueprintState>::const_iterator it =
m_blueprintLevels.find(shipId);
if (it == m_blueprintLevels.end())
{
return false;
}
return it->second.unlocked;
}
BuildingSystem& Simulation::buildings() BuildingSystem& Simulation::buildings()
{ {
return *m_buildingSystem; return *m_buildingSystem;

View File

@@ -1,7 +1,9 @@
#pragma once #pragma once
#include <map>
#include <memory> #include <memory>
#include <random> #include <random>
#include <string>
#include <vector> #include <vector>
#include "BeltSystem.h" #include "BeltSystem.h"
@@ -12,8 +14,10 @@
#include "Tick.h" #include "Tick.h"
class BuildingSystem; class BuildingSystem;
class CombatSystem;
class ShipSystem; class ShipSystem;
class ScrapSystem; class ScrapSystem;
class WaveSystem;
class Simulation class Simulation
{ {
@@ -31,8 +35,14 @@ public:
// Returns all blueprint drop events since the last drain. // Returns all blueprint drop events since the last drain.
std::vector<BlueprintDropEvent> drainBlueprintDropEvents(); std::vector<BlueprintDropEvent> drainBlueprintDropEvents();
Tick currentTick() const; Tick currentTick() const;
int buildingBlocksStock() const; int buildingBlocksStock() const;
bool isGameOver() const;
double threatLevel() const;
// Blueprint state queries.
int blueprintLevel(const std::string& shipId) const;
bool isBlueprintUnlocked(const std::string& shipId) const;
BuildingSystem& buildings(); BuildingSystem& buildings();
const BuildingSystem& buildings() const; const BuildingSystem& buildings() const;
@@ -46,17 +56,47 @@ public:
private: private:
EntityId allocateId(); // Strictly increasing; never returns kInvalidEntityId. EntityId allocateId(); // Strictly increasing; never returns kInvalidEntityId.
// Populate HQ, player defence stations, and the first enemy station set.
void placeInitialStructures();
// Place two enemy defence stations for the given generation level.
// Stores their IDs in m_currentEnemyStationIds.
void placeEnemyStationSet(int generation);
// Tick step 9: remove dead ships and buildings, drop scrap, handle push.
void tickDeathsAndLoot();
// Award a random blueprint drop (REQ-DEF-BLUEPRINT-DROP) and emit the event.
void awardBlueprintDrop();
const GameConfig& m_config; const GameConfig& m_config;
std::mt19937 m_rng; std::mt19937 m_rng;
Tick m_currentTick; Tick m_currentTick;
EntityId m_nextId; EntityId m_nextId;
int m_buildingBlocksStock; int m_buildingBlocksStock;
bool m_gameOver = false;
// Pre-placed structure IDs.
EntityId m_hqId;
EntityId m_playerStation1Id;
EntityId m_playerStation2Id;
EntityId m_currentEnemyStationIds[2];
// Blueprint unlock state (REQ-DEF-BLUEPRINT-DROP).
struct BlueprintState
{
bool unlocked;
int level;
};
std::map<std::string, BlueprintState> m_blueprintLevels;
BeltSystem m_beltSystem; BeltSystem m_beltSystem;
std::unique_ptr<BuildingSystem> m_buildingSystem; std::unique_ptr<BuildingSystem> m_buildingSystem;
std::unique_ptr<ShipSystem> m_shipSystem; std::unique_ptr<ShipSystem> m_shipSystem;
std::unique_ptr<ScrapSystem> m_scrapSystem; std::unique_ptr<ScrapSystem> m_scrapSystem;
std::unique_ptr<WaveSystem> m_waveSystem;
std::unique_ptr<CombatSystem> m_combatSystem;
std::vector<FireEvent> m_fireEvents; std::vector<FireEvent> m_fireEvents;
std::vector<BlueprintDropEvent> m_blueprintDropEvents; std::vector<BlueprintDropEvent> m_blueprintDropEvents;

191
src/lib/sim/WaveSystem.cpp Normal file
View File

@@ -0,0 +1,191 @@
#include "WaveSystem.h"
#include <algorithm>
#include "ShipSystem.h"
WaveSystem::WaveSystem(const GameConfig& config, std::mt19937& rng)
: m_config(config)
, m_rng(rng)
{
// Draw the initial inter-wave gap (REQ-WAV-GAP, REQ-WAV-GRACE-PERIOD).
m_nextEventTick = drawGapTicks();
}
void WaveSystem::tickWaveScheduler(Tick currentTick, ShipSystem& ships,
int worldHeightTiles)
{
if (!m_waveActive)
{
if (currentTick < m_nextEventTick)
{
return;
}
// Gap expired: compose the next wave.
m_pendingSpawns = composeWave(currentTick, worldHeightTiles);
m_waveActive = true;
}
// Spawn any ships whose scheduled tick has arrived.
std::vector<SpawnEntry> remaining;
remaining.reserve(m_pendingSpawns.size());
for (const SpawnEntry& entry : m_pendingSpawns)
{
if (currentTick >= entry.spawnAt)
{
ships.spawn(entry.blueprintId, entry.level, entry.position,
/*isEnemy=*/true);
}
else
{
remaining.push_back(entry);
}
}
m_pendingSpawns = std::move(remaining);
if (m_pendingSpawns.empty())
{
m_waveActive = false;
m_nextEventTick = currentTick + drawGapTicks();
}
}
void WaveSystem::tickThreatAccumulation(Tick currentTick)
{
const double elapsedSeconds = static_cast<double>(currentTick) * kTickDurationSeconds;
const double rate = m_config.world.waves.threatRateFormula.evaluate(elapsedSeconds);
if (rate > 0.0)
{
m_threatLevel += rate * m_pushScalingMultiplier * kTickDurationSeconds;
}
}
void WaveSystem::applyPush()
{
m_pushScalingMultiplier *= m_config.world.push.scalingFactor;
++m_generation;
}
double WaveSystem::threatLevel() const
{
return m_threatLevel;
}
int WaveSystem::generation() const
{
return m_generation;
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
std::vector<WaveSystem::SpawnEntry> WaveSystem::composeWave(Tick currentTick,
int worldHeightTiles)
{
const double elapsedSeconds = static_cast<double>(currentTick) * kTickDurationSeconds;
const int shipLevel = std::max(1, static_cast<int>(
m_config.world.waves.shipLevelFormula.evaluate(elapsedSeconds)));
// Build eligible ship list with their costs at the current level.
struct EligibleShip
{
std::string blueprintId;
double cost;
};
std::vector<EligibleShip> eligible;
for (const ShipDef& def : m_config.ships.ships)
{
const double cost = def.threat.costFormula.evaluate(static_cast<double>(shipLevel));
if (cost > 0.0)
{
EligibleShip es;
es.blueprintId = def.id;
es.cost = cost;
eligible.push_back(es);
}
}
if (eligible.empty())
{
return {};
}
// Take the current threat level as the wave budget and reset it.
// Unspent budget is re-added after composition (carry-over).
double budget = m_threatLevel;
m_threatLevel = 0.0;
// Enemy spawn buffer X range for the current generation.
const float leftX = static_cast<float>(
m_config.world.regions.playerBufferWidth
+ m_config.world.regions.contestZoneWidth
+ m_generation * m_config.world.push.pushExpandColumns);
const float rightX = leftX + static_cast<float>(m_config.world.regions.enemyBufferWidth) - 1.0f;
std::uniform_real_distribution<float> xDist(leftX, rightX);
std::uniform_int_distribution<int> yDist(0, worldHeightTiles - 1);
std::vector<SpawnEntry> picked;
while (true)
{
// Collect indices of ships whose cost fits the remaining budget.
std::vector<std::size_t> fitting;
for (std::size_t i = 0; i < eligible.size(); ++i)
{
if (eligible[i].cost <= budget)
{
fitting.push_back(i);
}
}
if (fitting.empty())
{
break;
}
std::uniform_int_distribution<int> pick(0, static_cast<int>(fitting.size()) - 1);
const std::size_t chosenIdx = fitting[static_cast<std::size_t>(pick(m_rng))];
const EligibleShip& chosen = eligible[chosenIdx];
budget -= chosen.cost;
SpawnEntry entry;
entry.blueprintId = chosen.blueprintId;
entry.level = shipLevel;
entry.spawnAt = 0; // set below after all picks are done
entry.position = QVector2D(xDist(m_rng),
static_cast<float>(yDist(m_rng)) + 0.5f);
picked.push_back(entry);
}
// Carry leftover budget forward to the next wave.
m_threatLevel += budget;
// Spread spawn times evenly across spawnDurationSeconds.
const int count = static_cast<int>(picked.size());
if (count == 1)
{
picked[0].spawnAt = currentTick;
}
else if (count > 1)
{
const Tick spawnDurationTicks =
secondsToTicks(m_config.world.waves.spawnDurationSeconds);
for (int i = 0; i < count; ++i)
{
picked[static_cast<std::size_t>(i)].spawnAt =
currentTick + static_cast<Tick>(i) * spawnDurationTicks / (count - 1);
}
}
return picked;
}
Tick WaveSystem::drawGapTicks()
{
const Tick minTicks = secondsToTicks(m_config.world.waves.gapMinSeconds);
const Tick maxTicks = secondsToTicks(m_config.world.waves.gapMaxSeconds);
std::uniform_int_distribution<Tick> dist(minTicks, maxTicks);
return dist(m_rng);
}

67
src/lib/sim/WaveSystem.h Normal file
View File

@@ -0,0 +1,67 @@
#pragma once
#include <random>
#include <string>
#include <vector>
#include <QVector2D>
#include "GameConfig.h"
#include "Tick.h"
class ShipSystem;
// Manages wave scheduling (tick-order step 1) and threat-level accumulation
// (tick-order step 2). REQ-WAV-*.
class WaveSystem
{
public:
WaveSystem(const GameConfig& config, std::mt19937& rng);
// Tick step 1: start a new wave when the current gap has expired; spawn
// any ships in the pending list whose scheduled tick has arrived.
void tickWaveScheduler(Tick currentTick, ShipSystem& ships,
int worldHeightTiles);
// Tick step 2: accumulate threat from the rate formula, scaled by the
// current push multiplier (REQ-WAV-THREAT-RATE, REQ-PSH-ACCUMULATION).
void tickThreatAccumulation(Tick currentTick);
// Called by Simulation (tick step 9) when the current enemy-station set
// is fully destroyed: multiplies the push scaling and increments generation.
void applyPush();
double threatLevel() const;
// Current enemy-station generation level (0 for initial set,
// incremented by 1 after each push — REQ-PSH-STATION-STATS).
int generation() const;
private:
struct SpawnEntry
{
std::string blueprintId;
int level;
Tick spawnAt;
QVector2D position;
};
// Compose the next wave from the current threat budget, returning timed
// spawn entries spread across spawnDurationSeconds. Leaves any unspent
// budget in m_threatLevel (carry-over, REQ-WAV-TRIGGER).
std::vector<SpawnEntry> composeWave(Tick currentTick, int worldHeightTiles);
// Draw a random gap duration in ticks from [gapMin, gapMax].
Tick drawGapTicks();
const GameConfig& m_config;
std::mt19937& m_rng;
double m_threatLevel = 0.0;
double m_pushScalingMultiplier = 1.0;
int m_generation = 0;
bool m_waveActive = false;
Tick m_nextEventTick = 0; // absolute tick when the current gap expires
std::vector<SpawnEntry> m_pendingSpawns;
};

View File

@@ -12,4 +12,6 @@ add_files(
ShipTest.cpp ShipTest.cpp
ScrapTest.cpp ScrapTest.cpp
BehaviorSystemTest.cpp BehaviorSystemTest.cpp
WaveSystemTest.cpp
CombatSystemTest.cpp
) )

View File

@@ -0,0 +1,351 @@
#include "catch.hpp"
#include <random>
#include "BeltSystem.h"
#include "Building.h"
#include "BuildingSystem.h"
#include "BuildingType.h"
#include "CombatSystem.h"
#include "ConfigLoader.h"
#include "FireEvent.h"
#include "ScrapSystem.h"
#include "Ship.h"
#include "ShipSystem.h"
#include "Simulation.h"
#include "Tick.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
}
// Find the first ShipDef with a combat component.
static const ShipDef* findCombatShip(const GameConfig& cfg)
{
for (const ShipDef& def : cfg.ships.ships)
{
if (def.combat)
{
return &def;
}
}
return nullptr;
}
// ---------------------------------------------------------------------------
// Ship weapon firing
// ---------------------------------------------------------------------------
TEST_CASE("CombatSystem: ship fires when cooldown=0 and target in range", "[combat]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr);
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
EntityId nextShipId = 1;
EntityId nextBldId = 100;
ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; });
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
rng);
// Spawn an enemy combat ship close to the player side.
const EntityId enemyId = ships.spawn(combatDef->id, 1,
QVector2D(5.0f, 5.0f), /*isEnemy=*/true);
// Spawn a player combat ship in front of the enemy.
const EntityId playerId = ships.spawn(combatDef->id, 1,
QVector2D(4.0f, 5.0f), /*isEnemy=*/false);
// Wire the enemy's weapon target manually.
ships.forEach([&](Ship& s)
{
if (s.id == enemyId && s.weapon)
{
s.weapon->currentTarget = playerId;
s.weapon->cooldownTicks = 0.0f;
if (s.threatResponse)
{
s.threatResponse->currentTarget = playerId;
}
}
});
// Record player HP before combat.
float hpBefore = 0.0f;
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hpBefore = s.hp; }
}
CombatSystem combat(cfg);
std::vector<FireEvent> events;
combat.tick(0, ships, buildings, events);
float hpAfter = 0.0f;
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hpAfter = s.hp; }
}
REQUIRE(hpAfter < hpBefore);
REQUIRE(events.size() >= 1);
}
TEST_CASE("CombatSystem: cooldown prevents firing before it expires", "[combat]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr);
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
EntityId nextShipId = 1;
EntityId nextBldId = 100;
ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; });
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
rng);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
const EntityId playerId = ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
// Set cooldown to 3 so it won't fire on tick 0 or 1 or 2.
ships.forEach([&](Ship& s)
{
if (s.id == enemyId && s.weapon)
{
s.weapon->currentTarget = playerId;
s.weapon->cooldownTicks = 3.0f;
if (s.threatResponse)
{
s.threatResponse->currentTarget = playerId;
}
}
});
CombatSystem combat(cfg);
std::vector<FireEvent> events;
// Ticks 0 and 1: cooldown still > 0 after decrement → no fire.
combat.tick(0, ships, buildings, events);
combat.tick(1, ships, buildings, events);
REQUIRE(events.empty());
// Tick 2: cooldown reaches 0 → fires.
combat.tick(2, ships, buildings, events);
REQUIRE(events.size() == 1);
}
TEST_CASE("CombatSystem: no fire when target is out of range", "[combat]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr);
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
EntityId nextShipId = 1;
EntityId nextBldId = 100;
ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; });
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
rng);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(0.0f, 0.0f), true);
const EntityId playerId = ships.spawn(combatDef->id, 1, QVector2D(500.0f, 0.0f), false);
ships.forEach([&](Ship& s)
{
if (s.id == enemyId && s.weapon)
{
s.weapon->currentTarget = playerId;
s.weapon->cooldownTicks = 0.0f;
if (s.threatResponse)
{
s.threatResponse->currentTarget = playerId;
}
}
});
CombatSystem combat(cfg);
std::vector<FireEvent> events;
combat.tick(0, ships, buildings, events);
REQUIRE(events.empty());
}
// ---------------------------------------------------------------------------
// Station weapon firing
// ---------------------------------------------------------------------------
TEST_CASE("CombatSystem: player station fires at enemy ship in range", "[combat]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
// Find the player defence station.
EntityId stationId = kInvalidEntityId;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::PlayerDefenceStation)
{
stationId = b.id;
break;
}
}
REQUIRE(stationId != kInvalidEntityId);
// Place an enemy ship close to the player station.
QVector2D stationCenter;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.id == stationId)
{
stationCenter = QVector2D(
b.anchor.x() + b.footprint.width() / 2.0f,
b.anchor.y() + b.footprint.height() / 2.0f);
break;
}
}
// Find a combat ship blueprint for the enemy.
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr);
const EntityId enemyId = sim.ships().spawn(
combatDef->id, 1,
QVector2D(stationCenter.x() + 1.0f, stationCenter.y()),
/*isEnemy=*/true);
// Tick to let station auto-acquire and fire.
sim.tick();
// Check that a fire event was emitted with stationId as shooter.
const std::vector<FireEvent> events = sim.drainFireEvents();
bool stationFired = false;
for (const FireEvent& e : events)
{
if (e.shooter == stationId) { stationFired = true; }
}
REQUIRE(stationFired);
}
TEST_CASE("CombatSystem: enemy station fires at player ship in range", "[combat]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
// Find the enemy defence station.
EntityId stationId = kInvalidEntityId;
QVector2D stationCenter;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::EnemyDefenceStation)
{
stationId = b.id;
stationCenter = QVector2D(
b.anchor.x() + b.footprint.width() / 2.0f,
b.anchor.y() + b.footprint.height() / 2.0f);
break;
}
}
REQUIRE(stationId != kInvalidEntityId);
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr);
// Spawn a player ship right next to the enemy station.
sim.ships().spawn(
combatDef->id, 1,
QVector2D(stationCenter.x() - 1.0f, stationCenter.y()),
/*isEnemy=*/false);
sim.tick();
const std::vector<FireEvent> events = sim.drainFireEvents();
bool stationFired = false;
for (const FireEvent& e : events)
{
if (e.shooter == stationId) { stationFired = true; }
}
REQUIRE(stationFired);
}
// ---------------------------------------------------------------------------
// Deaths & loot (tick step 9)
// ---------------------------------------------------------------------------
TEST_CASE("CombatSystem: dead ship is removed after tick step 9", "[combat]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr);
const EntityId shipId = sim.ships().spawn(combatDef->id, 1,
QVector2D(10.0f, 10.0f));
// Set hp to lethal.
sim.ships().damageShip(shipId, 9999.0f);
sim.tick();
REQUIRE(sim.ships().findShip(shipId) == nullptr);
}
TEST_CASE("CombatSystem: scrap is spawned on ship death", "[combat]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
// Find a ship def that drops scrap.
const ShipDef* droppingDef = nullptr;
for (const ShipDef& def : cfg.ships.ships)
{
if (def.loot.scrapDrop > 0)
{
droppingDef = &def;
break;
}
}
REQUIRE(droppingDef != nullptr);
const EntityId shipId = sim.ships().spawn(droppingDef->id, 1,
QVector2D(10.0f, 10.0f));
sim.ships().damageShip(shipId, 9999.0f);
sim.tick();
// At least one scrap entity should now exist.
REQUIRE(!sim.scraps().allScraps().empty());
}
TEST_CASE("CombatSystem: HQ death sets game over", "[combat]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
sim.buildings().forEachBuilding([](Building& b)
{
if (b.type == BuildingType::Hq)
{
b.hp = -1.0f;
}
});
sim.tick();
REQUIRE(sim.isGameOver());
}

View File

@@ -1,17 +1,23 @@
#include "catch.hpp" #include "catch.hpp"
#include "ConfigLoader.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "Simulation.h" #include "Simulation.h"
#include "Tick.h" #include "Tick.h"
#include "TickDriver.h" #include "TickDriver.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Simulation // Simulation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
TEST_CASE("Simulation::currentTick starts at 0", "[simulation]") TEST_CASE("Simulation::currentTick starts at 0", "[simulation]")
{ {
const GameConfig config; const GameConfig config = loadConfig();
const Simulation sim(config); const Simulation sim(config);
REQUIRE(sim.currentTick() == 0); REQUIRE(sim.currentTick() == 0);
@@ -19,7 +25,7 @@ TEST_CASE("Simulation::currentTick starts at 0", "[simulation]")
TEST_CASE("Simulation::tick increments currentTick by 1", "[simulation]") TEST_CASE("Simulation::tick increments currentTick by 1", "[simulation]")
{ {
const GameConfig config; const GameConfig config = loadConfig();
Simulation sim(config); Simulation sim(config);
sim.tick(); sim.tick();
@@ -29,7 +35,7 @@ TEST_CASE("Simulation::tick increments currentTick by 1", "[simulation]")
TEST_CASE("Simulation::tick 10 times yields currentTick == 10", "[simulation]") TEST_CASE("Simulation::tick 10 times yields currentTick == 10", "[simulation]")
{ {
const GameConfig config; const GameConfig config = loadConfig();
Simulation sim(config); Simulation sim(config);
for (int i = 0; i < 10; ++i) for (int i = 0; i < 10; ++i)
@@ -42,7 +48,7 @@ TEST_CASE("Simulation::tick 10 times yields currentTick == 10", "[simulation]")
TEST_CASE("Simulation::drainFireEvents returns empty initially", "[simulation]") TEST_CASE("Simulation::drainFireEvents returns empty initially", "[simulation]")
{ {
const GameConfig config; const GameConfig config = loadConfig();
Simulation sim(config); Simulation sim(config);
REQUIRE(sim.drainFireEvents().empty()); REQUIRE(sim.drainFireEvents().empty());
@@ -50,7 +56,7 @@ TEST_CASE("Simulation::drainFireEvents returns empty initially", "[simulation]")
TEST_CASE("Simulation::drainFireEvents clears queue on drain", "[simulation]") TEST_CASE("Simulation::drainFireEvents clears queue on drain", "[simulation]")
{ {
const GameConfig config; const GameConfig config = loadConfig();
Simulation sim(config); Simulation sim(config);
// First drain: empty. // First drain: empty.
@@ -62,7 +68,7 @@ TEST_CASE("Simulation::drainFireEvents clears queue on drain", "[simulation]")
TEST_CASE("Simulation::drainBlueprintDropEvents returns empty initially", "[simulation]") TEST_CASE("Simulation::drainBlueprintDropEvents returns empty initially", "[simulation]")
{ {
const GameConfig config; const GameConfig config = loadConfig();
Simulation sim(config); Simulation sim(config);
REQUIRE(sim.drainBlueprintDropEvents().empty()); REQUIRE(sim.drainBlueprintDropEvents().empty());

357
src/test/WaveSystemTest.cpp Normal file
View File

@@ -0,0 +1,357 @@
#include "catch.hpp"
#include <random>
#include "Building.h"
#include "BuildingSystem.h"
#include "BuildingType.h"
#include "ConfigLoader.h"
#include "Rotation.h"
#include "Ship.h"
#include "ShipSystem.h"
#include "Simulation.h"
#include "Tick.h"
#include "WaveSystem.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
}
// ---------------------------------------------------------------------------
// Threat accumulation
// ---------------------------------------------------------------------------
TEST_CASE("WaveSystem: threat stays 0 for first 30 game-seconds", "[wave]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
// threat_rate_formula = "1*x - 30", which is <= 0 for x <= 30.
const int ticks30s = static_cast<int>(secondsToTicks(30.0));
for (int i = 0; i < ticks30s; ++i)
{
ws.tickThreatAccumulation(static_cast<Tick>(i));
}
REQUIRE(ws.threatLevel() == Approx(0.0));
}
TEST_CASE("WaveSystem: threat accumulates after 30 game-seconds", "[wave]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
// Run 31 seconds worth of ticks.
const int ticks31s = static_cast<int>(secondsToTicks(31.0));
for (int i = 0; i < ticks31s; ++i)
{
ws.tickThreatAccumulation(static_cast<Tick>(i));
}
REQUIRE(ws.threatLevel() > 0.0);
}
TEST_CASE("WaveSystem: applyPush increases threat accumulation rate", "[wave]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
// Accumulate for 1 tick past the 30s mark to get a baseline rate.
const Tick baseTick = secondsToTicks(31.0);
ws.tickThreatAccumulation(baseTick);
const double levelBefore = ws.threatLevel();
// Apply push: multiplier should increase.
ws.applyPush();
WaveSystem ws2(cfg, rng);
ws2.tickThreatAccumulation(baseTick);
// After the push the same tick adds more threat.
ws.tickThreatAccumulation(baseTick + 1);
ws2.tickThreatAccumulation(baseTick + 1);
// ws has the push multiplier applied; ws2 does not.
REQUIRE(ws.threatLevel() > ws2.threatLevel());
}
TEST_CASE("WaveSystem: generation starts at 0 and increments on push", "[wave]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
REQUIRE(ws.generation() == 0);
ws.applyPush();
REQUIRE(ws.generation() == 1);
ws.applyPush();
REQUIRE(ws.generation() == 2);
}
// ---------------------------------------------------------------------------
// Pre-placed structures
// ---------------------------------------------------------------------------
TEST_CASE("WaveSystem: Simulation pre-places HQ + 2 player + 2 enemy stations", "[wave]")
{
const GameConfig cfg = loadConfig();
const Simulation sim(cfg, 42);
int hqCount = 0;
int playerCount = 0;
int enemyCount = 0;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::Hq) { ++hqCount; }
else if (b.type == BuildingType::PlayerDefenceStation) { ++playerCount; }
else if (b.type == BuildingType::EnemyDefenceStation) { ++enemyCount; }
}
REQUIRE(hqCount == 1);
REQUIRE(playerCount == 2);
REQUIRE(enemyCount == 2);
}
TEST_CASE("WaveSystem: HQ has correct initial HP from config", "[wave]")
{
const GameConfig cfg = loadConfig();
const Simulation sim(cfg, 42);
const float expectedHp =
static_cast<float>(cfg.stations.hq.hpFormula.evaluate(0.0));
bool found = false;
float actualHp = 0.0f;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::Hq)
{
found = true;
actualHp = b.hp;
break;
}
}
REQUIRE(found);
REQUIRE(actualHp == Approx(expectedHp));
}
TEST_CASE("WaveSystem: HQ anchor is at asteroid right edge", "[wave]")
{
const GameConfig cfg = loadConfig();
const Simulation sim(cfg, 42);
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type != BuildingType::Hq) { continue; }
// Rightmost body cell must be at x = -1 (asteroid right edge).
int maxX = std::numeric_limits<int>::min();
for (const QPoint& cell : b.bodyCells)
{
if (cell.x() > maxX) { maxX = cell.x(); }
}
REQUIRE(maxX == -1);
}
}
TEST_CASE("WaveSystem: player stations have weapon set", "[wave]")
{
const GameConfig cfg = loadConfig();
const Simulation sim(cfg, 42);
int armedPlayerStations = 0;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::PlayerDefenceStation && b.weapon)
{
++armedPlayerStations;
REQUIRE(b.weapon->damage > 0.0f);
REQUIRE(b.weapon->range > 0.0f);
REQUIRE(b.weapon->fireRateHz > 0.0f);
}
}
REQUIRE(armedPlayerStations == 2);
}
TEST_CASE("WaveSystem: enemy stations have weapon set", "[wave]")
{
const GameConfig cfg = loadConfig();
const Simulation sim(cfg, 42);
int armedEnemyStations = 0;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::EnemyDefenceStation && b.weapon)
{
++armedEnemyStations;
REQUIRE(b.weapon->damage > 0.0f);
REQUIRE(b.weapon->range > 0.0f);
REQUIRE(b.weapon->fireRateHz > 0.0f);
}
}
REQUIRE(armedEnemyStations == 2);
}
// ---------------------------------------------------------------------------
// Wave spawning
// ---------------------------------------------------------------------------
TEST_CASE("WaveSystem: enemy ships spawn after the initial gap elapses", "[wave]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
// The maximum gap is gapMaxSeconds = 45s → 1350 ticks.
// Run 1500 ticks to guarantee at least one wave has triggered.
const int limit = static_cast<int>(secondsToTicks(50.0));
for (int i = 0; i < limit; ++i)
{
sim.tick();
}
bool foundEnemyShip = false;
for (const Ship& s : sim.ships().allShips())
{
if (s.isEnemy)
{
foundEnemyShip = true;
break;
}
}
REQUIRE(foundEnemyShip);
}
TEST_CASE("WaveSystem: only eligible ships (cost > 0) appear in waves", "[wave]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
// Run long enough for several waves.
const int limit = static_cast<int>(secondsToTicks(120.0));
for (int i = 0; i < limit; ++i)
{
sim.tick();
}
for (const Ship& s : sim.ships().allShips())
{
if (!s.isEnemy) { continue; }
// salvage_ship and repair_ship have cost_formula = "0" and must not spawn.
REQUIRE(s.blueprintId != "salvage_ship");
REQUIRE(s.blueprintId != "repair_ship");
}
}
// ---------------------------------------------------------------------------
// Push
// ---------------------------------------------------------------------------
TEST_CASE("WaveSystem: destroying both enemy stations triggers a push", "[wave]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
// Damage both enemy stations to 0.
sim.buildings().forEachBuilding([](Building& b)
{
if (b.type == BuildingType::EnemyDefenceStation)
{
b.hp = -1.0f;
}
});
sim.tick();
// After push: should have 2 new enemy stations.
int enemyCount = 0;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::EnemyDefenceStation) { ++enemyCount; }
}
REQUIRE(enemyCount == 2);
}
TEST_CASE("WaveSystem: push emits exactly one BlueprintDropEvent", "[wave]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
sim.buildings().forEachBuilding([](Building& b)
{
if (b.type == BuildingType::EnemyDefenceStation)
{
b.hp = -1.0f;
}
});
sim.tick();
const std::vector<BlueprintDropEvent> events = sim.drainBlueprintDropEvents();
REQUIRE(events.size() == 1);
}
TEST_CASE("WaveSystem: push blueprint drop awards a known ship id", "[wave]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
sim.buildings().forEachBuilding([](Building& b)
{
if (b.type == BuildingType::EnemyDefenceStation)
{
b.hp = -1.0f;
}
});
sim.tick();
const std::vector<BlueprintDropEvent> events = sim.drainBlueprintDropEvents();
REQUIRE(events.size() == 1);
bool validId = false;
for (const ShipDef& def : cfg.ships.ships)
{
if (def.id == events[0].blueprintId)
{
validId = true;
break;
}
}
REQUIRE(validId);
REQUIRE(events[0].newLevel >= 1);
}
TEST_CASE("WaveSystem: push places new enemy stations further right", "[wave]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
// Record the X position of the initial enemy stations.
int initialX = std::numeric_limits<int>::min();
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::EnemyDefenceStation)
{
if (b.anchor.x() > initialX) { initialX = b.anchor.x(); }
}
}
sim.buildings().forEachBuilding([](Building& b)
{
if (b.type == BuildingType::EnemyDefenceStation) { b.hp = -1.0f; }
});
sim.tick();
int newX = std::numeric_limits<int>::min();
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::EnemyDefenceStation)
{
if (b.anchor.x() > newX) { newX = b.anchor.x(); }
}
}
REQUIRE(newX > initialX);
}