boss waves
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user