diff --git a/docs/plan.md b/docs/plan.md index a9526ed..82733a1 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -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): diff --git a/src/lib/sim/Building.h b/src/lib/sim/Building.h index 84af0bb..43833d8 100644 --- a/src/lib/sim/Building.h +++ b/src/lib/sim/Building.h @@ -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 currentTarget; +}; + // A fully constructed, operational building. struct Building { @@ -73,4 +84,7 @@ struct Building std::vector outputPorts; std::vector inputPorts; // perimeter tiles (minus output-port tiles), // direction pointing INTO building + + // Set only for defence stations; nullopt for all other building types. + std::optional weapon; }; diff --git a/src/lib/sim/BuildingSystem.cpp b/src/lib/sim/BuildingSystem.cpp index c0209ef..da15b97 100644 --- a/src/lib/sim/BuildingSystem.cpp +++ b/src/lib/sim/BuildingSystem.cpp @@ -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& 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::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 fn) +{ + for (Building& b : m_buildings) + { + fn(b); + } +} diff --git a/src/lib/sim/BuildingSystem.h b/src/lib/sim/BuildingSystem.h index 7e08c05..f4c1623 100644 --- a/src/lib/sim/BuildingSystem.h +++ b/src/lib/sim/BuildingSystem.h @@ -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& 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 fn); + private: struct BeltEntry { diff --git a/src/lib/sim/CMakeLists.txt b/src/lib/sim/CMakeLists.txt index 339ee67..d7f75a5 100644 --- a/src/lib/sim/CMakeLists.txt +++ b/src/lib/sim/CMakeLists.txt @@ -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 ) diff --git a/src/lib/sim/CombatSystem.cpp b/src/lib/sim/CombatSystem.cpp new file mode 100644 index 0000000..b1cb40d --- /dev/null +++ b/src/lib/sim/CombatSystem.cpp @@ -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& 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& 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 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(kTickRateHz) / w.fireRateHz; +} + +void CombatSystem::resolveStationWeapon(Building& station, Tick currentTick, + ShipSystem& ships, + BuildingSystem& buildings, + std::vector& 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 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 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(kTickRateHz) / w.fireRateHz; +} + +std::optional 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 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 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; +} diff --git a/src/lib/sim/CombatSystem.h b/src/lib/sim/CombatSystem.h new file mode 100644 index 0000000..5ba3e34 --- /dev/null +++ b/src/lib/sim/CombatSystem.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include + +#include + +#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& outFireEvents); + +private: + // Process one ship's weapon for this tick. + void resolveShipWeapon(Ship& ship, Tick currentTick, + ShipSystem& ships, + BuildingSystem& buildings, + std::vector& out); + + // Process one defence-station's weapon for this tick. + void resolveStationWeapon(Building& station, Tick currentTick, + ShipSystem& ships, + BuildingSystem& buildings, + std::vector& 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 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 targetPosition(EntityId id, + const ShipSystem& ships, + const BuildingSystem& buildings) const; + + const GameConfig& m_config; +}; diff --git a/src/lib/sim/ShipSystem.cpp b/src/lib/sim/ShipSystem.cpp index fd11cab..b6c7f8a 100644 --- a/src/lib/sim/ShipSystem.cpp +++ b/src/lib/sim/ShipSystem.cpp @@ -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 // --------------------------------------------------------------------------- diff --git a/src/lib/sim/ShipSystem.h b/src/lib/sim/ShipSystem.h index fb56cb9..0a96f0b 100644 --- a/src/lib/sim/ShipSystem.h +++ b/src/lib/sim/ShipSystem.h @@ -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; diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index 53c8f62..a17065c 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -1,8 +1,13 @@ #include "Simulation.h" +#include + #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( config, m_beltSystem, @@ -20,35 +32,303 @@ Simulation::Simulation(const GameConfig& config, unsigned int seed) m_rng); m_shipSystem = std::make_unique(config, [this]() { return allocateId(); }); m_scrapSystem = std::make_unique([this]() { return allocateId(); }); + m_waveSystem = std::make_unique(config, m_rng); + m_combatSystem = std::make_unique(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(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(m_config.stations.playerStation.level); + const float psHp = static_cast( + m_config.stations.playerStation.hpFormula.evaluate(psLevel)); + + StationWeapon psWeapon; + psWeapon.damage = static_cast( + m_config.stations.playerStation.damageFormula.evaluate(psLevel)); + psWeapon.range = static_cast( + m_config.stations.playerStation.rangeFormula.evaluate(psLevel)); + psWeapon.fireRateHz = static_cast( + 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(generation); + const float esHp = static_cast( + m_config.stations.enemyStation.hpFormula.evaluate(genD)); + + StationWeapon esWeapon; + esWeapon.damage = static_cast( + m_config.stations.enemyStation.damageFormula.evaluate(genD)); + esWeapon.range = static_cast( + m_config.stations.enemyStation.rangeFormula.evaluate(genD)); + esWeapon.fireRateHz = static_cast( + 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 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 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( + m_config.stations.playerStation.level); + scrap = static_cast( + m_config.stations.playerStation.scrapDropFormula.evaluate(lv)); + } + else if (b->type == BuildingType::EnemyDefenceStation) + { + const double genD = static_cast(m_waveSystem->generation()); + scrap = static_cast( + 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 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 dist(0, static_cast(ids.size()) - 1); + const std::string chosen = ids[static_cast(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 Simulation::drainFireEvents() { std::vector result; @@ -63,6 +343,10 @@ std::vector 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::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::const_iterator it = + m_blueprintLevels.find(shipId); + if (it == m_blueprintLevels.end()) + { + return false; + } + return it->second.unlocked; +} + BuildingSystem& Simulation::buildings() { return *m_buildingSystem; diff --git a/src/lib/sim/Simulation.h b/src/lib/sim/Simulation.h index f3e135d..cbc4d7d 100644 --- a/src/lib/sim/Simulation.h +++ b/src/lib/sim/Simulation.h @@ -1,7 +1,9 @@ #pragma once +#include #include #include +#include #include #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 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 m_blueprintLevels; BeltSystem m_beltSystem; std::unique_ptr m_buildingSystem; std::unique_ptr m_shipSystem; std::unique_ptr m_scrapSystem; + std::unique_ptr m_waveSystem; + std::unique_ptr m_combatSystem; std::vector m_fireEvents; std::vector m_blueprintDropEvents; diff --git a/src/lib/sim/WaveSystem.cpp b/src/lib/sim/WaveSystem.cpp new file mode 100644 index 0000000..93413d9 --- /dev/null +++ b/src/lib/sim/WaveSystem.cpp @@ -0,0 +1,191 @@ +#include "WaveSystem.h" + +#include + +#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 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(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::composeWave(Tick currentTick, + int worldHeightTiles) +{ + const double elapsedSeconds = static_cast(currentTick) * kTickDurationSeconds; + const int shipLevel = std::max(1, static_cast( + 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 eligible; + for (const ShipDef& def : m_config.ships.ships) + { + const double cost = def.threat.costFormula.evaluate(static_cast(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( + m_config.world.regions.playerBufferWidth + + m_config.world.regions.contestZoneWidth + + m_generation * m_config.world.push.pushExpandColumns); + const float rightX = leftX + static_cast(m_config.world.regions.enemyBufferWidth) - 1.0f; + + std::uniform_real_distribution xDist(leftX, rightX); + std::uniform_int_distribution yDist(0, worldHeightTiles - 1); + + std::vector picked; + + while (true) + { + // Collect indices of ships whose cost fits the remaining budget. + std::vector 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 pick(0, static_cast(fitting.size()) - 1); + const std::size_t chosenIdx = fitting[static_cast(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(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(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(i)].spawnAt = + currentTick + static_cast(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 dist(minTicks, maxTicks); + return dist(m_rng); +} diff --git a/src/lib/sim/WaveSystem.h b/src/lib/sim/WaveSystem.h new file mode 100644 index 0000000..3304bfd --- /dev/null +++ b/src/lib/sim/WaveSystem.h @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include + +#include + +#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 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 m_pendingSpawns; +}; diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index 2828916..88b5f3f 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -12,4 +12,6 @@ add_files( ShipTest.cpp ScrapTest.cpp BehaviorSystemTest.cpp + WaveSystemTest.cpp + CombatSystemTest.cpp ) diff --git a/src/test/CombatSystemTest.cpp b/src/test/CombatSystemTest.cpp new file mode 100644 index 0000000..6775bc0 --- /dev/null +++ b/src/test/CombatSystemTest.cpp @@ -0,0 +1,351 @@ +#include "catch.hpp" + +#include + +#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 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 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 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 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 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()); +} diff --git a/src/test/SimulationTest.cpp b/src/test/SimulationTest.cpp index 0b45c7f..dd7fc2c 100644 --- a/src/test/SimulationTest.cpp +++ b/src/test/SimulationTest.cpp @@ -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()); diff --git a/src/test/WaveSystemTest.cpp b/src/test/WaveSystemTest.cpp new file mode 100644 index 0000000..ad4ca76 --- /dev/null +++ b/src/test/WaveSystemTest.cpp @@ -0,0 +1,357 @@ +#include "catch.hpp" + +#include + +#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(secondsToTicks(30.0)); + for (int i = 0; i < ticks30s; ++i) + { + ws.tickThreatAccumulation(static_cast(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(secondsToTicks(31.0)); + for (int i = 0; i < ticks31s; ++i) + { + ws.tickThreatAccumulation(static_cast(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(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::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(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(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 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 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::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::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); +}