DeconstructionSystem takes over the demolition queue: it runs the front entry's timer and, when it elapses, removes the building, releases its tiles and credits the partial refund. Simulation::tick calls it directly, in the position tickDeconstruction held. It needs no BeltSystem, unlike its construction counterpart: a belt, splitter or tunnel end is unregistered the moment it is queued, not when the timer completes. It does need the refund sink, so it takes the same addBuildingBlocks callback BuildingSystem holds. startFrontDeconstruction becomes a shared free function rather than moving: BuildingSystem::deconstruct starts the timer when it queues the first entry, and the system restarts it after each completion. Stubbing the refund sink out in the test helper made two tests fail on the refund not arriving — correctly. runTicks now threads the caller's stock through instead. Verified with a golden-checksum capture before and after — all four sample ticks identical. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
964 lines
32 KiB
C++
964 lines
32 KiB
C++
#include "Simulation.h"
|
|
|
|
#include "FactoryQueries.h"
|
|
#include "ConstructionSystem.h"
|
|
#include "DeconstructionSystem.h"
|
|
#include "PlacementRules.h"
|
|
|
|
#include <algorithm>
|
|
#include <cassert>
|
|
#include <cmath>
|
|
|
|
#include "AiSystem.h"
|
|
#include "Command.h"
|
|
#include "BuildingSystem.h"
|
|
#include "CombatSystem.h"
|
|
#include "DynamicBodyComponent.h"
|
|
#include "DynamicBodySystem.h"
|
|
#include "FacingComponent.h"
|
|
#include "FactionComponent.h"
|
|
#include "EventManager.h"
|
|
#include "HealthComponent.h"
|
|
#include "ModuleOwnerComponent.h"
|
|
#include "MovementIntentSystem.h"
|
|
#include "PositionComponent.h"
|
|
#include "RepairSystem.h"
|
|
#include "SalvagerSystem.h"
|
|
#include "DebrisComponent.h"
|
|
#include "DebrisSystem.h"
|
|
#include "ShipIdentityComponent.h"
|
|
#include "ShipSystem.h"
|
|
#include "StateChecksum.h"
|
|
#include "StationBodyComponent.h"
|
|
#include "SurfaceMask.h"
|
|
#include "tracing.h"
|
|
#include "WaveSystem.h"
|
|
#include "WeaponComponent.h"
|
|
|
|
Simulation::Simulation(GameConfig config, unsigned int seed)
|
|
: m_config(std::move(config))
|
|
, m_rng(seed)
|
|
, m_seed(seed)
|
|
, m_currentTick(0)
|
|
, m_nextDepartureTick(secondsToTicks(m_config.world.departureIntervalSeconds))
|
|
, m_nextBuildingId(1)
|
|
, m_buildingBlocksStock(m_config.world.startingBuildingBlocks)
|
|
, m_gameOver(false)
|
|
, m_hqProxyEntity(entt::null)
|
|
, m_playerStation1Entity(entt::null)
|
|
, m_playerStation2Entity(entt::null)
|
|
, m_unlockState(m_config)
|
|
, m_beltSystem(m_config.world.beltSpeed_tps)
|
|
{
|
|
m_currentEnemyStationEntities[0] = entt::null;
|
|
m_currentEnemyStationEntities[1] = entt::null;
|
|
m_factoryState = makeFactoryState(m_config);
|
|
|
|
initializeSubsystems();
|
|
|
|
m_unlockState.initializeUnlockState();
|
|
placeInitialStructures();
|
|
registerForEvents();
|
|
}
|
|
|
|
Simulation::~Simulation()
|
|
{
|
|
unregisterForEvents();
|
|
}
|
|
|
|
const GameConfig& Simulation::getConfig() const
|
|
{
|
|
return m_config;
|
|
}
|
|
|
|
void Simulation::reset(GameConfig newConfig, unsigned int seed)
|
|
{
|
|
m_config = std::move(newConfig);
|
|
reset(seed);
|
|
}
|
|
|
|
void Simulation::reset(unsigned int seed)
|
|
{
|
|
EventManager::getInstance()->clearEvents();
|
|
m_rng.seed(seed);
|
|
m_seed = seed;
|
|
m_currentTick = 0;
|
|
m_nextDepartureTick = secondsToTicks(m_config.world.departureIntervalSeconds);
|
|
m_nextBuildingId = 1;
|
|
m_buildingBlocksStock = m_config.world.startingBuildingBlocks;
|
|
m_expansionsPurchased = 0;
|
|
m_gameOver = false;
|
|
m_isWon = false;
|
|
m_artifactCount = 0;
|
|
m_hqBuildingId = std::nullopt;
|
|
m_hqProxyEntity = entt::null;
|
|
m_playerStation1Entity = entt::null;
|
|
m_playerStation2Entity = entt::null;
|
|
m_currentEnemyStationEntities[0] = entt::null;
|
|
m_currentEnemyStationEntities[1] = entt::null;
|
|
m_beamFiredEvents.clear();
|
|
m_pendingSchematicChoices.clear();
|
|
|
|
m_admin.clear();
|
|
m_factoryState = makeFactoryState(m_config);
|
|
m_beltSystem = BeltSystem(m_config.world.beltSpeed_tps);
|
|
initializeSubsystems();
|
|
|
|
m_unlockState.initializeUnlockState();
|
|
placeInitialStructures();
|
|
}
|
|
|
|
void Simulation::initializeSubsystems()
|
|
{
|
|
m_buildingSystem = std::make_unique<BuildingSystem>(
|
|
m_config,
|
|
m_beltSystem,
|
|
[this]() { return allocateBuildingId(); },
|
|
[this](int amount) { m_buildingBlocksStock += amount; },
|
|
[this](const std::string& id, QVector2D pos,
|
|
const std::optional<ShipLayoutConfig>& layout) {
|
|
if (!isSchematicUnlocked(id))
|
|
{
|
|
return;
|
|
}
|
|
m_shipSystem->spawn(id, pos, /*isEnemy=*/false, layout);
|
|
},
|
|
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
|
|
m_rng);
|
|
m_constructionSystem = std::make_unique<ConstructionSystem>(m_config);
|
|
m_deconstructionSystem = std::make_unique<DeconstructionSystem>(
|
|
m_config, [this](int amount) { m_buildingBlocksStock += amount; });
|
|
m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
|
|
m_aiSystem = std::make_unique<AiSystem>(m_config);
|
|
m_movementIntentSystem = std::make_unique<MovementIntentSystem>();
|
|
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
|
|
m_debrisSystem = std::make_unique<DebrisSystem>(m_admin);
|
|
m_salvagerSystem = std::make_unique<SalvagerSystem>(m_admin);
|
|
m_repairSystem = std::make_unique<RepairSystem>(m_admin);
|
|
m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng);
|
|
m_combatSystem = std::make_unique<CombatSystem>(m_config);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// tick
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void Simulation::apply(const Command& command)
|
|
{
|
|
switch (command.kind)
|
|
{
|
|
case CommandKind::PlaceBuilding:
|
|
{
|
|
const PlaceBuildingCommand& c = static_cast<const PlaceBuildingCommand&>(command);
|
|
const std::optional<BuildingId> placed = tryPlaceBuilding(c.type, c.anchor, c.rotation);
|
|
if (!placed.has_value())
|
|
{
|
|
break;
|
|
}
|
|
const BuildingId id = *placed;
|
|
if (c.recipeId.has_value())
|
|
{
|
|
m_buildingSystem->setRecipe(m_factoryState, id, *c.recipeId);
|
|
}
|
|
if (c.shipLayout.has_value())
|
|
{
|
|
m_buildingSystem->setShipLayout(m_factoryState, id, *c.shipLayout);
|
|
}
|
|
if (c.hasSplitterFilters)
|
|
{
|
|
m_buildingSystem->setSiteSplitterFilters(m_factoryState, id, c.splitterFilterA, c.splitterFilterB);
|
|
}
|
|
break;
|
|
}
|
|
case CommandKind::Deconstruct:
|
|
deconstruct(*static_cast<const DeconstructCommand&>(command).id);
|
|
break;
|
|
case CommandKind::CancelDeconstruction:
|
|
cancelDeconstruction(*static_cast<const CancelDeconstructionCommand&>(command).id);
|
|
break;
|
|
case CommandKind::RotateInPlace:
|
|
{
|
|
const RotateInPlaceCommand& c = static_cast<const RotateInPlaceCommand&>(command);
|
|
m_buildingSystem->rotateInPlace(m_factoryState, *c.id, c.newRotation);
|
|
break;
|
|
}
|
|
case CommandKind::SetRecipe:
|
|
{
|
|
const SetRecipeCommand& c = static_cast<const SetRecipeCommand&>(command);
|
|
m_buildingSystem->setRecipe(m_factoryState, *c.id, c.recipeId);
|
|
break;
|
|
}
|
|
case CommandKind::SetShipLayout:
|
|
{
|
|
const SetShipLayoutCommand& c = static_cast<const SetShipLayoutCommand&>(command);
|
|
m_buildingSystem->setShipLayout(m_factoryState, *c.id, c.layout);
|
|
break;
|
|
}
|
|
case CommandKind::SetSiteSplitterFilters:
|
|
{
|
|
const SetSiteSplitterFiltersCommand& c =
|
|
static_cast<const SetSiteSplitterFiltersCommand&>(command);
|
|
m_buildingSystem->setSiteSplitterFilters(m_factoryState, *c.id, c.filterA, c.filterB);
|
|
break;
|
|
}
|
|
case CommandKind::SetSplitterFilters:
|
|
{
|
|
const SetSplitterFiltersCommand& c =
|
|
static_cast<const SetSplitterFiltersCommand&>(command);
|
|
m_beltSystem.setSplitterFilters(c.tile, c.filterA, c.filterB);
|
|
break;
|
|
}
|
|
case CommandKind::ClearBeltTiles:
|
|
m_beltSystem.clearTiles(static_cast<const ClearBeltTilesCommand&>(command).tiles);
|
|
break;
|
|
case CommandKind::ApplySchematicChoice:
|
|
applySchematicChoice(static_cast<const ApplySchematicChoiceCommand&>(command).choiceIndex);
|
|
break;
|
|
case CommandKind::ExpandAsteroid:
|
|
tryExpandAsteroid();
|
|
break;
|
|
case CommandKind::Reset:
|
|
{
|
|
const ResetCommand& c = static_cast<const ResetCommand&>(command);
|
|
if (c.config)
|
|
{
|
|
// operator* on a const shared_ptr yields a mutable GameConfig&, so
|
|
// the move-only config moves into reset without a copy. The command
|
|
// is applied once, so leaving its config moved-from is fine.
|
|
reset(std::move(*c.config), c.seed);
|
|
}
|
|
else
|
|
{
|
|
reset(c.seed);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
void Simulation::tick()
|
|
{
|
|
EventManager::getInstance()->processEvents();
|
|
|
|
// Step 1: wave scheduler
|
|
m_waveSystem->tickWaveScheduler(m_currentTick, *m_shipSystem,
|
|
m_config.world.heightTiles);
|
|
|
|
// Step 2: threat accumulation
|
|
m_waveSystem->tickThreatAccumulation();
|
|
|
|
// Construction + production pipeline
|
|
m_constructionSystem->tick(m_factoryState, m_beltSystem, m_currentTick);
|
|
m_deconstructionSystem->tick(m_factoryState, m_currentTick); // parallel to construction
|
|
m_buildingSystem->tickBeltPull(m_factoryState); // step 3
|
|
m_buildingSystem->tickProduction(m_factoryState, m_currentTick); // step 4
|
|
m_buildingSystem->tickShipyardProduction(m_factoryState, m_currentTick); // step 4b
|
|
m_buildingSystem->tickOutputBelts(m_factoryState); // step 5
|
|
m_beltSystem.tick(); // step 6
|
|
|
|
// Step 7: ship behavior systems (movement arbitration via intent priority)
|
|
|
|
// Departure timer: release gathered combat ships on a fixed interval (REQ-SHP-RALLY).
|
|
if (m_currentTick >= m_nextDepartureTick)
|
|
{
|
|
m_shipSystem->triggerRallyDeparture();
|
|
m_nextDepartureTick += secondsToTicks(m_config.world.departureIntervalSeconds);
|
|
}
|
|
|
|
m_shipSystem->clearMovementIntents();
|
|
// Score-based behavior selection: evaluate, select winner, execute (sets
|
|
// movement intent + preferred module targets only — no world mutation).
|
|
m_aiSystem->tick(m_admin, m_factoryState, *m_debrisSystem);
|
|
// Module systems perform the world mutation (collection/delivery, healing).
|
|
// Each emits its tool beams and applies its own delayed (mid-beam) effects.
|
|
m_salvagerSystem->tick(m_currentTick, *m_debrisSystem, m_factoryState, m_beamFiredEvents);
|
|
m_repairSystem->tick(m_currentTick, m_beamFiredEvents);
|
|
|
|
// Step 8: combat resolution
|
|
m_combatSystem->tick(m_currentTick, m_admin, m_beamFiredEvents);
|
|
|
|
// Step 8b: deferred damage whose impact tick has arrived
|
|
m_combatSystem->applyPendingDamage(m_currentTick, m_admin);
|
|
|
|
// Step 9: deaths & loot
|
|
if (!m_gameOver)
|
|
{
|
|
tickDeathsAndLoot();
|
|
}
|
|
|
|
// Step 10: advance ship positions
|
|
m_movementIntentSystem->tick(m_admin);
|
|
m_dynamicBodySystem->tick(m_admin);
|
|
|
|
// Step 11: debris despawn
|
|
m_debrisSystem->tickDespawn(m_currentTick);
|
|
|
|
++m_currentTick;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pre-placement
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void Simulation::placeInitialStructures()
|
|
{
|
|
// HQ — right edge of asteroid (rightmost asteroid tile is x = -1).
|
|
// Placed as a Building (for belt input) plus an ECS proxy (for HP/targeting).
|
|
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_hqBuildingId = m_buildingSystem->placeImmediate(m_factoryState, BuildingType::Hq,
|
|
m_config.stations.hq.surfaceMask,
|
|
QPoint(hqAnchorX, hqAnchorY),
|
|
Rotation::East);
|
|
|
|
const QVector2D hqCenter(
|
|
hqAnchorX + hqParsed.footprint.width() / 2.0f,
|
|
hqAnchorY + hqParsed.footprint.height() / 2.0f);
|
|
m_hqProxyEntity = m_admin.spawnHqProxy(hqCenter, hqHp, hqHp);
|
|
|
|
// Player defence stations — ECS entities with tile occupancy.
|
|
const ParsedSurfaceMask psParsed =
|
|
parseSurfaceMask(m_config.stations.playerStation.surfaceMask, Rotation::East);
|
|
const int psAnchorX =
|
|
m_config.world.regions.playerBufferWidth_tiles - 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));
|
|
|
|
const float tileSize = static_cast<float>(m_config.world.tileSize_m);
|
|
|
|
WeaponComponent psWeapon;
|
|
psWeapon.damage = static_cast<float>(
|
|
m_config.stations.playerStation.damageFormula.evaluate(psLevel));
|
|
psWeapon.range_tiles = static_cast<float>(
|
|
m_config.stations.playerStation.rangeFormula.evaluate(psLevel)) / tileSize;
|
|
psWeapon.fireRateHz = static_cast<float>(
|
|
m_config.stations.playerStation.fireRateFormula.evaluate(psLevel));
|
|
psWeapon.cooldownTicks = 0.0f;
|
|
psWeapon.currentTarget = std::nullopt;
|
|
|
|
const int ps1Y = m_config.world.heightTiles / 4;
|
|
const int ps2Y = 3 * m_config.world.heightTiles / 4;
|
|
|
|
{
|
|
const QPoint anchor(psAnchorX, ps1Y);
|
|
std::vector<QPoint> absCells;
|
|
for (const QPoint& rel : psParsed.bodyCells)
|
|
{
|
|
absCells.push_back(QPoint(anchor.x() + rel.x(), anchor.y() + rel.y()));
|
|
}
|
|
m_playerStation1Entity = m_admin.spawnStation(
|
|
anchor, psParsed.footprint, absCells, psHp, psHp, false);
|
|
{
|
|
entt::entity wChild = m_admin.createModuleEntity();
|
|
m_admin.addComponent<WeaponComponent>(wChild, psWeapon);
|
|
m_admin.addComponent<ModuleOwnerComponent>(wChild,
|
|
ModuleOwnerComponent{m_playerStation1Entity});
|
|
}
|
|
m_buildingSystem->registerTileOccupancy(m_factoryState, absCells, allocateBuildingId());
|
|
}
|
|
{
|
|
const QPoint anchor(psAnchorX, ps2Y);
|
|
std::vector<QPoint> absCells;
|
|
for (const QPoint& rel : psParsed.bodyCells)
|
|
{
|
|
absCells.push_back(QPoint(anchor.x() + rel.x(), anchor.y() + rel.y()));
|
|
}
|
|
m_playerStation2Entity = m_admin.spawnStation(
|
|
anchor, psParsed.footprint, absCells, psHp, psHp, false);
|
|
{
|
|
entt::entity wChild = m_admin.createModuleEntity();
|
|
m_admin.addComponent<WeaponComponent>(wChild, psWeapon);
|
|
m_admin.addComponent<ModuleOwnerComponent>(wChild,
|
|
ModuleOwnerComponent{m_playerStation2Entity});
|
|
}
|
|
m_buildingSystem->registerTileOccupancy(m_factoryState, absCells, allocateBuildingId());
|
|
}
|
|
|
|
// Rally point: center of the player defence stations' X column, world vertical midpoint.
|
|
const float rallyX = static_cast<float>(psAnchorX) + psParsed.footprint.width() / 2.0f;
|
|
const float rallyY = static_cast<float>(m_config.world.heightTiles) / 2.0f;
|
|
m_shipSystem->setRallyPoint(QVector2D(rallyX, rallyY));
|
|
|
|
// Enemy defence stations — generation 0 (initial set).
|
|
placeEnemyStationSet(0);
|
|
}
|
|
|
|
void Simulation::placeEnemyStationSet(int generation)
|
|
{
|
|
const float tileSize = static_cast<float>(m_config.world.tileSize_m);
|
|
const ParsedSurfaceMask esParsed =
|
|
parseSurfaceMask(m_config.stations.enemyStation.surfaceMask, Rotation::East);
|
|
|
|
const int rightEdgeX = m_config.world.regions.playerBufferWidth_tiles
|
|
+ m_config.world.regions.contestZoneWidth_tiles
|
|
+ generation * m_config.world.push.pushExpandColumns_tiles;
|
|
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));
|
|
|
|
WeaponComponent esWeapon;
|
|
esWeapon.damage = static_cast<float>(
|
|
m_config.stations.enemyStation.damageFormula.evaluate(genD));
|
|
esWeapon.range_tiles = static_cast<float>(
|
|
m_config.stations.enemyStation.rangeFormula.evaluate(genD)) / tileSize;
|
|
esWeapon.fireRateHz = static_cast<float>(
|
|
m_config.stations.enemyStation.fireRateFormula.evaluate(genD));
|
|
esWeapon.cooldownTicks = 0.0f;
|
|
esWeapon.currentTarget = std::nullopt;
|
|
|
|
const int y1 = m_config.world.heightTiles / 4;
|
|
const int y2 = 3 * m_config.world.heightTiles / 4;
|
|
|
|
{
|
|
const QPoint anchor(anchorX, y1);
|
|
std::vector<QPoint> absCells;
|
|
for (const QPoint& rel : esParsed.bodyCells)
|
|
{
|
|
absCells.push_back(QPoint(anchor.x() + rel.x(), anchor.y() + rel.y()));
|
|
}
|
|
m_currentEnemyStationEntities[0] = m_admin.spawnStation(
|
|
anchor, esParsed.footprint, absCells, esHp, esHp, true);
|
|
{
|
|
entt::entity wChild = m_admin.createModuleEntity();
|
|
m_admin.addComponent<WeaponComponent>(wChild, esWeapon);
|
|
m_admin.addComponent<ModuleOwnerComponent>(wChild,
|
|
ModuleOwnerComponent{m_currentEnemyStationEntities[0]});
|
|
}
|
|
m_buildingSystem->registerTileOccupancy(m_factoryState, absCells, allocateBuildingId());
|
|
}
|
|
{
|
|
const QPoint anchor(anchorX, y2);
|
|
std::vector<QPoint> absCells;
|
|
for (const QPoint& rel : esParsed.bodyCells)
|
|
{
|
|
absCells.push_back(QPoint(anchor.x() + rel.x(), anchor.y() + rel.y()));
|
|
}
|
|
m_currentEnemyStationEntities[1] = m_admin.spawnStation(
|
|
anchor, esParsed.footprint, absCells, esHp, esHp, true);
|
|
{
|
|
entt::entity wChild = m_admin.createModuleEntity();
|
|
m_admin.addComponent<WeaponComponent>(wChild, esWeapon);
|
|
m_admin.addComponent<ModuleOwnerComponent>(wChild,
|
|
ModuleOwnerComponent{m_currentEnemyStationEntities[1]});
|
|
}
|
|
m_buildingSystem->registerTileOccupancy(m_factoryState, absCells, allocateBuildingId());
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Deaths & loot (tick step 9)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void Simulation::tickDeathsAndLoot()
|
|
{
|
|
TRACE();
|
|
// --- Dead ships ---
|
|
std::vector<entt::entity> deadShips;
|
|
m_admin.forEach<ShipIdentityComponent, HealthComponent>(
|
|
[&deadShips](entt::entity e, const ShipIdentityComponent& /*si*/,
|
|
const HealthComponent& h)
|
|
{
|
|
if (h.hp <= 0.0f)
|
|
{
|
|
deadShips.push_back(e);
|
|
}
|
|
});
|
|
|
|
for (entt::entity deadEntity : deadShips)
|
|
{
|
|
const ShipIdentityComponent& si = m_admin.get<ShipIdentityComponent>(deadEntity);
|
|
const PositionComponent& pos = m_admin.get<PositionComponent>(deadEntity);
|
|
if (si.scrapDrop > 0)
|
|
{
|
|
const Tick despawnAt = m_currentTick
|
|
+ secondsToTicks(m_config.world.debrisDespawnSeconds);
|
|
m_debrisSystem->spawn(pos.value, si.scrapDrop, despawnAt);
|
|
}
|
|
m_shipSystem->despawn(deadEntity);
|
|
}
|
|
|
|
// --- Dead stations ---
|
|
std::vector<entt::entity> deadStations;
|
|
m_admin.forEach<StationBodyComponent, HealthComponent>(
|
|
[&deadStations](entt::entity e, const StationBodyComponent& /*sb*/,
|
|
const HealthComponent& h)
|
|
{
|
|
if (h.hp <= 0.0f)
|
|
{
|
|
deadStations.push_back(e);
|
|
}
|
|
});
|
|
|
|
for (entt::entity deadEntity : deadStations)
|
|
{
|
|
const StationBodyComponent& sb = m_admin.get<StationBodyComponent>(deadEntity);
|
|
const PositionComponent& pos = m_admin.get<PositionComponent>(deadEntity);
|
|
const FactionComponent& fac = m_admin.get<FactionComponent>(deadEntity);
|
|
|
|
const Tick despawnAt = m_currentTick
|
|
+ secondsToTicks(m_config.world.debrisDespawnSeconds);
|
|
int scrap = 0;
|
|
if (!fac.isEnemy)
|
|
{
|
|
const double lv = static_cast<double>(
|
|
m_config.stations.playerStation.level);
|
|
scrap = static_cast<int>(
|
|
m_config.stations.playerStation.scrapDropFormula.evaluate(lv));
|
|
}
|
|
else
|
|
{
|
|
const double genD = static_cast<double>(m_waveSystem->getGeneration());
|
|
scrap = static_cast<int>(
|
|
m_config.stations.enemyStation.scrapDropFormula.evaluate(genD));
|
|
}
|
|
if (scrap > 0)
|
|
{
|
|
m_debrisSystem->spawn(pos.value, scrap, despawnAt);
|
|
}
|
|
m_buildingSystem->unregisterTileOccupancy(m_factoryState, sb.bodyCells);
|
|
{
|
|
std::vector<entt::entity> stationChildren;
|
|
m_admin.forEach<ModuleOwnerComponent>(
|
|
[&](entt::entity ce, const ModuleOwnerComponent& o)
|
|
{
|
|
if (o.owner == deadEntity) { stationChildren.push_back(ce); }
|
|
});
|
|
for (entt::entity ce : stationChildren) { m_admin.destroy(ce); }
|
|
}
|
|
m_admin.destroy(deadEntity);
|
|
}
|
|
|
|
// --- HQ death check ---
|
|
if (m_admin.isValid(m_hqProxyEntity))
|
|
{
|
|
const HealthComponent& hqHealth = m_admin.get<HealthComponent>(m_hqProxyEntity);
|
|
if (hqHealth.hp <= 0.0f)
|
|
{
|
|
m_gameOver = true;
|
|
}
|
|
}
|
|
|
|
// --- Push check: if both current enemy stations are gone, trigger push ---
|
|
const bool es0Gone = !m_admin.isValid(m_currentEnemyStationEntities[0])
|
|
|| m_admin.get<HealthComponent>(m_currentEnemyStationEntities[0]).hp <= 0.0f;
|
|
const bool es1Gone = !m_admin.isValid(m_currentEnemyStationEntities[1])
|
|
|| m_admin.get<HealthComponent>(m_currentEnemyStationEntities[1]).hp <= 0.0f;
|
|
|
|
if (es0Gone && es1Gone &&
|
|
m_currentEnemyStationEntities[0] != entt::null)
|
|
{
|
|
const int destroyedLevel = m_waveSystem->getGeneration();
|
|
m_waveSystem->onEnemyStationsDestroyed();
|
|
placeEnemyStationSet(m_waveSystem->getGeneration());
|
|
generateSchematicChoices(destroyedLevel);
|
|
}
|
|
}
|
|
|
|
void Simulation::generateSchematicChoices(int destroyedStationLevel)
|
|
{
|
|
// Build the eligible pool of unlock groups (REQ-DEF-SCHEMATIC-DROP,
|
|
// REQ-LOCK-EXPLICIT, REQ-LOCK-PREREQ): not yet awarded, station level in
|
|
// range, and every prerequisite group already awarded.
|
|
std::vector<const UnlockGroupDef*> pool;
|
|
for (const UnlockGroupDef& group : m_config.unlocks.groups)
|
|
{
|
|
if (m_unlockState.isUnlockGroupAwarded(group.id)) { continue; }
|
|
if (group.stationLevel < 0 || group.stationLevel > destroyedStationLevel) { continue; }
|
|
if (!m_unlockState.prerequisitesSatisfied(group.requiredGroupIds)) { continue; }
|
|
pool.push_back(&group);
|
|
}
|
|
|
|
if (pool.empty()) { return; }
|
|
|
|
const double artifactChance = std::clamp(
|
|
m_config.world.artifacts.artifactChanceFormula.evaluate(
|
|
static_cast<double>(destroyedStationLevel)),
|
|
0.0, 1.0);
|
|
std::uniform_real_distribution<double> realDist(0.0, 1.0);
|
|
const bool artifactRolled = realDist(m_rng) < artifactChance;
|
|
|
|
const int numChoices = std::min(static_cast<int>(pool.size()), artifactRolled ? 2 : 3);
|
|
m_pendingSchematicChoices.clear();
|
|
|
|
for (int i = 0; i < numChoices; ++i)
|
|
{
|
|
std::uniform_int_distribution<int> dist(0, static_cast<int>(pool.size()) - 1 - i);
|
|
const int roll = dist(m_rng);
|
|
const std::size_t rollIdx = static_cast<std::size_t>(roll);
|
|
const std::size_t endIdx = pool.size() - 1 - static_cast<std::size_t>(i);
|
|
std::swap(pool[rollIdx], pool[endIdx]);
|
|
|
|
m_pendingSchematicChoices.push_back(m_unlockState.makeUnlockOption(*pool[endIdx]));
|
|
}
|
|
|
|
if (artifactRolled)
|
|
{
|
|
SchematicChoiceOption artifactOption;
|
|
artifactOption.isArtifact = true;
|
|
artifactOption.displayName = "Artifact";
|
|
m_pendingSchematicChoices.push_back(std::move(artifactOption));
|
|
}
|
|
}
|
|
|
|
void Simulation::applySchematicChoice(int choiceIndex)
|
|
{
|
|
assert(choiceIndex >= 0 && choiceIndex < static_cast<int>(m_pendingSchematicChoices.size()));
|
|
const SchematicChoiceOption& chosen = m_pendingSchematicChoices[static_cast<std::size_t>(choiceIndex)];
|
|
|
|
if (chosen.isArtifact)
|
|
{
|
|
m_artifactCount += 1;
|
|
if (m_artifactCount >= m_config.world.artifacts.artifactWinCount)
|
|
{
|
|
m_isWon = true;
|
|
}
|
|
m_pendingSchematicChoices.clear();
|
|
return;
|
|
}
|
|
|
|
m_unlockState.awardUnlockGroup(chosen);
|
|
m_pendingSchematicChoices.clear();
|
|
}
|
|
|
|
bool Simulation::isRecipeUnlocked(const std::string& recipeId) const
|
|
{
|
|
return m_unlockState.isRecipeUnlocked(recipeId);
|
|
}
|
|
|
|
bool Simulation::isItemUnlocked(const std::string& itemId) const
|
|
{
|
|
return m_unlockState.isItemUnlocked(itemId);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Determinism (see docs/replay_design.md)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
unsigned long long Simulation::getRngFingerprint() const
|
|
{
|
|
return fingerprintRng(m_rng);
|
|
}
|
|
|
|
unsigned long long Simulation::computeStateChecksum() const
|
|
{
|
|
Hasher hasher;
|
|
|
|
// RNG stream — the most sensitive signal of divergence.
|
|
hasher.append(fingerprintRng(m_rng));
|
|
|
|
// Top-level scalars.
|
|
hasher.append(m_currentTick);
|
|
hasher.append(m_nextDepartureTick);
|
|
hasher.append(m_nextBuildingId);
|
|
hasher.append(m_buildingBlocksStock);
|
|
hasher.append(m_gameOver);
|
|
hasher.append(m_isWon);
|
|
hasher.append(m_artifactCount);
|
|
hasher.append(m_expansionsPurchased);
|
|
|
|
// WaveSystem scalar state, reached through existing accessors.
|
|
hasher.append(getThreatLevel());
|
|
hasher.append(getThreatAccumulationRate());
|
|
hasher.append(getBossWaveCounter());
|
|
hasher.append(getBossCountdownTicks());
|
|
hasher.append(getNormalGapRemainingTicks());
|
|
|
|
// Schematic / unlock state (std::map and std::set iterate in sorted order).
|
|
m_unlockState.appendChecksum(hasher);
|
|
|
|
// Subsystems contribute their own state.
|
|
m_buildingSystem->appendChecksum(m_factoryState, hasher);
|
|
m_beltSystem.appendChecksum(hasher);
|
|
|
|
// ECS component state. View iteration order is a pure function of the
|
|
// (identical) operation sequence on a fixed binary; each entity's raw id is
|
|
// folded in so the fingerprint is keyed, not merely a sum of fields.
|
|
m_admin.forEach<PositionComponent>(
|
|
[&hasher](entt::entity entity, const PositionComponent& c)
|
|
{
|
|
hasher.append(static_cast<std::uint32_t>(entity));
|
|
hasher.append(c.value);
|
|
});
|
|
m_admin.forEach<HealthComponent>(
|
|
[&hasher](entt::entity entity, const HealthComponent& c)
|
|
{
|
|
hasher.append(static_cast<std::uint32_t>(entity));
|
|
hasher.append(c.hp);
|
|
hasher.append(c.maxHp);
|
|
});
|
|
m_admin.forEach<FacingComponent>(
|
|
[&hasher](entt::entity entity, const FacingComponent& c)
|
|
{
|
|
hasher.append(static_cast<std::uint32_t>(entity));
|
|
hasher.append(c.radians);
|
|
});
|
|
m_admin.forEach<DynamicBodyComponent>(
|
|
[&hasher](entt::entity entity, const DynamicBodyComponent& c)
|
|
{
|
|
hasher.append(static_cast<std::uint32_t>(entity));
|
|
hasher.append(c.velocity_tpt);
|
|
hasher.append(c.angularVelocity_rpt);
|
|
hasher.append(c.linearAcceleration_tptt);
|
|
hasher.append(c.angularAcceleration_rptt);
|
|
});
|
|
m_admin.forEach<DebrisComponent>(
|
|
[&hasher](entt::entity entity, const DebrisComponent& c)
|
|
{
|
|
hasher.append(static_cast<std::uint32_t>(entity));
|
|
hasher.append(c.amount);
|
|
});
|
|
m_admin.forEach<ShipIdentityComponent>(
|
|
[&hasher](entt::entity entity, const ShipIdentityComponent& c)
|
|
{
|
|
hasher.append(static_cast<std::uint32_t>(entity));
|
|
hasher.append(c.schematicId);
|
|
});
|
|
|
|
return hasher.getValue();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Drains
|
|
// ---------------------------------------------------------------------------
|
|
|
|
std::vector<BeamFiredEvent> Simulation::drainBeamFiredEvents()
|
|
{
|
|
std::vector<BeamFiredEvent> result;
|
|
result.swap(m_beamFiredEvents);
|
|
return result;
|
|
}
|
|
|
|
const std::vector<SchematicChoiceOption>& Simulation::getPendingSchematicChoices() const
|
|
{
|
|
return m_pendingSchematicChoices;
|
|
}
|
|
|
|
bool Simulation::hasSchematicChoicesPending() const
|
|
{
|
|
return !m_pendingSchematicChoices.empty();
|
|
}
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Accessors
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Tick Simulation::getCurrentTick() const
|
|
{
|
|
return m_currentTick;
|
|
}
|
|
|
|
unsigned int Simulation::getSeed() const
|
|
{
|
|
return m_seed;
|
|
}
|
|
|
|
int Simulation::getBuildingBlocksStock() const
|
|
{
|
|
return m_buildingBlocksStock;
|
|
}
|
|
|
|
int Simulation::getCurrentAsteroidWidth_tiles() const
|
|
{
|
|
return m_config.world.regions.asteroidWidth_tiles
|
|
+ m_expansionsPurchased * m_config.world.expansion.columnsPerExpansion_tiles;
|
|
}
|
|
|
|
int Simulation::getCurrentExpansionCost() const
|
|
{
|
|
const double cost = m_config.world.expansion.costBuildingBlocksFormula.evaluate(
|
|
static_cast<double>(m_expansionsPurchased));
|
|
return static_cast<int>(std::floor(cost));
|
|
}
|
|
|
|
void Simulation::tryExpandAsteroid()
|
|
{
|
|
const int cost = getCurrentExpansionCost();
|
|
if (m_buildingBlocksStock < cost)
|
|
{
|
|
return;
|
|
}
|
|
m_buildingBlocksStock -= cost;
|
|
++m_expansionsPurchased;
|
|
m_buildingSystem->setAsteroidWidth_tiles(m_factoryState, getCurrentAsteroidWidth_tiles());
|
|
}
|
|
|
|
bool Simulation::isGameOver() const
|
|
{
|
|
return m_gameOver;
|
|
}
|
|
|
|
bool Simulation::isWon() const
|
|
{
|
|
return m_isWon;
|
|
}
|
|
|
|
int Simulation::getArtifactCount() const
|
|
{
|
|
return m_artifactCount;
|
|
}
|
|
|
|
double Simulation::getThreatLevel() const
|
|
{
|
|
return m_waveSystem->getThreatLevel();
|
|
}
|
|
|
|
double Simulation::getThreatAccumulationRate() const
|
|
{
|
|
return m_waveSystem->getThreatAccumulationRate();
|
|
}
|
|
|
|
double Simulation::getMaxFactoryProductionThreatRate() const
|
|
{
|
|
return static_cast<double>(getProductionBuildingCount(m_factoryState));
|
|
}
|
|
|
|
double Simulation::getCurrentFactoryProductionThreatRate() const
|
|
{
|
|
return static_cast<double>(getActiveProductionBuildingCount(m_factoryState));
|
|
}
|
|
|
|
int Simulation::getBossWaveCounter() const
|
|
{
|
|
return m_waveSystem->getBossWaveCounter();
|
|
}
|
|
|
|
Tick Simulation::getBossCountdownTicks() const
|
|
{
|
|
return m_waveSystem->getBossCountdownTicks();
|
|
}
|
|
|
|
Tick Simulation::getNormalGapRemainingTicks() const
|
|
{
|
|
return m_waveSystem->getNormalGapRemainingTicks();
|
|
}
|
|
|
|
bool Simulation::isSchematicUnlocked(const std::string& shipId) const
|
|
{
|
|
return m_unlockState.isSchematicUnlocked(shipId);
|
|
}
|
|
|
|
bool Simulation::isModuleSchematicUnlocked(const std::string& moduleId) const
|
|
{
|
|
return m_unlockState.isModuleSchematicUnlocked(moduleId);
|
|
}
|
|
|
|
bool Simulation::isBuildingUnlocked(BuildingType type) const
|
|
{
|
|
return m_unlockState.isBuildingUnlocked(type);
|
|
}
|
|
|
|
std::optional<BuildingId> Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)
|
|
{
|
|
// Locked building types cannot be placed (REQ-LOCK-BUILDING); the build menu
|
|
// hides them, this is the simulation-side backstop (e.g. blueprint placement).
|
|
if (!isBuildingUnlocked(type))
|
|
{
|
|
return std::nullopt;
|
|
}
|
|
|
|
if (!isPlacementValid(m_factoryState, m_config, type, anchor, rotation))
|
|
{
|
|
return std::nullopt;
|
|
}
|
|
|
|
int cost = 0;
|
|
for (const BuildingDef& def : m_config.buildings.buildings)
|
|
{
|
|
if (def.type == type)
|
|
{
|
|
cost = def.cost;
|
|
break;
|
|
}
|
|
}
|
|
if (m_buildingBlocksStock < cost)
|
|
{
|
|
return std::nullopt;
|
|
}
|
|
m_buildingBlocksStock -= cost;
|
|
return m_buildingSystem->place(m_factoryState, type, anchor, rotation, m_currentTick);
|
|
}
|
|
|
|
void Simulation::deconstruct(BuildingId id)
|
|
{
|
|
m_buildingBlocksStock += m_buildingSystem->deconstruct(m_factoryState, id, m_currentTick);
|
|
}
|
|
|
|
void Simulation::cancelDeconstruction(BuildingId id)
|
|
{
|
|
m_buildingSystem->cancelDeconstruction(m_factoryState, id);
|
|
}
|
|
|
|
BuildingSystem& Simulation::getBuildingsMutable()
|
|
{
|
|
return *m_buildingSystem;
|
|
}
|
|
|
|
const FactoryState& Simulation::getFactoryState() const
|
|
{
|
|
return m_factoryState;
|
|
}
|
|
|
|
const BuildingSystem& Simulation::getBuildings() const
|
|
{
|
|
return *m_buildingSystem;
|
|
}
|
|
|
|
BeltSystem& Simulation::getBeltsMutable()
|
|
{
|
|
return m_beltSystem;
|
|
}
|
|
|
|
const BeltSystem& Simulation::getBelts() const
|
|
{
|
|
return m_beltSystem;
|
|
}
|
|
|
|
ShipSystem& Simulation::getShips()
|
|
{
|
|
return *m_shipSystem;
|
|
}
|
|
|
|
const ShipSystem& Simulation::getShips() const
|
|
{
|
|
return *m_shipSystem;
|
|
}
|
|
|
|
DebrisSystem& Simulation::getDebrisSystem()
|
|
{
|
|
return *m_debrisSystem;
|
|
}
|
|
|
|
const DebrisSystem& Simulation::getDebrisSystem() const
|
|
{
|
|
return *m_debrisSystem;
|
|
}
|
|
|
|
EntityAdmin& Simulation::getAdmin()
|
|
{
|
|
return m_admin;
|
|
}
|
|
|
|
const EntityAdmin& Simulation::getAdmin() const
|
|
{
|
|
return m_admin;
|
|
}
|
|
|
|
void Simulation::handleEvent(std::shared_ptr<const TracePrintRequestedEvent> event)
|
|
{
|
|
PRINT_TRACES();
|
|
}
|
|
|
|
BuildingId Simulation::allocateBuildingId()
|
|
{
|
|
return m_nextBuildingId++;
|
|
}
|