From b5185b0906f1a2a1dbd3f18b6221ba9fd33cff4d Mon Sep 17 00:00:00 2001 From: mlangkabel Date: Wed, 3 Jun 2026 21:30:38 +0200 Subject: [PATCH] boss waves --- bin/app/data/config/world.toml | 6 +- bin/test/data/config/world.toml | 10 ++- docs/requirements.md | 20 +++-- src/lib/config/ConfigLoader.cpp | 16 ++-- src/lib/config/WorldConfig.h | 14 +-- src/lib/sim/Simulation.cpp | 4 +- src/lib/sim/WaveSystem.cpp | 155 +++++++++++++++++++++----------- src/lib/sim/WaveSystem.h | 60 ++++++++----- src/test/ConfigLoaderTest.cpp | 12 +-- src/test/WaveSystemTest.cpp | 79 +++++----------- 10 files changed, 214 insertions(+), 162 deletions(-) diff --git a/bin/app/data/config/world.toml b/bin/app/data/config/world.toml index 69b959c..0dd0add 100644 --- a/bin/app/data/config/world.toml +++ b/bin/app/data/config/world.toml @@ -19,7 +19,7 @@ cost_building_blocks = 200 [push] push_expand_columns = 10 -scaling_factor = 1.2 +boss_advance_seconds = 60 [waves] threat_rate_formula = "0.01*x" @@ -27,3 +27,7 @@ ship_level_formula = "1" gap_min_seconds = 15 gap_max_seconds = 45 spawn_duration_seconds = 10 +boss_countdown_seconds = 300 +boss_threat_duration_seconds = 60 +boss_quiet_before_seconds = 60 +boss_quiet_after_seconds = 60 diff --git a/bin/test/data/config/world.toml b/bin/test/data/config/world.toml index c4874a0..a3fda86 100644 --- a/bin/test/data/config/world.toml +++ b/bin/test/data/config/world.toml @@ -19,11 +19,15 @@ cost_building_blocks = 200 [push] push_expand_columns = 20 -scaling_factor = 1.2 +boss_advance_seconds = 60 [waves] -threat_rate_formula = "1*x - 30" -ship_level_formula = "1 + x / 120" +threat_rate_formula = "x" +ship_level_formula = "1 + x / 10" gap_min_seconds = 15 gap_max_seconds = 45 spawn_duration_seconds = 10 +boss_countdown_seconds = 300 +boss_threat_duration_seconds = 60 +boss_quiet_before_seconds = 60 +boss_quiet_after_seconds = 60 diff --git a/docs/requirements.md b/docs/requirements.md index ce77ef9..3381b7d 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -4,7 +4,7 @@ Config files use the TOML format. The following config files drive game parameters: -- **world.toml** — world dimensions, region widths, expansion amounts, building refund percentage, wave timing, enemy ship level formula, belt speed, starting building blocks, departure interval. +- **world.toml** — world dimensions, region widths, expansion amounts, building refund percentage, wave timing, boss wave timing, enemy ship level formula, belt speed, starting building blocks, departure interval. - **buildings.toml** — building block cost and construction time per building type. - **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities. - **ships.toml** — per schematic: a human-readable display name (used in toasts and UI), hull stats (HP, max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, max rotation speed) as formulas of ship level, required build materials, threat cost formula, player production level, whether the schematic is available from game start, a layout grid defining the ship's module slots, and a `default_modules` list used for enemy wave ships (see REQ-WAV-DEFAULT-MODULES). @@ -249,21 +249,25 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des - REQ-DEF-ENEMY-PLACEMENT: 2 enemy defence stations are placed at the right boundary of the scrollable area at game start, and again each time a new set is spawned after a push. Stats scale with the station level (REQ-PSH-STATION-STATS). - REQ-DEF-ENEMY-FIRE: Enemy defence stations automatically fire at player ships within range. - REQ-DEF-NO-CROSSFIRE: Enemy and player defence stations are never in each other's firing range. -- REQ-DEF-PUSH: When both enemy defence stations in a set are destroyed, the push scaling multiplier is applied (REQ-PSH-ACCUMULATION), the scrollable area is extended (REQ-GW-PUSH-EXPAND), a new set of enemy defence stations is placed at the new boundary, and exactly one schematic drop is awarded for the destroyed set (REQ-DEF-SCHEMATIC-DROP). +- REQ-DEF-PUSH: When both enemy defence stations in a set are destroyed, the boss countdown is advanced (REQ-WAV-BOSS-ADVANCE), the scrollable area is extended (REQ-GW-PUSH-EXPAND), a new set of enemy defence stations is placed at the new boundary, and exactly one schematic drop is awarded for the destroyed set (REQ-DEF-SCHEMATIC-DROP). - REQ-DEF-SCHEMATIC-DROP: Each destroyed set of enemy defence stations awards exactly one schematic drop (not one per station). The drop is automatic — no physical item to collect. A schematic is chosen uniformly at random from all schematics defined in `ships.toml`. If the player does not yet have that schematic, it is unlocked. If the player already has it, the schematic's `[ship.schematic].player_production_level` is incremented by 1 — so subsequent ships of that type are produced at a higher level. The player is notified via a toast (REQ-UI-SCHEMATIC-TOAST). ## Threat Level & Enemy Waves -- REQ-WAV-THREAT-RATE: A global **threat level** accumulates continuously over time. The rate of increase per second is determined by `world.toml [waves].threat_rate_formula` where x is elapsed game time in seconds, clamped to a minimum of 0 (negative formula values are treated as 0). Example: `1*x - 30` yields 0 threat/s for x ≤ 30s and increases linearly after that. -- REQ-WAV-GAP: At game start and immediately after each wave is triggered, a random inter-wave gap is drawn uniformly from [`world.toml [waves].gap_min_seconds`, `gap_max_seconds`]. -- REQ-WAV-TRIGGER: When the gap expires, a wave is triggered. Ships are selected one at a time: from all schematics whose `threat.cost_formula` evaluates to > 0 at the current enemy ship level, uniformly randomly pick one whose cost fits the remaining threat budget. Repeat until no eligible schematic fits. Any remaining threat carries over to the next wave. A longer gap results in a larger wave. Because enemy ship level increases with time (REQ-WAV-SHIP-LEVEL), threat cost per ship rises naturally over the course of the game. -- REQ-WAV-SHIP-LEVEL: Each wave's enemy ships are assigned a level determined by `world.toml [waves].ship_level_formula` where x is elapsed game time in seconds. This is the sole mechanism by which individual enemy ships become stronger over time. Wave *size* grows separately via threat accumulation (REQ-WAV-THREAT-RATE) and push scaling (REQ-PSH-ACCUMULATION). Per-ship stats and threat cost are computed from the ship level via the formulas in `ships.toml` (see REQ-SHP-STATS). +- REQ-WAV-BOSS-COUNTER: A global **boss wave counter** `x` starts at 1 at game start and increments by 1 immediately after each boss wave fires. It represents the current boss wave cycle number and is used as the variable in the threat rate and ship level formulas. +- REQ-WAV-THREAT-RATE: A global **threat level** accumulates continuously over real game time. The rate of increase per second is determined by `world.toml [waves].threat_rate_formula` where `x` is the boss wave counter (REQ-WAV-BOSS-COUNTER), clamped to a minimum of 0 (negative formula values are treated as 0). The rate is constant within each boss wave cycle and steps up each time `x` increments. Threat accumulation continues uninterrupted during quiet windows (REQ-WAV-QUIET). Example: `1*x - 30` yields 0 threat/s when x ≤ 30 and increases linearly beyond that. +- REQ-WAV-GAP: At game start and immediately after each normal wave is triggered, a random inter-wave gap is drawn uniformly from [`world.toml [waves].gap_min_seconds`, `gap_max_seconds`]. The gap timer does not advance while inside a quiet window (REQ-WAV-QUIET); if a gap would expire inside a quiet window, its expiry is deferred until the quiet window ends. +- REQ-WAV-TRIGGER: When the gap timer expires outside a quiet window, a normal wave is triggered. Ships are selected one at a time: from all schematics whose `threat.cost_formula` evaluates to > 0 at the current enemy ship level, uniformly randomly pick one whose cost fits the remaining threat budget. Repeat until no eligible schematic fits. Any remaining threat carries over to the next normal wave. A longer gap results in a larger wave. Because enemy ship level increases with the boss wave counter (REQ-WAV-SHIP-LEVEL), threat cost per ship rises as the game progresses. +- REQ-WAV-SHIP-LEVEL: Each wave's (normal and boss) enemy ships are assigned a level determined by `world.toml [waves].ship_level_formula` where `x` is the boss wave counter (REQ-WAV-BOSS-COUNTER). Per-ship stats and threat cost are computed from the ship level via the formulas in `ships.toml` (see REQ-SHP-STATS). +- REQ-WAV-BOSS-COUNTDOWN: A **boss countdown** timer starts at `world.toml [waves].boss_countdown_seconds` (default 300) at game start and counts down continuously in real game-time seconds. It is not paused during quiet windows. When it reaches 0, a boss wave is triggered (REQ-WAV-BOSS-TRIGGER). Immediately after the boss wave fires, `x` increments (REQ-WAV-BOSS-COUNTER) and a fresh countdown starts at the same configured value. +- REQ-WAV-BOSS-ADVANCE: When the player destroys a set of enemy defence stations, the boss countdown is reduced by `world.toml [push].boss_advance_seconds` (default 60), clamped to a minimum of 0. Threat that would have accumulated during the skipped time is not added. If the countdown reaches 0 by this reduction, the boss wave is triggered immediately. +- REQ-WAV-QUIET: A **quiet window** suppresses normal wave spawning around each boss wave. The pre-boss quiet window begins when the boss countdown falls to or below `world.toml [waves].boss_quiet_before_seconds` and ends when the countdown reaches 0. The post-boss quiet window begins immediately when the boss wave fires and lasts `world.toml [waves].boss_quiet_after_seconds` seconds. Threat continues to accumulate during both windows. The normal wave gap timer does not advance during either window (REQ-WAV-GAP). The new boss countdown runs during the post-boss quiet window. +- REQ-WAV-BOSS-TRIGGER: When the boss countdown reaches 0, a boss wave is triggered. Its threat budget is the sum of: (a) `world.toml [waves].boss_threat_duration_seconds` (default 60) multiplied by the current threat rate, and (b) all unspent threat carried over from normal waves. Ships are selected using the same random process as normal waves (REQ-WAV-TRIGGER). Any threat remaining unspent after ship selection carries over to the first normal wave of the new cycle. - REQ-WAV-DEFAULT-MODULES: Enemy ships spawned by waves use the `default_modules` list defined per schematic in `ships.toml`. The `default_modules` array uses the same format as layout blueprints (see Layout Blueprint TOML Format). If `default_modules` is absent or empty, the ship spawns with no modules. Invalid module instances (unknown type, position outside the grid, position on a non-buildable cell, or overlapping another module) are silently skipped. - REQ-WAV-SPAWN-DURATION: Ships in a wave are spawned one at a time over `world.toml [waves].spawn_duration_seconds`. -## Push Scaling +## Push Effects -- REQ-PSH-ACCUMULATION: Each time the player destroys a set of enemy defence stations, `world.toml [push].scaling_factor` is multiplied permanently into the threat level accumulation rate. Scaling factors stack multiplicatively with each other and with the time-based threat formula, causing all subsequent waves to be larger. - REQ-PSH-STATION-STATS: Enemy defence station stats are each defined as formulas in `stations.toml [enemy_station]`: `hp_formula`, `damage_formula`, `range_formula`, `fire_rate_formula`, `scrap_drop_formula`, where x is the station level — an integer starting at 0 for the initial set and incrementing by 1 each time a new set is placed. ## Asteroid Expansion diff --git a/src/lib/config/ConfigLoader.cpp b/src/lib/config/ConfigLoader.cpp index 7ca144c..fa91e1c 100644 --- a/src/lib/config/ConfigLoader.cpp +++ b/src/lib/config/ConfigLoader.cpp @@ -277,13 +277,17 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path) cfg.expansion.costBuildingBlocks = static_cast(requireInt(tbl["expansion"]["cost_building_blocks"], file, "expansion.cost_building_blocks")); cfg.push.pushExpandColumns = static_cast(requireInt(tbl["push"]["push_expand_columns"], file, "push.push_expand_columns")); - cfg.push.scalingFactor = requireDouble(tbl["push"]["scaling_factor"], file, "push.scaling_factor"); + cfg.push.bossAdvanceSeconds = requireDouble(tbl["push"]["boss_advance_seconds"], file, "push.boss_advance_seconds"); - cfg.waves.threatRateFormula = requireFormula(tbl["waves"]["threat_rate_formula"], file, "waves.threat_rate_formula"); - cfg.waves.shipLevelFormula = requireFormula(tbl["waves"]["ship_level_formula"], file, "waves.ship_level_formula"); - cfg.waves.gapMinSeconds = requireDouble(tbl["waves"]["gap_min_seconds"], file, "waves.gap_min_seconds"); - cfg.waves.gapMaxSeconds = requireDouble(tbl["waves"]["gap_max_seconds"], file, "waves.gap_max_seconds"); - cfg.waves.spawnDurationSeconds = requireDouble(tbl["waves"]["spawn_duration_seconds"], file, "waves.spawn_duration_seconds"); + cfg.waves.threatRateFormula = requireFormula(tbl["waves"]["threat_rate_formula"], file, "waves.threat_rate_formula"); + cfg.waves.shipLevelFormula = requireFormula(tbl["waves"]["ship_level_formula"], file, "waves.ship_level_formula"); + cfg.waves.gapMinSeconds = requireDouble(tbl["waves"]["gap_min_seconds"], file, "waves.gap_min_seconds"); + cfg.waves.gapMaxSeconds = requireDouble(tbl["waves"]["gap_max_seconds"], file, "waves.gap_max_seconds"); + cfg.waves.spawnDurationSeconds = requireDouble(tbl["waves"]["spawn_duration_seconds"], file, "waves.spawn_duration_seconds"); + cfg.waves.bossCountdownSeconds = requireDouble(tbl["waves"]["boss_countdown_seconds"], file, "waves.boss_countdown_seconds"); + cfg.waves.bossThreatDurationSeconds = requireDouble(tbl["waves"]["boss_threat_duration_seconds"], file, "waves.boss_threat_duration_seconds"); + cfg.waves.bossQuietBeforeSeconds = requireDouble(tbl["waves"]["boss_quiet_before_seconds"], file, "waves.boss_quiet_before_seconds"); + cfg.waves.bossQuietAfterSeconds = requireDouble(tbl["waves"]["boss_quiet_after_seconds"], file, "waves.boss_quiet_after_seconds"); if (cfg.waves.gapMinSeconds > cfg.waves.gapMaxSeconds) { diff --git a/src/lib/config/WorldConfig.h b/src/lib/config/WorldConfig.h index 4124aeb..84181ab 100644 --- a/src/lib/config/WorldConfig.h +++ b/src/lib/config/WorldConfig.h @@ -18,21 +18,25 @@ struct WorldExpansion int costBuildingBlocks; }; -// Push scaling (REQ-PSH-*). +// Push effects (REQ-PSH-*, REQ-WAV-BOSS-ADVANCE). struct WorldPush { - int pushExpandColumns; - double scalingFactor; + int pushExpandColumns; + double bossAdvanceSeconds; // boss countdown advanced by this much per push }; // Wave scheduling (REQ-WAV-*). struct WorldWaves { - Formula threatRateFormula; // threat/s as a function of elapsed game-time seconds - Formula shipLevelFormula; // enemy ship level as a function of elapsed game-time seconds + Formula threatRateFormula; // threat/s as a function of boss wave counter x + Formula shipLevelFormula; // enemy ship level as a function of boss wave counter x double gapMinSeconds; double gapMaxSeconds; double spawnDurationSeconds; + double bossCountdownSeconds; // duration of each boss cycle (REQ-WAV-BOSS-COUNTDOWN) + double bossThreatDurationSeconds; // boss budget = rate * this (REQ-WAV-BOSS-TRIGGER) + double bossQuietBeforeSeconds; // suppress normal waves this long before boss (REQ-WAV-QUIET) + double bossQuietAfterSeconds; // suppress normal waves this long after boss (REQ-WAV-QUIET) }; struct WorldConfig diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index 3c77810..805382c 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -151,7 +151,7 @@ void Simulation::tick() m_config.world.heightTiles); // Step 2: threat accumulation - m_waveSystem->tickThreatAccumulation(m_currentTick); + m_waveSystem->tickThreatAccumulation(); // Construction + production pipeline m_buildingSystem->tickConstruction(m_currentTick); @@ -460,7 +460,7 @@ void Simulation::tickDeathsAndLoot() if (es0Gone && es1Gone && m_currentEnemyStationEntities[0] != entt::null) { - m_waveSystem->applyPush(); + m_waveSystem->onEnemyStationsDestroyed(); placeEnemyStationSet(m_waveSystem->generation()); awardSchematicDrop(); } diff --git a/src/lib/sim/WaveSystem.cpp b/src/lib/sim/WaveSystem.cpp index 97366c3..962c425 100644 --- a/src/lib/sim/WaveSystem.cpp +++ b/src/lib/sim/WaveSystem.cpp @@ -8,61 +8,85 @@ WaveSystem::WaveSystem(const GameConfig& config, std::mt19937& rng) : m_config(config) , m_rng(rng) { - // Draw the initial inter-wave gap (REQ-WAV-GAP, REQ-WAV-GRACE-PERIOD). - m_nextEventTick = drawGapTicks(); + m_bossCountdownTicks = secondsToTicks(config.world.waves.bossCountdownSeconds); + m_normalGapRemainingTicks = drawGapTicks(); } void WaveSystem::tickWaveScheduler(Tick currentTick, ShipSystem& ships, int worldHeightTiles) { - if (!m_waveActive) + // 1. Advance boss countdown. + --m_bossCountdownTicks; + + // 2. Trigger boss wave when countdown expires. + if (m_bossCountdownTicks <= 0) { - if (currentTick < m_nextEventTick) - { - return; - } - // Gap expired: compose the next wave. - m_pendingSpawns = composeWave(currentTick, worldHeightTiles); - m_waveActive = true; + triggerBossWave(currentTick, worldHeightTiles); } - // Spawn any ships whose scheduled tick has arrived. - std::vector remaining; - remaining.reserve(m_pendingSpawns.size()); - for (const SpawnEntry& entry : m_pendingSpawns) + // 3. Advance post-boss quiet window. + if (m_postBossQuietRemainingTicks > 0) { - if (currentTick >= entry.spawnAt) + --m_postBossQuietRemainingTicks; + } + + // 4. Normal wave gap: only advances outside quiet windows and between waves. + if (!isInQuietWindow() && !m_normalWaveActive) + { + if (m_normalGapRemainingTicks > 0) { - ships.spawn(entry.schematicId, entry.level, entry.position, - /*isEnemy=*/true, entry.layout); + --m_normalGapRemainingTicks; } - else + if (m_normalGapRemainingTicks == 0) { - remaining.push_back(entry); + triggerNormalWave(currentTick, worldHeightTiles); } } - m_pendingSpawns = std::move(remaining); - if (m_pendingSpawns.empty()) + // 5. Spawn any ships (from either queue) whose scheduled tick has arrived. + auto spawnDue = [&](std::vector& queue) { - m_waveActive = false; - m_nextEventTick = currentTick + drawGapTicks(); + std::vector remaining; + remaining.reserve(queue.size()); + for (const SpawnEntry& entry : queue) + { + if (currentTick >= entry.spawnAt) + { + ships.spawn(entry.schematicId, entry.level, entry.position, + /*isEnemy=*/true, entry.layout); + } + else + { + remaining.push_back(entry); + } + } + queue = std::move(remaining); + }; + spawnDue(m_normalPendingSpawns); + spawnDue(m_bossPendingSpawns); + + // 6. When all normal wave ships have spawned, draw the next gap. + if (m_normalWaveActive && m_normalPendingSpawns.empty()) + { + m_normalWaveActive = false; + m_normalGapRemainingTicks = drawGapTicks(); } } -void WaveSystem::tickThreatAccumulation(Tick currentTick) +void WaveSystem::tickThreatAccumulation() { - const double elapsedSeconds = static_cast(currentTick) * kTickDurationSeconds; - const double rate = m_config.world.waves.threatRateFormula.evaluate(elapsedSeconds); + const double x = static_cast(m_bossWaveCounter); + const double rate = m_config.world.waves.threatRateFormula.evaluate(x); if (rate > 0.0) { - m_threatLevel += rate * m_pushScalingMultiplier * kTickDurationSeconds; + m_threatLevel += rate * kTickDurationSeconds; } } -void WaveSystem::applyPush() +void WaveSystem::onEnemyStationsDestroyed() { - m_pushScalingMultiplier *= m_config.world.push.scalingFactor; + const Tick advance = secondsToTicks(m_config.world.push.bossAdvanceSeconds); + m_bossCountdownTicks = std::max(Tick{0}, m_bossCountdownTicks - advance); ++m_generation; } @@ -80,18 +104,52 @@ int WaveSystem::generation() const // Private helpers // --------------------------------------------------------------------------- -std::vector WaveSystem::composeWave(Tick currentTick, - int worldHeightTiles) +bool WaveSystem::isInQuietWindow() const +{ + if (m_postBossQuietRemainingTicks > 0) + { + return true; + } + const Tick quietBeforeTicks = secondsToTicks(m_config.world.waves.bossQuietBeforeSeconds); + return m_bossCountdownTicks <= quietBeforeTicks; +} + +void WaveSystem::triggerNormalWave(Tick currentTick, int worldHeightTiles) +{ + double budget = m_threatLevel; + m_threatLevel = 0.0; + m_normalPendingSpawns = selectWaveShips(budget, currentTick, worldHeightTiles); + m_threatLevel += budget; // carry leftover forward + m_normalWaveActive = true; +} + +void WaveSystem::triggerBossWave(Tick currentTick, int worldHeightTiles) +{ + const double x = static_cast(m_bossWaveCounter); + const double rate = std::max(0.0, m_config.world.waves.threatRateFormula.evaluate(x)); + double budget = rate * m_config.world.waves.bossThreatDurationSeconds + m_threatLevel; + m_threatLevel = 0.0; + m_bossPendingSpawns = selectWaveShips(budget, currentTick, worldHeightTiles); + m_threatLevel += budget; // carry leftover to first normal wave of new cycle + + m_bossCountdownTicks = secondsToTicks(m_config.world.waves.bossCountdownSeconds); + m_postBossQuietRemainingTicks = secondsToTicks(m_config.world.waves.bossQuietAfterSeconds); + ++m_bossWaveCounter; +} + +std::vector WaveSystem::selectWaveShips(double& budget, + Tick currentTick, + int worldHeightTiles) { - const double elapsedSeconds = static_cast(currentTick) * kTickDurationSeconds; const int shipLevel = std::max(1, static_cast( - m_config.world.waves.shipLevelFormula.evaluate(elapsedSeconds))); + m_config.world.waves.shipLevelFormula.evaluate( + static_cast(m_bossWaveCounter)))); // Build eligible ship list with their costs at the current level. struct EligibleShip { - std::string schematicId; - double cost; + std::string schematicId; + double cost; std::vector defaultModules; }; std::vector eligible; @@ -113,17 +171,13 @@ std::vector WaveSystem::composeWave(Tick currentTick, return {}; } - // Take the current threat level as the wave budget and reset it. - // Unspent budget is re-added after composition (carry-over). - double budget = m_threatLevel; - m_threatLevel = 0.0; - // Enemy spawn buffer X range for the current generation. const float leftX = static_cast( m_config.world.regions.playerBufferWidth + m_config.world.regions.contestZoneWidth + m_generation * m_config.world.push.pushExpandColumns); - const float rightX = leftX + static_cast(m_config.world.regions.enemyBufferWidth) - 1.0f; + const float rightX = leftX + + static_cast(m_config.world.regions.enemyBufferWidth) - 1.0f; std::uniform_real_distribution xDist(leftX, rightX); std::uniform_int_distribution yDist(0, worldHeightTiles - 1); @@ -147,24 +201,21 @@ std::vector WaveSystem::composeWave(Tick currentTick, } std::uniform_int_distribution pick(0, static_cast(fitting.size()) - 1); - const std::size_t chosenIdx = fitting[static_cast(pick(m_rng))]; - const EligibleShip& chosen = eligible[chosenIdx]; + const std::size_t chosenIdx = fitting[static_cast(pick(m_rng))]; + const EligibleShip& chosen = eligible[chosenIdx]; budget -= chosen.cost; SpawnEntry entry; - entry.schematicId = chosen.schematicId; - entry.level = shipLevel; - entry.spawnAt = 0; // set below after all picks are done - entry.position = QVector2D(xDist(m_rng), - static_cast(yDist(m_rng)) + 0.5f); - entry.layout.placedModules = chosen.defaultModules; + entry.schematicId = chosen.schematicId; + entry.level = shipLevel; + entry.spawnAt = 0; // set below after all picks are done + entry.position = QVector2D(xDist(m_rng), + static_cast(yDist(m_rng)) + 0.5f); + entry.layout.placedModules = chosen.defaultModules; picked.push_back(entry); } - // Carry leftover budget forward to the next wave. - m_threatLevel += budget; - // Spread spawn times evenly across spawnDurationSeconds. const int count = static_cast(picked.size()); if (count == 1) diff --git a/src/lib/sim/WaveSystem.h b/src/lib/sim/WaveSystem.h index cc961e3..468ff8a 100644 --- a/src/lib/sim/WaveSystem.h +++ b/src/lib/sim/WaveSystem.h @@ -19,18 +19,20 @@ class WaveSystem public: WaveSystem(const GameConfig& config, std::mt19937& rng); - // Tick step 1: start a new wave when the current gap has expired; spawn - // any ships in the pending list whose scheduled tick has arrived. + // Tick step 1: advance the boss countdown and normal wave gap; trigger + // normal or boss waves when their timers expire; spawn any ships in the + // pending queues whose scheduled tick has arrived. void tickWaveScheduler(Tick currentTick, ShipSystem& ships, int worldHeightTiles); - // Tick step 2: accumulate threat from the rate formula, scaled by the - // current push multiplier (REQ-WAV-THREAT-RATE, REQ-PSH-ACCUMULATION). - void tickThreatAccumulation(Tick currentTick); + // Tick step 2: accumulate threat from the rate formula evaluated at the + // current boss wave counter (REQ-WAV-THREAT-RATE). + void tickThreatAccumulation(); // Called by Simulation (tick step 9) when the current enemy-station set - // is fully destroyed: multiplies the push scaling and increments generation. - void applyPush(); + // is fully destroyed: advances the boss countdown and increments generation + // (REQ-WAV-BOSS-ADVANCE, REQ-PSH-STATION-STATS). + void onEnemyStationsDestroyed(); double threatLevel() const; @@ -41,17 +43,23 @@ public: private: struct SpawnEntry { - std::string schematicId; - int level; - Tick spawnAt; - QVector2D position; - ShipLayoutConfig layout; + std::string schematicId; + int level; + Tick spawnAt; + QVector2D position; + ShipLayoutConfig layout; }; - // Compose the next wave from the current threat budget, returning timed - // spawn entries spread across spawnDurationSeconds. Leaves any unspent - // budget in m_threatLevel (carry-over, REQ-WAV-TRIGGER). - std::vector composeWave(Tick currentTick, int worldHeightTiles); + // Select ships from the given threat budget until no eligible ship fits. + // Reduces budget in-place to the remaining (carry-over) amount. + std::vector selectWaveShips(double& budget, Tick currentTick, + int worldHeightTiles); + + void triggerNormalWave(Tick currentTick, int worldHeightTiles); + void triggerBossWave(Tick currentTick, int worldHeightTiles); + + // Returns true while normal-wave spawning should be suppressed (REQ-WAV-QUIET). + bool isInQuietWindow() const; // Draw a random gap duration in ticks from [gapMin, gapMax]. Tick drawGapTicks(); @@ -59,11 +67,19 @@ private: const GameConfig& m_config; std::mt19937& m_rng; - double m_threatLevel = 0.0; - double m_pushScalingMultiplier = 1.0; - int m_generation = 0; + double m_threatLevel = 0.0; + int m_generation = 0; - bool m_waveActive = false; - Tick m_nextEventTick = 0; // absolute tick when the current gap expires - std::vector m_pendingSpawns; + // Boss wave cycle (REQ-WAV-BOSS-COUNTER, REQ-WAV-BOSS-COUNTDOWN). + int m_bossWaveCounter = 1; + Tick m_bossCountdownTicks; // counts down each tick; reset after boss fires + Tick m_postBossQuietRemainingTicks = 0; + + // Normal wave gap — frozen during quiet windows (REQ-WAV-GAP). + bool m_normalWaveActive = false; + Tick m_normalGapRemainingTicks; // replaces old m_nextEventTick + + // Spawn queues — kept separate so normal-wave completion is trackable. + std::vector m_normalPendingSpawns; + std::vector m_bossPendingSpawns; }; diff --git a/src/test/ConfigLoaderTest.cpp b/src/test/ConfigLoaderTest.cpp index 3d523dc..7c0824f 100644 --- a/src/test/ConfigLoaderTest.cpp +++ b/src/test/ConfigLoaderTest.cpp @@ -75,12 +75,12 @@ TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[c REQUIRE(cfg.world.regions.playerBufferWidth == 10); REQUIRE(cfg.world.regions.enemyBufferWidth == 15); REQUIRE(cfg.world.expansion.columnsPerExpansion == 10); - REQUIRE(cfg.world.push.scalingFactor == Approx(1.2)); + REQUIRE(cfg.world.push.bossAdvanceSeconds == Approx(60.0)); // Spot-check that a config-derived formula computes as expected. - // threat_rate_formula = "1*x - 30": zero at x=30, 30 at x=60. - REQUIRE(cfg.world.waves.threatRateFormula.evaluate(30.0) == Approx(0.0)); - REQUIRE(cfg.world.waves.threatRateFormula.evaluate(60.0) == Approx(30.0)); + // threat_rate_formula = "x": evaluates to the input value. + REQUIRE(cfg.world.waves.threatRateFormula.evaluate(1.0) == Approx(1.0)); + REQUIRE(cfg.world.waves.threatRateFormula.evaluate(5.0) == Approx(5.0)); // buildings.toml REQUIRE(cfg.buildings.buildings.size() >= 8); @@ -222,11 +222,11 @@ cost_building_blocks = 200 [push] push_expand_columns = 20 -scaling_factor = 1.2 +boss_advance_seconds = 60 [waves] threat_rate_formula = "1 +" -ship_level_formula = "1 + x / 120" +ship_level_formula = "1 + x / 10" gap_min_seconds = 15 gap_max_seconds = 45 spawn_duration_seconds = 10 diff --git a/src/test/WaveSystemTest.cpp b/src/test/WaveSystemTest.cpp index 1b3f9fa..d360134 100644 --- a/src/test/WaveSystemTest.cpp +++ b/src/test/WaveSystemTest.cpp @@ -29,73 +29,33 @@ static GameConfig loadConfig() // Threat accumulation // --------------------------------------------------------------------------- -TEST_CASE("WaveSystem: threat stays 0 for first 30 game-seconds", "[wave]") +TEST_CASE("WaveSystem: threat accumulates at boss wave counter rate", "[wave]") { const GameConfig cfg = loadConfig(); std::mt19937 rng(42); WaveSystem ws(cfg, rng); - // threat_rate_formula = "1*x - 30", which is <= 0 for x <= 30. - const int ticks30s = static_cast(secondsToTicks(30.0)); - for (int i = 0; i < ticks30s; ++i) + // threat_rate_formula = "x", boss wave counter starts at 1 → rate = 1 threat/s. + // After 1 second: threat ≈ 1.0. + const int ticks1s = static_cast(secondsToTicks(1.0)); + for (int i = 0; i < ticks1s; ++i) { - ws.tickThreatAccumulation(static_cast(i)); + ws.tickThreatAccumulation(); } - REQUIRE(ws.threatLevel() == Approx(0.0)); + REQUIRE(ws.threatLevel() == Approx(1.0)); } -TEST_CASE("WaveSystem: threat accumulates after 30 game-seconds", "[wave]") -{ - const GameConfig cfg = loadConfig(); - std::mt19937 rng(42); - WaveSystem ws(cfg, rng); - - // Run 31 seconds worth of ticks. - const int ticks31s = static_cast(secondsToTicks(31.0)); - for (int i = 0; i < ticks31s; ++i) - { - ws.tickThreatAccumulation(static_cast(i)); - } - - REQUIRE(ws.threatLevel() > 0.0); -} - -TEST_CASE("WaveSystem: applyPush increases threat accumulation rate", "[wave]") -{ - const GameConfig cfg = loadConfig(); - std::mt19937 rng(42); - WaveSystem ws(cfg, rng); - - // Accumulate for 1 tick past the 30s mark to get a baseline rate. - const Tick baseTick = secondsToTicks(31.0); - ws.tickThreatAccumulation(baseTick); - const double levelBefore = ws.threatLevel(); - - // Apply push: multiplier should increase. - ws.applyPush(); - - WaveSystem ws2(cfg, rng); - ws2.tickThreatAccumulation(baseTick); - - // After the push the same tick adds more threat. - ws.tickThreatAccumulation(baseTick + 1); - ws2.tickThreatAccumulation(baseTick + 1); - - // ws has the push multiplier applied; ws2 does not. - REQUIRE(ws.threatLevel() > ws2.threatLevel()); -} - -TEST_CASE("WaveSystem: generation starts at 0 and increments on push", "[wave]") +TEST_CASE("WaveSystem: generation starts at 0 and increments on station destruction", "[wave]") { const GameConfig cfg = loadConfig(); std::mt19937 rng(42); WaveSystem ws(cfg, rng); REQUIRE(ws.generation() == 0); - ws.applyPush(); + ws.onEnemyStationsDestroyed(); REQUIRE(ws.generation() == 1); - ws.applyPush(); + ws.onEnemyStationsDestroyed(); REQUIRE(ws.generation() == 2); } @@ -217,18 +177,23 @@ TEST_CASE("WaveSystem: enemy ships spawn after the initial gap elapses", "[wave] // The maximum gap is gapMaxSeconds = 45s → 1350 ticks. // Run 1500 ticks to guarantee at least one wave has triggered. + // Check each tick: enemy ships may be killed quickly by player stations, + // so we must detect them while they are alive, not only after the loop. const int limit = static_cast(secondsToTicks(50.0)); + bool foundEnemyShip = false; for (int i = 0; i < limit; ++i) { sim.tick(); - } - - bool foundEnemyShip = false; - sim.admin().forEach( - [&](entt::entity /*e*/, const ShipIdentityComponent& /*si*/, const FactionComponent& f) + if (!foundEnemyShip) { - if (f.isEnemy) { foundEnemyShip = true; } - }); + sim.admin().forEach( + [&](entt::entity /*e*/, const ShipIdentityComponent& /*si*/, + const FactionComponent& f) + { + if (f.isEnemy) { foundEnemyShip = true; } + }); + } + } REQUIRE(foundEnemyShip); }