implement waves
This commit is contained in:
@@ -12,7 +12,7 @@ Cross-references: `architecture.md` (design), `requirements.md` (REQ-* citations
|
||||
| 4 | Buildings + placement + belt↔building transport | ✅ done |
|
||||
| 5 | Scrap + ships skeleton (data + spawning, no AI) | ✅ 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) | ⬜ |
|
||||
|
||||
Tick order reference (architecture.md §Tick Order):
|
||||
|
||||
@@ -52,6 +52,17 @@ struct ConstructionSite
|
||||
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.
|
||||
struct Building
|
||||
{
|
||||
@@ -73,4 +84,7 @@ struct Building
|
||||
std::vector<Port> outputPorts;
|
||||
std::vector<Port> inputPorts; // perimeter tiles (minus output-port tiles),
|
||||
// direction pointing INTO building
|
||||
|
||||
// Set only for defence stations; nullopt for all other building types.
|
||||
std::optional<StationWeapon> weapon;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,27 @@ public:
|
||||
// Increase a building's HP by amount, clamped to maxHp.
|
||||
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:
|
||||
struct BeltEntry
|
||||
{
|
||||
|
||||
@@ -9,6 +9,8 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/Ship.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipSystem.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ScrapSystem.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/CombatSystem.h
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
@@ -20,6 +22,8 @@ SET(SRCS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipSystem.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ScrapSystem.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/CombatSystem.cpp
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
|
||||
246
src/lib/sim/CombatSystem.cpp
Normal file
246
src/lib/sim/CombatSystem.cpp
Normal 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;
|
||||
}
|
||||
61
src/lib/sim/CombatSystem.h
Normal file
61
src/lib/sim/CombatSystem.h
Normal 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;
|
||||
};
|
||||
@@ -173,6 +173,19 @@ bool ShipSystem::healShip(EntityId id, float amount)
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -47,6 +47,10 @@ public:
|
||||
// -- Movement (tick-order step 10) ---------------------------------------
|
||||
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:
|
||||
const ShipDef* findShipDef(const std::string& blueprintId) const;
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
#include "Simulation.h"
|
||||
|
||||
#include <cassert>
|
||||
|
||||
#include "BuildingSystem.h"
|
||||
#include "CombatSystem.h"
|
||||
#include "ScrapSystem.h"
|
||||
#include "ShipSystem.h"
|
||||
#include "SurfaceMask.h"
|
||||
#include "WaveSystem.h"
|
||||
|
||||
Simulation::Simulation(const GameConfig& config, unsigned int seed)
|
||||
: m_config(config)
|
||||
@@ -10,8 +15,15 @@ Simulation::Simulation(const GameConfig& config, unsigned int seed)
|
||||
, m_currentTick(0)
|
||||
, m_nextId(1)
|
||||
, m_buildingBlocksStock(config.world.startingBuildingBlocks)
|
||||
, m_gameOver(false)
|
||||
, m_hqId(kInvalidEntityId)
|
||||
, m_playerStation1Id(kInvalidEntityId)
|
||||
, m_playerStation2Id(kInvalidEntityId)
|
||||
, m_beltSystem(config.world.beltSpeedTilesPerSecond)
|
||||
{
|
||||
m_currentEnemyStationIds[0] = kInvalidEntityId;
|
||||
m_currentEnemyStationIds[1] = kInvalidEntityId;
|
||||
|
||||
m_buildingSystem = std::make_unique<BuildingSystem>(
|
||||
config,
|
||||
m_beltSystem,
|
||||
@@ -20,35 +32,303 @@ Simulation::Simulation(const GameConfig& config, unsigned int seed)
|
||||
m_rng);
|
||||
m_shipSystem = std::make_unique<ShipSystem>(config, [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;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tick
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void Simulation::tick()
|
||||
{
|
||||
m_buildingSystem->tickConstruction(m_currentTick);
|
||||
m_buildingSystem->tickBeltPull(); // tick order step 3
|
||||
m_buildingSystem->tickProduction(m_currentTick); // step 4
|
||||
m_buildingSystem->tickBeltPush(); // step 5
|
||||
m_beltSystem.tick(); // step 6
|
||||
// Step 1: wave scheduler
|
||||
m_waveSystem->tickWaveScheduler(m_currentTick, *m_shipSystem,
|
||||
m_config.world.heightTiles);
|
||||
|
||||
// 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->tickHomeReturn(); // priority 4
|
||||
m_shipSystem->tickThreatResponse(*m_buildingSystem); // priority 3
|
||||
m_shipSystem->tickRepairBehavior(*m_buildingSystem); // priority 2
|
||||
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_scrapSystem->tickDespawn(m_currentTick); // step 11
|
||||
// Step 11: scrap despawn
|
||||
m_scrapSystem->tickDespawn(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> result;
|
||||
@@ -63,6 +343,10 @@ std::vector<BlueprintDropEvent> Simulation::drainBlueprintDropEvents()
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accessors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Tick Simulation::currentTick() const
|
||||
{
|
||||
return m_currentTick;
|
||||
@@ -73,6 +357,38 @@ int Simulation::buildingBlocksStock() const
|
||||
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()
|
||||
{
|
||||
return *m_buildingSystem;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "BeltSystem.h"
|
||||
@@ -12,8 +14,10 @@
|
||||
#include "Tick.h"
|
||||
|
||||
class BuildingSystem;
|
||||
class CombatSystem;
|
||||
class ShipSystem;
|
||||
class ScrapSystem;
|
||||
class WaveSystem;
|
||||
|
||||
class Simulation
|
||||
{
|
||||
@@ -31,8 +35,14 @@ public:
|
||||
// Returns all blueprint drop events since the last drain.
|
||||
std::vector<BlueprintDropEvent> drainBlueprintDropEvents();
|
||||
|
||||
Tick currentTick() const;
|
||||
int buildingBlocksStock() const;
|
||||
Tick currentTick() 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();
|
||||
const BuildingSystem& buildings() const;
|
||||
@@ -46,17 +56,47 @@ public:
|
||||
private:
|
||||
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;
|
||||
std::mt19937 m_rng;
|
||||
|
||||
Tick m_currentTick;
|
||||
EntityId m_nextId;
|
||||
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;
|
||||
std::unique_ptr<BuildingSystem> m_buildingSystem;
|
||||
std::unique_ptr<ShipSystem> m_shipSystem;
|
||||
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<BlueprintDropEvent> m_blueprintDropEvents;
|
||||
|
||||
191
src/lib/sim/WaveSystem.cpp
Normal file
191
src/lib/sim/WaveSystem.cpp
Normal 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
67
src/lib/sim/WaveSystem.h
Normal 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;
|
||||
};
|
||||
@@ -12,4 +12,6 @@ add_files(
|
||||
ShipTest.cpp
|
||||
ScrapTest.cpp
|
||||
BehaviorSystemTest.cpp
|
||||
WaveSystemTest.cpp
|
||||
CombatSystemTest.cpp
|
||||
)
|
||||
|
||||
351
src/test/CombatSystemTest.cpp
Normal file
351
src/test/CombatSystemTest.cpp
Normal 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());
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
#include "catch.hpp"
|
||||
|
||||
#include "ConfigLoader.h"
|
||||
#include "GameConfig.h"
|
||||
#include "Simulation.h"
|
||||
#include "Tick.h"
|
||||
#include "TickDriver.h"
|
||||
|
||||
static GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simulation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("Simulation::currentTick starts at 0", "[simulation]")
|
||||
{
|
||||
const GameConfig config;
|
||||
const GameConfig config = loadConfig();
|
||||
const Simulation sim(config);
|
||||
|
||||
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]")
|
||||
{
|
||||
const GameConfig config;
|
||||
const GameConfig config = loadConfig();
|
||||
Simulation sim(config);
|
||||
|
||||
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]")
|
||||
{
|
||||
const GameConfig config;
|
||||
const GameConfig config = loadConfig();
|
||||
Simulation sim(config);
|
||||
|
||||
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]")
|
||||
{
|
||||
const GameConfig config;
|
||||
const GameConfig config = loadConfig();
|
||||
Simulation sim(config);
|
||||
|
||||
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]")
|
||||
{
|
||||
const GameConfig config;
|
||||
const GameConfig config = loadConfig();
|
||||
Simulation sim(config);
|
||||
|
||||
// First drain: empty.
|
||||
@@ -62,7 +68,7 @@ TEST_CASE("Simulation::drainFireEvents clears queue on drain", "[simulation]")
|
||||
|
||||
TEST_CASE("Simulation::drainBlueprintDropEvents returns empty initially", "[simulation]")
|
||||
{
|
||||
const GameConfig config;
|
||||
const GameConfig config = loadConfig();
|
||||
Simulation sim(config);
|
||||
|
||||
REQUIRE(sim.drainBlueprintDropEvents().empty());
|
||||
|
||||
357
src/test/WaveSystemTest.cpp
Normal file
357
src/test/WaveSystemTest.cpp
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user