Files
dota_factory/src/lib/sim/Simulation.cpp

1284 lines
43 KiB
C++

#include "Simulation.h"
#include <algorithm>
#include <cassert>
#include <cmath>
#include "AiSystem.h"
#include "Command.h"
#include "DisplayName.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 "ScrapDataComponent.h"
#include "ScrapSystem.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_hqBuildingId(kInvalidBuildingId)
, m_hqProxyEntity(entt::null)
, m_playerStation1Entity(entt::null)
, m_playerStation2Entity(entt::null)
, m_beltSystem(m_config.world.beltSpeed_tps)
{
m_currentEnemyStationEntities[0] = entt::null;
m_currentEnemyStationEntities[1] = entt::null;
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) {
const std::map<std::string, SchematicState>::const_iterator it =
m_schematicLevels.find(id);
if (it == m_schematicLevels.end() || !it->second.unlocked)
{
return;
}
m_shipSystem->spawn(id, pos, /*isEnemy=*/false, layout);
},
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
m_rng);
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_scrapSystem = std::make_unique<ScrapSystem>(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);
// Initialize ship schematic unlock state.
for (const ShipDef& def : m_config.ships.ships)
{
SchematicState state;
state.unlocked = (def.unlockAtStationLevel == -1);
m_schematicLevels[def.id] = state;
}
// Initialize module schematic unlock state.
for (const ModuleDef& def : m_config.modules.modules)
{
SchematicState state;
state.unlocked = (def.unlockAtStationLevel == -1);
m_moduleSchematicLevels[def.id] = state;
}
// Initialize assembler recipe schematic unlock state.
for (const RecipeDef& def : m_config.recipes.recipes)
{
if (def.building == BuildingType::Assembler
&& def.unlockAtStationLevel.has_value()
&& def.unlockAtStationLevel.value() == -1)
{
m_unlockedRecipeSchematicIds.insert(def.id);
}
}
recomputeUnlocked();
placeInitialStructures();
registerForEvents();
}
Simulation::~Simulation()
{
unregisterForEvents();
}
const GameConfig& Simulation::config() 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 = kInvalidBuildingId;
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_beltSystem = BeltSystem(m_config.world.beltSpeed_tps);
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) {
const std::map<std::string, SchematicState>::const_iterator it =
m_schematicLevels.find(id);
if (it == m_schematicLevels.end() || !it->second.unlocked)
{
return;
}
m_shipSystem->spawn(id, pos, /*isEnemy=*/false, layout);
},
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
m_rng);
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_scrapSystem = std::make_unique<ScrapSystem>(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);
m_schematicLevels.clear();
for (const ShipDef& def : m_config.ships.ships)
{
SchematicState state;
state.unlocked = (def.unlockAtStationLevel == -1);
m_schematicLevels[def.id] = state;
}
m_moduleSchematicLevels.clear();
for (const ModuleDef& def : m_config.modules.modules)
{
SchematicState state;
state.unlocked = (def.unlockAtStationLevel == -1);
m_moduleSchematicLevels[def.id] = state;
}
m_unlockedRecipeSchematicIds.clear();
for (const RecipeDef& def : m_config.recipes.recipes)
{
if (def.building == BuildingType::Assembler
&& def.unlockAtStationLevel.has_value()
&& def.unlockAtStationLevel.value() == -1)
{
m_unlockedRecipeSchematicIds.insert(def.id);
}
}
recomputeUnlocked();
placeInitialStructures();
}
// ---------------------------------------------------------------------------
// tick
// ---------------------------------------------------------------------------
void Simulation::apply(const Command& command)
{
switch (command.kind)
{
case CommandKind::PlaceBuilding:
{
const PlaceBuildingCommand& c = static_cast<const PlaceBuildingCommand&>(command);
const BuildingId id = tryPlaceBuilding(c.type, c.anchor, c.rotation);
if (id == kInvalidBuildingId)
{
break;
}
if (c.recipeId.has_value())
{
m_buildingSystem->setRecipe(id, *c.recipeId);
}
if (c.shipLayout.has_value())
{
m_buildingSystem->setShipLayout(id, *c.shipLayout);
}
if (c.hasSplitterFilters)
{
m_buildingSystem->setSiteSplitterFilters(id, c.splitterFilterA, c.splitterFilterB);
}
break;
}
case CommandKind::Demolish:
demolish(static_cast<const DemolishCommand&>(command).id);
break;
case CommandKind::RotateInPlace:
{
const RotateInPlaceCommand& c = static_cast<const RotateInPlaceCommand&>(command);
m_buildingSystem->rotateInPlace(c.id, c.newRotation);
break;
}
case CommandKind::SetRecipe:
{
const SetRecipeCommand& c = static_cast<const SetRecipeCommand&>(command);
m_buildingSystem->setRecipe(c.id, c.recipeId);
break;
}
case CommandKind::SetShipLayout:
{
const SetShipLayoutCommand& c = static_cast<const SetShipLayoutCommand&>(command);
m_buildingSystem->setShipLayout(c.id, c.layout);
break;
}
case CommandKind::SetSiteSplitterFilters:
{
const SetSiteSplitterFiltersCommand& c =
static_cast<const SetSiteSplitterFiltersCommand&>(command);
m_buildingSystem->setSiteSplitterFilters(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_buildingSystem->tickConstruction(m_currentTick);
m_buildingSystem->tickBeltPull(); // step 3
m_buildingSystem->tickProduction(m_currentTick); // step 4
m_buildingSystem->tickShipyardProduction(m_currentTick); // step 4b
m_buildingSystem->tickBeltPush(); // 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_buildingSystem, *m_scrapSystem);
// 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_scrapSystem, *m_buildingSystem, m_beamFiredEvents);
m_repairSystem->tick(m_currentTick, m_beamFiredEvents);
// Step 8: combat resolution
m_combatSystem->tick(m_currentTick, m_admin,
*m_buildingSystem, 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: 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).
// 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(
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(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(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(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(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.scrapDespawnSeconds);
m_scrapSystem->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.scrapDespawnSeconds);
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->generation());
scrap = static_cast<int>(
m_config.stations.enemyStation.scrapDropFormula.evaluate(genD));
}
if (scrap > 0)
{
m_scrapSystem->spawn(pos.value, scrap, despawnAt);
}
m_buildingSystem->unregisterTileOccupancy(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->generation();
m_waveSystem->onEnemyStationsDestroyed();
placeEnemyStationSet(m_waveSystem->generation());
generateSchematicChoices(destroyedLevel);
}
}
void Simulation::generateSchematicChoices(int destroyedStationLevel)
{
enum class DropType { Ship, Module, Recipe };
struct PoolEntry { std::string id; DropType type; };
std::vector<PoolEntry> pool;
// Owned schematics leave the pool (REQ-DEF-SCHEMATIC-DROP): only offer
// ship/module schematics that are gated to a station level in range and not
// yet unlocked. Schematics with unlock_at_station_level -1 start unlocked, so
// they are always owned and never eligible.
for (const ShipDef& def : m_config.ships.ships)
{
if (def.unlockAtStationLevel < 0 || def.unlockAtStationLevel > destroyedStationLevel) { continue; }
if (m_schematicLevels.at(def.id).unlocked) { continue; }
if (!prerequisitesSatisfied(def.unlockRequires)) { continue; }
pool.push_back({def.id, DropType::Ship});
}
for (const ModuleDef& def : m_config.modules.modules)
{
if (def.unlockAtStationLevel < 0 || def.unlockAtStationLevel > destroyedStationLevel) { continue; }
if (m_moduleSchematicLevels.at(def.id).unlocked) { continue; }
if (!prerequisitesSatisfied(def.unlockRequires)) { continue; }
pool.push_back({def.id, DropType::Module});
}
for (const RecipeDef& def : m_config.recipes.recipes)
{
if (def.building != BuildingType::Assembler) { continue; }
if (!def.unlockAtStationLevel.has_value()) { continue; }
const int level = def.unlockAtStationLevel.value();
if (level < 0 || level > destroyedStationLevel) { continue; }
if (m_unlockedRecipeSchematicIds.count(def.id) > 0) { continue; }
bool outputUnlocked = false;
for (const RecipeOutput& out : def.outputs)
{
if (m_unlockedItemIds.count(out.item) > 0) { outputUnlocked = true; break; }
}
if (!outputUnlocked) { continue; }
if (!prerequisitesSatisfied(def.unlockRequires)) { continue; }
pool.push_back({def.id, DropType::Recipe});
}
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();
const std::set<std::string> currentShipIds = getUnlockedShipSchematicIds();
const std::set<std::string> currentModuleIds = getUnlockedModuleSchematicIds();
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]);
const PoolEntry& entry = pool[endIdx];
SchematicChoiceOption option;
option.schematicId = entry.id;
if (entry.type == DropType::Ship)
{
option.type = SchematicType::Ship;
option.displayName = toDisplayName(entry.id);
}
else if (entry.type == DropType::Module)
{
option.type = SchematicType::Module;
option.displayName = toDisplayName(entry.id);
}
else
{
option.type = SchematicType::Recipe;
option.displayName = toDisplayName(entry.id);
}
// REQ-DEF-SCHEMATIC-DROP: preview recipes newly implicitly unlocked by this option.
std::set<std::string> hypotheticalShipIds = currentShipIds;
std::set<std::string> hypotheticalModuleIds = currentModuleIds;
std::set<std::string> hypotheticalRecipeSchematicIds = m_unlockedRecipeSchematicIds;
if (entry.type == DropType::Ship)
{
hypotheticalShipIds.insert(entry.id);
}
else if (entry.type == DropType::Module)
{
hypotheticalModuleIds.insert(entry.id);
}
else if (entry.type == DropType::Recipe)
{
hypotheticalRecipeSchematicIds.insert(entry.id);
}
const UnlockedSets hypothetical = computeUnlockedSets(
hypotheticalShipIds, hypotheticalModuleIds, hypotheticalRecipeSchematicIds);
option.newlyUnlockedRecipeIds = computeNewlyUnlockedRecipeIds(hypothetical);
m_pendingSchematicChoices.push_back(option);
}
if (artifactRolled)
{
SchematicChoiceOption artifactOption;
artifactOption.schematicId = "";
artifactOption.type = SchematicType::Artifact;
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.type == SchematicType::Artifact)
{
m_artifactCount += 1;
if (m_artifactCount >= m_config.world.artifacts.artifactWinCount)
{
m_isWon = true;
}
m_pendingSchematicChoices.clear();
return;
}
if (chosen.type == SchematicType::Recipe)
{
m_unlockedRecipeSchematicIds.insert(chosen.schematicId);
}
else
{
SchematicState& state = (chosen.type == SchematicType::Module)
? m_moduleSchematicLevels.at(chosen.schematicId)
: m_schematicLevels.at(chosen.schematicId);
state.unlocked = true;
}
recomputeUnlocked();
m_pendingSchematicChoices.clear();
}
// ---------------------------------------------------------------------------
// Implicit unlock computation (REQ-LOCK-IMPLICIT)
// ---------------------------------------------------------------------------
void Simulation::recomputeUnlocked()
{
const UnlockedSets result = computeUnlockedSets(
getUnlockedShipSchematicIds(), getUnlockedModuleSchematicIds(), m_unlockedRecipeSchematicIds);
m_unlockedItemIds = result.itemIds;
m_unlockedRecipeIds = result.recipeIds;
}
std::set<std::string> Simulation::getUnlockedShipSchematicIds() const
{
std::set<std::string> ids;
for (const auto& [id, state] : m_schematicLevels)
{
if (state.unlocked) { ids.insert(id); }
}
return ids;
}
std::set<std::string> Simulation::getUnlockedModuleSchematicIds() const
{
std::set<std::string> ids;
for (const auto& [id, state] : m_moduleSchematicLevels)
{
if (state.unlocked) { ids.insert(id); }
}
return ids;
}
bool Simulation::isSchematicIdExplicitlyUnlocked(const std::string& schematicId) const
{
// A prerequisite is satisfied only by an explicitly-unlocked schematic
// (REQ-LOCK-PREREQ): a ship or module schematic marked unlocked, or a recipe
// schematic in the explicit-unlock set. Cross-type ids are looked up with
// find/count so an id from one namespace never throws against another.
const std::map<std::string, SchematicState>::const_iterator shipIt =
m_schematicLevels.find(schematicId);
if (shipIt != m_schematicLevels.end() && shipIt->second.unlocked) { return true; }
const std::map<std::string, SchematicState>::const_iterator moduleIt =
m_moduleSchematicLevels.find(schematicId);
if (moduleIt != m_moduleSchematicLevels.end() && moduleIt->second.unlocked) { return true; }
return m_unlockedRecipeSchematicIds.count(schematicId) > 0;
}
bool Simulation::prerequisitesSatisfied(const std::vector<std::string>& unlockRequires) const
{
for (const std::string& requiredId : unlockRequires)
{
if (!isSchematicIdExplicitlyUnlocked(requiredId)) { return false; }
}
return true;
}
Simulation::UnlockedSets Simulation::computeUnlockedSets(
const std::set<std::string>& unlockedShipSchematicIds,
const std::set<std::string>& unlockedModuleSchematicIds,
const std::set<std::string>& unlockedRecipeSchematicIds) const
{
UnlockedSets result;
for (const ShipDef& def : m_config.ships.ships)
{
if (unlockedShipSchematicIds.count(def.id) == 0) { continue; }
for (const RecipeIngredient& mat : def.schematic.materials)
{
result.itemIds.insert(mat.item);
}
}
for (const ModuleDef& def : m_config.modules.modules)
{
if (unlockedModuleSchematicIds.count(def.id) == 0) { continue; }
for (const RecipeIngredient& mat : def.materials)
{
result.itemIds.insert(mat.item);
}
}
for (const RecipeDef& def : m_config.recipes.recipes)
{
if (def.building == BuildingType::Assembler
&& def.unlockAtStationLevel.has_value()
&& unlockedRecipeSchematicIds.count(def.id) > 0)
{
for (const RecipeOutput& out : def.outputs)
{
result.itemIds.insert(out.item);
}
}
}
bool changed = true;
while (changed)
{
changed = false;
for (const RecipeDef& recipe : m_config.recipes.recipes)
{
if (recipe.building != BuildingType::Miner
&& recipe.building != BuildingType::Smelter
&& recipe.building != BuildingType::Assembler)
{
continue;
}
if (recipe.building == BuildingType::Assembler
&& recipe.unlockAtStationLevel.has_value()
&& unlockedRecipeSchematicIds.count(recipe.id) == 0)
{
continue;
}
bool producesUnlocked = false;
for (const RecipeOutput& out : recipe.outputs)
{
if (result.itemIds.count(out.item) > 0)
{
producesUnlocked = true;
break;
}
}
if (!producesUnlocked) { continue; }
if (recipe.building == BuildingType::Miner
|| recipe.building == BuildingType::Assembler)
{
result.recipeIds.insert(recipe.id);
}
for (const RecipeIngredient& ing : recipe.inputs)
{
if (result.itemIds.insert(ing.item).second)
{
changed = true;
}
}
}
}
return result;
}
std::vector<std::string> Simulation::computeNewlyUnlockedRecipeIds(const UnlockedSets& hypothetical) const
{
std::vector<std::string> recipeIds;
for (const std::string& recipeId : hypothetical.recipeIds)
{
if (m_unlockedRecipeIds.count(recipeId) > 0) { continue; }
recipeIds.push_back(recipeId);
}
std::sort(recipeIds.begin(), recipeIds.end(),
[](const std::string& lhs, const std::string& rhs)
{
return toDisplayName(lhs) < toDisplayName(rhs);
});
return recipeIds;
}
bool Simulation::isRecipeUnlocked(const std::string& recipeId) const
{
return m_unlockedRecipeIds.count(recipeId) > 0;
}
bool Simulation::isItemUnlocked(const std::string& itemId) const
{
return m_unlockedItemIds.count(itemId) > 0;
}
// ---------------------------------------------------------------------------
// Determinism (see docs/replay_design.md)
// ---------------------------------------------------------------------------
void Simulation::appendSchematicMap(Hasher& hasher,
const std::map<std::string, SchematicState>& levels)
{
hasher.append(levels.size());
for (const std::pair<const std::string, SchematicState>& entry : levels)
{
hasher.append(entry.first);
hasher.append(entry.second.unlocked);
}
}
void Simulation::appendStringSet(Hasher& hasher, const std::set<std::string>& ids)
{
hasher.append(ids.size());
for (const std::string& id : ids)
{
hasher.append(id);
}
}
unsigned long long Simulation::rngFingerprint() 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(threatLevel());
hasher.append(threatAccumulationRate());
hasher.append(bossWaveCounter());
hasher.append(bossCountdownTicks());
hasher.append(normalGapRemainingTicks());
// Schematic / unlock state (std::map and std::set iterate in sorted order).
appendSchematicMap(hasher, m_schematicLevels);
appendSchematicMap(hasher, m_moduleSchematicLevels);
appendStringSet(hasher, m_unlockedRecipeSchematicIds);
appendStringSet(hasher, m_unlockedRecipeIds);
appendStringSet(hasher, m_unlockedItemIds);
// Subsystems contribute their own state.
m_buildingSystem->appendChecksum(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<ScrapDataComponent>(
[&hasher](entt::entity entity, const ScrapDataComponent& 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.value();
}
// ---------------------------------------------------------------------------
// 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::currentTick() const
{
return m_currentTick;
}
unsigned int Simulation::getSeed() const
{
return m_seed;
}
int Simulation::buildingBlocksStock() const
{
return m_buildingBlocksStock;
}
int Simulation::currentAsteroidWidth_tiles() const
{
return m_config.world.regions.asteroidWidth_tiles
+ m_expansionsPurchased * m_config.world.expansion.columnsPerExpansion_tiles;
}
int Simulation::currentExpansionCost() 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 = currentExpansionCost();
if (m_buildingBlocksStock < cost)
{
return;
}
m_buildingBlocksStock -= cost;
++m_expansionsPurchased;
m_buildingSystem->setAsteroidWidth_tiles(currentAsteroidWidth_tiles());
}
bool Simulation::isGameOver() const
{
return m_gameOver;
}
bool Simulation::isWon() const
{
return m_isWon;
}
int Simulation::artifactCount() const
{
return m_artifactCount;
}
double Simulation::threatLevel() const
{
return m_waveSystem->threatLevel();
}
double Simulation::threatAccumulationRate() const
{
return m_waveSystem->threatAccumulationRate();
}
double Simulation::maxFactoryProductionThreatRate() const
{
return static_cast<double>(m_buildingSystem->productionBuildingCount());
}
double Simulation::currentFactoryProductionThreatRate() const
{
return static_cast<double>(m_buildingSystem->activeProductionBuildingCount());
}
int Simulation::bossWaveCounter() const
{
return m_waveSystem->bossWaveCounter();
}
Tick Simulation::bossCountdownTicks() const
{
return m_waveSystem->bossCountdownTicks();
}
Tick Simulation::normalGapRemainingTicks() const
{
return m_waveSystem->normalGapRemainingTicks();
}
bool Simulation::isSchematicUnlocked(const std::string& shipId) const
{
const std::map<std::string, SchematicState>::const_iterator it =
m_schematicLevels.find(shipId);
if (it == m_schematicLevels.end())
{
return false;
}
return it->second.unlocked;
}
bool Simulation::isModuleSchematicUnlocked(const std::string& moduleId) const
{
const std::map<std::string, SchematicState>::const_iterator it =
m_moduleSchematicLevels.find(moduleId);
if (it == m_moduleSchematicLevels.end())
{
return false;
}
return it->second.unlocked;
}
BuildingId Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)
{
if (!m_buildingSystem->isPlacementValid(type, anchor, rotation))
{
return kInvalidBuildingId;
}
int cost = 0;
for (const BuildingDef& def : m_config.buildings.buildings)
{
if (def.type == type)
{
cost = def.cost;
break;
}
}
if (m_buildingBlocksStock < cost)
{
return kInvalidBuildingId;
}
m_buildingBlocksStock -= cost;
return m_buildingSystem->place(type, anchor, rotation, m_currentTick);
}
void Simulation::demolish(BuildingId id)
{
m_buildingBlocksStock += m_buildingSystem->demolish(id);
}
BuildingSystem& Simulation::buildingsMutable()
{
return *m_buildingSystem;
}
const BuildingSystem& Simulation::buildings() const
{
return *m_buildingSystem;
}
BeltSystem& Simulation::beltsMutable()
{
return m_beltSystem;
}
const BeltSystem& Simulation::belts() const
{
return m_beltSystem;
}
ShipSystem& Simulation::ships()
{
return *m_shipSystem;
}
const ShipSystem& Simulation::ships() const
{
return *m_shipSystem;
}
ScrapSystem& Simulation::scraps()
{
return *m_scrapSystem;
}
const ScrapSystem& Simulation::scraps() const
{
return *m_scrapSystem;
}
EntityAdmin& Simulation::admin()
{
return m_admin;
}
const EntityAdmin& Simulation::admin() const
{
return m_admin;
}
void Simulation::handleEvent(std::shared_ptr<const TracePrintRequestedEvent> event)
{
PRINT_TRACES();
}
BuildingId Simulation::allocateBuildingId()
{
return m_nextBuildingId++;
}