From e7d70a3640d5ac0cb1d2993db4512cddceeb3d79 Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Tue, 30 Jun 2026 08:44:02 +0200 Subject: [PATCH 1/5] add win condition and artifact system to requirements Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn --- docs/requirements.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/requirements.md b/docs/requirements.md index 82b77e8..73c0312 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, boss wave timing, enemy ship level formula, belt speed, starting building blocks, departure interval, ship orbit factor, rally orbit radius, and combat target-selection parameters (target score formula, overclaim penalty formula, target hysteresis). +- **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, ship orbit factor, rally orbit radius, combat target-selection parameters (target score formula, overclaim penalty formula, target hysteresis), artifact chance formula, and artifact win count. - **buildings.toml** — building block cost and construction time per building type. - **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities. Assembler recipe entries may optionally define `unlock_at_station_level` (integer): -1 means the recipe is explicitly unlocked at game start; a value ≥ 0 means the recipe starts locked and a schematic for it can be awarded via defence station destruction (see REQ-LOCK-EXPLICIT, REQ-DEF-SCHEMATIC-DROP). - **ships.toml** — per schematic: a human-readable display name (used in the 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, player production level, the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the schematic already unlocked), a layout grid defining the ship's module slots, a `scrap_drop` loot value, and a `default_modules` list used for enemy wave ships (see REQ-WAV-DEFAULT-MODULES). @@ -90,6 +90,11 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des - REQ-HQ-GAME-OVER: If the HQ is destroyed, the game ends. A game-over screen shows the final survival time and offers "Restart" and "Quit" buttons. - REQ-HQ-INVULNERABLE: Factory buildings (other than the HQ) are never targeted or destroyed by enemies. +## Win Condition + +- REQ-WIN-ARTIFACT-COUNT: The player has an artifact count, starting at 0 at game start and resetting to 0 on Restart (REQ-CFG-RELOAD). When the player selects an artifact option in the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP), the artifact count increments by 1. When the artifact count reaches `world.toml [world].artifact_win_count`, the player wins and the win screen is shown (REQ-WIN-SCREEN). +- REQ-WIN-SCREEN: When the player wins (REQ-WIN-ARTIFACT-COUNT), the simulation stops and a win screen is shown. The win screen functions identically to the game-over screen (REQ-HQ-GAME-OVER) — it displays the final survival time and offers "Restart" and "Quit" buttons — but with the caption "Won!" instead of the game-over caption. + ## Building Placement & Management - REQ-BLD-COST: The player places buildings from a build menu. Placement costs building blocks from the global stock. The cost per building type is read from `buildings.toml [[building]].cost`. @@ -299,11 +304,11 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des - 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 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 opens a **schematic choice dialog** — a modal dialog that pauses the game (speed set to 0×; on close, speed is restored to what it was before the dialog opened). Up to three schematic options are drawn uniformly at random **without replacement** from the eligible drop pool. If the pool contains fewer than three entries, only that many options are shown. The eligible drop pool contains: +- REQ-DEF-SCHEMATIC-DROP: Each destroyed set of enemy defence stations awards exactly one schematic drop (not one per station). The drop opens a **schematic choice dialog** — a modal dialog that pauses the game (speed set to 0×; on close, speed is restored to what it was before the dialog opened). Before drawing schematic picks, an artifact roll is made: evaluate `world.toml [world].artifact_chance_formula` with `x` set to the level of the destroyed station set, clamp the result to [0, 1], then compare against a uniform random value in [0, 1). If the roll succeeds, the dialog presents one **artifact option** plus two schematic picks drawn from the eligible pool; otherwise it presents three schematic picks. Up to three (or two, if an artifact option is present) schematic options are drawn uniformly at random **without replacement** from the eligible drop pool. If the pool contains fewer than the required number of entries, only that many schematic options are shown (the artifact option is always shown if the roll succeeded). The eligible drop pool contains: - All **ship schematics** and **module schematics** whose `unlock_at_station_level` is -1 or is ≤ the level of the destroyed station set. - All **assembler recipe schematics** whose `unlock_at_station_level` is ≥ 0 and ≤ the level of the destroyed station set, whose output item is currently implicitly unlocked (REQ-LOCK-IMPLICIT), and which have not yet been awarded. - Each option in the dialog displays: the schematic name (ship `display_name` from `ships.toml`, module `id` from `modules.toml`, or the output item type for assembler recipes), the schematic type (ship, module, or assembler recipe), and whether selecting it would be a **new unlock** or a **level-up** (showing the target level for level-ups). Assembler recipe schematics are always new unlocks since they are removed from the pool once awarded. + Each option in the dialog displays: the schematic name (ship `display_name` from `ships.toml`, module `id` from `modules.toml`, or the output item type for assembler recipes), the schematic type (ship, module, or assembler recipe), and whether selecting it would be a **new unlock** or a **level-up** (showing the target level for level-ups). Assembler recipe schematics are always new unlocks since they are removed from the pool once awarded. The artifact option (if present) is displayed as a distinct entry with the name "Artifact" and no unlock/level-up annotation. Each option additionally displays a vertical list of item names labeled "Unlocks recipes for:", showing which recipes would newly become implicitly unlocked (REQ-LOCK-IMPLICIT) if this option were selected — specifically, the output items of miner recipes and assembler recipes (without `unlock_at_station_level`) that are not currently implicitly unlocked but would become so after applying this option's effect: - For a ship or module schematic that would be a **new unlock**, its `materials` are added to the base set per REQ-LOCK-IMPLICIT step 1a before recomputation. @@ -312,7 +317,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des Item names are deduplicated and sorted alphabetically. If no recipes would be newly unlocked, the list shows "None". - The player selects one option by clicking it. The selected schematic is applied and the dialog closes: + The player selects one option by clicking it. If the player selects the artifact option, the player's artifact count is incremented by 1 (REQ-WIN-ARTIFACT-COUNT) and the dialog closes; no schematic is applied. Otherwise, the selected schematic is applied and the dialog closes: For a **ship or module schematic**: if the player does not yet have the schematic, it is unlocked (ship schematics unlock the corresponding shipyard selection; module schematics unlock the module type for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG)). If the player already has it, the schematic's `player_production_level` is incremented by 1 — for ship schematics, subsequent ships of that type are produced at a higher level; for module schematics, all instances of that module type use the higher level in their stat formulas. @@ -384,7 +389,7 @@ The screen is divided into two columns: a main column (75% width) containing the (75% width) (25% width) ``` -- REQ-UI-HEADER: The header bar spans the width of the game world column (75% of the screen width) and always shows the elapsed survival time and the current global building blocks stock on the left, the boss wave counter and boss countdown (REQ-UI-BOSS-STATUS) to the left of the speed buttons, and game speed controls on the right. +- REQ-UI-HEADER: The header bar spans the width of the game world column (75% of the screen width) and always shows the elapsed survival time, the current global building blocks stock, and the artifact count (REQ-WIN-ARTIFACT-COUNT) displayed as `Artifacts: x/y` (where `x` is the current artifact count and `y` is `world.toml [world].artifact_win_count`) on the left, the boss wave counter and boss countdown (REQ-UI-BOSS-STATUS) to the left of the speed buttons, and game speed controls on the right. - REQ-UI-BOSS-STATUS: The header bar displays, to the left of the speed buttons, the current boss wave counter (REQ-WAV-BOSS-COUNTER) and the time remaining on the boss countdown (REQ-WAV-BOSS-COUNTDOWN). The boss wave counter is shown as `Boss Wave #` and the countdown as `Next boss: `, where `` is the remaining seconds formatted as whole minutes and two-digit seconds. Both values update continuously as the simulation runs. - REQ-UI-SPEED: The game speed controls in the header bar are buttons for 0×, 0.5×, 1×, 2×, and 4× speed. The currently active speed is shown as selected. All game simulation (production, movement, threat accumulation, wave timing) scales with the selected speed. 0× pauses the game. - REQ-UI-WORLD-SIZE: The game world view occupies the full height below the header bar in the main column (75% of the screen width). -- 2.49.1 From 2b6dafcf507ac30bb314b8c3e57779d14fb0ab01 Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Tue, 30 Jun 2026 09:18:19 +0200 Subject: [PATCH 2/5] implement artifact win condition (REQ-WIN-ARTIFACT-COUNT, REQ-WIN-SCREEN) - Add [artifacts] config section to world.toml with artifact_chance_formula (x = station level) and artifact_win_count - Roll artifact chance before each schematic drop; on success show 2 schematics + Artifact option instead of 3 - Selecting Artifact increments artifact count; reaching artifact_win_count triggers win screen - Display "Artifacts: x/y" in header bar via ArtifactCountChangedEvent - Win screen mirrors game-over screen with "Won!" caption and same Restart/Quit buttons Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn --- bin/app/data/config/world.toml | 4 + bin/test/data/config/world.toml | 4 + src/lib/config/ConfigLoader.cpp | 3 + src/lib/config/WorldConfig.h | 8 ++ src/lib/core/SchematicChoiceOption.h | 3 +- .../event/ArtifactCountChangedEvent.h | 11 ++ src/lib/eventsystem/event/CMakeLists.txt | 2 + src/lib/eventsystem/event/WinEvent.h | 5 + src/lib/sim/Simulation.cpp | 43 +++++++- src/lib/sim/Simulation.h | 6 +- src/ui/GameWorldView.cpp | 24 +++- src/ui/GameWorldView.h | 4 + src/ui/HeaderBar.cpp | 13 ++- src/ui/HeaderBar.h | 6 +- src/ui/MainWindow.cpp | 39 +++++++ src/ui/MainWindow.h | 3 + src/ui/SchematicChoiceDialog.cpp | 103 ++++++++++-------- 17 files changed, 225 insertions(+), 56 deletions(-) create mode 100644 src/lib/eventsystem/event/ArtifactCountChangedEvent.h create mode 100644 src/lib/eventsystem/event/WinEvent.h diff --git a/bin/app/data/config/world.toml b/bin/app/data/config/world.toml index b67ca18..8ba6195 100644 --- a/bin/app/data/config/world.toml +++ b/bin/app/data/config/world.toml @@ -29,6 +29,10 @@ target_score_formula = "1 / (1 + x)" # x = distance / max weapon r overclaim_penalty_formula = "max(0.5, 1 - 0.1*x)" # x = competing claim count; multiplies score, clamped to [0,1] target_hysteresis = 0.40 # keep current target unless a challenger beats it by >10% +[artifacts] +artifact_chance_formula = "0.05 * x" # 5% chance per station level +artifact_win_count = 3 + [waves] threat_rate_formula = "x" ship_level_formula = "1" diff --git a/bin/test/data/config/world.toml b/bin/test/data/config/world.toml index d7424e0..811f44d 100644 --- a/bin/test/data/config/world.toml +++ b/bin/test/data/config/world.toml @@ -29,6 +29,10 @@ target_score_formula = "1 / (1 + x)" # x = distance / max weapon r overclaim_penalty_formula = "max(0.5, 1 - 0.1*x)" # x = competing claim count; multiplies score, clamped to [0,1] target_hysteresis = 0.10 # keep current target unless a challenger beats it by >10% +[artifacts] +artifact_chance_formula = "0.05 * x" # 5% chance per station level +artifact_win_count = 3 + [waves] threat_rate_formula = "x" ship_level_formula = "1 + x / 10" diff --git a/src/lib/config/ConfigLoader.cpp b/src/lib/config/ConfigLoader.cpp index c21c538..136ad9c 100644 --- a/src/lib/config/ConfigLoader.cpp +++ b/src/lib/config/ConfigLoader.cpp @@ -301,6 +301,9 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path) cfg.targeting.overclaimPenaltyFormula = requireFormula(tbl["targeting"]["overclaim_penalty_formula"], file, "targeting.overclaim_penalty_formula"); cfg.targeting.hysteresis = requireDouble(tbl["targeting"]["target_hysteresis"], file, "targeting.target_hysteresis"); + cfg.artifacts.artifactChanceFormula = requireFormula(tbl["artifacts"]["artifact_chance_formula"], file, "artifacts.artifact_chance_formula"); + cfg.artifacts.artifactWinCount = static_cast(requireInt(tbl["artifacts"]["artifact_win_count"], file, "artifacts.artifact_win_count")); + return cfg; } diff --git a/src/lib/config/WorldConfig.h b/src/lib/config/WorldConfig.h index 764e18d..f0b7491 100644 --- a/src/lib/config/WorldConfig.h +++ b/src/lib/config/WorldConfig.h @@ -47,6 +47,13 @@ struct WorldTargeting double hysteresis; // fractional margin a challenger must beat the current target by }; +// Artifact win condition (REQ-WIN-ARTIFACT-COUNT, REQ-WIN-SCREEN). +struct WorldArtifacts +{ + Formula artifactChanceFormula; // x = station level, result clamped to [0,1] + int artifactWinCount; +}; + struct WorldConfig { int heightTiles; // REQ-GW-HEIGHT @@ -65,4 +72,5 @@ struct WorldConfig WorldPush push; WorldWaves waves; WorldTargeting targeting; + WorldArtifacts artifacts; }; diff --git a/src/lib/core/SchematicChoiceOption.h b/src/lib/core/SchematicChoiceOption.h index aa3a8e4..5554276 100644 --- a/src/lib/core/SchematicChoiceOption.h +++ b/src/lib/core/SchematicChoiceOption.h @@ -7,7 +7,8 @@ enum class SchematicType { Ship, Module, - Recipe + Recipe, + Artifact }; // One option presented to the player in the schematic choice dialog diff --git a/src/lib/eventsystem/event/ArtifactCountChangedEvent.h b/src/lib/eventsystem/event/ArtifactCountChangedEvent.h new file mode 100644 index 0000000..258d99b --- /dev/null +++ b/src/lib/eventsystem/event/ArtifactCountChangedEvent.h @@ -0,0 +1,11 @@ +#pragma once + +#include "Event.h" + +class ArtifactCountChangedEvent : public Event +{ +public: + ArtifactCountChangedEvent(int count, int winCount) : count(count), winCount(winCount) {} + const int count; + const int winCount; +}; diff --git a/src/lib/eventsystem/event/CMakeLists.txt b/src/lib/eventsystem/event/CMakeLists.txt index 80fb640..a7011fe 100644 --- a/src/lib/eventsystem/event/CMakeLists.txt +++ b/src/lib/eventsystem/event/CMakeLists.txt @@ -9,6 +9,8 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoicesAvailableEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/SelectionChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/GameOverEvent.h + ${CMAKE_CURRENT_SOURCE_DIR}/WinEvent.h + ${CMAKE_CURRENT_SOURCE_DIR}/ArtifactCountChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BuilderModeExitedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintModeExitedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/EscapeMenuRequestedEvent.h diff --git a/src/lib/eventsystem/event/WinEvent.h b/src/lib/eventsystem/event/WinEvent.h new file mode 100644 index 0000000..338d894 --- /dev/null +++ b/src/lib/eventsystem/event/WinEvent.h @@ -0,0 +1,5 @@ +#pragma once + +#include "Event.h" + +class WinEvent : public Event {}; diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index 07d92de..e49128a 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -134,6 +134,8 @@ void Simulation::reset(unsigned int seed) m_nextBuildingId = 1; m_buildingBlocksStock = m_config.world.startingBuildingBlocks; m_gameOver = false; + m_isWon = false; + m_artifactCount = 0; m_hqBuildingId = kInvalidBuildingId; m_hqProxyEntity = entt::null; m_playerStation1Entity = entt::null; @@ -584,7 +586,14 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel) if (pool.empty()) { return; } - const int numChoices = std::min(static_cast(pool.size()), 3); + const double artifactChance = std::clamp( + m_config.world.artifacts.artifactChanceFormula.evaluate( + static_cast(destroyedStationLevel)), + 0.0, 1.0); + std::uniform_real_distribution realDist(0.0, 1.0); + const bool artifactRolled = realDist(m_rng) < artifactChance; + + const int numChoices = std::min(static_cast(pool.size()), artifactRolled ? 2 : 3); m_pendingSchematicChoices.clear(); const std::set currentShipIds = getUnlockedShipSchematicIds(); @@ -657,6 +666,17 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel) m_pendingSchematicChoices.push_back(option); } + + if (artifactRolled) + { + SchematicChoiceOption artifactOption; + artifactOption.schematicId = ""; + artifactOption.type = SchematicType::Artifact; + artifactOption.displayName = "Artifact"; + artifactOption.isNewUnlock = false; + artifactOption.targetLevel = 0; + m_pendingSchematicChoices.push_back(std::move(artifactOption)); + } } void Simulation::applySchematicChoice(int choiceIndex) @@ -664,6 +684,17 @@ void Simulation::applySchematicChoice(int choiceIndex) assert(choiceIndex >= 0 && choiceIndex < static_cast(m_pendingSchematicChoices.size())); const SchematicChoiceOption& chosen = m_pendingSchematicChoices[static_cast(choiceIndex)]; + if (chosen.type == SchematicType::Artifact) + { + m_artifactCount += 1; + if (m_artifactCount >= m_config.world.artifacts.artifactWinCount) + { + m_isWon = true; + } + m_pendingSchematicChoices.clear(); + return; + } + if (chosen.type == SchematicType::Recipe) { m_unlockedRecipeSchematicIds.insert(chosen.schematicId); @@ -866,6 +897,16 @@ bool Simulation::isGameOver() const return m_gameOver; } +bool Simulation::isWon() const +{ + return m_isWon; +} + +int Simulation::artifactCount() const +{ + return m_artifactCount; +} + double Simulation::threatLevel() const { return m_waveSystem->threatLevel(); diff --git a/src/lib/sim/Simulation.h b/src/lib/sim/Simulation.h index 173e2ee..c1ccb28 100644 --- a/src/lib/sim/Simulation.h +++ b/src/lib/sim/Simulation.h @@ -68,6 +68,8 @@ public: Tick currentTick() const; int buildingBlocksStock() const; bool isGameOver() const; + bool isWon() const; + int artifactCount() const; double threatLevel() const; double threatAccumulationRate() const; double maxFactoryProductionThreatRate() const; @@ -131,7 +133,9 @@ private: Tick m_nextDepartureTick; BuildingId m_nextBuildingId; int m_buildingBlocksStock; - bool m_gameOver = false; + bool m_gameOver = false; + bool m_isWon = false; + int m_artifactCount = 0; // Pre-placed structure IDs. BuildingId m_hqBuildingId; // Building id (for belt integration) diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index fcbe533..3b2e2ac 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -251,6 +251,24 @@ void GameWorldView::onFrame() m_schematicChoiceShown = false; } + // Artifact count update + const int currentArtifactCount = m_sim->artifactCount(); + if (currentArtifactCount != m_lastArtifactCount) + { + m_lastArtifactCount = currentArtifactCount; + EventManager::getInstance()->sendEventImmediately( + std::make_shared( + currentArtifactCount, m_sim->config().world.artifacts.artifactWinCount)); + } + + // Win check + if (m_sim->isWon() && !m_winShown) + { + m_winShown = true; + m_gameSpeedMultiplier = 0.0; + EventManager::getInstance()->sendEventImmediately(std::make_shared()); + } + // Game over check if (m_sim->isGameOver() && !m_gameOverShown) { @@ -1667,12 +1685,14 @@ void GameWorldView::resetForNewGame() m_scrollXTiles = 0.0f; m_scrollLeft = false; m_scrollRight = false; - m_gameOverShown = false; - m_prevNonZeroSpeed = 1.0; + m_gameOverShown = false; + m_winShown = false; + m_prevNonZeroSpeed = 1.0; m_lastTick = Tick(-1); m_lastBlocks = -1; m_lastBossCounter = -1; m_lastBossCountdown = Tick(-1); + m_lastArtifactCount = -1; EventManager::getInstance()->sendEventImmediately( std::make_shared(std::vector{})); setGameSpeed(1.0); diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 082ef52..26eb24c 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -25,8 +25,10 @@ #include "ExitBlueprintModeRequestedEvent.h" #include "ExitBuilderModeRequestedEvent.h" #include "DebugDrawToggledEvent.h" +#include "ArtifactCountChangedEvent.h" #include "BeamFiredEvent.h" #include "SchematicChoiceOption.h" +#include "WinEvent.h" #include "SpeedChangeRequestedEvent.h" #include "entt/entity/entity.hpp" @@ -191,10 +193,12 @@ private: bool m_scrollLeft; bool m_scrollRight; bool m_gameOverShown; + bool m_winShown; bool m_schematicChoiceShown; Tick m_lastTick = Tick(-1); int m_lastBlocks = -1; int m_lastBossCounter = -1; Tick m_lastBossCountdown = Tick(-1); + int m_lastArtifactCount = -1; }; diff --git a/src/ui/HeaderBar.cpp b/src/ui/HeaderBar.cpp index 83cf533..d87c165 100644 --- a/src/ui/HeaderBar.cpp +++ b/src/ui/HeaderBar.cpp @@ -22,11 +22,13 @@ HeaderBar::HeaderBar(QWidget* parent) layout->setContentsMargins(8, 4, 8, 4); layout->setSpacing(8); - m_timeLabel = new QLabel("00:00", this); - m_blocksLabel = new QLabel(tr("Blocks: 0"), this); - m_bossLabel = new QLabel(tr("Boss Wave #1 Next boss: 5:00"), this); + m_timeLabel = new QLabel("00:00", this); + m_blocksLabel = new QLabel(tr("Blocks: 0"), this); + m_artifactsLabel = new QLabel(tr("Artifacts: 0/?"), this); + m_bossLabel = new QLabel(tr("Boss Wave #1 Next boss: 5:00"), this); layout->addWidget(m_timeLabel); layout->addWidget(m_blocksLabel); + layout->addWidget(m_artifactsLabel); layout->addStretch(); layout->addWidget(m_bossLabel); @@ -88,6 +90,11 @@ void HeaderBar::handleEvent(std::shared_ptr event) .arg(bossSeconds % 60, 2, 10, QChar('0'))); } +void HeaderBar::handleEvent(std::shared_ptr event) +{ + m_artifactsLabel->setText(tr("Artifacts: %1/%2").arg(event->count).arg(event->winCount)); +} + void HeaderBar::onSpeedButton(int index) { if (index >= 0 && index < kSpeedCount) diff --git a/src/ui/HeaderBar.h b/src/ui/HeaderBar.h index 5d5e731..4cbba9d 100644 --- a/src/ui/HeaderBar.h +++ b/src/ui/HeaderBar.h @@ -4,6 +4,7 @@ #include +#include "ArtifactCountChangedEvent.h" #include "BossWaveUpdatedEvent.h" #include "BuildingBlocksChangedEvent.h" #include "EventHandler.h" @@ -18,7 +19,8 @@ class HeaderBar : public QWidget, public CombinedEventHandler + BossWaveUpdatedEvent, + ArtifactCountChangedEvent> { Q_OBJECT @@ -34,9 +36,11 @@ private: void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; + void handleEvent(std::shared_ptr event) override; QLabel* m_timeLabel; QLabel* m_blocksLabel; + QLabel* m_artifactsLabel; QLabel* m_bossLabel; std::vector m_speedButtons; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 3a25574..f5f83b0 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -313,3 +313,42 @@ void MainWindow::handleEvent(std::shared_ptr /*event*/) close(); } } + +void MainWindow::handleEvent(std::shared_ptr /*event*/) +{ + const Tick tick = m_sim->currentTick(); + const int totalSeconds = static_cast(ticksToSeconds(tick)); + const int minutes = totalSeconds / 60; + const int seconds = totalSeconds % 60; + + QMessageBox box(this); + box.setWindowTitle(tr("Won!")); + box.setText(tr("You collected all artifacts!\nSurvival time: %1:%2") + .arg(minutes, 2, 10, QChar('0')) + .arg(seconds, 2, 10, QChar('0'))); + QPushButton* restartBtn = box.addButton(tr("Restart"), QMessageBox::AcceptRole); + box.addButton(tr("Quit"), QMessageBox::RejectRole); + box.exec(); + + if (box.clickedButton() == restartBtn) + { + try + { + GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir); + VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml"); + m_visuals = std::move(newVisuals); + m_sim->reset(std::move(newConfig)); + } + catch (const std::exception& e) + { + QMessageBox::critical(this, tr("Config Error"), + tr("Failed to reload config:\n%1").arg(e.what())); + return; + } + m_gameWorldView->resetForNewGame(); + } + else + { + close(); + } +} diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 9c0996a..c7d2dfb 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -11,6 +11,7 @@ #include "EventHandler.h" #include "GameOverEvent.h" #include "LayoutDialogRequestedEvent.h" +#include "WinEvent.h" #include "RecipeSelectionRequestedEvent.h" #include "SchematicChoicesAvailableEvent.h" #include "ShipLayoutBlueprint.h" @@ -30,6 +31,7 @@ class MainWindow : public QWidget, public CombinedEventHandler @@ -48,6 +50,7 @@ private: void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; + void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; diff --git a/src/ui/SchematicChoiceDialog.cpp b/src/ui/SchematicChoiceDialog.cpp index 92b8091..1ffb2f5 100644 --- a/src/ui/SchematicChoiceDialog.cpp +++ b/src/ui/SchematicChoiceDialog.cpp @@ -45,60 +45,69 @@ SchematicChoiceDialog::SchematicChoiceDialog( nameLabel->setAlignment(Qt::AlignCenter); cardLayout->addWidget(nameLabel); - QString typeText; - if (option.type == SchematicType::Ship) + if (option.type == SchematicType::Artifact) { - typeText = tr("Ship"); - } - else if (option.type == SchematicType::Module) - { - typeText = tr("Module"); + QLabel* artifactTypeLabel = new QLabel(tr("Artifact"), card); + artifactTypeLabel->setAlignment(Qt::AlignCenter); + cardLayout->addWidget(artifactTypeLabel); } else { - typeText = tr("Recipe"); - } - QLabel* typeLabel = new QLabel(typeText, card); - typeLabel->setAlignment(Qt::AlignCenter); - cardLayout->addWidget(typeLabel); - - QString statusText; - if (option.isNewUnlock) - { - statusText = tr("New unlock"); - } - else - { - statusText = tr("Level up -> %1").arg(option.targetLevel); - } - QLabel* statusLabel = new QLabel(statusText, card); - statusLabel->setAlignment(Qt::AlignCenter); - cardLayout->addWidget(statusLabel); - - QLabel* unlocksHeaderLabel = new QLabel(tr("Unlocks recipes for:"), card); - QFont unlocksHeaderFont = unlocksHeaderLabel->font(); - unlocksHeaderFont.setBold(true); - unlocksHeaderLabel->setFont(unlocksHeaderFont); - unlocksHeaderLabel->setAlignment(Qt::AlignCenter); - cardLayout->addWidget(unlocksHeaderLabel); - - QString unlocksText; - if (option.newlyUnlockedItemNames.empty()) - { - unlocksText = tr("None"); - } - else - { - QStringList itemLines; - for (const std::string& itemName : option.newlyUnlockedItemNames) + QString typeText; + if (option.type == SchematicType::Ship) { - itemLines << QString::fromStdString(itemName); + typeText = tr("Ship"); } - unlocksText = itemLines.join("\n"); + else if (option.type == SchematicType::Module) + { + typeText = tr("Module"); + } + else + { + typeText = tr("Recipe"); + } + QLabel* typeLabel = new QLabel(typeText, card); + typeLabel->setAlignment(Qt::AlignCenter); + cardLayout->addWidget(typeLabel); + + QString statusText; + if (option.isNewUnlock) + { + statusText = tr("New unlock"); + } + else + { + statusText = tr("Level up -> %1").arg(option.targetLevel); + } + QLabel* statusLabel = new QLabel(statusText, card); + statusLabel->setAlignment(Qt::AlignCenter); + cardLayout->addWidget(statusLabel); + + QLabel* unlocksHeaderLabel = new QLabel(tr("Unlocks recipes for:"), card); + QFont unlocksHeaderFont = unlocksHeaderLabel->font(); + unlocksHeaderFont.setBold(true); + unlocksHeaderLabel->setFont(unlocksHeaderFont); + unlocksHeaderLabel->setAlignment(Qt::AlignCenter); + cardLayout->addWidget(unlocksHeaderLabel); + + QString unlocksText; + if (option.newlyUnlockedItemNames.empty()) + { + unlocksText = tr("None"); + } + else + { + QStringList itemLines; + for (const std::string& itemName : option.newlyUnlockedItemNames) + { + itemLines << QString::fromStdString(itemName); + } + unlocksText = itemLines.join("\n"); + } + QLabel* unlocksLabel = new QLabel(unlocksText, card); + unlocksLabel->setAlignment(Qt::AlignCenter); + cardLayout->addWidget(unlocksLabel); } - QLabel* unlocksLabel = new QLabel(unlocksText, card); - unlocksLabel->setAlignment(Qt::AlignCenter); - cardLayout->addWidget(unlocksLabel); QPushButton* selectButton = new QPushButton(tr("Select"), card); cardLayout->addWidget(selectButton); -- 2.49.1 From 09313c2d8a9f5af502266f26d5d4874ae96de5a9 Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Tue, 30 Jun 2026 09:58:39 +0200 Subject: [PATCH 3/5] add tests for artifact win condition Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn --- src/test/ArtifactWinConditionTest.cpp | 253 ++++++++++++++++++++++++++ src/test/CMakeLists.txt | 1 + 2 files changed, 254 insertions(+) create mode 100644 src/test/ArtifactWinConditionTest.cpp diff --git a/src/test/ArtifactWinConditionTest.cpp b/src/test/ArtifactWinConditionTest.cpp new file mode 100644 index 0000000..3d60c9f --- /dev/null +++ b/src/test/ArtifactWinConditionTest.cpp @@ -0,0 +1,253 @@ +#include + +#include "catch.hpp" + +#include "ConfigLoader.h" +#include "FactionComponent.h" +#include "Formula.h" +#include "GameConfig.h" +#include "HealthComponent.h" +#include "SchematicChoiceOption.h" +#include "Simulation.h" +#include "StationBodyComponent.h" + +static GameConfig loadConfig() +{ + return ConfigLoader::loadFromDirectory(CONFIG_DIR); +} + +static void killEnemyStations(Simulation& sim) +{ + sim.admin().forEach( + [](entt::entity, StationBodyComponent&, FactionComponent& faction, HealthComponent& health) + { + if (faction.isEnemy) + { + health.hp = 0.0f; + } + }); + sim.tick(); +} + +// Returns the index of the Artifact option in the pending choices, or -1 if absent. +static int findArtifactChoiceIndex(const Simulation& sim) +{ + const auto& choices = sim.getPendingSchematicChoices(); + for (int i = 0; i < static_cast(choices.size()); ++i) + { + if (choices[static_cast(i)].type == SchematicType::Artifact) + { + return i; + } + } + return -1; +} + +// --------------------------------------------------------------------------- +// Config: new artifact fields are parsed correctly (REQ-WIN-ARTIFACT-COUNT) +// --------------------------------------------------------------------------- + +TEST_CASE("ArtifactWinCondition: artifact_chance_formula and artifact_win_count are loaded", + "[artifact_win]") +{ + const GameConfig cfg = loadConfig(); + CHECK(cfg.world.artifacts.artifactWinCount == 3); + // 0.05 * x at x=2 should be 0.1 + CHECK(cfg.world.artifacts.artifactChanceFormula.evaluate(2.0) == Approx(0.1)); +} + +// --------------------------------------------------------------------------- +// Simulation initial state +// --------------------------------------------------------------------------- + +TEST_CASE("ArtifactWinCondition: artifact count is 0 and isWon is false at game start", + "[artifact_win]") +{ + const Simulation sim(loadConfig()); + CHECK(sim.artifactCount() == 0); + CHECK_FALSE(sim.isWon()); +} + +// --------------------------------------------------------------------------- +// Artifact option presence in schematic choices +// --------------------------------------------------------------------------- + +TEST_CASE("ArtifactWinCondition: artifact option appears when chance formula returns 1", + "[artifact_win]") +{ + GameConfig cfg = loadConfig(); + cfg.world.artifacts.artifactChanceFormula = Formula::compile("1"); + Simulation sim(std::move(cfg)); + + killEnemyStations(sim); + + REQUIRE(sim.hasSchematicChoicesPending()); + const auto& choices = sim.getPendingSchematicChoices(); + + const long artifactCount = std::count_if(choices.begin(), choices.end(), + [](const SchematicChoiceOption& opt) { return opt.type == SchematicType::Artifact; }); + CHECK(artifactCount == 1); + // Exactly 2 schematic picks + 1 artifact (or fewer if pool was small) + CHECK(choices.size() <= 3); +} + +TEST_CASE("ArtifactWinCondition: at most 2 schematic options accompany the artifact", + "[artifact_win]") +{ + GameConfig cfg = loadConfig(); + cfg.world.artifacts.artifactChanceFormula = Formula::compile("1"); + Simulation sim(std::move(cfg)); + + killEnemyStations(sim); + + REQUIRE(sim.hasSchematicChoicesPending()); + const auto& choices = sim.getPendingSchematicChoices(); + + const long schematicCount = std::count_if(choices.begin(), choices.end(), + [](const SchematicChoiceOption& opt) { return opt.type != SchematicType::Artifact; }); + CHECK(schematicCount <= 2); +} + +TEST_CASE("ArtifactWinCondition: no artifact option when chance formula returns 0", + "[artifact_win]") +{ + GameConfig cfg = loadConfig(); + cfg.world.artifacts.artifactChanceFormula = Formula::compile("0"); + Simulation sim(std::move(cfg)); + + for (int i = 0; i < 20; ++i) + { + killEnemyStations(sim); + if (!sim.hasSchematicChoicesPending()) { continue; } + + CHECK(findArtifactChoiceIndex(sim) == -1); + sim.applySchematicChoice(0); + } +} + +// --------------------------------------------------------------------------- +// Selecting the artifact option +// --------------------------------------------------------------------------- + +TEST_CASE("ArtifactWinCondition: selecting artifact increments artifact count", + "[artifact_win]") +{ + GameConfig cfg = loadConfig(); + cfg.world.artifacts.artifactChanceFormula = Formula::compile("1"); + Simulation sim(std::move(cfg)); + + killEnemyStations(sim); + REQUIRE(sim.hasSchematicChoicesPending()); + + const int index = findArtifactChoiceIndex(sim); + REQUIRE(index >= 0); + + sim.applySchematicChoice(index); + + CHECK(sim.artifactCount() == 1); +} + +TEST_CASE("ArtifactWinCondition: selecting a non-artifact option does not increment artifact count", + "[artifact_win]") +{ + GameConfig cfg = loadConfig(); + cfg.world.artifacts.artifactChanceFormula = Formula::compile("1"); + Simulation sim(std::move(cfg)); + + killEnemyStations(sim); + REQUIRE(sim.hasSchematicChoicesPending()); + + // Pick the first non-artifact choice. + const auto& choices = sim.getPendingSchematicChoices(); + const auto it = std::find_if(choices.begin(), choices.end(), + [](const SchematicChoiceOption& opt) { return opt.type != SchematicType::Artifact; }); + REQUIRE(it != choices.end()); + + sim.applySchematicChoice(static_cast(it - choices.begin())); + + CHECK(sim.artifactCount() == 0); +} + +// --------------------------------------------------------------------------- +// Win condition (REQ-WIN-ARTIFACT-COUNT) +// --------------------------------------------------------------------------- + +TEST_CASE("ArtifactWinCondition: isWon becomes true when artifact count reaches win count", + "[artifact_win]") +{ + GameConfig cfg = loadConfig(); + cfg.world.artifacts.artifactChanceFormula = Formula::compile("1"); + cfg.world.artifacts.artifactWinCount = 1; + Simulation sim(std::move(cfg)); + + CHECK_FALSE(sim.isWon()); + + killEnemyStations(sim); + REQUIRE(sim.hasSchematicChoicesPending()); + + const int index = findArtifactChoiceIndex(sim); + REQUIRE(index >= 0); + sim.applySchematicChoice(index); + + CHECK(sim.isWon()); +} + +TEST_CASE("ArtifactWinCondition: isWon stays false when artifact count is below win count", + "[artifact_win]") +{ + GameConfig cfg = loadConfig(); + cfg.world.artifacts.artifactChanceFormula = Formula::compile("1"); + cfg.world.artifacts.artifactWinCount = 2; + Simulation sim(std::move(cfg)); + + killEnemyStations(sim); + REQUIRE(sim.hasSchematicChoicesPending()); + sim.applySchematicChoice(findArtifactChoiceIndex(sim)); + + CHECK(sim.artifactCount() == 1); + CHECK_FALSE(sim.isWon()); +} + +TEST_CASE("ArtifactWinCondition: isWon becomes true after collecting required number of artifacts", + "[artifact_win]") +{ + GameConfig cfg = loadConfig(); + cfg.world.artifacts.artifactChanceFormula = Formula::compile("1"); + cfg.world.artifacts.artifactWinCount = 2; + Simulation sim(std::move(cfg)); + + for (int i = 0; i < 2; ++i) + { + killEnemyStations(sim); + REQUIRE(sim.hasSchematicChoicesPending()); + const int index = findArtifactChoiceIndex(sim); + REQUIRE(index >= 0); + sim.applySchematicChoice(index); + } + + CHECK(sim.artifactCount() == 2); + CHECK(sim.isWon()); +} + +// --------------------------------------------------------------------------- +// reset() restores initial state (REQ-CFG-RELOAD) +// --------------------------------------------------------------------------- + +TEST_CASE("ArtifactWinCondition: reset clears artifact count and win state", + "[artifact_win]") +{ + GameConfig cfg = loadConfig(); + cfg.world.artifacts.artifactChanceFormula = Formula::compile("1"); + cfg.world.artifacts.artifactWinCount = 1; + Simulation sim(std::move(cfg)); + + killEnemyStations(sim); + REQUIRE(sim.hasSchematicChoicesPending()); + sim.applySchematicChoice(findArtifactChoiceIndex(sim)); + REQUIRE(sim.isWon()); + + sim.reset(); + + CHECK(sim.artifactCount() == 0); + CHECK_FALSE(sim.isWon()); +} diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index 79c0bdd..2887bac 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -21,4 +21,5 @@ add_files( ShipModuleTest.cpp ThreatCostCalculatorTest.cpp RecipeSchematicTest.cpp + ArtifactWinConditionTest.cpp ) -- 2.49.1 From d38f68e301dc8c6d893e0d9e6422a3b28a9767ed Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Wed, 1 Jul 2026 22:06:32 +0200 Subject: [PATCH 4/5] Fix artifact header showing "0/?" during replay The artifact-count poll was gated behind if (!m_replayPlayer) (from the merge resolution), so ArtifactCountChangedEvent never fired in replay mode: the header stayed at its initial "Artifacts: 0/?" literal and never reflected replayed artifact picks. Unlike the schematic/win/game-over polls (live-input sources / interruptions that stay gated), artifact count is a passive reflection of sim state. Move it out of the replay gate so it tracks m_sim->artifactCount() during playback too, including the first-frame emit that populates the win-count max. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn --- src/ui/GameWorldView.cpp | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index c5893bc..86679cd 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -310,16 +310,6 @@ void GameWorldView::onFrame() m_schematicChoiceShown = false; } - // Artifact count update - const int currentArtifactCount = m_sim->artifactCount(); - if (currentArtifactCount != m_lastArtifactCount) - { - m_lastArtifactCount = currentArtifactCount; - EventManager::getInstance()->sendEventImmediately( - std::make_shared( - currentArtifactCount, m_sim->config().world.artifacts.artifactWinCount)); - } - // Win check if (m_sim->isWon() && !m_winShown) { @@ -338,6 +328,20 @@ void GameWorldView::onFrame() } } + // Artifact count is a passive reflection of sim state (not a live-input source + // like the schematic/game-over polls above), so it updates during replay too — + // the recorded ApplySchematicChoice drives m_sim->artifactCount() forward and + // the header bar must track it. Fires on the first frame (count 0 vs the -1 + // sentinel), which also populates the win-count max (replacing the "0/?" label). + const int currentArtifactCount = m_sim->artifactCount(); + if (currentArtifactCount != m_lastArtifactCount) + { + m_lastArtifactCount = currentArtifactCount; + EventManager::getInstance()->sendEventImmediately( + std::make_shared( + currentArtifactCount, m_sim->config().world.artifacts.artifactWinCount)); + } + update(); } -- 2.49.1 From a5cccc03baa6cedba7e8c9c961d0cb1d94ebc061 Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Wed, 1 Jul 2026 22:14:43 +0200 Subject: [PATCH 5/5] Freeze replay at the run's terminal state (win/defeat) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Playback only stopped when the recorded command stream ran out, so a run that ended on the artifact win (e.g. 3/3) kept ticking during replay because win and game-over are gated off in replay mode and the recording ran a few ticks past the win. Stop advancing when m_sim->isWon() || isGameOver() (in addition to the stream end), and show it in the replay overlay: "Replay ended — Victory" / "— Defeat" (desync still takes priority). This matches where the real game froze. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn --- src/ui/GameWorldView.cpp | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 86679cd..8020246 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -194,7 +194,11 @@ void GameWorldView::onFrame() static_cast(elapsed), m_gameSpeedMultiplier); for (int i = 0; i < ticks; ++i) { - if (m_replayPlayer->isFinished()) { break; } + // Stop at the recorded stream's end, or at the run's terminal state: + // the real game froze when it was won or lost, so the replay does too + // (the recording may run a few ticks past that point). + if (m_replayPlayer->isFinished() + || m_sim->isWon() || m_sim->isGameOver()) { break; } m_sim->tick(); m_replayPlayer->advanceTo(m_sim->currentTick()); } @@ -1314,7 +1318,11 @@ void GameWorldView::drawReplayOverlay(QPainter& painter) painter.setPen(QColor(255, 220, 80)); painter.drawText(QRect(0, 8, width(), 24), Qt::AlignHCenter | Qt::AlignTop, tr("REPLAY")); - if (m_replayPlayer->isFinished()) + // The replay is over once the recorded stream is exhausted or the run reached + // its terminal state (win / defeat) — matching where playback freezes above. + const bool ended = m_replayPlayer->isFinished() + || m_sim->isWon() || m_sim->isGameOver(); + if (ended) { // Dim the world so the end message reads clearly over it. painter.fillRect(rect(), QColor(0, 0, 0, 140)); @@ -1326,6 +1334,16 @@ void GameWorldView::drawReplayOverlay(QPainter& painter) message = tr("Desync at tick %1").arg(static_cast(*desync)); painter.setPen(QColor(255, 90, 90)); } + else if (m_sim->isWon()) + { + message = tr("Replay ended — Victory"); + painter.setPen(QColor(120, 255, 120)); + } + else if (m_sim->isGameOver()) + { + message = tr("Replay ended — Defeat"); + painter.setPen(QColor(255, 255, 255)); + } else { message = tr("Replay ended"); -- 2.49.1