#include "Simulation.h" #include #include #include #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( m_config, m_beltSystem, [this]() { return allocateBuildingId(); }, [this](int amount) { m_buildingBlocksStock += amount; }, [this](const std::string& id, QVector2D pos, const std::optional& layout) { const std::map::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(m_config, m_admin); m_aiSystem = std::make_unique(m_config); m_movementIntentSystem = std::make_unique(); m_dynamicBodySystem = std::make_unique(); m_scrapSystem = std::make_unique(m_admin); m_salvagerSystem = std::make_unique(m_admin); m_repairSystem = std::make_unique(m_admin); m_waveSystem = std::make_unique(m_config, m_rng); m_combatSystem = std::make_unique(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( m_config, m_beltSystem, [this]() { return allocateBuildingId(); }, [this](int amount) { m_buildingBlocksStock += amount; }, [this](const std::string& id, QVector2D pos, const std::optional& layout) { const std::map::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(m_config, m_admin); m_aiSystem = std::make_unique(m_config); m_movementIntentSystem = std::make_unique(); m_dynamicBodySystem = std::make_unique(); m_scrapSystem = std::make_unique(m_admin); m_salvagerSystem = std::make_unique(m_admin); m_repairSystem = std::make_unique(m_admin); m_waveSystem = std::make_unique(m_config, m_rng); m_combatSystem = std::make_unique(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(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(command).id); break; case CommandKind::RotateInPlace: { const RotateInPlaceCommand& c = static_cast(command); m_buildingSystem->rotateInPlace(c.id, c.newRotation); break; } case CommandKind::SetRecipe: { const SetRecipeCommand& c = static_cast(command); m_buildingSystem->setRecipe(c.id, c.recipeId); break; } case CommandKind::SetShipLayout: { const SetShipLayoutCommand& c = static_cast(command); m_buildingSystem->setShipLayout(c.id, c.layout); break; } case CommandKind::SetSiteSplitterFilters: { const SetSiteSplitterFiltersCommand& c = static_cast(command); m_buildingSystem->setSiteSplitterFilters(c.id, c.filterA, c.filterB); break; } case CommandKind::SetSplitterFilters: { const SetSplitterFiltersCommand& c = static_cast(command); m_beltSystem.setSplitterFilters(c.tile, c.filterA, c.filterB); break; } case CommandKind::ClearBeltTiles: m_beltSystem.clearTiles(static_cast(command).tiles); break; case CommandKind::ApplySchematicChoice: applySchematicChoice(static_cast(command).choiceIndex); break; case CommandKind::ExpandAsteroid: tryExpandAsteroid(); break; case CommandKind::Reset: { const ResetCommand& c = static_cast(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->tickOutputBelts(); // 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(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(m_config.stations.playerStation.level); const float psHp = static_cast( m_config.stations.playerStation.hpFormula.evaluate(psLevel)); const float tileSize = static_cast(m_config.world.tileSize_m); WeaponComponent psWeapon; psWeapon.damage = static_cast( m_config.stations.playerStation.damageFormula.evaluate(psLevel)); psWeapon.range_tiles = static_cast( m_config.stations.playerStation.rangeFormula.evaluate(psLevel)) / tileSize; psWeapon.fireRateHz = static_cast( 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 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(wChild, psWeapon); m_admin.addComponent(wChild, ModuleOwnerComponent{m_playerStation1Entity}); } m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId()); } { const QPoint anchor(psAnchorX, ps2Y); std::vector 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(wChild, psWeapon); m_admin.addComponent(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(psAnchorX) + psParsed.footprint.width() / 2.0f; const float rallyY = static_cast(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(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(generation); const float esHp = static_cast( m_config.stations.enemyStation.hpFormula.evaluate(genD)); WeaponComponent esWeapon; esWeapon.damage = static_cast( m_config.stations.enemyStation.damageFormula.evaluate(genD)); esWeapon.range_tiles = static_cast( m_config.stations.enemyStation.rangeFormula.evaluate(genD)) / tileSize; esWeapon.fireRateHz = static_cast( 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 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(wChild, esWeapon); m_admin.addComponent(wChild, ModuleOwnerComponent{m_currentEnemyStationEntities[0]}); } m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId()); } { const QPoint anchor(anchorX, y2); std::vector 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(wChild, esWeapon); m_admin.addComponent(wChild, ModuleOwnerComponent{m_currentEnemyStationEntities[1]}); } m_buildingSystem->registerTileOccupancy(absCells, allocateBuildingId()); } } // --------------------------------------------------------------------------- // Deaths & loot (tick step 9) // --------------------------------------------------------------------------- void Simulation::tickDeathsAndLoot() { TRACE(); // --- Dead ships --- std::vector deadShips; m_admin.forEach( [&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(deadEntity); const PositionComponent& pos = m_admin.get(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 deadStations; m_admin.forEach( [&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(deadEntity); const PositionComponent& pos = m_admin.get(deadEntity); const FactionComponent& fac = m_admin.get(deadEntity); const Tick despawnAt = m_currentTick + secondsToTicks(m_config.world.scrapDespawnSeconds); int scrap = 0; if (!fac.isEnemy) { const double lv = static_cast( m_config.stations.playerStation.level); scrap = static_cast( m_config.stations.playerStation.scrapDropFormula.evaluate(lv)); } else { const double genD = static_cast(m_waveSystem->generation()); scrap = static_cast( m_config.stations.enemyStation.scrapDropFormula.evaluate(genD)); } if (scrap > 0) { m_scrapSystem->spawn(pos.value, scrap, despawnAt); } m_buildingSystem->unregisterTileOccupancy(sb.bodyCells); { std::vector stationChildren; m_admin.forEach( [&](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(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(m_currentEnemyStationEntities[0]).hp <= 0.0f; const bool es1Gone = !m_admin.isValid(m_currentEnemyStationEntities[1]) || m_admin.get(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 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(destroyedStationLevel)), 0.0, 1.0); std::uniform_real_distribution realDist(0.0, 1.0); const bool artifactRolled = realDist(m_rng) < artifactChance; const int numChoices = std::min(static_cast(pool.size()), artifactRolled ? 2 : 3); m_pendingSchematicChoices.clear(); const std::set currentShipIds = getUnlockedShipSchematicIds(); const std::set currentModuleIds = getUnlockedModuleSchematicIds(); for (int i = 0; i < numChoices; ++i) { std::uniform_int_distribution dist(0, static_cast(pool.size()) - 1 - i); const int roll = dist(m_rng); const std::size_t rollIdx = static_cast(roll); const std::size_t endIdx = pool.size() - 1 - static_cast(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 hypotheticalShipIds = currentShipIds; std::set hypotheticalModuleIds = currentModuleIds; std::set 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(m_pendingSchematicChoices.size())); const SchematicChoiceOption& chosen = m_pendingSchematicChoices[static_cast(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 Simulation::getUnlockedShipSchematicIds() const { std::set ids; for (const auto& [id, state] : m_schematicLevels) { if (state.unlocked) { ids.insert(id); } } return ids; } std::set Simulation::getUnlockedModuleSchematicIds() const { std::set 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::const_iterator shipIt = m_schematicLevels.find(schematicId); if (shipIt != m_schematicLevels.end() && shipIt->second.unlocked) { return true; } const std::map::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& unlockRequires) const { for (const std::string& requiredId : unlockRequires) { if (!isSchematicIdExplicitlyUnlocked(requiredId)) { return false; } } return true; } Simulation::UnlockedSets Simulation::computeUnlockedSets( const std::set& unlockedShipSchematicIds, const std::set& unlockedModuleSchematicIds, const std::set& 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 Simulation::computeNewlyUnlockedRecipeIds(const UnlockedSets& hypothetical) const { std::vector 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& levels) { hasher.append(levels.size()); for (const std::pair& entry : levels) { hasher.append(entry.first); hasher.append(entry.second.unlocked); } } void Simulation::appendStringSet(Hasher& hasher, const std::set& 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( [&hasher](entt::entity entity, const PositionComponent& c) { hasher.append(static_cast(entity)); hasher.append(c.value); }); m_admin.forEach( [&hasher](entt::entity entity, const HealthComponent& c) { hasher.append(static_cast(entity)); hasher.append(c.hp); hasher.append(c.maxHp); }); m_admin.forEach( [&hasher](entt::entity entity, const FacingComponent& c) { hasher.append(static_cast(entity)); hasher.append(c.radians); }); m_admin.forEach( [&hasher](entt::entity entity, const DynamicBodyComponent& c) { hasher.append(static_cast(entity)); hasher.append(c.velocity_tpt); hasher.append(c.angularVelocity_rpt); hasher.append(c.linearAcceleration_tptt); hasher.append(c.angularAcceleration_rptt); }); m_admin.forEach( [&hasher](entt::entity entity, const ScrapDataComponent& c) { hasher.append(static_cast(entity)); hasher.append(c.amount); }); m_admin.forEach( [&hasher](entt::entity entity, const ShipIdentityComponent& c) { hasher.append(static_cast(entity)); hasher.append(c.schematicId); }); return hasher.value(); } // --------------------------------------------------------------------------- // Drains // --------------------------------------------------------------------------- std::vector Simulation::drainBeamFiredEvents() { std::vector result; result.swap(m_beamFiredEvents); return result; } const std::vector& 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(m_expansionsPurchased)); return static_cast(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(m_buildingSystem->productionBuildingCount()); } double Simulation::currentFactoryProductionThreatRate() const { return static_cast(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::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::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 event) { PRINT_TRACES(); } BuildingId Simulation::allocateBuildingId() { return m_nextBuildingId++; }