#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_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); 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_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); initializeUnlockState(); placeInitialStructures(); } void Simulation::initializeUnlockState() { // Cache the ids granted by some unlock group (REQ-LOCK-EXPLICIT); an item // starts locked iff it is granted by a group. m_grantedShipIds.clear(); m_grantedModuleIds.clear(); m_grantedBuildingIds.clear(); m_grantedRecipeIds.clear(); for (const UnlockGroupDef& group : m_config.unlocks.groups) { m_grantedShipIds.insert(group.ships.begin(), group.ships.end()); m_grantedModuleIds.insert(group.modules.begin(), group.modules.end()); m_grantedBuildingIds.insert(group.buildings.begin(), group.buildings.end()); m_grantedRecipeIds.insert(group.recipes.begin(), group.recipes.end()); } m_awardedUnlockGroupIds.clear(); m_schematicLevels.clear(); for (const ShipDef& def : m_config.ships.ships) { SchematicState state; state.unlocked = (m_grantedShipIds.count(def.id) == 0); m_schematicLevels[def.id] = state; } m_moduleSchematicLevels.clear(); for (const ModuleDef& def : m_config.modules.modules) { SchematicState state; state.unlocked = (m_grantedModuleIds.count(def.id) == 0); m_moduleSchematicLevels[def.id] = state; } m_buildingLevels.clear(); for (const BuildingDef& def : m_config.buildings.buildings) { SchematicState state; state.unlocked = (m_grantedBuildingIds.count(def.id) == 0); m_buildingLevels[def.id] = state; } // Gated assembler recipes start locked; unlocked_at_start recipes are handled // in the REQ-LOCK-IMPLICIT traversal, not tracked here. m_unlockedRecipeSchematicIds.clear(); recomputeUnlocked(); } // --------------------------------------------------------------------------- // tick // --------------------------------------------------------------------------- void Simulation::apply(const Command& command) { switch (command.kind) { case CommandKind::PlaceBuilding: { const PlaceBuildingCommand& c = static_cast(command); const std::optional 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(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->getGeneration()); 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->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 pool; for (const UnlockGroupDef& group : m_config.unlocks.groups) { if (m_awardedUnlockGroupIds.count(group.id) > 0) { continue; } if (group.stationLevel < 0 || group.stationLevel > destroyedStationLevel) { continue; } if (!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(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(); 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]); m_pendingSchematicChoices.push_back(makeUnlockOption(*pool[endIdx])); } if (artifactRolled) { SchematicChoiceOption artifactOption; artifactOption.isArtifact = true; artifactOption.displayName = "Artifact"; m_pendingSchematicChoices.push_back(std::move(artifactOption)); } } SchematicChoiceOption Simulation::makeUnlockOption(const UnlockGroupDef& group) const { SchematicChoiceOption option; option.isArtifact = false; option.unlockGroupId = group.id; option.displayName = toDisplayName(group.id); for (const std::string& id : group.ships) { option.grantedItems.push_back({SchematicType::Ship, id, toDisplayName(id)}); } for (const std::string& id : group.modules) { option.grantedItems.push_back({SchematicType::Module, id, toDisplayName(id)}); } for (const std::string& id : group.buildings) { option.grantedItems.push_back({SchematicType::Building, id, toDisplayName(id)}); } for (const std::string& id : group.recipes) { option.grantedItems.push_back({SchematicType::Recipe, id, toDisplayName(id)}); } // REQ-DEF-SCHEMATIC-DROP: preview recipes newly implicitly unlocked by // awarding this whole group. Seed the hypothetical explicit-unlock sets with // every grant (ship + module materials via step 1a, recipe outputs via step // 1b), then diff against the current implicit set. std::set hypotheticalShipIds = getUnlockedShipSchematicIds(); std::set hypotheticalModuleIds = getUnlockedModuleSchematicIds(); std::set hypotheticalRecipeSchematicIds = m_unlockedRecipeSchematicIds; for (const std::string& id : group.ships) { hypotheticalShipIds.insert(id); } for (const std::string& id : group.modules) { hypotheticalModuleIds.insert(id); } for (const std::string& id : group.recipes) { hypotheticalRecipeSchematicIds.insert(id); } const UnlockedSets hypothetical = computeUnlockedSets( hypotheticalShipIds, hypotheticalModuleIds, hypotheticalRecipeSchematicIds); option.newlyUnlockedRecipeIds = computeNewlyUnlockedRecipeIds(hypothetical); return option; } void Simulation::applySchematicChoice(int choiceIndex) { assert(choiceIndex >= 0 && choiceIndex < static_cast(m_pendingSchematicChoices.size())); const SchematicChoiceOption& chosen = m_pendingSchematicChoices[static_cast(choiceIndex)]; if (chosen.isArtifact) { m_artifactCount += 1; if (m_artifactCount >= m_config.world.artifacts.artifactWinCount) { m_isWon = true; } m_pendingSchematicChoices.clear(); return; } // Award the whole unlock group (REQ-DEF-SCHEMATIC-DROP): unlock every granted // ship, module, building, and assembler recipe at once. m_awardedUnlockGroupIds.insert(chosen.unlockGroupId); for (const GrantedSchematic& grant : chosen.grantedItems) { switch (grant.type) { case SchematicType::Ship: m_schematicLevels.at(grant.id).unlocked = true; break; case SchematicType::Module: m_moduleSchematicLevels.at(grant.id).unlocked = true; break; case SchematicType::Building: m_buildingLevels.at(grant.id).unlocked = true; break; case SchematicType::Recipe: m_unlockedRecipeSchematicIds.insert(grant.id); break; } } 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::prerequisitesSatisfied(const std::vector& requiredGroupIds) const { // A prerequisite is satisfied only once the named unlock group has been // awarded (REQ-LOCK-PREREQ). for (const std::string& groupId : requiredGroupIds) { if (m_awardedUnlockGroupIds.count(groupId) == 0) { 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) { // An assembler recipe seeds the base set when it is explicitly available: // flagged unlocked_at_start (base recipes the graph can't reach), or a // gated recipe whose unlock group has been awarded (REQ-LOCK-EXPLICIT). if (def.building == BuildingType::Assembler && (def.unlockedAtStart || 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; } // Skip a gated assembler recipe (granted by an unlock group) whose // group has not yet been awarded (REQ-LOCK-IMPLICIT step 2). if (recipe.building == BuildingType::Assembler && m_grantedRecipeIds.count(recipe.id) > 0 && 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::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). appendSchematicMap(hasher, m_schematicLevels); appendSchematicMap(hasher, m_moduleSchematicLevels); appendSchematicMap(hasher, m_buildingLevels); appendStringSet(hasher, m_awardedUnlockGroupIds); 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.getValue(); } // --------------------------------------------------------------------------- // 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::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(m_expansionsPurchased)); return static_cast(std::floor(cost)); } void Simulation::tryExpandAsteroid() { const int cost = getCurrentExpansionCost(); if (m_buildingBlocksStock < cost) { return; } m_buildingBlocksStock -= cost; ++m_expansionsPurchased; m_buildingSystem->setAsteroidWidth_tiles(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(m_buildingSystem->getProductionBuildingCount()); } double Simulation::getCurrentFactoryProductionThreatRate() const { return static_cast(m_buildingSystem->getActiveProductionBuildingCount()); } 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 { 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; } bool Simulation::isBuildingUnlocked(BuildingType type) const { const BuildingDef* def = m_config.buildings.findBuildingDef(type); if (def == nullptr) { // Types without a config entry (e.g. HQ, defence stations) are unrestricted. return true; } const std::map::const_iterator it = m_buildingLevels.find(def->id); return it == m_buildingLevels.end() ? true : it->second.unlocked; } std::optional 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 (!m_buildingSystem->isPlacementValid(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(type, anchor, rotation, m_currentTick); } void Simulation::demolish(BuildingId id) { m_buildingBlocksStock += m_buildingSystem->demolish(id); } BuildingSystem& Simulation::getBuildingsMutable() { return *m_buildingSystem; } 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; } ScrapSystem& Simulation::getScraps() { return *m_scrapSystem; } const ScrapSystem& Simulation::getScraps() const { return *m_scrapSystem; } EntityAdmin& Simulation::getAdmin() { return m_admin; } const EntityAdmin& Simulation::getAdmin() const { return m_admin; } void Simulation::handleEvent(std::shared_ptr event) { PRINT_TRACES(); } BuildingId Simulation::allocateBuildingId() { return m_nextBuildingId++; }