boss waves

This commit is contained in:
2026-06-03 21:30:38 +02:00
parent 457fc47c75
commit b5185b0906
10 changed files with 214 additions and 162 deletions

View File

@@ -277,13 +277,17 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path)
cfg.expansion.costBuildingBlocks = static_cast<int>(requireInt(tbl["expansion"]["cost_building_blocks"], file, "expansion.cost_building_blocks"));
cfg.push.pushExpandColumns = static_cast<int>(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)
{

View File

@@ -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

View File

@@ -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();
}

View File

@@ -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<SpawnEntry> 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<SpawnEntry>& queue)
{
m_waveActive = false;
m_nextEventTick = currentTick + drawGapTicks();
std::vector<SpawnEntry> 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<double>(currentTick) * kTickDurationSeconds;
const double rate = m_config.world.waves.threatRateFormula.evaluate(elapsedSeconds);
const double x = static_cast<double>(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::SpawnEntry> 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<double>(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::SpawnEntry> WaveSystem::selectWaveShips(double& budget,
Tick currentTick,
int worldHeightTiles)
{
const double elapsedSeconds = static_cast<double>(currentTick) * kTickDurationSeconds;
const int shipLevel = std::max(1, static_cast<int>(
m_config.world.waves.shipLevelFormula.evaluate(elapsedSeconds)));
m_config.world.waves.shipLevelFormula.evaluate(
static_cast<double>(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<PlacedModule> defaultModules;
};
std::vector<EligibleShip> eligible;
@@ -113,17 +171,13 @@ std::vector<WaveSystem::SpawnEntry> 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<float>(
m_config.world.regions.playerBufferWidth
+ m_config.world.regions.contestZoneWidth
+ m_generation * m_config.world.push.pushExpandColumns);
const float rightX = leftX + static_cast<float>(m_config.world.regions.enemyBufferWidth) - 1.0f;
const float rightX = leftX
+ static_cast<float>(m_config.world.regions.enemyBufferWidth) - 1.0f;
std::uniform_real_distribution<float> xDist(leftX, rightX);
std::uniform_int_distribution<int> yDist(0, worldHeightTiles - 1);
@@ -147,24 +201,21 @@ std::vector<WaveSystem::SpawnEntry> WaveSystem::composeWave(Tick currentTick,
}
std::uniform_int_distribution<int> pick(0, static_cast<int>(fitting.size()) - 1);
const std::size_t chosenIdx = fitting[static_cast<std::size_t>(pick(m_rng))];
const EligibleShip& chosen = eligible[chosenIdx];
const std::size_t chosenIdx = fitting[static_cast<std::size_t>(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<float>(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<float>(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<int>(picked.size());
if (count == 1)

View File

@@ -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<SpawnEntry> 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<SpawnEntry> 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<SpawnEntry> 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<SpawnEntry> m_normalPendingSpawns;
std::vector<SpawnEntry> m_bossPendingSpawns;
};

View File

@@ -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

View File

@@ -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<int>(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<int>(secondsToTicks(1.0));
for (int i = 0; i < ticks1s; ++i)
{
ws.tickThreatAccumulation(static_cast<Tick>(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<int>(secondsToTicks(31.0));
for (int i = 0; i < ticks31s; ++i)
{
ws.tickThreatAccumulation(static_cast<Tick>(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<int>(secondsToTicks(50.0));
bool foundEnemyShip = false;
for (int i = 0; i < limit; ++i)
{
sim.tick();
}
bool foundEnemyShip = false;
sim.admin().forEach<ShipIdentityComponent, FactionComponent>(
[&](entt::entity /*e*/, const ShipIdentityComponent& /*si*/, const FactionComponent& f)
if (!foundEnemyShip)
{
if (f.isEnemy) { foundEnemyShip = true; }
});
sim.admin().forEach<ShipIdentityComponent, FactionComponent>(
[&](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
const FactionComponent& f)
{
if (f.isEnemy) { foundEnemyShip = true; }
});
}
}
REQUIRE(foundEnemyShip);
}