11 Commits

82 changed files with 1561 additions and 1028 deletions

View File

@@ -10,7 +10,8 @@ tunnel_max_distance_tiles = 10
departure_interval_seconds = 20
orbit_factor = 0.8
rally_orbit_radius_tiles = 5.0
building_blocks_tooltip = "Building blocks are the currency for construction. Spend them to place buildings and to expand the asteroid. Produce building blocks in your factory and deliver them to the HQ on a belt to grow your stock."
building_blocks_tooltip = "Building blocks are the currency for construction. Spend them to place buildings and to expand the asteroid. Produce building blocks in your assemblers and deliver them to the HQ on a belt to grow your stock."
artifact_tooltip = "Artifacts are the key to victory. Earn one by choosing the artifact reward when you destroy a set of enemy defence stations. Collect enough of them to win the game."
[regions]
asteroid_width_tiles = 60

View File

@@ -11,6 +11,7 @@ departure_interval_seconds = 20
orbit_factor = 0.8
rally_orbit_radius_tiles = 5.0
building_blocks_tooltip = "Spend building blocks to build; deliver them to the HQ to gain more."
artifact_tooltip = "Choose the artifact reward when destroying enemy stations; collect enough to win."
[regions]
asteroid_width_tiles = 40

View File

@@ -97,6 +97,12 @@ All UI interactions — building selection, builder/blueprint mode transitions,
Bidirectional interactions use separate request/notification event types to avoid infinite recursion (e.g., `ExitBuilderModeRequestedEvent` from `BuildButtonGrid``GameWorldView`, vs. `BuilderModeExitedEvent` from `GameWorldView``BuildButtonGrid`).
### Reading Simulation State
The simulation is the single source of truth for every game value (building block stock, expansion cost, threat level, tick, etc.). A UI widget that needs such a value holds the `Simulation*` it was constructed with and **pulls the value on demand** via the corresponding getter (e.g., `m_sim->getBuildingBlocksStock()`), rather than caching its own copy.
State-change events (e.g., `BuildingBlocksChangedEvent`) are treated as *refresh signals*, not as carriers of truth: a widget subscribes to the event to learn *when* the value changed and then re-reads it from the simulation to learn *what* it now is. The value carried in the event payload is not authoritative and should not be stored. This keeps a single copy of each value and avoids stale-cache bugs (a widget acting on a value that has since moved on because nothing refreshed its local copy).
## Tick Order
Within a single simulation tick, subsystems run in this fixed order. The order is load-bearing for determinism and for avoiding one-tick-delay artifacts (e.g., items landing on a belt but not advancing in the same tick).

View File

@@ -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, belt speed, starting building blocks, departure interval, ship orbit factor, rally orbit radius, scrap-per-threat conversion, combat target-selection parameters (target score formula, overclaim penalty formula, target hysteresis), artifact chance formula, artifact win count, view pan speeds (slow and fast horizontal pan speed and pan ramp band width), and an optional building blocks tooltip string (shown as the header bar's building blocks stock hover tooltip, REQ-UI-BLOCKS-TOOLTIP; omitted when unset).
- **world.toml** — world dimensions, region widths, expansion amounts, building refund percentage, wave timing, boss wave timing, belt speed, starting building blocks, departure interval, ship orbit factor, rally orbit radius, scrap-per-threat conversion, combat target-selection parameters (target score formula, overclaim penalty formula, target hysteresis), artifact chance formula, artifact win count, view pan speeds (slow and fast horizontal pan speed and pan ramp band width), an optional building blocks tooltip string (shown as the header bar's building blocks stock hover tooltip, REQ-UI-BLOCKS-TOOLTIP; omitted when unset), and an optional artifact tooltip string (shown as the header bar's artifact count hover tooltip, REQ-UI-ARTIFACTS-TOOLTIP; omitted when unset).
- **buildings.toml** — building block cost and construction time per building type, plus an optional tooltip description string per building type (shown as the build button's hover tooltip, REQ-UI-BUILD-TOOLTIP; omitted when unset).
- **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). An assembler recipe schematic entry may also define an optional `unlock_requires` list of prerequisite schematic ids (REQ-LOCK-PREREQ).
- **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 plain values, required build materials, 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), an optional `unlock_requires` prerequisite list (REQ-LOCK-PREREQ), a layout grid defining the ship's module slots, and a `default_modules` list used for enemy wave ships (see REQ-WAV-DEFAULT-MODULES).
@@ -424,8 +424,11 @@ The screen is divided into two columns: a main column (75% width) containing the
- 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) and an asteroid expansion button (REQ-UI-EXPAND-BUTTON) to the left of the speed buttons, and game speed controls on the right.
- REQ-UI-BLOCKS-TOOLTIP: The header bar's building blocks stock display (REQ-UI-HEADER) shows a hover tooltip with the descriptive text defined in `world.toml [world].building_blocks_tooltip` — intended to tell the player what building blocks are used for and how to obtain them. If the field is unset, the stock display shows no tooltip. This tooltip is distinct from the build/module button tooltips (REQ-UI-BUILD-TOOLTIP, REQ-MOD-UI-MODULE-TOOLTIP).
- REQ-UI-ARTIFACTS-TOOLTIP: The header bar's artifact count display (REQ-UI-HEADER) shows a hover tooltip with the descriptive text defined in `world.toml [world].artifact_tooltip` — intended to tell the player what artifacts are, how they are obtained (REQ-DEF-SCHEMATIC-DROP), and that collecting `world.toml [world].artifact_win_count` of them wins the game (REQ-WIN-ARTIFACT-COUNT). If the field is unset, the artifact count display shows no tooltip. This tooltip is distinct from the building blocks tooltip (REQ-UI-BLOCKS-TOOLTIP) and the build/module button tooltips (REQ-UI-BUILD-TOOLTIP, REQ-MOD-UI-MODULE-TOOLTIP).
- 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 #<x>` and the countdown as `Next boss: <M:SS>`, where `<M:SS>` 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-SPEED: The game speed controls in the header bar are buttons for 0×, 0.5×, 1×, 2×, and 10× 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-PAUSE-BORDER: While the game is paused (speed 0×, whether set via the speed controls (REQ-UI-SPEED), the Space toggle (REQ-UI-HOTKEYS), or an auto-pausing modal), a vignette border is drawn around the edges of the game world view to make the paused state hard to miss. The border is black and fades in the alpha channel from fully transparent at its inner (center-facing) edge to 50% opacity at the viewport edge, over a thickness of 100 pixels (capped at half the smaller viewport dimension on very small views).
- REQ-UI-DEMOLISH-BORDER: While demolish mode is active (REQ-UI-DEMOLISH-BUTTON, REQ-UI-HOTKEYS), a vignette border is drawn around the edges of the game world view to signal the mode, matching the geometry of the paused-state vignette (REQ-UI-PAUSE-BORDER): a 100-pixel thickness (capped at half the smaller viewport dimension on very small views) with the four sides meeting along mitred corner diagonals. It fades in the alpha channel from fully transparent at its inner (center-facing) edge to the demolish tint color at the viewport edge. The color — including its alpha, which sets the peak opacity at the viewport edge — is read from `visuals.toml [overlays].demolish_tint`, the same demolish-mode color used for the hover tint. The border is presentation-only and has no effect on the simulation. If the game is both paused and in demolish mode, both vignettes are drawn and compose over each other.
- REQ-UI-EXPAND-BUTTON: The header bar shows an asteroid expansion button captioned `Expand: <x> Blocks`, where `<x>` is the current expansion cost computed from `world.toml [expansion].cost_building_blocks_formula` at the current number of purchased expansions (REQ-EXP-COST). Clicking the button unlocks the next asteroid expansion (REQ-EXP-UNLOCK, REQ-GW-ASTEROID-EXPAND), spending that many building blocks from the global stock. The button is disabled when the player cannot currently afford the cost (consistent with REQ-UI-BUILD-DISABLED). The caption updates as the cost changes with each purchased expansion.
- 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).
- REQ-UI-PANEL-COLUMN: The side panel column occupies 25% of the screen width and the full screen height. It is divided into three equal-height panels stacked top to bottom: selected building panel (top), build button grid (middle), and blueprint panel (bottom).
@@ -442,11 +445,12 @@ The screen is divided into two columns: a main column (75% width) containing the
Because the contest-zone boundaries shift as the scrollable area grows with each push (REQ-GW-PUSH-EXPAND, REQ-GW-SCROLL-LIMIT), the ramp bands are recomputed from the current contest-zone boundaries. This is a presentation-only concern and does not affect the simulation, consistent with REQ-UI-NO-ZOOM.
- REQ-UI-CONSTRUCTION-PROGRESS: Construction sites display the building's glyph centered on the footprint (same as an operational building). Below the glyph — or centered on the footprint if the building has no glyph — a construction progress percentage is shown (integer, e.g. `42%`), increasing from 0% to 100% as construction completes.
- REQ-UI-PORT-GLYPH: Every output port of every building is indicated by a directional glyph drawn on the port's tile. The glyph is a `>` rotated to face the port's exit direction (`>` for East, `^` for North, `<` for West, `v` for South). It is drawn at the midpoint between the tile center and the tile edge that the port exits through (i.e. halfway from center toward the exit edge). The indicator is rendered for all building states: operational buildings, construction sites, and the builder-mode ghost. Buildings with multiple output ports (e.g. splitters) show one indicator per port.
- REQ-UI-PORT-TARGET-GLYPH: While in builder mode (REQ-BLD-BUILDER-MODE), the builder-mode ghost additionally shows, for each of the building's output ports, a directional glyph drawn centered in the port's **target cell** — the cell immediately outside the footprint that the port pushes into, i.e. the cell the surface-mask output-port indicator occupies (see Surface Mask Format). As in REQ-UI-PORT-GLYPH the glyph is a `>` rotated to face the port's exit direction (`>` East, `^` North, `<` West, `v` South), previewing where the port's output will go before placement. This is in addition to the on-tile port glyph of REQ-UI-PORT-GLYPH, and — unlike that indicator — is shown only for the builder-mode ghost, not for operational buildings, construction sites, or the blueprint-placement ghost (REQ-UI-BLUEPRINT-PLACE). A building with multiple output ports (e.g. a splitter) shows one target-cell glyph per port. The target-cell glyph is drawn larger than the on-tile port glyph so it stands out as the flow-direction preview. Exceptions: the Tunnel Entry shows no target-cell glyph, because it receives items (which may arrive from any of its non-mouth edges, REQ-BLD-TUNNEL-ENTRY) rather than emitting into a single adjacent cell; the Shipyard shows none either, because its output port is a ship-spawn point (REQ-SHP-SPAWN-PLAYER) rather than a belt-item output (REQ-MAT-OUTPUT-EMERGE).
- REQ-UI-HP-BARS: All entities with HP — the HQ, player and enemy defence stations, and player and enemy ships — render an HP bar below them. The bar is always visible regardless of current HP. The bar's filled portion represents the fraction of current HP to maximum HP.
- REQ-UI-NO-ZOOM: The view has a fixed zoom level; the player cannot zoom in or out.
- REQ-UI-HOTKEYS: Global keyboard shortcuts:
- **Space** — toggles pause. Pressing Space pauses (sets speed to 0×) and stores the previously selected non-zero speed; pressing Space again restores that speed.
- **W** — increases game speed by one step in the sequence 0×, 0.5×, 1×, 2×, 4× (no wrap-around past 4×).
- **W** — increases game speed by one step in the sequence 0×, 0.5×, 1×, 2×, 10× (no wrap-around past 10×).
- **S** — decreases game speed by one step in the same sequence (no wrap-around past 0×).
- **A / D** — scroll the view left / right (REQ-UI-SCROLL).
- **Q** — context-sensitive. If a build mode is active (builder mode or blueprint placement mode), pressing Q exits it. Otherwise, pressing Q toggles demolish mode: it enters demolish mode if inactive, or exits demolish mode if already active. (See also REQ-UI-DEMOLISH-BUTTON for the equivalent button.)
@@ -479,9 +483,10 @@ The screen is divided into two columns: a main column (75% width) containing the
### Selected Building Panel
- REQ-UI-EMPTY-SELECTION: When nothing is selected (no building, construction site, ship, defence station, or scrap pile), the panel is empty.
- REQ-UI-SELECTION-CATEGORIES: **Selection categories and precedence.** Every selectable object belongs to one of two mutually exclusive selection categories: **buildings** (buildings and construction sites) and **field objects** (ships and defence stations — player or enemy — together with scrap piles). A single selection holds objects from only one category at a time. Field objects of different kinds may be selected together (e.g. several ships plus scrap piles, freely mixing player and enemy actors). Buildings are exclusive and take precedence — **buildings win**: selecting a building (by click, Ctrl+click, or a box-drag covering at least one building) clears any field selection and yields a buildings-only selection, and conversely selecting any field object clears any building selection. Point hit-testing prefers a building over a coincident field object, and among field objects prefers an actor (ship or defence station) over a coincident scrap pile (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-SCRAP-CLICK-SELECT).
- REQ-UI-SINGLE-SELECTION: When one building is selected, the panel shows: building name, current recipe or schematic selection, input buffer contents, and output buffer contents. Buffer counts are displayed as `a/b` where `a` is the current item count and `b` is the per-cycle amount (items consumed per run for inputs; items produced per run for outputs). For a selected construction site, the recipe/schematic selection (and, for a shipyard, the layout preview and "Configure" button) are shown but the buffer rows are omitted (REQ-BLD-SITE-CONFIG).
- REQ-UI-PRODUCTION-PROGRESS: For buildings that produce items or ships (miner, smelter, assembler, reprocessing plant, shipyard), the selected building panel also shows: (a) the cycle time of the currently selected recipe or schematic in seconds, and (b) the completion percentage of the active production cycle as an integer (e.g. `42%`), or the text `idle` when no production cycle is active. When no recipe or schematic is selected, neither the cycle time nor the progress indicator is shown.
- REQ-UI-MULTI-SELECT: The player selects multiple buildings by box-drag or by Ctrl+clicking individual buildings to add or remove them from the selection. A box-drag that covers at least one building selects buildings (any scrap piles within the box are ignored); a box-drag that covers no buildings but does cover scrap piles selects those scrap piles instead (REQ-UI-SCRAP-MULTI-SELECT).
- REQ-UI-MULTI-SELECT: The player selects multiple objects by box-drag or by Ctrl+clicking individual objects to add or remove them from the selection. Multi-select operates within a single category (REQ-UI-SELECTION-CATEGORIES). A box-drag that covers at least one building selects buildings (any field objects within the box are ignored — buildings win); a box-drag that covers no building but does cover ships, defence stations, or scrap piles selects all of those field objects together (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-SCRAP-MULTI-SELECT).
- REQ-UI-MULTI-SELECTION: When multiple buildings are selected, the panel shows how many of each building type are selected. No per-building detail is shown. The panel additionally shows the **total building block cost** of the selection — the sum of each selected building's placement cost (`buildings.toml [[building]].cost`, per REQ-BLD-COST), counting only player-placeable buildings (buildings with a button in the build button grid); non-player-placeable buildings (the HQ and defence stations) are excluded from the total, consistent with the blueprint total (REQ-UI-BLUEPRINT-BUTTON). Construction sites count at their building type's full placement cost regardless of construction progress.
- REQ-UI-CONFIG-INLINE: Recipe and schematic configuration for a selected building is shown within this panel. Recipe selection (miner, assembler) and schematic selection (shipyard) use the selection button and dialog (REQ-UI-SELECT-BUTTON) rather than an inline control. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic selection button (REQ-MOD-UI-PREVIEW).
- REQ-UI-SELECT-BUTTON: **Recipe and schematic selection control.** Recipe selection (Miner ore type, Assembler recipe) and schematic selection (Shipyard) are each presented in the selected building panel as a single **selection button** whose caption is the name of the currently selected recipe or schematic, or a placeholder ("Select recipe" / "Select schematic") when none is selected. Clicking the button opens a modal **selection dialog** that pauses the game (speed set to 0×; on close, the speed is restored to what it was before the dialog was opened). The dialog contains a grid of option buttons, one per selectable option — only options that are currently unlocked are shown (REQ-LOCK-UI-RECIPE for recipes, REQ-LOCK-UI-SCHEMATIC for schematics). Hovering an option button shows the selection info tooltip (REQ-UI-SELECT-TOOLTIP). Clicking an option button selects that recipe/schematic, closes the dialog, and updates the selection button's caption in the selected building panel. The dialog can be dismissed without changing the current selection (e.g. closing it without clicking an option). Selecting a new recipe or schematic has the same effects as before (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD).
@@ -489,8 +494,8 @@ The screen is divided into two columns: a main column (75% width) containing the
- For a **recipe** (Miner or Assembler): the recipe name; the name and quantity of each input item (no inputs are listed for miner recipes, which consume nothing); the completion time (`duration_seconds`); and the name and quantity of the produced output item.
- For a **ship schematic** (Shipyard): the ship's `display_name`; the name and quantity of each base required material (`[ship.schematic].materials`, excluding any module contributions); the base production time (`[ship.schematic].production_time_seconds`); and "Produces: 1 <ship display name>".
- REQ-UI-BELT-CLEAR: When one or more belt, splitter, tunnel entry, or tunnel exit tiles are selected, the panel shows a "Clear" button that removes all items from the selected tiles. Clearing a tunnel entry or exit also discards all items currently in transit through that tunnel (REQ-BLD-TUNNEL-TRANSIT). This can be used to resolve stalled belts, splitters, and tunnels.
- REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. Clicking a ship or defence station clears any existing selection and establishes a single-entity selection containing only that entity. Ships and defence stations cannot participate in multi-select together with buildings. Clicking a scrap pile instead establishes a scrap selection (REQ-UI-SCRAP-CLICK-SELECT). Clicking empty world space (no building, ship, defence station, or scrap pile) clears the selection.
- REQ-UI-SHIP-STATS-PANEL: When a single ship is selected (REQ-UI-ENTITY-CLICK-SELECT), the selected building panel shows a **ship stats panel**. The panel structure mirrors REQ-MOD-UI-STATS-PANEL but reflects the ship's actual live state: stats are computed from its installed modules per REQ-MOD-STAT-CALC. The panel always shows all hull stats: HP (current / maximum), max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, and max rotation speed. In addition, capability module summaries are shown conditioned on which module types are installed, using the same aggregation rules as REQ-MOD-UI-STATS-PANEL: weapons (combined DPS, maximum range), salvage (combined collection rate, maximum range), and repair (combined repair rate, maximum range), each section appearing only if at least one instance of that module type is installed. While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT).
- REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. A plain click on a ship or defence station makes it the sole selection, clearing any previous selection. Ships and defence stations can be multi-selected — by Ctrl+clicking individual actors to add or remove them, or by box-drag (REQ-UI-MULTI-SELECT) — and can be selected together with scrap piles and with one another in a single field selection (REQ-UI-SELECTION-CATEGORIES), freely mixing player and enemy actors. Actors cannot be selected together with buildings: selecting a ship or defence station clears any building selection, and selecting a building clears the actors (buildings win). Clicking a scrap pile adds to or establishes a field selection (REQ-UI-SCRAP-CLICK-SELECT). Clicking empty world space (no building, ship, defence station, or scrap pile) clears the selection.
- REQ-UI-SHIP-STATS-PANEL: When exactly one ship is selected (REQ-UI-ENTITY-CLICK-SELECT) and no scrap is selected, the selected building panel shows a **ship stats panel**. (If scrap is also selected, the panel shows the compact count summary instead, per REQ-UI-FIELD-MULTI-SELECTION.) The panel structure mirrors REQ-MOD-UI-STATS-PANEL but reflects the ship's actual live state: stats are computed from its installed modules per REQ-MOD-STAT-CALC. The panel always shows all hull stats: HP (current / maximum), max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, and max rotation speed. In addition, capability module summaries are shown conditioned on which module types are installed, using the same aggregation rules as REQ-MOD-UI-STATS-PANEL: weapons (combined DPS, maximum range), salvage (combined collection rate, maximum range), and repair (combined repair rate, maximum range), each section appearing only if at least one instance of that module type is installed. While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT).
- REQ-UI-SHIP-BEHAVIOR: The ship stats panel (REQ-UI-SHIP-STATS-PANEL) additionally displays the selected ship's **current behavior** — a single label naming the top-priority behavior currently governing the ship's navigation, as resolved by the fixed-priority behavior arbitration. Only the winning behavior is named; lower-priority behaviors that are suppressed are not shown, and neither are the salvage/repair cycles that run regardless of the active behavior (REQ-SHP-SALVAGE, REQ-SHP-REPAIR). The label updates live as the ship's behavior changes, and it is always shown (independent of debug draw mode, unlike the threat-cost line of REQ-UI-SHIP-STATS-PANEL). This applies to both player and enemy ships (REQ-UI-ENTITY-CLICK-SELECT); enemy ships only ever show **Engaging** or **Advancing**. The behavior labels (all wrapped in `tr()`) are:
- **Retreating** — the ship is retreating (REQ-SHP-RETREAT).
- **Engaging** — the ship is engaging a combat target (player: REQ-SHP-COMBAT; enemy: REQ-SHP-ENEMY-AI).
@@ -499,10 +504,11 @@ The screen is divided into two columns: a main column (75% width) containing the
- **Rallying** — the ship is moving to or orbiting the rally point (REQ-SHP-RALLY).
- **Standby** — the ship is holding with its fleet (REQ-SHP-STANDBY).
- **Advancing** — the ship is executing the baseline forward advance with no higher-priority behavior active (player: REQ-SHP-COMBAT advance toward the enemy; enemy: REQ-SHP-ENEMY-AI advance toward the asteroid).
- REQ-UI-STATION-STATS-PANEL: When a single defence station is selected (REQ-UI-ENTITY-CLICK-SELECT), the selected building panel shows a **station stats panel** displaying the station's stats computed at its current level: HP (current / maximum), damage, range, and fire rate.
- REQ-UI-SCRAP-CLICK-SELECT: The player can click any scrap pile (REQ-RES-SCRAP-DROP) in the game world to select it. Scrap forms its own selection category: clicking a scrap pile clears any existing selection and establishes a scrap selection containing only that pile. Scrap piles cannot participate in a selection together with buildings, ships, or defence stations. Hit-testing prioritizes actors over scrap: when a building, ship, or defence station lies under the cursor at the same point as a scrap pile, that object is selected in preference to the scrap; a scrap pile is selected only when no building, ship, or defence station is under the cursor. A selected scrap pile that despawns or is fully collected (REQ-RES-SCRAP-DROP) is removed from the selection; once the last selected pile is gone, the selection becomes empty (REQ-UI-EMPTY-SELECTION).
- REQ-UI-SCRAP-MULTI-SELECT: Multiple scrap piles can be selected by box-drag or by Ctrl+clicking individual piles to add or remove them from the selection, mirroring building multi-select (REQ-UI-MULTI-SELECT). A scrap selection contains only scrap piles. Because scrap cannot mix with other object types (REQ-UI-SCRAP-CLICK-SELECT), Ctrl+clicking a scrap pile while a building, ship, or defence station selection is active first clears that selection and begins a scrap selection; conversely, selecting a building, ship, or defence station while a scrap selection is active clears the scrap selection. Box-drag disambiguation between buildings and scrap follows REQ-UI-MULTI-SELECT (a box covering any building selects buildings; a box covering scrap but no buildings selects the scrap).
- REQ-UI-SCRAP-PANEL: When one or more scrap piles are selected, the selected building panel shows the **total remaining scrap amount** across all selected piles — the sum of the piles' current remaining amounts (REQ-RES-SCRAP-DROP), e.g. "Scrap: 47". The same summed-amount display is used whether one pile or many are selected; no per-pile detail and no pile count are shown. The displayed total updates as selected piles are partially collected or despawn (REQ-UI-SCRAP-CLICK-SELECT).
- REQ-UI-STATION-STATS-PANEL: When exactly one defence station is selected (REQ-UI-ENTITY-CLICK-SELECT) and no scrap is selected, the selected building panel shows a **station stats panel** displaying the station's stats computed at its current level: HP (current / maximum), damage, range, and fire rate. (If scrap is also selected, the panel shows the compact count summary instead, per REQ-UI-FIELD-MULTI-SELECTION.)
- REQ-UI-FIELD-MULTI-SELECTION: The full single-actor stats panel (REQ-UI-SHIP-STATS-PANEL, REQ-UI-STATION-STATS-PANEL) is shown only when the field selection holds exactly one actor and no scrap. Whenever the selection holds more than one object — multiple actors, or a single actor together with scrap — the panel shows a **compact summary** instead: a count per actor type, one line per type rendered as "<type> x <count>" (the same `x`-count notation as the recipe tooltip and the building multi-selection, REQ-UI-MULTI-SELECTION). Ships are grouped by schematic display name and defence stations as a group, distinguishing player from enemy. No per-actor detail and no total-actor-count header are shown (consistent with the building panel). If scrap piles are also part of the field selection (REQ-UI-SELECTION-CATEGORIES) their total is appended as a final line of the same summary (REQ-UI-SCRAP-PANEL), so all lines share uniform spacing. Building selections use REQ-UI-SINGLE-SELECTION / REQ-UI-MULTI-SELECTION instead.
- REQ-UI-SCRAP-CLICK-SELECT: The player can click any scrap pile (REQ-RES-SCRAP-DROP) in the game world to select it. Scrap piles are field objects (REQ-UI-SELECTION-CATEGORIES) and can be selected together with ships and defence stations, but not with buildings. A plain click on a scrap pile makes it the sole selection, clearing any previous selection; selecting a building clears any scrap (buildings win), and selecting a scrap pile clears any building selection. Hit-testing prefers a building over a coincident actor or scrap pile, and an actor (ship or defence station) over a coincident scrap pile: a scrap pile is selected only when no building or actor is under the cursor. A selected scrap pile that despawns or is fully collected (REQ-RES-SCRAP-DROP) is removed from the selection; if no selected object remains, the panel becomes empty (REQ-UI-EMPTY-SELECTION).
- REQ-UI-SCRAP-MULTI-SELECT: Multiple scrap piles can be selected by box-drag or by Ctrl+clicking individual piles to add or remove them, mirroring building multi-select (REQ-UI-MULTI-SELECT). Scrap shares the field-object category with ships and defence stations (REQ-UI-SELECTION-CATEGORIES), so a field selection may hold scrap piles and actors together. Ctrl+clicking a scrap pile while a field selection is active adds or removes that pile within the same selection; Ctrl+clicking a scrap pile while a building selection is active first clears the buildings and begins a field selection (buildings win). Conversely, selecting a building while a field selection is active clears it. Box-drag disambiguation follows REQ-UI-MULTI-SELECT (a box covering any building selects buildings; a box covering no building selects the ships, defence stations, and scrap piles within it).
- REQ-UI-SCRAP-PANEL: When one or more scrap piles are selected, the selected building panel shows the **total remaining scrap amount** across all selected piles — the sum of the piles' current remaining amounts (REQ-RES-SCRAP-DROP), e.g. "Scrap x 47". The same summed-amount display is used whether one pile or many are selected; no per-pile detail and no pile count are shown. The displayed total updates as selected piles are partially collected or despawn (REQ-UI-SCRAP-CLICK-SELECT). When actors are also selected, this scrap total is shown as an additional line of the actor count summary rather than alongside a single-actor stats panel (REQ-UI-FIELD-MULTI-SELECTION).
### Build Button Grid
@@ -560,7 +566,7 @@ A separate executable target (`balancing`) that links against `lib` but contains
- REQ-BAL-SIM-ENV: Each arena simulates a pure-space environment using the same tick-based simulation as the main game. There is no asteroid, no buildings, no belts, no wave system, and no threat accumulation. Only ships, HQs, defence stations, and combat are active.
- REQ-BAL-SIM-AI: Ships use the same AI and stats as in the main game. Ships with no target in sensor range advance toward the enemy team's HQ. Ships that detect an enemy in sensor range engage it as in the normal game (REQ-SHP-COMBAT, REQ-SHP-ENEMY-AI).
- REQ-BAL-SIM-SPEED: Each arena that is not being inspected runs its simulation at maximum tick rate (as many ticks per second as the hardware allows), with no rendering. An inspected arena runs at a player-controllable game speed (same speed steps as the main game: 0×, 0.5×, 1×, 2×, 4×) with full rendering in the inspect window, defaulting to 1× on open.
- REQ-BAL-SIM-SPEED: Each arena that is not being inspected runs its simulation at maximum tick rate (as many ticks per second as the hardware allows), with no rendering. An inspected arena runs at a player-controllable game speed (same speed steps as the main game: 0×, 0.5×, 1×, 2×, 10×) with full rendering in the inspect window, defaulting to 1× on open.
- REQ-BAL-SIM-PARALLEL: All arenas are simulated in parallel, each on its own thread.
- REQ-BAL-SIM-END: An arena fight ends when either team's HQ is destroyed or all ships and defence stations of one team have been destroyed. If a team has no defence stations, destroying all its ships is sufficient. When the fight ends, the simulation for that arena stops.
@@ -577,6 +583,6 @@ A separate executable target (`balancing`) that links against `lib` but contains
- REQ-BAL-UI-WIDGET-START: Each arena widget contains a "Start" button that starts the simulation for that arena. The button is disabled while the arena's simulation is running. When a finished arena's Start button is clicked, a fresh simulation is created and started (the widget resets to initial unit counts, the border returns to blue, and the previous results are replaced).
- REQ-BAL-UI-WIDGET-BORDER: Each arena widget has a colored border indicating its state: grey when not yet started, blue while its simulation is running, and green when the fight has ended.
- REQ-BAL-UI-INSPECT: Clicking an arena widget's "Inspect" button opens a new inspect window for that arena. Any previously open inspect window is closed first (its arena's simulation is aborted and its widget border returns to grey). The inspected arena is restarted with a fresh simulation that runs at controllable game speed with full rendering (REQ-BAL-SIM-SPEED). The arena widget updates live during inspection (surviving counts, border color, `[WON]` prefix) as it does for non-inspected arenas. Only one inspect window may be open at a time.
- REQ-BAL-UI-INSPECT-WINDOW: The inspect window consists of three sections, top to bottom: a title bar area containing the arena name and game speed controls (same buttons as the main game: 0×, 0.5×, 1×, 2×, 4×, with Space to toggle pause — see REQ-UI-SPEED and REQ-UI-HOTKEYS), the arena view in the center, and an info panel at the bottom displaying the same team columns and entry format as the arena widget in the main window (REQ-BAL-UI-WIDGET), updated live, including the arena's battle duration once the fight has ended (REQ-BAL-UI-WIDGET).
- REQ-BAL-UI-INSPECT-WINDOW: The inspect window consists of three sections, top to bottom: a title bar area containing the arena name and game speed controls (same buttons as the main game: 0×, 0.5×, 1×, 2×, 10×, with Space to toggle pause — see REQ-UI-SPEED and REQ-UI-HOTKEYS), the arena view in the center, and an info panel at the bottom displaying the same team columns and entry format as the arena widget in the main window (REQ-BAL-UI-WIDGET), updated live, including the arena's battle duration once the fight has ended (REQ-BAL-UI-WIDGET).
- REQ-BAL-UI-INSPECT-VIEW: The arena view renders all tiles of the arena and displays ships, HQs, defence stations, and laser beams using the same visual elements and `visuals.toml` colors as the main game. Team 1 uses player visual styles; team 2 uses enemy visual styles. The view has a fixed zoom level — no zoom or scroll is possible. The tile size is derived so that the full arena (all tiles) fits within the view.
- REQ-BAL-UI-INSPECT-CLOSE: Closing the inspect window (via the window's close button) aborts the inspected arena's simulation. The arena widget's border returns to grey and its surviving counts are left as they were at the moment of closing. All main window buttons and controls are re-enabled.

View File

@@ -44,7 +44,6 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
, m_team1HqEntity(entt::null)
, m_team2HqEntity(entt::null)
, m_finished(false)
, m_winnerTeam(-1)
, m_stopRequested(false)
{
m_buildingSystem = std::make_unique<BuildingSystem>(
@@ -95,7 +94,7 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
updateStatus();
}
std::string ArenaStatus::TeamStatus::ehpPercentText() const
std::string ArenaStatus::TeamStatus::getEhpPercentText() const
{
if (maxEhp <= 0.0)
{
@@ -312,7 +311,7 @@ void ArenaSimulation::requestStop()
m_stopRequested.store(true, std::memory_order_relaxed);
}
ArenaStatus ArenaSimulation::status() const
ArenaStatus ArenaSimulation::getStatus() const
{
std::lock_guard<std::mutex> lock(m_statusMutex);
return m_status;
@@ -473,42 +472,42 @@ bool ArenaSimulation::isFinished() const
return m_finished;
}
int ArenaSimulation::winnerTeam() const
std::optional<int> ArenaSimulation::getWinnerTeam() const
{
return m_winnerTeam;
}
Tick ArenaSimulation::currentTick() const
Tick ArenaSimulation::getCurrentTick() const
{
return m_currentTick;
}
const ArenaConfig& ArenaSimulation::arenaConfig() const
const ArenaConfig& ArenaSimulation::getArenaConfig() const
{
return m_arenaConfig;
}
const BuildingSystem& ArenaSimulation::buildings() const
const BuildingSystem& ArenaSimulation::getBuildings() const
{
return *m_buildingSystem;
}
const ShipSystem& ArenaSimulation::ships() const
const ShipSystem& ArenaSimulation::getShips() const
{
return *m_shipSystem;
}
const ScrapSystem& ArenaSimulation::scraps() const
const ScrapSystem& ArenaSimulation::getScraps() const
{
return *m_scrapSystem;
}
EntityAdmin& ArenaSimulation::admin()
EntityAdmin& ArenaSimulation::getAdmin()
{
return m_admin;
}
const EntityAdmin& ArenaSimulation::admin() const
const EntityAdmin& ArenaSimulation::getAdmin() const
{
return m_admin;
}

View File

@@ -46,19 +46,19 @@ struct ArenaStatus
double threatLevel = 0.0; // accumulated threat of the team's configured ships
// Remaining durability of the team's ships and defence stations (HQ
// excluded). currentEhp is summed live; maxEhp is the fixed full-HP
// baseline. See ehpPercentText() for the displayed value.
// baseline. See getEhpPercentText() for the displayed value.
double currentEhp = 0.0;
double maxEhp = 0.0;
std::vector<Entry> entries; // HQ first, then ships, then stations
// Remaining EHP as a whole-number percentage ("NN%"), or "n/a" when the
// team has no ships or stations (maxEhp == 0).
std::string ehpPercentText() const;
std::string getEhpPercentText() const;
};
TeamStatus teams[2];
bool finished = false;
int winnerTeam = -1; // 0 or 1 when finished; -1 while running
std::optional<int> winnerTeam; // 0 or 1 when finished; nullopt while running
// Game time the fight has lasted (simulated ticks * fixed tick duration).
// Meaningful once finished; the battle duration shown for completed runs.
double durationSeconds = 0.0;
@@ -78,17 +78,17 @@ public:
void tickOnce();
std::vector<BeamFiredEvent> drainBeamFiredEvents();
ArenaStatus status() const;
ArenaStatus getStatus() const;
bool isFinished() const;
int winnerTeam() const;
Tick currentTick() const;
std::optional<int> getWinnerTeam() const;
Tick getCurrentTick() const;
const ArenaConfig& arenaConfig() const;
const BuildingSystem& buildings() const;
const ShipSystem& ships() const;
const ScrapSystem& scraps() const;
EntityAdmin& admin();
const EntityAdmin& admin() const;
const ArenaConfig& getArenaConfig() const;
const BuildingSystem& getBuildings() const;
const ShipSystem& getShips() const;
const ScrapSystem& getScraps() const;
EntityAdmin& getAdmin();
const EntityAdmin& getAdmin() const;
private:
BuildingId allocateBuildingId();
@@ -122,7 +122,7 @@ private:
entt::entity m_team2HqEntity;
bool m_finished;
int m_winnerTeam;
std::optional<int> m_winnerTeam;
std::atomic<bool> m_stopRequested;
// Static accumulated threat per team, computed once from the configured roster.

View File

@@ -15,7 +15,7 @@
#include "Building.h"
#include "BuildingSystem.h"
#include "EntityHitTest.h"
#include "EntitySelectedEvent.h"
#include "EntitySelectionChangedEvent.h"
#include "EventManager.h"
#include "FacingComponent.h"
#include "FactionComponent.h"
@@ -73,7 +73,7 @@ void ArenaView::setGameSpeed(double multiplier)
std::make_shared<GameSpeedChangedEvent>(multiplier));
}
double ArenaView::gameSpeed() const
double ArenaView::getGameSpeed() const
{
return m_gameSpeedMultiplier;
}
@@ -121,7 +121,7 @@ void ArenaView::onFrame()
// Expire old beams. Lifetime is measured in game ticks so beams stay
// visible while the simulation is paused or slowed (REQ-SHP-FIRING-BEAM).
{
const Tick now = m_sim->currentTick();
const Tick now = m_sim->getCurrentTick();
std::vector<ActiveBeam> live;
for (const ActiveBeam& b : m_activeBeams)
{
@@ -144,16 +144,16 @@ void ArenaView::onFrame()
void ArenaView::handleEvent(std::shared_ptr<const BeamFiredEvent> event)
{
float maxRadius = 0.125f;
if (m_sim->admin().isValid(event->target)
&& m_sim->admin().hasAll<StationBodyComponent>(event->target))
if (m_sim->getAdmin().isValid(event->target)
&& m_sim->getAdmin().hasAll<StationBodyComponent>(event->target))
{
const StationBodyComponent& sb = m_sim->admin().get<StationBodyComponent>(event->target);
const StationBodyComponent& sb = m_sim->getAdmin().get<StationBodyComponent>(event->target);
const int shorter = std::min(sb.footprint.width(),
sb.footprint.height());
maxRadius = shorter / 2.0f;
}
else if (m_sim->admin().isValid(event->target)
&& m_sim->admin().hasAll<ScrapDataComponent>(event->target))
else if (m_sim->getAdmin().isValid(event->target)
&& m_sim->getAdmin().hasAll<ScrapDataComponent>(event->target))
{
maxRadius = 0.1f;
}
@@ -192,9 +192,9 @@ void ArenaView::paintGL()
// Coordinate helpers
// ---------------------------------------------------------------------------
float ArenaView::tilePx() const
float ArenaView::getTilePx() const
{
const ArenaConfig& ac = m_sim->arenaConfig();
const ArenaConfig& ac = m_sim->getArenaConfig();
const int totalWidth = ac.playerBufferWidth_tiles
+ ac.contestZoneWidth_tiles
+ ac.enemyBufferWidth_tiles;
@@ -209,8 +209,8 @@ float ArenaView::tilePx() const
QPointF ArenaView::worldToWidget(QVector2D worldPos) const
{
return QPointF(
static_cast<qreal>(worldPos.x() * tilePx()),
static_cast<qreal>(worldPos.y() * tilePx()));
static_cast<qreal>(worldPos.x() * getTilePx()),
static_cast<qreal>(worldPos.y() * getTilePx()));
}
QPointF ArenaView::tileToWidget(QPoint tile) const
@@ -223,21 +223,21 @@ QRectF ArenaView::tileRect(QPoint tile) const
{
const QPointF tl = tileToWidget(tile);
return QRectF(tl.x(), tl.y(),
static_cast<qreal>(tilePx()), static_cast<qreal>(tilePx()));
static_cast<qreal>(getTilePx()), static_cast<qreal>(getTilePx()));
}
std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const
{
if (!m_sim->admin().isValid(entity) || !m_sim->admin().hasAll<PositionComponent>(entity))
if (!m_sim->getAdmin().isValid(entity) || !m_sim->getAdmin().hasAll<PositionComponent>(entity))
{
return std::nullopt;
}
return m_sim->admin().get<PositionComponent>(entity).value;
return m_sim->getAdmin().get<PositionComponent>(entity).value;
}
QVector2D ArenaView::widgetToWorld(QPoint widgetPt) const
{
const float px = tilePx();
const float px = getTilePx();
if (px < 0.001f) { return QVector2D(0.0f, 0.0f); }
return QVector2D(static_cast<float>(widgetPt.x()) / px,
static_cast<float>(widgetPt.y()) / px);
@@ -248,7 +248,7 @@ void ArenaView::mousePressEvent(QMouseEvent* event)
if (event->button() == Qt::LeftButton)
{
const QVector2D worldPos = widgetToWorld(event->pos());
entt::entity hit = entityAtWorldPos(m_sim->admin(), worldPos);
entt::entity hit = entityAtWorldPos(m_sim->getAdmin(), worldPos);
if (hit != entt::null)
{
@@ -259,8 +259,14 @@ void ArenaView::mousePressEvent(QMouseEvent* event)
m_selectedEntity = std::nullopt;
}
// The arena is strictly single-select; emit a vector of size 0 or 1.
std::vector<entt::entity> selection;
if (m_selectedEntity.has_value())
{
selection.push_back(*m_selectedEntity);
}
EventManager::getInstance()->sendEventImmediately(
std::make_shared<EntitySelectedEvent>(m_selectedEntity));
std::make_shared<EntitySelectionChangedEvent>(selection));
}
QOpenGLWidget::mousePressEvent(event);
@@ -282,7 +288,7 @@ void ArenaView::keyPressEvent(QKeyEvent* event)
void ArenaView::drawTiles(QPainter& painter)
{
const ArenaConfig& ac = m_sim->arenaConfig();
const ArenaConfig& ac = m_sim->getArenaConfig();
const int totalWidth = ac.playerBufferWidth_tiles
+ ac.contestZoneWidth_tiles
+ ac.enemyBufferWidth_tiles;
@@ -300,7 +306,7 @@ void ArenaView::drawTiles(QPainter& painter)
void ArenaView::drawBuildings(QPainter& painter)
{
for (const Building& b : m_sim->buildings().allBuildings())
for (const Building& b : m_sim->getBuildings().getAllBuildings())
{
const std::map<BuildingType, BuildingVisuals>::const_iterator it =
m_visuals->buildings.find(b.type);
@@ -315,8 +321,8 @@ void ArenaView::drawBuildings(QPainter& painter)
const QPointF tl = tileToWidget(b.anchor);
const QRectF bboxRect(tl.x(), tl.y(),
b.footprint.width() * static_cast<qreal>(tilePx()),
b.footprint.height() * static_cast<qreal>(tilePx()));
b.footprint.width() * static_cast<qreal>(getTilePx()),
b.footprint.height() * static_cast<qreal>(getTilePx()));
painter.setPen(QPen(bv.outline, 1));
painter.setBrush(Qt::NoBrush);
@@ -332,8 +338,8 @@ void ArenaView::drawBuildings(QPainter& painter)
void ArenaView::drawScrap(QPainter& painter)
{
const float r = tilePx() * 0.2f;
for (const ScrapInfo& scrap : m_sim->scraps().allScrapInfo())
const float r = getTilePx() * 0.2f;
for (const ScrapInfo& scrap : m_sim->getScraps().getAllScrapInfo())
{
const QPointF center = worldToWidget(scrap.position);
painter.setBrush(QColor(128, 110, 90));
@@ -345,7 +351,7 @@ void ArenaView::drawScrap(QPainter& painter)
void ArenaView::drawStations(QPainter& painter)
{
m_sim->admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
m_sim->getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f, const HealthComponent& h)
{
const BuildingType visType = f.isEnemy
@@ -364,8 +370,8 @@ void ArenaView::drawStations(QPainter& painter)
const QPointF tl = tileToWidget(sb.anchor);
const QRectF bboxRect(tl.x(), tl.y(),
sb.footprint.width() * static_cast<qreal>(tilePx()),
sb.footprint.height() * static_cast<qreal>(tilePx()));
sb.footprint.width() * static_cast<qreal>(getTilePx()),
sb.footprint.height() * static_cast<qreal>(getTilePx()));
painter.setPen(QPen(bv.outline, 1));
painter.setBrush(Qt::NoBrush);
@@ -374,7 +380,7 @@ void ArenaView::drawStations(QPainter& painter)
if (h.maxHp > 0.0f)
{
const float fraction = std::max(0.0f, h.hp / h.maxHp);
const qreal barH = static_cast<qreal>(tilePx()) * 0.12;
const qreal barH = static_cast<qreal>(getTilePx()) * 0.12;
const qreal barY = bboxRect.bottom() + 1.0;
const qreal barW = bboxRect.width();
painter.fillRect(QRectF(bboxRect.left(), barY, barW, barH),
@@ -394,7 +400,7 @@ void ArenaView::drawStations(QPainter& painter)
void ArenaView::drawShips(QPainter& painter)
{
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
FactionComponent, HealthComponent>(
[&](entt::entity e, const ShipIdentityComponent& si,
const PositionComponent& pos, const FacingComponent& facing,
@@ -408,8 +414,8 @@ void ArenaView::drawShips(QPainter& painter)
const QVector2D dir(std::cos(facing.radians), std::sin(facing.radians));
const QVector2D perp(-dir.y(), dir.x());
const float fwd = tilePx() * 0.45f;
const float side = tilePx() * 0.25f;
const float fwd = getTilePx() * 0.45f;
const float side = getTilePx() * 0.25f;
QPolygonF tri;
tri << QPointF(center.x() + static_cast<qreal>(dir.x() * fwd),
@@ -427,7 +433,7 @@ void ArenaView::drawShips(QPainter& painter)
{
const float fraction = std::max(0.0f, h.hp / h.maxHp);
const qreal barW = static_cast<qreal>(fwd) * 2.0;
const qreal barH = static_cast<qreal>(tilePx()) * 0.12;
const qreal barH = static_cast<qreal>(getTilePx()) * 0.12;
const qreal barX = center.x() - static_cast<qreal>(fwd);
const qreal barY = center.y() + static_cast<qreal>(fwd) + 1.0;
painter.fillRect(QRectF(barX, barY, barW, barH), QColor(60, 60, 60));
@@ -437,7 +443,7 @@ void ArenaView::drawShips(QPainter& painter)
if (m_selectedEntity.has_value() && *m_selectedEntity == e)
{
const qreal radius = static_cast<qreal>(tilePx()) * 0.55;
const qreal radius = static_cast<qreal>(getTilePx()) * 0.55;
painter.setPen(QPen(QColor(255, 255, 0), 2));
painter.setBrush(Qt::NoBrush);
painter.drawEllipse(center, radius, radius);
@@ -448,7 +454,7 @@ void ArenaView::drawShips(QPainter& painter)
void ArenaView::drawDebugSensorRanges(QPainter& painter)
{
painter.setBrush(Qt::NoBrush);
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent, SensorRangeComponent>(
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, SensorRangeComponent>(
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
const PositionComponent& pos, const SensorRangeComponent& sensor)
{
@@ -458,7 +464,7 @@ void ArenaView::drawDebugSensorRanges(QPainter& painter)
const QPointF center = worldToWidget(pos.value);
const qreal radiusPx = static_cast<qreal>(sensor.value_tiles)
* static_cast<qreal>(tilePx());
* static_cast<qreal>(getTilePx());
QColor circleColor = it->second.outline;
circleColor.setAlpha(77);
painter.setPen(QPen(circleColor, 1));
@@ -487,7 +493,7 @@ void ArenaView::drawDebugTargetLines(QPainter& painter)
painter.drawLine(worldToWidget(from), worldToWidget(to));
};
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent,
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent,
FactionComponent, AttackBehavior>(
[&](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
const PositionComponent& pos, const FactionComponent& fac,
@@ -502,7 +508,7 @@ void ArenaView::drawDebugTargetLines(QPainter& painter)
drawTargetLine(fac.isEnemy, pos.value, *targetPos);
});
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent,
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent,
FactionComponent, RepairBehavior>(
[&](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
const PositionComponent& pos, const FactionComponent& fac,
@@ -517,7 +523,7 @@ void ArenaView::drawDebugTargetLines(QPainter& painter)
drawTargetLine(fac.isEnemy, pos.value, *targetPos);
});
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent,
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent,
FactionComponent, SalvageScrapBehavior>(
[&](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
const PositionComponent& pos, const FactionComponent& fac,

View File

@@ -13,7 +13,7 @@
#include "BeamFiredEvent.h"
#include "entt/entity/entity.hpp"
#include "EntitySelectedEvent.h"
#include "EntitySelectionChangedEvent.h"
#include "Tick.h"
#include "TickDriver.h"
#include "VisualsConfig.h"
@@ -32,7 +32,7 @@ public:
~ArenaView() override;
void setGameSpeed(double multiplier);
double gameSpeed() const;
double getGameSpeed() const;
void togglePause();
void stopRendering();
@@ -56,7 +56,7 @@ private:
void drawDebugTargetLines(QPainter& painter);
void drawBeams(QPainter& painter);
float tilePx() const;
float getTilePx() const;
QPointF worldToWidget(QVector2D worldPos) const;
QPointF tileToWidget(QPoint tile) const;
QRectF tileRect(QPoint tile) const;

View File

@@ -141,7 +141,7 @@ void ArenaWidget::updateStatus(const ArenaStatus& status)
}
threat->setText(tr("Threat: %1").arg(QString::number(team.threatLevel, 'f', 0)));
ehp->setText(tr("EHP: %1").arg(QString::fromStdString(team.ehpPercentText())));
ehp->setText(tr("EHP: %1").arg(QString::fromStdString(team.getEhpPercentText())));
QString lines;
for (const ArenaStatus::Entry& entry : team.entries)

View File

@@ -46,7 +46,7 @@ namespace
header = QStringLiteral("[WON] ") + header;
}
header += QStringLiteral(" - threat %1").arg(QString::number(team.threatLevel, 'f', 0));
header += QStringLiteral(" - EHP %1").arg(QString::fromStdString(team.ehpPercentText()));
header += QStringLiteral(" - EHP %1").arg(QString::fromStdString(team.getEhpPercentText()));
return escapeCell(header);
}
@@ -76,7 +76,6 @@ BalancingWindow::BalancingWindow(const BalancingConfig& balancingConfig,
, m_balancingConfigPath(balancingConfigPath)
, m_nextSeed(0)
, m_inspectWindow(nullptr)
, m_inspectedArenaIndex(-1)
{
m_visuals = VisualsLoader::load(m_configDir + "/visuals.toml");
setWindowTitle(tr("DotaFactory — Balancing Tool"));
@@ -147,7 +146,7 @@ void BalancingWindow::populateArenas(const BalancingConfig& balancingConfig)
entry.widget = new ArenaWidget(index, arenaConfig.name, scrollContent);
contentLayout->addWidget(entry.widget);
entry.widget->updateStatus(entry.simulation->status());
entry.widget->updateStatus(entry.simulation->getStatus());
m_arenas.push_back(std::move(entry));
}
@@ -179,15 +178,15 @@ void BalancingWindow::pollStatuses()
{
if (entry.worker.joinable())
{
const ArenaStatus status = entry.simulation->status();
const ArenaStatus status = entry.simulation->getStatus();
entry.widget->updateStatus(status);
}
}
if (m_inspectedSim && m_inspectedArenaIndex >= 0)
if (m_inspectedSim && m_inspectedArenaIndex.has_value())
{
const ArenaStatus status = m_inspectedSim->status();
m_arenas[static_cast<std::size_t>(m_inspectedArenaIndex)].widget->updateStatus(status);
const ArenaStatus status = m_inspectedSim->getStatus();
m_arenas[static_cast<std::size_t>(*m_inspectedArenaIndex)].widget->updateStatus(status);
}
updateButtons();
@@ -242,7 +241,7 @@ void BalancingWindow::startArena(int index)
entry.simulation = std::make_unique<ArenaSimulation>(
m_gameConfig, entry.config, m_nextSeed++);
entry.widget->startSimulation();
entry.widget->updateStatus(entry.simulation->status());
entry.widget->updateStatus(entry.simulation->getStatus());
ArenaSimulation* sim = entry.simulation.get();
entry.worker = std::thread([sim]() { sim->run(); });
updateButtons();
@@ -255,13 +254,13 @@ void BalancingWindow::inspectArena(int index)
delete m_inspectWindow;
m_inspectWindow = nullptr;
if (m_inspectedSim && m_inspectedArenaIndex >= 0
if (m_inspectedSim && m_inspectedArenaIndex.has_value()
&& !m_inspectedSim->isFinished())
{
m_arenas[static_cast<std::size_t>(m_inspectedArenaIndex)].widget->resetToGrey();
m_arenas[static_cast<std::size_t>(*m_inspectedArenaIndex)].widget->resetToGrey();
}
m_inspectedSim.reset();
m_inspectedArenaIndex = -1;
m_inspectedArenaIndex = std::nullopt;
}
ArenaEntry& entry = m_arenas[static_cast<std::size_t>(index)];
@@ -278,7 +277,7 @@ void BalancingWindow::inspectArena(int index)
entry.widget->resetToGrey();
entry.widget->startSimulation();
entry.widget->updateStatus(m_inspectedSim->status());
entry.widget->updateStatus(m_inspectedSim->getStatus());
m_inspectWindow = new InspectWindow(
m_inspectedSim.get(), &m_gameConfig, &m_visuals, entry.config.name, nullptr);
@@ -297,16 +296,16 @@ void BalancingWindow::closeInspectWindow()
m_inspectWindow->deleteLater();
m_inspectWindow = nullptr;
if (m_inspectedArenaIndex >= 0 && m_inspectedSim)
if (m_inspectedArenaIndex.has_value() && m_inspectedSim)
{
if (!m_inspectedSim->isFinished())
{
m_arenas[static_cast<std::size_t>(m_inspectedArenaIndex)].widget->resetToGrey();
m_arenas[static_cast<std::size_t>(*m_inspectedArenaIndex)].widget->resetToGrey();
}
}
m_inspectedSim.reset();
m_inspectedArenaIndex = -1;
m_inspectedArenaIndex = std::nullopt;
setMainControlsEnabled(true);
updateButtons();
}
@@ -336,7 +335,7 @@ void BalancingWindow::updateButtons()
bool allRunning = true;
for (ArenaEntry& entry : m_arenas)
{
if (entry.worker.joinable() && !entry.simulation->status().finished)
if (entry.worker.joinable() && !entry.simulation->getStatus().finished)
{
anyRunning = true;
}

View File

@@ -1,6 +1,7 @@
#pragma once
#include <memory>
#include <optional>
#include <string>
#include <thread>
#include <vector>
@@ -78,6 +79,6 @@ private:
QTimer* m_pollTimer;
InspectWindow* m_inspectWindow;
int m_inspectedArenaIndex;
std::optional<int> m_inspectedArenaIndex; // nullopt = no arena inspected
std::unique_ptr<ArenaSimulation> m_inspectedSim;
};

View File

@@ -199,7 +199,7 @@ void InspectWindow::handleEvent(std::shared_ptr<const GameSpeedChangedEvent> eve
void InspectWindow::pollStatus()
{
const ArenaStatus status = m_sim->status();
const ArenaStatus status = m_sim->getStatus();
updateInfoPanel(status);
refreshEntityStats();
}
@@ -228,7 +228,7 @@ void InspectWindow::updateInfoPanel(const ArenaStatus& status)
}
threat->setText(tr("Threat: %1").arg(QString::number(team.threatLevel, 'f', 0)));
ehp->setText(tr("EHP: %1").arg(QString::fromStdString(team.ehpPercentText())));
ehp->setText(tr("EHP: %1").arg(QString::fromStdString(team.getEhpPercentText())));
QString lines;
for (const ArenaStatus::Entry& entry : team.entries)
@@ -250,13 +250,14 @@ void InspectWindow::updateInfoPanel(const ArenaStatus& status)
}
}
void InspectWindow::handleEvent(std::shared_ptr<const EntitySelectedEvent> event)
void InspectWindow::handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event)
{
if (event->entity.has_value())
if (!event->entities.empty())
{
m_selectedEntity = event->entity;
// The arena is single-select, so only the first entity is inspected.
m_selectedEntity = event->entities.front();
EntityAdmin& admin = m_sim->admin();
EntityAdmin& admin = m_sim->getAdmin();
entt::entity entity = *m_selectedEntity;
if (!admin.isValid(entity))
@@ -332,7 +333,7 @@ void InspectWindow::refreshEntityStats()
{
if (!m_selectedEntity.has_value()) { return; }
EntityAdmin& admin = m_sim->admin();
EntityAdmin& admin = m_sim->getAdmin();
entt::entity entity = *m_selectedEntity;
if (!admin.isValid(entity))

View File

@@ -12,7 +12,7 @@
#include "entt/entity/entity.hpp"
#include "ArenaSimulation.h"
#include "EntitySelectedEvent.h"
#include "EntitySelectionChangedEvent.h"
#include "EventHandler.h"
#include "GameConfig.h"
#include "GameSpeedChangedEvent.h"
@@ -22,7 +22,7 @@ class ArenaView;
class ShipStatsPanel;
class InspectWindow : public QWidget,
public CombinedEventHandler<EntitySelectedEvent,
public CombinedEventHandler<EntitySelectionChangedEvent,
GameSpeedChangedEvent>
{
Q_OBJECT
@@ -38,7 +38,7 @@ protected:
void keyPressEvent(QKeyEvent* event) override;
private:
void handleEvent(std::shared_ptr<const EntitySelectedEvent> event) override;
void handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const GameSpeedChangedEvent> event) override;
private slots:

View File

@@ -279,6 +279,12 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path)
cfg.buildingBlocksTooltip = *tip;
}
if (const std::optional<std::string> tip =
tbl["world"]["artifact_tooltip"].value<std::string>())
{
cfg.artifactTooltip = *tip;
}
cfg.regions.asteroidWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["asteroid_width_tiles"], file, "regions.asteroid_width_tiles"));
cfg.regions.playerBufferWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["player_buffer_width_tiles"], file, "regions.player_buffer_width_tiles"));
cfg.regions.contestZoneWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["contest_zone_width_tiles"], file, "regions.contest_zone_width_tiles"));

View File

@@ -30,7 +30,7 @@ public:
// Evaluates the expression at the given x. Requires a compiled formula.
double evaluate(double x) const;
const std::string& source() const { return m_source; }
const std::string& getSource() const { return m_source; }
bool isValid() const { return m_expr != nullptr; }
private:

View File

@@ -82,6 +82,10 @@ struct WorldConfig
// (REQ-UI-BLOCKS-TOOLTIP). Presentation-only; the simulation ignores it.
std::optional<std::string> buildingBlocksTooltip;
// Optional hover-tooltip for the header artifact count display
// (REQ-UI-ARTIFACTS-TOOLTIP). Presentation-only; the simulation ignores it.
std::optional<std::string> artifactTooltip;
WorldRegions regions;
WorldExpansion expansion;
WorldPush push;

View File

@@ -1,5 +1,7 @@
#pragma once
#include <optional>
#include "BuildingId.h"
// Deliver-scrap behavior (one half of the old SalvageBehaviorComponent). Scored
@@ -7,6 +9,6 @@
// SalvagerSystem performs the actual delivery.
struct DeliverScrapBehavior
{
BuildingId deliveryBay = kInvalidBuildingId;
float score = 0.0f;
std::optional<BuildingId> deliveryBay; // nullopt until a bay is assigned
float score = 0.0f;
};

View File

@@ -30,7 +30,7 @@ void SalvagerSystem::tick(Tick currentTick, ScrapSystem& scraps, BuildingSystem&
// Apply collections whose mid-beam delay has elapsed (cycles started earlier).
applyPendingCollections(currentTick, scraps);
const std::vector<ScrapInfo> allScrap = scraps.allScrapInfo();
const std::vector<ScrapInfo> allScrap = scraps.getAllScrapInfo();
// Tick down per-module collection cooldowns.
m_admin.forEach<SalvagerComponent>(
@@ -88,8 +88,8 @@ void SalvagerSystem::tick(Tick currentTick, ScrapSystem& scraps, BuildingSystem&
m_admin.forEach<DeliverScrapBehavior, PositionComponent>(
[&](entt::entity ship, const DeliverScrapBehavior& deliver, const PositionComponent& pos)
{
if (deliver.deliveryBay == kInvalidBuildingId) { return; }
const Building* bay = buildings.findBuilding(deliver.deliveryBay);
if (!deliver.deliveryBay.has_value()) { return; }
const Building* bay = buildings.findBuilding(*deliver.deliveryBay);
if (!bay) { return; }
const QVector2D bayCenter(bay->anchor.x() + bay->footprint.width() / 2.0f,
@@ -100,7 +100,7 @@ void SalvagerSystem::tick(Tick currentTick, ScrapSystem& scraps, BuildingSystem&
if (!m_admin.hasAll<CargoComponent>(ship)) { return; }
CargoComponent& cargo = m_admin.get<CargoComponent>(ship);
if (cargo.current <= 0) { return; }
if (buildings.deliverScrapToSalvageBay(deliver.deliveryBay))
if (buildings.deliverScrapToSalvageBay(*deliver.deliveryBay))
{
--cargo.current;
}

View File

@@ -65,7 +65,7 @@ bool ScrapSystem::collectOne(entt::entity entity)
return true;
}
std::vector<ScrapInfo> ScrapSystem::allScrapInfo() const
std::vector<ScrapInfo> ScrapSystem::getAllScrapInfo() const
{
std::vector<ScrapInfo> result;
m_admin.forEach<ScrapDataComponent>(

View File

@@ -35,7 +35,7 @@ public:
bool collectOne(entt::entity entity);
// Lightweight snapshot for callers that need to iterate all scrap.
std::vector<ScrapInfo> allScrapInfo() const;
std::vector<ScrapInfo> getAllScrapInfo() const;
private:
EntityAdmin& m_admin;

View File

@@ -398,8 +398,7 @@ entt::entity ShipSystem::spawn(const std::string& schematicId,
maxCollRange * static_cast<float>(m_config.world.orbitFactor);
m_admin.addComponent<SalvageScrapBehavior>(entity, salvage);
DeliverScrapBehavior deliver;
deliver.deliveryBay = kInvalidBuildingId;
DeliverScrapBehavior deliver; // deliveryBay starts unassigned (nullopt)
m_admin.addComponent<DeliverScrapBehavior>(entity, deliver);
}

View File

@@ -31,7 +31,7 @@ void DeliverScrapEvaluator::evaluate(EntityAdmin& admin, const BuildingSystem& b
}
// Assign nearest SalvageBay if not yet assigned.
if (deliver.deliveryBay == kInvalidBuildingId)
if (!deliver.deliveryBay.has_value())
{
const Building* bay =
buildings.findNearestBuilding(pos.value, BuildingType::SalvageBay);

View File

@@ -24,9 +24,9 @@ void DeliverScrapExecutor::execute(EntityAdmin& admin, const BuildingSystem& bui
if (selected.winner != BehaviorKind::DeliverScrap) { return; }
QVector2D dest = pos.value;
if (deliver.deliveryBay != kInvalidBuildingId)
if (deliver.deliveryBay.has_value())
{
const Building* bay = buildings.findBuilding(deliver.deliveryBay);
const Building* bay = buildings.findBuilding(*deliver.deliveryBay);
if (bay)
{
dest = QVector2D(bay->anchor.x() + bay->footprint.width() / 2.0f,

View File

@@ -19,7 +19,7 @@ void SalvageScrapEvaluator::evaluate(EntityAdmin& admin, const ScrapSystem& scra
{
TRACE();
const std::unordered_map<entt::entity, CargoState> cargoByShip = buildCargoByShip(admin);
const std::vector<ScrapInfo> allScrap = scraps.allScrapInfo();
const std::vector<ScrapInfo> allScrap = scraps.getAllScrapInfo();
admin.forEach<SalvageScrapBehavior, PositionComponent, SensorRangeComponent>(
[&](entt::entity e, SalvageScrapBehavior& salvage, const PositionComponent& pos,

View File

@@ -4,7 +4,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/TickAdvancedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingBlocksChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/ExpansionCostChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/EntitySelectedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/EntitySelectionChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/GameSpeedChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BossWaveUpdatedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoicesAvailableEvent.h

View File

@@ -1,21 +0,0 @@
#ifndef ENTITY_SELECTED_EVENT_H
#define ENTITY_SELECTED_EVENT_H
#include <optional>
#include "entt/entity/entity.hpp"
#include "Event.h"
class EntitySelectedEvent : public Event
{
public:
explicit EntitySelectedEvent(std::optional<entt::entity> entity)
: entity(entity)
{
}
const std::optional<entt::entity> entity;
};
#endif // ENTITY_SELECTED_EVENT_H

View File

@@ -0,0 +1,25 @@
#ifndef ENTITY_SELECTION_CHANGED_EVENT_H
#define ENTITY_SELECTION_CHANGED_EVENT_H
#include <vector>
#include "entt/entity/entity.hpp"
#include "Event.h"
// The set of currently selected ships and/or defence stations. An empty list means
// no actor is selected. Actors share the "field" selection category with scrap piles
// (REQ-UI-SELECTION-CATEGORIES): they can be selected together, but never together with
// buildings.
class EntitySelectionChangedEvent : public Event
{
public:
explicit EntitySelectionChangedEvent(std::vector<entt::entity> entities)
: entities(std::move(entities))
{
}
const std::vector<entt::entity> entities;
};
#endif // ENTITY_SELECTION_CHANGED_EVENT_H

View File

@@ -86,7 +86,7 @@ struct Building
// Total items held on the output side: buffered plus still-emerging. The
// output-buffer capacity rule (REQ-MAT-OUTPUT-BUFFER) counts emerging items,
// since they have not yet left the building.
int outputItemCount() const
int getOutputItemCount() const
{
int count = static_cast<int>(outputBuffer.items.size());
for (const std::vector<BeltItemSlot>& lane : emergingItems)

View File

@@ -26,15 +26,15 @@ struct SelectedBuilding
// (the HQ and defence stations, per REQ-UI-BLUEPRINT-CREATE).
std::optional<SelectedBuilding> resolvePlaceable(const Simulation& sim, BuildingId id)
{
const Building* building = sim.buildings().findBuilding(id);
const ConstructionSite* site = building ? nullptr : sim.buildings().findSite(id);
const Building* building = sim.getBuildings().findBuilding(id);
const ConstructionSite* site = building ? nullptr : sim.getBuildings().findSite(id);
if (!building && !site)
{
return std::nullopt;
}
const BuildingType type = building ? building->type : site->type;
const BuildingDef* def = sim.config().buildings.findBuildingDef(type);
const BuildingDef* def = sim.getConfig().buildings.findBuildingDef(type);
if (!def || !def->playerPlaceable)
{
return std::nullopt;
@@ -52,8 +52,8 @@ std::optional<SelectedBuilding> resolvePlaceable(const Simulation& sim, Building
std::optional<BuildingConfig> readBuildingConfig(const Simulation& sim, BuildingId id)
{
const Building* building = sim.buildings().findBuilding(id);
const ConstructionSite* site = building ? nullptr : sim.buildings().findSite(id);
const Building* building = sim.getBuildings().findBuilding(id);
const ConstructionSite* site = building ? nullptr : sim.getBuildings().findSite(id);
if (!building && !site)
{
return std::nullopt;
@@ -77,7 +77,7 @@ std::optional<BuildingConfig> readBuildingConfig(const Simulation& sim, Building
{
// Operational splitter filters live in the BeltSystem, keyed by tile.
const std::optional<BeltSystem::SplitterInfo> info =
sim.belts().getSplitterInfo(building->anchor);
sim.getBelts().getSplitterInfo(building->anchor);
if (info.has_value())
{
config.splitterFilterA = info->filterA;

View File

@@ -346,7 +346,7 @@ std::vector<Item> BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe
// Placement
// ---------------------------------------------------------------------------
BuildingId BuildingSystem::place(BuildingType type, QPoint anchor,
std::optional<BuildingId> BuildingSystem::place(BuildingType type, QPoint anchor,
Rotation rotation, Tick currentTick)
{
const BuildingDef* def = findBuildingDef(type);
@@ -356,7 +356,7 @@ BuildingId BuildingSystem::place(BuildingType type, QPoint anchor,
// Reject placements that fall outside the world (REQ-BLD-PLACE-VALID).
if (!bodyCellsWithinWorldBounds(mask.bodyCells, anchor))
{
return kInvalidBuildingId;
return std::nullopt;
}
const BuildingId id = m_allocateBuildingId();
@@ -1014,7 +1014,7 @@ void BuildingSystem::tickProduction(Tick currentTick)
// 3. Output buffer has space for chosen outputs? Emerging items still
// count against the buffer (REQ-MAT-OUTPUT-EMERGE).
const int newSize = building.outputItemCount()
const int newSize = building.getOutputItemCount()
+ static_cast<int>(chosen.size());
if (newSize > building.outputBuffer.capacity)
{
@@ -1279,12 +1279,12 @@ const ConstructionSite* BuildingSystem::findSite(BuildingId id) const
return nullptr;
}
std::vector<Building> BuildingSystem::allBuildings() const
std::vector<Building> BuildingSystem::getAllBuildings() const
{
return m_buildings;
}
std::vector<ConstructionSite> BuildingSystem::allSites() const
std::vector<ConstructionSite> BuildingSystem::getAllSites() const
{
return std::vector<ConstructionSite>(m_constructionQueue.begin(),
m_constructionQueue.end());
@@ -1308,7 +1308,7 @@ bool isProductionBuildingType(BuildingType type)
}
} // namespace
int BuildingSystem::productionBuildingCount() const
int BuildingSystem::getProductionBuildingCount() const
{
int count = 0;
for (const Building& b : m_buildings)
@@ -1318,7 +1318,7 @@ int BuildingSystem::productionBuildingCount() const
return count;
}
int BuildingSystem::activeProductionBuildingCount() const
int BuildingSystem::getActiveProductionBuildingCount() const
{
int count = 0;
for (const Building& b : m_buildings)
@@ -1328,7 +1328,7 @@ int BuildingSystem::activeProductionBuildingCount() const
return count;
}
std::vector<BuildingSystem::BeltTileInfo> BuildingSystem::allBeltTiles() const
std::vector<BuildingSystem::BeltTileInfo> BuildingSystem::getAllBeltTiles() const
{
std::vector<BeltTileInfo> result;
for (const Building& b : m_buildings)
@@ -1518,7 +1518,7 @@ bool BuildingSystem::deliverScrapToSalvageBay(BuildingId bayId)
}
// Emerging scrap still counts against the bay's holding capacity
// (REQ-MAT-OUTPUT-EMERGE).
if (bay->outputItemCount() >= bay->outputBuffer.capacity)
if (bay->getOutputItemCount() >= bay->outputBuffer.capacity)
{
return false;
}

View File

@@ -43,13 +43,13 @@ public:
std::mt19937& rng);
// -- Placement / demolish ------------------------------------------------
// Returns the new entity id, or kInvalidBuildingId if the placement falls
// outside the world bounds (vertical extent and asteroid left edge). Belt
// and Splitter register with BeltSystem directly; other types enter the
// construction queue. Terrain type (A vs S) is NOT checked here so that
// tests can stage arbitrary layouts; the player-facing entry point
// Returns the new entity id, or nullopt if the placement falls outside the
// world bounds (vertical extent and asteroid left edge). Belt and Splitter
// register with BeltSystem directly; other types enter the construction
// queue. Terrain type (A vs S) is NOT checked here so that tests can stage
// arbitrary layouts; the player-facing entry point
// (Simulation::tryPlaceBuilding) enforces the full rule via isPlacementValid.
BuildingId place(BuildingType type, QPoint anchor, Rotation rotation,
std::optional<BuildingId> place(BuildingType type, QPoint anchor, Rotation rotation,
Tick currentTick);
// Returns true if the placement satisfies REQ-BLD-PLACE-VALID terrain and
@@ -112,17 +112,17 @@ public:
const Building* findBuilding(BuildingId id) const;
const ConstructionSite* findSite(BuildingId id) const;
std::vector<Building> allBuildings() const;
std::vector<ConstructionSite> allSites() const;
std::vector<Building> getAllBuildings() const;
std::vector<ConstructionSite> getAllSites() const;
// REQ-UI-DEBUG-OVERLAY "Max Factory Production": count of completed
// (operational) Miner/Smelter/Assembler/ReprocessingPlant/Shipyard buildings.
int productionBuildingCount() const;
int getProductionBuildingCount() const;
// REQ-UI-DEBUG-OVERLAY "Current Factory Production": subset of the above
// that currently has an active production cycle.
int activeProductionBuildingCount() const;
std::vector<BeltTileInfo> allBeltTiles() const;
int getActiveProductionBuildingCount() const;
std::vector<BeltTileInfo> getAllBeltTiles() const;
bool isTileOccupied(QPoint tile) const;
// Visits every item currently emerging from a building output port on its

View File

@@ -75,28 +75,28 @@ struct PlaceBuildingCommand : Command
struct DemolishCommand : Command
{
DemolishCommand() : Command(CommandKind::Demolish) {}
BuildingId id = kInvalidBuildingId;
std::optional<BuildingId> id;
};
struct RotateInPlaceCommand : Command
{
RotateInPlaceCommand() : Command(CommandKind::RotateInPlace) {}
BuildingId id = kInvalidBuildingId;
Rotation newRotation = Rotation::East;
std::optional<BuildingId> id;
Rotation newRotation = Rotation::East;
};
struct SetRecipeCommand : Command
{
SetRecipeCommand() : Command(CommandKind::SetRecipe) {}
BuildingId id = kInvalidBuildingId;
std::string recipeId;
std::optional<BuildingId> id;
std::string recipeId;
};
struct SetShipLayoutCommand : Command
{
SetShipLayoutCommand() : Command(CommandKind::SetShipLayout) {}
BuildingId id = kInvalidBuildingId;
ShipLayoutConfig layout;
std::optional<BuildingId> id;
ShipLayoutConfig layout;
};
// Splitter filters for a queued / under-construction Splitter site (configured by
@@ -104,8 +104,8 @@ struct SetShipLayoutCommand : Command
struct SetSiteSplitterFiltersCommand : Command
{
SetSiteSplitterFiltersCommand() : Command(CommandKind::SetSiteSplitterFilters) {}
BuildingId id = kInvalidBuildingId;
std::vector<ItemType> filterA;
std::optional<BuildingId> id;
std::vector<ItemType> filterA;
std::vector<ItemType> filterB;
};

View File

@@ -46,18 +46,18 @@ void CommandManager::drain()
{
// Restart is a file boundary: a fresh file with the new seed.
m_recorder->startNewRun(m_simulation.getSeed(),
m_simulation.rngFingerprint());
m_simulation.getRngFingerprint());
}
}
else
{
// Commands drain before the tick batch, so currentTick is the count of
// completed ticks the command is pinned to.
const Tick tick = m_simulation.currentTick();
const Tick tick = m_simulation.getCurrentTick();
m_simulation.apply(*command);
if (m_recorder)
{
m_recorder->recordCommand(tick, *command, m_simulation.rngFingerprint());
m_recorder->recordCommand(tick, *command, m_simulation.getRngFingerprint());
}
}
}
@@ -74,7 +74,7 @@ void CommandManager::setRecorder(std::unique_ptr<ReplayRecorder> recorder)
m_recorder = std::move(recorder);
if (m_recorder)
{
m_recorder->startNewRun(m_simulation.getSeed(), m_simulation.rngFingerprint());
m_recorder->startNewRun(m_simulation.getSeed(), m_simulation.getRngFingerprint());
}
}
@@ -85,8 +85,8 @@ void CommandManager::setReplayMode(bool replayMode)
void CommandManager::recordTickCheckpoint()
{
if (m_recorder && (m_simulation.currentTick() % kChecksumIntervalTicks == 0))
if (m_recorder && (m_simulation.getCurrentTick() % kChecksumIntervalTicks == 0))
{
m_recorder->recordChecksum(m_simulation.currentTick(), m_simulation.rngFingerprint());
m_recorder->recordChecksum(m_simulation.getCurrentTick(), m_simulation.getRngFingerprint());
}
}

View File

@@ -129,24 +129,24 @@ std::string serializeCommand(const Command& command)
break;
}
case CommandKind::Demolish:
out << "demolish " << static_cast<const DemolishCommand&>(command).id;
out << "demolish " << static_cast<const DemolishCommand&>(command).id.value();
break;
case CommandKind::RotateInPlace:
{
const RotateInPlaceCommand& c = static_cast<const RotateInPlaceCommand&>(command);
out << "rotate " << c.id << ' ' << rotationToChar(c.newRotation);
out << "rotate " << c.id.value() << ' ' << rotationToChar(c.newRotation);
break;
}
case CommandKind::SetRecipe:
{
const SetRecipeCommand& c = static_cast<const SetRecipeCommand&>(command);
out << "setrecipe " << c.id << ' ' << c.recipeId;
out << "setrecipe " << c.id.value() << ' ' << c.recipeId;
break;
}
case CommandKind::SetShipLayout:
{
const SetShipLayoutCommand& c = static_cast<const SetShipLayoutCommand&>(command);
out << "setlayout " << c.id << ' ';
out << "setlayout " << c.id.value() << ' ';
appendLayout(out, c.layout);
break;
}
@@ -154,7 +154,7 @@ std::string serializeCommand(const Command& command)
{
const SetSiteSplitterFiltersCommand& c =
static_cast<const SetSiteSplitterFiltersCommand&>(command);
out << "sitefilters " << c.id << ' ';
out << "sitefilters " << c.id.value() << ' ';
appendFilters(out, c.filterA, c.filterB);
break;
}
@@ -242,27 +242,35 @@ std::shared_ptr<Command> parseCommand(const std::string& tokens)
if (verb == "demolish")
{
std::shared_ptr<DemolishCommand> c = std::make_shared<DemolishCommand>();
if (!(in >> c->id)) { return nullptr; }
BuildingId id = 0;
if (!(in >> id)) { return nullptr; }
c->id = id;
return c;
}
if (verb == "rotate")
{
std::shared_ptr<RotateInPlaceCommand> c = std::make_shared<RotateInPlaceCommand>();
std::string rotToken;
if (!(in >> c->id >> rotToken)) { return nullptr; }
BuildingId id = 0;
if (!(in >> id >> rotToken)) { return nullptr; }
c->id = id;
c->newRotation = rotationFromString(rotToken);
return c;
}
if (verb == "setrecipe")
{
std::shared_ptr<SetRecipeCommand> c = std::make_shared<SetRecipeCommand>();
if (!(in >> c->id >> c->recipeId)) { return nullptr; }
BuildingId id = 0;
if (!(in >> id >> c->recipeId)) { return nullptr; }
c->id = id;
return c;
}
if (verb == "setlayout")
{
std::shared_ptr<SetShipLayoutCommand> c = std::make_shared<SetShipLayoutCommand>();
if (!(in >> c->id)) { return nullptr; }
BuildingId id = 0;
if (!(in >> id)) { return nullptr; }
c->id = id;
c->layout = parseLayout(in, ok);
if (!ok) { return nullptr; }
return c;
@@ -271,7 +279,9 @@ std::shared_ptr<Command> parseCommand(const std::string& tokens)
{
std::shared_ptr<SetSiteSplitterFiltersCommand> c =
std::make_shared<SetSiteSplitterFiltersCommand>();
if (!(in >> c->id)) { return nullptr; }
BuildingId id = 0;
if (!(in >> id)) { return nullptr; }
c->id = id;
parseFilters(in, c->filterA, c->filterB, ok);
if (!ok) { return nullptr; }
return c;

View File

@@ -6,6 +6,7 @@
#include "EntityAdmin.h"
#include "PositionComponent.h"
#include "ScrapDataComponent.h"
#include "ShipIdentityComponent.h"
#include "StationBodyComponent.h"
#include "HealthComponent.h"
@@ -101,3 +102,46 @@ std::vector<entt::entity> scrapInBox(EntityAdmin& admin, QPoint tileA, QPoint ti
});
return result;
}
std::vector<entt::entity> actorsInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB)
{
const int minX = std::min(tileA.x(), tileB.x());
const int maxX = std::max(tileA.x(), tileB.x());
const int minY = std::min(tileA.y(), tileB.y());
const int maxY = std::max(tileA.y(), tileB.y());
std::vector<entt::entity> result;
// Stations: included when any occupied body cell lies in the box.
admin.forEach<StationBodyComponent, HealthComponent>(
[&](entt::entity entity, const StationBodyComponent& sb, const HealthComponent& h)
{
if (h.hp <= 0.0f) { return; }
for (const QPoint& cell : sb.bodyCells)
{
if (cell.x() >= minX && cell.x() <= maxX
&& cell.y() >= minY && cell.y() <= maxY)
{
result.push_back(entity);
return;
}
}
});
// Ships: included when the floored position tile lies in the box. Requiring
// ShipIdentityComponent excludes the HQ proxy and any station bodies.
admin.forEach<ShipIdentityComponent, PositionComponent, HealthComponent>(
[&](entt::entity entity, const ShipIdentityComponent& /*id*/,
const PositionComponent& pos, const HealthComponent& h)
{
if (h.hp <= 0.0f) { return; }
const int tileX = static_cast<int>(std::floor(pos.value.x()));
const int tileY = static_cast<int>(std::floor(pos.value.y()));
if (tileX >= minX && tileX <= maxX && tileY >= minY && tileY <= maxY)
{
result.push_back(entity);
}
});
return result;
}

View File

@@ -19,3 +19,10 @@ entt::entity scrapAtWorldPos(EntityAdmin& admin, QVector2D worldPos);
// Returns every scrap pile whose position falls within the inclusive tile rectangle
// spanned by tileA and tileB, in any corner order (REQ-UI-SCRAP-MULTI-SELECT).
std::vector<entt::entity> scrapInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB);
// Returns every living actor (ship or defence station, player or enemy) that falls
// within the inclusive tile rectangle spanned by tileA and tileB, in any corner order
// (REQ-UI-MULTI-SELECT, REQ-UI-ENTITY-CLICK-SELECT). A ship is included when its floored
// position tile lies in the box; a station is included when any of its body cells does.
// Dead actors (hp <= 0) and the HQ proxy are excluded.
std::vector<entt::entity> actorsInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB);

View File

@@ -48,7 +48,7 @@ void ReplayPlayer::processEntriesAt(Tick tick)
{
m_simulation.apply(*entry.command);
}
else if (m_simulation.rngFingerprint() != entry.fingerprint)
else if (m_simulation.getRngFingerprint() != entry.fingerprint)
{
m_desyncTick = tick;
}

View File

@@ -19,7 +19,7 @@ class Simulation;
// each frame, for each tick to run:
// if (player.isFinished()) break;
// sim.tick();
// player.advanceTo(sim.currentTick());
// player.advanceTo(sim.getCurrentTick());
class ReplayPlayer
{
public:

View File

@@ -58,7 +58,7 @@ std::string computeReplayConfigHash(const std::string& configDir)
hasher.appendBytes(bytes.constData(), static_cast<std::size_t>(bytes.size()));
}
}
return toHex(hasher.value());
return toHex(hasher.getValue());
}
void ReplayRecorder::startNewRun(unsigned int seed, std::uint64_t initialRngFingerprint)
@@ -124,7 +124,7 @@ bool ReplayRecorder::isOpen() const
return m_stream.is_open();
}
const std::string& ReplayRecorder::currentFilePath() const
const std::string& ReplayRecorder::getCurrentFilePath() const
{
return m_filePath;
}

View File

@@ -44,7 +44,7 @@ public:
void close();
bool isOpen() const;
const std::string& currentFilePath() const;
const std::string& getCurrentFilePath() const;
private:
std::string m_configDir;

View File

@@ -40,7 +40,6 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
, m_nextBuildingId(1)
, m_buildingBlocksStock(m_config.world.startingBuildingBlocks)
, m_gameOver(false)
, m_hqBuildingId(kInvalidBuildingId)
, m_hqProxyEntity(entt::null)
, m_playerStation1Entity(entt::null)
, m_playerStation2Entity(entt::null)
@@ -113,7 +112,7 @@ Simulation::~Simulation()
unregisterForEvents();
}
const GameConfig& Simulation::config() const
const GameConfig& Simulation::getConfig() const
{
return m_config;
}
@@ -137,7 +136,7 @@ void Simulation::reset(unsigned int seed)
m_gameOver = false;
m_isWon = false;
m_artifactCount = 0;
m_hqBuildingId = kInvalidBuildingId;
m_hqBuildingId = std::nullopt;
m_hqProxyEntity = entt::null;
m_playerStation1Entity = entt::null;
m_playerStation2Entity = entt::null;
@@ -217,11 +216,12 @@ void Simulation::apply(const Command& command)
case CommandKind::PlaceBuilding:
{
const PlaceBuildingCommand& c = static_cast<const PlaceBuildingCommand&>(command);
const BuildingId id = tryPlaceBuilding(c.type, c.anchor, c.rotation);
if (id == kInvalidBuildingId)
const std::optional<BuildingId> placed = tryPlaceBuilding(c.type, c.anchor, c.rotation);
if (!placed.has_value())
{
break;
}
const BuildingId id = *placed;
if (c.recipeId.has_value())
{
m_buildingSystem->setRecipe(id, *c.recipeId);
@@ -237,31 +237,31 @@ void Simulation::apply(const Command& command)
break;
}
case CommandKind::Demolish:
demolish(static_cast<const DemolishCommand&>(command).id);
demolish(*static_cast<const DemolishCommand&>(command).id);
break;
case CommandKind::RotateInPlace:
{
const RotateInPlaceCommand& c = static_cast<const RotateInPlaceCommand&>(command);
m_buildingSystem->rotateInPlace(c.id, c.newRotation);
m_buildingSystem->rotateInPlace(*c.id, c.newRotation);
break;
}
case CommandKind::SetRecipe:
{
const SetRecipeCommand& c = static_cast<const SetRecipeCommand&>(command);
m_buildingSystem->setRecipe(c.id, c.recipeId);
m_buildingSystem->setRecipe(*c.id, c.recipeId);
break;
}
case CommandKind::SetShipLayout:
{
const SetShipLayoutCommand& c = static_cast<const SetShipLayoutCommand&>(command);
m_buildingSystem->setShipLayout(c.id, c.layout);
m_buildingSystem->setShipLayout(*c.id, c.layout);
break;
}
case CommandKind::SetSiteSplitterFilters:
{
const SetSiteSplitterFiltersCommand& c =
static_cast<const SetSiteSplitterFiltersCommand&>(command);
m_buildingSystem->setSiteSplitterFilters(c.id, c.filterA, c.filterB);
m_buildingSystem->setSiteSplitterFilters(*c.id, c.filterA, c.filterB);
break;
}
case CommandKind::SetSplitterFilters:
@@ -579,7 +579,7 @@ void Simulation::tickDeathsAndLoot()
}
else
{
const double genD = static_cast<double>(m_waveSystem->generation());
const double genD = static_cast<double>(m_waveSystem->getGeneration());
scrap = static_cast<int>(
m_config.stations.enemyStation.scrapDropFormula.evaluate(genD));
}
@@ -619,9 +619,9 @@ void Simulation::tickDeathsAndLoot()
if (es0Gone && es1Gone &&
m_currentEnemyStationEntities[0] != entt::null)
{
const int destroyedLevel = m_waveSystem->generation();
const int destroyedLevel = m_waveSystem->getGeneration();
m_waveSystem->onEnemyStationsDestroyed();
placeEnemyStationSet(m_waveSystem->generation());
placeEnemyStationSet(m_waveSystem->getGeneration());
generateSchematicChoices(destroyedLevel);
}
}
@@ -968,7 +968,7 @@ void Simulation::appendStringSet(Hasher& hasher, const std::set<std::string>& id
}
}
unsigned long long Simulation::rngFingerprint() const
unsigned long long Simulation::getRngFingerprint() const
{
return fingerprintRng(m_rng);
}
@@ -991,11 +991,11 @@ unsigned long long Simulation::computeStateChecksum() const
hasher.append(m_expansionsPurchased);
// WaveSystem scalar state, reached through existing accessors.
hasher.append(threatLevel());
hasher.append(threatAccumulationRate());
hasher.append(bossWaveCounter());
hasher.append(bossCountdownTicks());
hasher.append(normalGapRemainingTicks());
hasher.append(getThreatLevel());
hasher.append(getThreatAccumulationRate());
hasher.append(getBossWaveCounter());
hasher.append(getBossCountdownTicks());
hasher.append(getNormalGapRemainingTicks());
// Schematic / unlock state (std::map and std::set iterate in sorted order).
appendSchematicMap(hasher, m_schematicLevels);
@@ -1052,7 +1052,7 @@ unsigned long long Simulation::computeStateChecksum() const
hasher.append(c.schematicId);
});
return hasher.value();
return hasher.getValue();
}
// ---------------------------------------------------------------------------
@@ -1081,7 +1081,7 @@ bool Simulation::hasSchematicChoicesPending() const
// Accessors
// ---------------------------------------------------------------------------
Tick Simulation::currentTick() const
Tick Simulation::getCurrentTick() const
{
return m_currentTick;
}
@@ -1091,18 +1091,18 @@ unsigned int Simulation::getSeed() const
return m_seed;
}
int Simulation::buildingBlocksStock() const
int Simulation::getBuildingBlocksStock() const
{
return m_buildingBlocksStock;
}
int Simulation::currentAsteroidWidth_tiles() const
int Simulation::getCurrentAsteroidWidth_tiles() const
{
return m_config.world.regions.asteroidWidth_tiles
+ m_expansionsPurchased * m_config.world.expansion.columnsPerExpansion_tiles;
}
int Simulation::currentExpansionCost() const
int Simulation::getCurrentExpansionCost() const
{
const double cost = m_config.world.expansion.costBuildingBlocksFormula.evaluate(
static_cast<double>(m_expansionsPurchased));
@@ -1111,14 +1111,14 @@ int Simulation::currentExpansionCost() const
void Simulation::tryExpandAsteroid()
{
const int cost = currentExpansionCost();
const int cost = getCurrentExpansionCost();
if (m_buildingBlocksStock < cost)
{
return;
}
m_buildingBlocksStock -= cost;
++m_expansionsPurchased;
m_buildingSystem->setAsteroidWidth_tiles(currentAsteroidWidth_tiles());
m_buildingSystem->setAsteroidWidth_tiles(getCurrentAsteroidWidth_tiles());
}
bool Simulation::isGameOver() const
@@ -1131,44 +1131,44 @@ bool Simulation::isWon() const
return m_isWon;
}
int Simulation::artifactCount() const
int Simulation::getArtifactCount() const
{
return m_artifactCount;
}
double Simulation::threatLevel() const
double Simulation::getThreatLevel() const
{
return m_waveSystem->threatLevel();
return m_waveSystem->getThreatLevel();
}
double Simulation::threatAccumulationRate() const
double Simulation::getThreatAccumulationRate() const
{
return m_waveSystem->threatAccumulationRate();
return m_waveSystem->getThreatAccumulationRate();
}
double Simulation::maxFactoryProductionThreatRate() const
double Simulation::getMaxFactoryProductionThreatRate() const
{
return static_cast<double>(m_buildingSystem->productionBuildingCount());
return static_cast<double>(m_buildingSystem->getProductionBuildingCount());
}
double Simulation::currentFactoryProductionThreatRate() const
double Simulation::getCurrentFactoryProductionThreatRate() const
{
return static_cast<double>(m_buildingSystem->activeProductionBuildingCount());
return static_cast<double>(m_buildingSystem->getActiveProductionBuildingCount());
}
int Simulation::bossWaveCounter() const
int Simulation::getBossWaveCounter() const
{
return m_waveSystem->bossWaveCounter();
return m_waveSystem->getBossWaveCounter();
}
Tick Simulation::bossCountdownTicks() const
Tick Simulation::getBossCountdownTicks() const
{
return m_waveSystem->bossCountdownTicks();
return m_waveSystem->getBossCountdownTicks();
}
Tick Simulation::normalGapRemainingTicks() const
Tick Simulation::getNormalGapRemainingTicks() const
{
return m_waveSystem->normalGapRemainingTicks();
return m_waveSystem->getNormalGapRemainingTicks();
}
bool Simulation::isSchematicUnlocked(const std::string& shipId) const
@@ -1193,11 +1193,11 @@ bool Simulation::isModuleSchematicUnlocked(const std::string& moduleId) const
return it->second.unlocked;
}
BuildingId Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)
std::optional<BuildingId> Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)
{
if (!m_buildingSystem->isPlacementValid(type, anchor, rotation))
{
return kInvalidBuildingId;
return std::nullopt;
}
int cost = 0;
@@ -1211,7 +1211,7 @@ BuildingId Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotati
}
if (m_buildingBlocksStock < cost)
{
return kInvalidBuildingId;
return std::nullopt;
}
m_buildingBlocksStock -= cost;
return m_buildingSystem->place(type, anchor, rotation, m_currentTick);
@@ -1222,52 +1222,52 @@ void Simulation::demolish(BuildingId id)
m_buildingBlocksStock += m_buildingSystem->demolish(id);
}
BuildingSystem& Simulation::buildingsMutable()
BuildingSystem& Simulation::getBuildingsMutable()
{
return *m_buildingSystem;
}
const BuildingSystem& Simulation::buildings() const
const BuildingSystem& Simulation::getBuildings() const
{
return *m_buildingSystem;
}
BeltSystem& Simulation::beltsMutable()
BeltSystem& Simulation::getBeltsMutable()
{
return m_beltSystem;
}
const BeltSystem& Simulation::belts() const
const BeltSystem& Simulation::getBelts() const
{
return m_beltSystem;
}
ShipSystem& Simulation::ships()
ShipSystem& Simulation::getShips()
{
return *m_shipSystem;
}
const ShipSystem& Simulation::ships() const
const ShipSystem& Simulation::getShips() const
{
return *m_shipSystem;
}
ScrapSystem& Simulation::scraps()
ScrapSystem& Simulation::getScraps()
{
return *m_scrapSystem;
}
const ScrapSystem& Simulation::scraps() const
const ScrapSystem& Simulation::getScraps() const
{
return *m_scrapSystem;
}
EntityAdmin& Simulation::admin()
EntityAdmin& Simulation::getAdmin()
{
return m_admin;
}
const EntityAdmin& Simulation::admin() const
const EntityAdmin& Simulation::getAdmin() const
{
return m_admin;
}

View File

@@ -2,6 +2,7 @@
#include <map>
#include <memory>
#include <optional>
#include <random>
#include <set>
#include <string>
@@ -41,7 +42,7 @@ public:
explicit Simulation(GameConfig config, unsigned int seed = 0);
~Simulation();
const GameConfig& config() const;
const GameConfig& getConfig() const;
// Reinitializes all simulation state as if constructed fresh.
void reset(unsigned int seed = 0);
@@ -68,26 +69,26 @@ public:
// Returns true if there are pending schematic choices waiting for player input.
bool hasSchematicChoicesPending() const;
Tick currentTick() const;
Tick getCurrentTick() const;
// The seed this run was (re)initialized with; written to the replay header.
unsigned int getSeed() const;
int buildingBlocksStock() const;
int getBuildingBlocksStock() const;
// Current asteroid width in tiles = base width + purchased expansions
// (REQ-EXP-UNLOCK, REQ-GW-ASTEROID-EXPAND).
int currentAsteroidWidth_tiles() const;
int getCurrentAsteroidWidth_tiles() const;
// Building block cost of the next expansion, floored to an integer
// (REQ-EXP-COST); x = number of expansions already purchased.
int currentExpansionCost() const;
int getCurrentExpansionCost() const;
bool isGameOver() const;
bool isWon() const;
int artifactCount() const;
double threatLevel() const;
double threatAccumulationRate() const;
double maxFactoryProductionThreatRate() const;
double currentFactoryProductionThreatRate() const;
int bossWaveCounter() const;
Tick bossCountdownTicks() const;
Tick normalGapRemainingTicks() const;
int getArtifactCount() const;
double getThreatLevel() const;
double getThreatAccumulationRate() const;
double getMaxFactoryProductionThreatRate() const;
double getCurrentFactoryProductionThreatRate() const;
int getBossWaveCounter() const;
Tick getBossCountdownTicks() const;
Tick getNormalGapRemainingTicks() const;
// Ship schematic state query.
bool isSchematicUnlocked(const std::string& shipId) const;
@@ -102,25 +103,25 @@ public:
// -- Determinism (see docs/replay_design.md) -----------------------------
// 64-bit fingerprint of the RNG stream state. Cheap; written to the replay
// file periodically + after each command for desync detection.
unsigned long long rngFingerprint() const;
unsigned long long getRngFingerprint() const;
// 64-bit fingerprint of the full simulation state (RNG, scalars, buildings,
// belts, and ECS component state). Used by the double-run determinism test;
// a superset of rngFingerprint().
// a superset of getRngFingerprint().
unsigned long long computeStateChecksum() const;
// Const subsystem accessors (queries only). The mutable counterparts are
// private and reachable only through Simulation::apply (the command
// chokepoint) or, in tests, SimulationTestAccess — so production code cannot
// mutate the factory outside the recorded command path (docs/replay_design.md).
const BuildingSystem& buildings() const;
const BeltSystem& belts() const;
ShipSystem& ships();
const ShipSystem& ships() const;
ScrapSystem& scraps();
const ScrapSystem& scraps() const;
EntityAdmin& admin();
const EntityAdmin& admin() const;
const BuildingSystem& getBuildings() const;
const BeltSystem& getBelts() const;
ShipSystem& getShips();
const ShipSystem& getShips() const;
ScrapSystem& getScraps();
const ScrapSystem& getScraps() const;
EntityAdmin& getAdmin();
const EntityAdmin& getAdmin() const;
private:
// Grants tests access to the private player-action mutators below without
@@ -131,8 +132,8 @@ private:
// Reached during play exclusively via apply(); never called by UI/app code.
// Checks affordability, deducts building blocks, and places the building.
// Returns the new entity id, or kInvalidBuildingId if blocks are insufficient.
BuildingId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation);
// Returns the new entity id, or nullopt if blocks are insufficient.
std::optional<BuildingId> tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation);
// Demolishes the building with the given id and refunds building blocks.
void demolish(BuildingId id);
@@ -148,8 +149,8 @@ private:
void tryExpandAsteroid();
// Mutable subsystem accessors; same chokepoint rule as the mutators above.
BuildingSystem& buildingsMutable();
BeltSystem& beltsMutable();
BuildingSystem& getBuildingsMutable();
BeltSystem& getBeltsMutable();
void handleEvent(std::shared_ptr<const TracePrintRequestedEvent> event) override;
@@ -182,7 +183,7 @@ private:
int m_artifactCount = 0;
// Pre-placed structure IDs.
BuildingId m_hqBuildingId; // Building id (for belt integration)
std::optional<BuildingId> m_hqBuildingId; // Building id (for belt integration)
entt::entity m_hqProxyEntity; // ECS entity (HP, targeting)
entt::entity m_playerStation1Entity;
entt::entity m_playerStation2Entity;

View File

@@ -57,5 +57,5 @@ std::uint64_t fingerprintRng(const std::mt19937& rng)
stream << rng; // full internal state as space-separated integers
Hasher hasher;
hasher.append(stream.str());
return hasher.value();
return hasher.getValue();
}

View File

@@ -43,7 +43,7 @@ public:
void append(const QVector2D& vector);
void append(const std::string& text);
std::uint64_t value() const { return m_state; }
std::uint64_t getValue() const { return m_state; }
private:
std::uint64_t m_state = 14695981039346656037ull; // FNV-1a 64-bit offset basis

View File

@@ -98,12 +98,12 @@ void WaveSystem::onEnemyStationsDestroyed()
++m_generation;
}
double WaveSystem::threatLevel() const
double WaveSystem::getThreatLevel() const
{
return m_threatLevel;
}
double WaveSystem::threatAccumulationRate() const
double WaveSystem::getThreatAccumulationRate() const
{
if (isInQuietWindow())
{
@@ -113,22 +113,22 @@ double WaveSystem::threatAccumulationRate() const
return std::max(0.0, m_config.world.waves.threatRateFormula.evaluate(x));
}
int WaveSystem::generation() const
int WaveSystem::getGeneration() const
{
return m_generation;
}
int WaveSystem::bossWaveCounter() const
int WaveSystem::getBossWaveCounter() const
{
return m_bossWaveCounter;
}
Tick WaveSystem::bossCountdownTicks() const
Tick WaveSystem::getBossCountdownTicks() const
{
return m_bossCountdownTicks;
}
Tick WaveSystem::normalGapRemainingTicks() const
Tick WaveSystem::getNormalGapRemainingTicks() const
{
return m_normalGapRemainingTicks;
}

View File

@@ -34,26 +34,26 @@ public:
// (REQ-WAV-BOSS-ADVANCE, REQ-PSH-STATION-STATS).
void onEnemyStationsDestroyed();
double threatLevel() const;
double getThreatLevel() const;
// Current rate at which threatLevel() is increasing, in threat/second
// Current rate at which getThreatLevel() is increasing, in threat/second
// (REQ-WAV-THREAT-RATE). 0 during a quiet window (REQ-WAV-QUIET) or when
// the rate formula evaluates to a negative value.
double threatAccumulationRate() const;
double getThreatAccumulationRate() const;
// Current enemy-station generation level (0 for initial set,
// incremented by 1 after each push — REQ-PSH-STATION-STATS).
int generation() const;
int getGeneration() const;
// Boss wave counter (REQ-WAV-BOSS-COUNTER): current cycle number, starts at 1.
int bossWaveCounter() const;
int getBossWaveCounter() const;
// Ticks remaining until the next boss wave fires (REQ-WAV-BOSS-COUNTDOWN).
Tick bossCountdownTicks() const;
Tick getBossCountdownTicks() const;
// Ticks remaining on the current normal-wave gap timer (REQ-WAV-GAP).
// Frozen during quiet windows.
Tick normalGapRemainingTicks() const;
Tick getNormalGapRemainingTicks() const;
private:
struct SpawnEntry

View File

@@ -19,7 +19,7 @@ static GameConfig loadConfig()
static void killEnemyStations(Simulation& sim)
{
sim.admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity, StationBodyComponent&, FactionComponent& faction, HealthComponent& health)
{
if (faction.isEnemy)
@@ -65,7 +65,7 @@ TEST_CASE("ArtifactWinCondition: artifact count is 0 and isWon is false at game
"[artifact_win]")
{
const Simulation sim(loadConfig());
CHECK(sim.artifactCount() == 0);
CHECK(sim.getArtifactCount() == 0);
CHECK_FALSE(sim.isWon());
}
@@ -145,7 +145,7 @@ TEST_CASE("ArtifactWinCondition: selecting artifact increments artifact count",
SimulationTestAccess::applySchematicChoice(sim,index);
CHECK(sim.artifactCount() == 1);
CHECK(sim.getArtifactCount() == 1);
}
TEST_CASE("ArtifactWinCondition: selecting a non-artifact option does not increment artifact count",
@@ -166,7 +166,7 @@ TEST_CASE("ArtifactWinCondition: selecting a non-artifact option does not increm
SimulationTestAccess::applySchematicChoice(sim,static_cast<int>(it - choices.begin()));
CHECK(sim.artifactCount() == 0);
CHECK(sim.getArtifactCount() == 0);
}
// ---------------------------------------------------------------------------
@@ -205,7 +205,7 @@ TEST_CASE("ArtifactWinCondition: isWon stays false when artifact count is below
REQUIRE(sim.hasSchematicChoicesPending());
SimulationTestAccess::applySchematicChoice(sim,findArtifactChoiceIndex(sim));
CHECK(sim.artifactCount() == 1);
CHECK(sim.getArtifactCount() == 1);
CHECK_FALSE(sim.isWon());
}
@@ -226,7 +226,7 @@ TEST_CASE("ArtifactWinCondition: isWon becomes true after collecting required nu
SimulationTestAccess::applySchematicChoice(sim,index);
}
CHECK(sim.artifactCount() == 2);
CHECK(sim.getArtifactCount() == 2);
CHECK(sim.isWon());
}
@@ -249,6 +249,6 @@ TEST_CASE("ArtifactWinCondition: reset clears artifact count and win state",
sim.reset();
CHECK(sim.artifactCount() == 0);
CHECK(sim.getArtifactCount() == 0);
CHECK_FALSE(sim.isWon());
}

View File

@@ -954,7 +954,7 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b
Fixture f;
const BuildingId bayId = f.buildings.place(BuildingType::SalvageBay,
QPoint(-4, 0), Rotation::East, 0);
QPoint(-4, 0), Rotation::East, 0).value();
Tick t = 0;
for (int i = 0; i < 500; ++i)
{
@@ -989,7 +989,7 @@ TEST_CASE("SalvagerSystem: full-cargo ship at its SalvageBay hands over cargo",
Fixture f;
const BuildingId bayId = f.buildings.place(BuildingType::SalvageBay,
QPoint(-4, 0), Rotation::East, 0);
QPoint(-4, 0), Rotation::East, 0).value();
Tick t = 0;
for (int i = 0; i < 500; ++i)
{

View File

@@ -527,15 +527,15 @@ TEST_CASE("Blueprint placement: buildings land at anchor + offset from cursor",
const QPoint offsetB( 1, 0);
const BuildingId idA = SimulationTestAccess::place(sim,
BuildingType::Belt, cursor + offsetA, Rotation::East);
BuildingType::Belt, cursor + offsetA, Rotation::East).value();
const BuildingId idB = SimulationTestAccess::place(sim,
BuildingType::Belt, cursor + offsetB, Rotation::East);
BuildingType::Belt, cursor + offsetB, Rotation::East).value();
REQUIRE(idA != kInvalidBuildingId);
REQUIRE(idB != kInvalidBuildingId);
REQUIRE(sim.buildings().isTileOccupied(cursor + offsetA)); // (-6, 0)
REQUIRE(sim.buildings().isTileOccupied(cursor + offsetB)); // (-4, 0)
REQUIRE_FALSE(sim.buildings().isTileOccupied(cursor)); // center not occupied
REQUIRE(sim.getBuildings().isTileOccupied(cursor + offsetA)); // (-6, 0)
REQUIRE(sim.getBuildings().isTileOccupied(cursor + offsetB)); // (-4, 0)
REQUIRE_FALSE(sim.getBuildings().isTileOccupied(cursor)); // center not occupied
}
TEST_CASE("Blueprint placement: cost is deducted for each building in sequence", "[blueprint]")
@@ -544,20 +544,20 @@ TEST_CASE("Blueprint placement: cost is deducted for each building in sequence",
// Find belt cost from config (belt cost = 2 in test config).
int beltCost = 0;
for (const BuildingDef& def : sim.config().buildings.buildings)
for (const BuildingDef& def : sim.getConfig().buildings.buildings)
{
if (def.type == BuildingType::Belt) { beltCost = def.cost; break; }
}
REQUIRE(beltCost > 0);
const int startBlocks = sim.buildingBlocksStock();
const int startBlocks = sim.getBuildingBlocksStock();
REQUIRE(startBlocks >= 2 * beltCost); // test config has enough starting blocks
SimulationTestAccess::place(sim,BuildingType::Belt, QPoint(-6, 0), Rotation::East);
REQUIRE(sim.buildingBlocksStock() == startBlocks - beltCost);
REQUIRE(sim.getBuildingBlocksStock() == startBlocks - beltCost);
SimulationTestAccess::place(sim,BuildingType::Belt, QPoint(-4, 0), Rotation::East);
REQUIRE(sim.buildingBlocksStock() == startBlocks - 2 * beltCost);
REQUIRE(sim.getBuildingBlocksStock() == startBlocks - 2 * beltCost);
}
TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidBuildingId and deducts nothing",
@@ -567,7 +567,7 @@ TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidBuildingId a
// Find miner cost (15 in test config) — expensive enough to exhaust a small stock.
int minerCost = 0;
for (const BuildingDef& def : sim.config().buildings.buildings)
for (const BuildingDef& def : sim.getConfig().buildings.buildings)
{
if (def.type == BuildingType::Miner) { minerCost = def.cost; break; }
}
@@ -576,35 +576,35 @@ TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidBuildingId a
// Drain the stock by placing miners until we no longer have enough.
// Non-overlapping columns: miner body is 2 wide, so step by 2.
int col = -2;
while (sim.buildingBlocksStock() >= minerCost)
while (sim.getBuildingBlocksStock() >= minerCost)
{
SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(col, 0), Rotation::East);
col -= 2;
}
const int blocksBeforeAttempt = sim.buildingBlocksStock();
const BuildingId id = SimulationTestAccess::place(sim,
const int blocksBeforeAttempt = sim.getBuildingBlocksStock();
const std::optional<BuildingId> id = SimulationTestAccess::place(sim,
BuildingType::Miner, QPoint(col - 2, 0), Rotation::East);
// Placement must fail and leave the stock unchanged.
REQUIRE(id == kInvalidBuildingId);
REQUIRE(sim.buildingBlocksStock() == blocksBeforeAttempt);
REQUIRE_FALSE(id.has_value());
REQUIRE(sim.getBuildingBlocksStock() == blocksBeforeAttempt);
}
TEST_CASE("Simulation: tryPlaceBuilding rejects terrain-invalid placement and charges nothing",
"[blueprint]")
{
Simulation sim(loadConfig());
const int startBlocks = sim.buildingBlocksStock();
const int startBlocks = sim.getBuildingBlocksStock();
// A miner is all-asteroid; placing it in space (x >= 0) violates the terrain
// rule, so it must be rejected without consuming building blocks.
const BuildingId id =
const std::optional<BuildingId> id =
SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(0, 0), Rotation::East);
REQUIRE(id == kInvalidBuildingId);
REQUIRE(sim.buildingBlocksStock() == startBlocks);
REQUIRE(sim.buildings().allSites().empty());
REQUIRE_FALSE(id.has_value());
REQUIRE(sim.getBuildingBlocksStock() == startBlocks);
REQUIRE(sim.getBuildings().getAllSites().empty());
}
TEST_CASE("Simulation: tryPlaceBuilding accepts a valid asteroid spot, occupies tiles, charges cost",
@@ -613,25 +613,25 @@ TEST_CASE("Simulation: tryPlaceBuilding accepts a valid asteroid spot, occupies
Simulation sim(loadConfig());
int minerCost = 0;
for (const BuildingDef& def : sim.config().buildings.buildings)
for (const BuildingDef& def : sim.getConfig().buildings.buildings)
{
if (def.type == BuildingType::Miner) { minerCost = def.cost; break; }
}
REQUIRE(minerCost > 0);
const int startBlocks = sim.buildingBlocksStock();
const int startBlocks = sim.getBuildingBlocksStock();
// Miner mask ["AA","A>"] East at (-3,0) → all-asteroid body at
// (-3,0),(-2,0),(-3,1); a valid spot.
const BuildingId id =
SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-3, 0), Rotation::East);
SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-3, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId);
REQUIRE(sim.buildingBlocksStock() == startBlocks - minerCost);
REQUIRE(sim.buildings().isTileOccupied(QPoint(-3, 0)));
REQUIRE(sim.buildings().isTileOccupied(QPoint(-2, 0)));
REQUIRE(sim.buildings().isTileOccupied(QPoint(-3, 1)));
REQUIRE(sim.getBuildingBlocksStock() == startBlocks - minerCost);
REQUIRE(sim.getBuildings().isTileOccupied(QPoint(-3, 0)));
REQUIRE(sim.getBuildings().isTileOccupied(QPoint(-2, 0)));
REQUIRE(sim.getBuildings().isTileOccupied(QPoint(-3, 1)));
// The output-port tile (1,1)+anchor = (-2,1) is not a body cell.
REQUIRE_FALSE(sim.buildings().isTileOccupied(QPoint(-2, 1)));
REQUIRE_FALSE(sim.getBuildings().isTileOccupied(QPoint(-2, 1)));
}
// ---------------------------------------------------------------------------
@@ -666,12 +666,12 @@ TEST_CASE("Blueprint placement: setRecipe on construction site stores recipe", "
Simulation sim(loadConfig());
// Miner body cells: (0,0),(1,0),(0,1) — all at x < 0, valid for asteroid.
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East);
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore");
const ConstructionSite* site = sim.buildings().findSite(id);
const ConstructionSite* site = sim.getBuildings().findSite(id);
REQUIRE(site != nullptr);
REQUIRE(site->recipeId == "mine_iron_ore");
}
@@ -681,7 +681,7 @@ TEST_CASE("Blueprint placement: recipe transfers to building after construction
{
Simulation sim(loadConfig());
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East);
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_copper_ore");
@@ -692,7 +692,7 @@ TEST_CASE("Blueprint placement: recipe transfers to building after construction
sim.tick();
}
const Building* b = sim.buildings().findBuilding(id);
const Building* b = sim.getBuildings().findBuilding(id);
REQUIRE(b != nullptr);
REQUIRE(b->recipeId == "mine_copper_ore");
}
@@ -710,10 +710,10 @@ TEST_CASE("Blueprint creation: a construction site is captured", "[blueprint]")
// Freshly placed → a ConstructionSite (not ticked to completion). A 1x1 belt keeps
// the body-cell bounding-box centered on the anchor, so a single site → zero offset.
const BuildingId id =
SimulationTestAccess::place(sim, BuildingType::Belt, QPoint(-2, 0), Rotation::East);
SimulationTestAccess::place(sim, BuildingType::Belt, QPoint(-2, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId);
REQUIRE(sim.buildings().findSite(id) != nullptr);
REQUIRE(sim.buildings().findBuilding(id) == nullptr);
REQUIRE(sim.getBuildings().findSite(id) != nullptr);
REQUIRE(sim.getBuildings().findBuilding(id) == nullptr);
const Blueprint bp = captureBlueprintFromSelection(sim, { id });
@@ -727,7 +727,7 @@ TEST_CASE("Blueprint creation: a construction site's recipe is captured", "[blue
Simulation sim(loadConfig());
const BuildingId id =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore");
@@ -744,18 +744,18 @@ TEST_CASE("Blueprint creation: mixed operational building and construction site
// Building A: place, configure, and tick to completion so it is operational.
const BuildingId idA =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
REQUIRE(idA != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(idA, "mine_iron_ore");
for (int i = 0; i <= static_cast<int>(secondsToTicks(10.0)); ++i) { sim.tick(); }
REQUIRE(sim.buildings().findBuilding(idA) != nullptr);
REQUIRE(sim.getBuildings().findBuilding(idA) != nullptr);
// Building B: place and configure, but leave as a construction site.
const BuildingId idB =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-6, 0), Rotation::East);
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-6, 0), Rotation::East).value();
REQUIRE(idB != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(idB, "mine_copper_ore");
REQUIRE(sim.buildings().findSite(idB) != nullptr);
REQUIRE(sim.getBuildings().findSite(idB) != nullptr);
const Blueprint bp = captureBlueprintFromSelection(sim, { idA, idB });
@@ -777,9 +777,9 @@ TEST_CASE("Blueprint creation: selectionHasPlaceableBuilding sees a construction
REQUIRE_FALSE(selectionHasPlaceableBuilding(sim, {}));
const BuildingId id =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId);
REQUIRE(sim.buildings().findSite(id) != nullptr);
REQUIRE(sim.getBuildings().findSite(id) != nullptr);
REQUIRE(selectionHasPlaceableBuilding(sim, { id }));
}
@@ -847,7 +847,7 @@ TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout
// Shipyard surface_mask ["AAAS>","AAAS "] with Rotation::East:
// A-tiles at (-3,0),(-2,0),(-1,0),(-3,1),(-2,1),(-1,1) — all x < 0, valid asteroid tiles.
// S-tile at (0,0) and (0,1) — x >= 0, valid space tiles.
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId);
ShipLayoutConfig layout;
@@ -859,7 +859,7 @@ TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout
SimulationTestAccess::buildings(sim).setShipLayout(id, layout);
const ConstructionSite* site = sim.buildings().findSite(id);
const ConstructionSite* site = sim.getBuildings().findSite(id);
REQUIRE(site != nullptr);
REQUIRE(site->shipLayout.has_value());
REQUIRE(site->shipLayout->placedModules.size() == 1);
@@ -871,7 +871,7 @@ TEST_CASE("Blueprint placement: ship layout transfers to building after construc
{
Simulation sim(loadConfig());
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId);
ShipLayoutConfig layout;
@@ -885,7 +885,7 @@ TEST_CASE("Blueprint placement: ship layout transfers to building after construc
// Shipyard construction_time_seconds = 30 in the test config.
double constructionTime = 0.0;
for (const BuildingDef& def : sim.config().buildings.buildings)
for (const BuildingDef& def : sim.getConfig().buildings.buildings)
{
if (def.type == BuildingType::Shipyard) { constructionTime = def.constructionTimeSeconds; break; }
}
@@ -896,7 +896,7 @@ TEST_CASE("Blueprint placement: ship layout transfers to building after construc
sim.tick();
}
const Building* b = sim.buildings().findBuilding(id);
const Building* b = sim.getBuildings().findBuilding(id);
REQUIRE(b != nullptr);
REQUIRE(b->shipLayout.has_value());
REQUIRE(b->shipLayout->placedModules.size() == 1);

View File

@@ -112,10 +112,10 @@ TEST_CASE("readBuildingConfig reads a queued construction site", "[copyconfig]")
// A placed miner enters the construction queue as a site (not yet operational).
const BuildingId id =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId);
REQUIRE(sim.buildings().findBuilding(id) == nullptr);
REQUIRE(sim.buildings().findSite(id) != nullptr);
REQUIRE(sim.getBuildings().findBuilding(id) == nullptr);
REQUIRE(sim.getBuildings().findSite(id) != nullptr);
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore");

View File

@@ -119,7 +119,7 @@ TEST_CASE("BuildingSystem: place miner occupies expected body tiles", "[building
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
REQUIRE(id != kInvalidBuildingId);
// Miner mask ["AA","A>"] with East rotation → body at (0,0),(1,0),(0,1).
@@ -138,9 +138,9 @@ TEST_CASE("BuildingSystem: place rejects a building above the world (y < 0)", "[
// Miner mask ["AA","A>"] East → body at (0,0),(1,0),(0,1); at y=-1 the top
// row sits above the world.
const BuildingId id = f.bs.place(BuildingType::Miner, QPoint(0, -1), Rotation::East, 0);
REQUIRE(id == kInvalidBuildingId);
REQUIRE(f.bs.allSites().empty());
const std::optional<BuildingId> id = f.bs.place(BuildingType::Miner, QPoint(0, -1), Rotation::East, 0);
REQUIRE_FALSE(id.has_value());
REQUIRE(f.bs.getAllSites().empty());
REQUIRE_FALSE(f.bs.isTileOccupied(QPoint(0, 0)));
}
@@ -151,10 +151,10 @@ TEST_CASE("BuildingSystem: place rejects a building below the world (y >= height
// Anchored on the last in-bounds row, the miner's lower body row reaches
// y == heightTiles, which is outside the world.
const BuildingId id = f.bs.place(BuildingType::Miner,
const std::optional<BuildingId> id = f.bs.place(BuildingType::Miner,
QPoint(0, heightTiles - 1), Rotation::East, 0);
REQUIRE(id == kInvalidBuildingId);
REQUIRE(f.bs.allSites().empty());
REQUIRE_FALSE(id.has_value());
REQUIRE(f.bs.getAllSites().empty());
}
TEST_CASE("BuildingSystem: place rejects a building left of the asteroid edge", "[building]")
@@ -162,10 +162,10 @@ TEST_CASE("BuildingSystem: place rejects a building left of the asteroid edge",
PlacementFixture f;
const int leftEdgeX = -f.cfg.world.regions.asteroidWidth_tiles;
const BuildingId id = f.bs.place(BuildingType::Miner,
const std::optional<BuildingId> id = f.bs.place(BuildingType::Miner,
QPoint(leftEdgeX - 1, 0), Rotation::East, 0);
REQUIRE(id == kInvalidBuildingId);
REQUIRE(f.bs.allSites().empty());
REQUIRE_FALSE(id.has_value());
REQUIRE(f.bs.getAllSites().empty());
}
TEST_CASE("BuildingSystem: place accepts a building flush against the world's left edge",
@@ -176,7 +176,7 @@ TEST_CASE("BuildingSystem: place accepts a building flush against the world's le
// Miner body min relative x is 0, so its leftmost cell sits exactly on the edge.
const BuildingId id = f.bs.place(BuildingType::Miner,
QPoint(leftEdgeX, 0), Rotation::East, 0);
QPoint(leftEdgeX, 0), Rotation::East, 0).value();
REQUIRE(id != kInvalidBuildingId);
REQUIRE(f.bs.isTileOccupied(QPoint(leftEdgeX, 0)));
}
@@ -187,7 +187,7 @@ TEST_CASE("BuildingSystem: place imposes no right-side bound (space extends righ
PlacementFixture f;
const BuildingId id = f.bs.place(BuildingType::Miner,
QPoint(1000, 0), Rotation::East, 0);
QPoint(1000, 0), Rotation::East, 0).value();
REQUIRE(id != kInvalidBuildingId);
}
@@ -237,9 +237,9 @@ TEST_CASE("BuildingSystem: placing a belt registers it with BeltSystem after con
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
REQUIRE(belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore"), Rotation::East));
REQUIRE(bs.allBuildings().size() == 1);
REQUIRE(bs.allBuildings()[0].type == BuildingType::Belt);
REQUIRE(bs.allBuildings()[0].anchor == QPoint(5, 5));
REQUIRE(bs.getAllBuildings().size() == 1);
REQUIRE(bs.getAllBuildings()[0].type == BuildingType::Belt);
REQUIRE(bs.getAllBuildings()[0].anchor == QPoint(5, 5));
}
TEST_CASE("BuildingSystem: placed building enters construction queue", "[building]")
@@ -256,10 +256,10 @@ TEST_CASE("BuildingSystem: placed building enters construction queue", "[buildin
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
REQUIRE(bs.allSites().size() == 1);
REQUIRE(bs.allBuildings().empty());
REQUIRE(bs.getAllSites().size() == 1);
REQUIRE(bs.getAllBuildings().empty());
REQUIRE(bs.findSite(id) != nullptr);
}
@@ -277,7 +277,7 @@ TEST_CASE("BuildingSystem: demolish frees tiles and returns refund", "[building]
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
// Miner construction_time_seconds = 10. completesAt = secondsToTicks(10) = 300.
// We need to process tick 300 itself, so run 301 ticks (ticks 0..300).
@@ -289,7 +289,7 @@ TEST_CASE("BuildingSystem: demolish frees tiles and returns refund", "[building]
// Miner cost = 15, refund = floor(15 * 75 / 100) = 11.
REQUIRE(refund == 15 * cfg.world.refundPercentage / 100);
REQUIRE_FALSE(bs.isTileOccupied(QPoint(0, 0)));
REQUIRE(bs.allSites().empty());
REQUIRE(bs.getAllSites().empty());
}
// ---------------------------------------------------------------------------
@@ -312,7 +312,7 @@ TEST_CASE("BuildingSystem: first queued building starts construction immediately
rng);
bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
REQUIRE(bs.allSites().front().completesAt > 0);
REQUIRE(bs.getAllSites().front().completesAt > 0);
}
TEST_CASE("BuildingSystem: second queued building waits (completesAt == 0)", "[building]")
@@ -332,9 +332,9 @@ TEST_CASE("BuildingSystem: second queued building waits (completesAt == 0)", "[b
bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
bs.place(BuildingType::Miner, QPoint(5, 5), Rotation::East, 0);
REQUIRE(bs.allSites().size() == 2);
REQUIRE(bs.allSites()[0].completesAt > 0);
REQUIRE(bs.allSites()[1].completesAt == 0);
REQUIRE(bs.getAllSites().size() == 2);
REQUIRE(bs.getAllSites()[0].completesAt > 0);
REQUIRE(bs.getAllSites()[1].completesAt == 0);
}
TEST_CASE("BuildingSystem: construction completes after configured duration", "[building]")
@@ -351,14 +351,14 @@ TEST_CASE("BuildingSystem: construction completes after configured duration", "[
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
// Miner construction_time_seconds = 10. completesAt = secondsToTicks(10) = 300.
// We need to process tick 300 itself, so run 301 ticks (ticks 0..300).
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(10.0)) + 1, tick);
REQUIRE(bs.allSites().empty());
REQUIRE(bs.getAllSites().empty());
REQUIRE(bs.findBuilding(id) != nullptr);
}
@@ -377,15 +377,15 @@ TEST_CASE("BuildingSystem: second building starts after first completes", "[buil
rng);
bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId id2 = bs.place(BuildingType::Miner, QPoint(5, 5), Rotation::East, 0);
const BuildingId id2 = bs.place(BuildingType::Miner, QPoint(5, 5), Rotation::East, 0).value();
// Process through tick 300 to complete first miner's construction.
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(10.0)) + 1, tick);
REQUIRE(bs.allSites().size() == 1);
REQUIRE(bs.allSites().front().id == id2);
REQUIRE(bs.allSites().front().completesAt > 0);
REQUIRE(bs.getAllSites().size() == 1);
REQUIRE(bs.getAllSites().front().id == id2);
REQUIRE(bs.getAllSites().front().completesAt > 0);
}
// ---------------------------------------------------------------------------
@@ -406,7 +406,7 @@ TEST_CASE("BuildingSystem: miner produces iron_ore after recipe duration", "[bui
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
bs.setRecipe(id, "mine_iron_ore");
Tick tick = 0;
@@ -439,7 +439,7 @@ TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
bs.setRecipe(id, "mine_iron_ore");
Tick tick = 0;
@@ -457,7 +457,7 @@ TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
REQUIRE(b != nullptr);
// Both produced items are held on the output side (buffer + emerging lane),
// which is what the capacity rule counts (REQ-MAT-OUTPUT-EMERGE).
REQUIRE(b->outputItemCount() == 2);
REQUIRE(b->getOutputItemCount() == 2);
REQUIRE_FALSE(b->production.has_value());
}
@@ -479,29 +479,29 @@ TEST_CASE("BuildingSystem: productionBuildingCount excludes construction sites",
[](const std::string&) -> bool { return true; },
rng);
const BuildingId minerId = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId smelterId = bs.place(BuildingType::Smelter, QPoint(10, 0), Rotation::East, 0);
const BuildingId minerId = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
const BuildingId smelterId = bs.place(BuildingType::Smelter, QPoint(10, 0), Rotation::East, 0).value();
(void)smelterId;
Tick tick = 0;
// Both still under construction.
REQUIRE(bs.productionBuildingCount() == 0);
REQUIRE(bs.getProductionBuildingCount() == 0);
// The queue builds one at a time: miner (10s) completes at tick 300, then
// the smelter (15s) starts and completes at tick 300 + 450 = 750.
runTicks(bs, belts, static_cast<int>(secondsToTicks(10.0)) + 1, tick);
REQUIRE(bs.productionBuildingCount() == 1);
REQUIRE(bs.getProductionBuildingCount() == 1);
runTicks(bs, belts, static_cast<int>(secondsToTicks(15.0)), tick);
REQUIRE(bs.productionBuildingCount() == 2);
REQUIRE(bs.getProductionBuildingCount() == 2);
// Neither is producing yet: the miner has no recipe selected, and the
// smelter (auto-recipe, REQ-BLD-SMELTER) has no input feeding it.
REQUIRE(bs.activeProductionBuildingCount() == 0);
REQUIRE(bs.getActiveProductionBuildingCount() == 0);
bs.setRecipe(minerId, "mine_iron_ore");
runTicks(bs, belts, 1, tick);
REQUIRE(bs.activeProductionBuildingCount() == 1);
REQUIRE(bs.getActiveProductionBuildingCount() == 1);
}
TEST_CASE("BuildingSystem: activeProductionBuildingCount tracks production cycle state",
@@ -519,16 +519,16 @@ TEST_CASE("BuildingSystem: activeProductionBuildingCount tracks production cycle
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
bs.setRecipe(id, "mine_iron_ore");
Tick tick = 0;
// Not yet operational while under construction.
REQUIRE(bs.activeProductionBuildingCount() == 0);
REQUIRE(bs.getActiveProductionBuildingCount() == 0);
// Construction completes at tick 300; cycle 1 starts the same tick (completesAt=330).
runTicks(bs, belts, static_cast<int>(secondsToTicks(10.0)) + 1, tick);
REQUIRE(bs.activeProductionBuildingCount() == 1);
REQUIRE(bs.getActiveProductionBuildingCount() == 1);
// Run cycles 1 and 2 to completion (1s each); cycle 3 stalls once the
// output buffer (capacity 2) is full (REQ-MAT-OUTPUT-BUFFER).
@@ -536,9 +536,9 @@ TEST_CASE("BuildingSystem: activeProductionBuildingCount tracks production cycle
const Building* b = bs.findBuilding(id);
REQUIRE(b != nullptr);
REQUIRE(b->outputItemCount() == 2);
REQUIRE(b->getOutputItemCount() == 2);
REQUIRE_FALSE(b->production.has_value());
REQUIRE(bs.activeProductionBuildingCount() == 0);
REQUIRE(bs.getActiveProductionBuildingCount() == 0);
}
// ---------------------------------------------------------------------------
@@ -563,7 +563,7 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing
// Smelter mask ["AA ","AA>"] → body (0,0),(1,0),(0,1),(1,1).
// Output port (2,1) East. Input port example: (2,0) West.
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();
// Smelters have no recipe selection (REQ-BLD-SMELTER); they auto-accept any
// ore/scrap that is an input to a smelter recipe.
@@ -603,7 +603,7 @@ TEST_CASE("BuildingSystem: accepted input travels inward before entering the buf
[](const std::string&) -> bool { return true; },
rng);
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
@@ -645,7 +645,7 @@ TEST_CASE("BuildingSystem: input reservation caps buffered plus in-transit at th
rng);
const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0);
QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(25.0)) + 1, tick);
@@ -686,7 +686,7 @@ TEST_CASE("BuildingSystem: smelter auto-smelts ore without a recipe selection",
[](const std::string&) -> bool { return true; },
rng);
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
@@ -732,7 +732,7 @@ TEST_CASE("BuildingSystem: smelter runs a satisfiable recipe while an incomplete
[](const std::string&) -> bool { return true; },
rng);
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
@@ -786,7 +786,7 @@ TEST_CASE("BuildingSystem: miner output buffer drains onto adjacent belt", "[bui
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
bs.setRecipe(id, "mine_iron_ore");
// Belt at the miner's output port tile (1,1) flowing East.
@@ -826,12 +826,12 @@ TEST_CASE("BuildingSystem: output port couples directly into an adjacent input p
rng);
// Miner at (0,0): body (0,0),(1,0),(0,1); output port tile (1,1) flowing East.
const BuildingId minerId = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId minerId = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
bs.setRecipe(minerId, "mine_iron_ore");
// Smelter anchored at (1,1): body (1,1),(2,1),(1,2),(2,2). Its body cell (1,1) is
// the miner's output-port tile, and its west input edge there faces East, so the
// two ports meet — no belt placed anywhere.
const BuildingId smelterId = bs.place(BuildingType::Smelter, QPoint(1, 1), Rotation::East, 0);
const BuildingId smelterId = bs.place(BuildingType::Smelter, QPoint(1, 1), Rotation::East, 0).value();
Tick tick = 0;
// Smelter build (15s) + margin for coupling and a smelt cycle.
@@ -866,11 +866,11 @@ TEST_CASE("BuildingSystem: direct coupling to a non-consumer leaves the item stu
rng);
// Producing miner at (0,0), output port (1,1) East.
const BuildingId minerId = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId minerId = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
bs.setRecipe(minerId, "mine_iron_ore");
// A second, idle miner anchored at (1,1) occupies the output-port tile but takes
// no inputs, so it cannot accept the iron_ore.
const BuildingId sinkId = bs.place(BuildingType::Miner, QPoint(1, 1), Rotation::East, 0);
const BuildingId sinkId = bs.place(BuildingType::Miner, QPoint(1, 1), Rotation::East, 0).value();
Tick tick = 0;
// Both miners build sequentially (10s each), then the producer runs and jams.
@@ -882,7 +882,7 @@ TEST_CASE("BuildingSystem: direct coupling to a non-consumer leaves the item stu
REQUIRE(sink != nullptr);
// Nothing was delivered, and the producer's output side has backed up to its cap.
REQUIRE(sink->pendingInputCount(ItemType{"iron_ore"}) == 0);
REQUIRE(miner->outputItemCount() == miner->outputBuffer.capacity);
REQUIRE(miner->getOutputItemCount() == miner->outputBuffer.capacity);
}
// ---------------------------------------------------------------------------
@@ -904,7 +904,7 @@ TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production"
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
bs.setRecipe(id, "mine_iron_ore");
Tick tick = 0;
@@ -916,7 +916,7 @@ TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production"
{
const Building* b = bs.findBuilding(id);
REQUIRE(b != nullptr);
REQUIRE(b->outputItemCount() > 0);
REQUIRE(b->getOutputItemCount() > 0);
}
bs.setRecipe(id, "mine_copper_ore");
@@ -924,7 +924,7 @@ TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production"
const Building* b = bs.findBuilding(id);
// Clearing the output buffer on a recipe change also discards emerging items
// (REQ-MAT-OUTPUT-EMERGE).
REQUIRE(b->outputItemCount() == 0);
REQUIRE(b->getOutputItemCount() == 0);
REQUIRE_FALSE(b->production.has_value());
}
@@ -948,7 +948,7 @@ TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max
rng);
const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0);
QPoint(0, 0), Rotation::East, 0).value();
// Reprocessing plants have no recipe selection (REQ-BLD-REPROCESSING); the
// single reprocessing recipe is applied automatically on completion.
@@ -980,7 +980,7 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta
rng);
const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0);
QPoint(0, 0), Rotation::East, 0).value();
// Reprocessing plants have no recipe selection (REQ-BLD-REPROCESSING); the
// single reprocessing recipe is applied automatically on completion.
@@ -1056,7 +1056,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the site id for a que
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();
const std::optional<BuildingId> result =
bs.findRotateInPlaceTarget(BuildingType::Belt, QPoint(0, 0), Rotation::North);
@@ -1079,11 +1079,11 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the building id for a
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
REQUIRE(bs.allSites().empty());
REQUIRE(bs.getAllSites().empty());
const std::optional<BuildingId> result =
bs.findRotateInPlaceTarget(BuildingType::Belt, QPoint(0, 0), Rotation::South);
@@ -1154,7 +1154,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-t
// Smelter is a fully filled 2×2 footprint — rotating the ghost produces the
// same four body tiles, so findRotateInPlaceTarget must still return the id.
const BuildingId id = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();
const std::optional<BuildingId> result =
bs.findRotateInPlaceTarget(BuildingType::Smelter, QPoint(0, 0), Rotation::North);
@@ -1181,7 +1181,7 @@ TEST_CASE("BuildingSystem: rotateInPlace updates the rotation field of a constru
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();
REQUIRE(bs.findSite(id)->rotation == Rotation::East);
bs.rotateInPlace(id, Rotation::North);
@@ -1204,7 +1204,7 @@ TEST_CASE("BuildingSystem: rotateInPlace preserves the construction progress of
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();
const Tick completesAt = bs.findSite(id)->completesAt;
REQUIRE(completesAt > 0);
@@ -1228,7 +1228,7 @@ TEST_CASE("BuildingSystem: rotateInPlace updates rotation and output port direct
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
@@ -1259,7 +1259,7 @@ TEST_CASE("BuildingSystem: rotateInPlace re-registers a belt tile with BeltSyste
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
@@ -1276,7 +1276,7 @@ TEST_CASE("BuildingSystem: splitter filters configured on a construction site ca
PlacementFixture f;
const QPoint tile(5, 5);
const BuildingId id = f.bs.place(BuildingType::Splitter, tile, Rotation::East, 0);
const BuildingId id = f.bs.place(BuildingType::Splitter, tile, Rotation::East, 0).value();
REQUIRE(id != kInvalidBuildingId);
REQUIRE(f.bs.findSite(id) != nullptr);
@@ -1295,12 +1295,12 @@ TEST_CASE("BuildingSystem: splitter filters configured on a construction site ca
// Run until construction completes.
Tick tick = 0;
while (f.bs.allBuildings().empty() && tick < 100000)
while (f.bs.getAllBuildings().empty() && tick < 100000)
{
runTicks(f.bs, f.belts, 1, tick);
}
REQUIRE(f.bs.allBuildings().size() == 1);
REQUIRE(f.bs.allBuildings()[0].type == BuildingType::Splitter);
REQUIRE(f.bs.getAllBuildings().size() == 1);
REQUIRE(f.bs.getAllBuildings()[0].type == BuildingType::Splitter);
// The built splitter is registered with BeltSystem carrying the filters.
const std::optional<BeltSystem::SplitterInfo> builtInfo = f.belts.getSplitterInfo(tile);

View File

@@ -183,7 +183,7 @@ TEST_CASE("CombatSystem: player station fires at enemy ship in range", "[combat]
// Find the player station entity via ECS.
entt::entity stationEntity = entt::null;
QVector2D stationCenter;
sim.admin().forEach<StationBodyComponent, FactionComponent>(
sim.getAdmin().forEach<StationBodyComponent, FactionComponent>(
[&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f)
{
if (!f.isEnemy && stationEntity == entt::null)
@@ -194,12 +194,12 @@ TEST_CASE("CombatSystem: player station fires at enemy ship in range", "[combat]
sb.anchor.y() + sb.footprint.height() / 2.0f);
}
});
REQUIRE(sim.admin().isValid(stationEntity));
REQUIRE(sim.getAdmin().isValid(stationEntity));
const ShipDef* combatDef = findCombatShip(sim.config());
const ShipDef* combatDef = findCombatShip(sim.getConfig());
REQUIRE(combatDef != nullptr);
const entt::entity enemyShip = sim.ships().spawn(
const entt::entity enemyShip = sim.getShips().spawn(
combatDef->id,
QVector2D(stationCenter.x() + 1.0f, stationCenter.y()),
/*isEnemy=*/true);
@@ -221,7 +221,7 @@ TEST_CASE("CombatSystem: enemy station fires at player ship in range", "[combat]
entt::entity stationEntity = entt::null;
QVector2D stationCenter;
sim.admin().forEach<StationBodyComponent, FactionComponent>(
sim.getAdmin().forEach<StationBodyComponent, FactionComponent>(
[&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f)
{
if (f.isEnemy && stationEntity == entt::null)
@@ -232,12 +232,12 @@ TEST_CASE("CombatSystem: enemy station fires at player ship in range", "[combat]
sb.anchor.y() + sb.footprint.height() / 2.0f);
}
});
REQUIRE(sim.admin().isValid(stationEntity));
REQUIRE(sim.getAdmin().isValid(stationEntity));
const ShipDef* combatDef = findCombatShip(sim.config());
const ShipDef* combatDef = findCombatShip(sim.getConfig());
REQUIRE(combatDef != nullptr);
sim.ships().spawn(
sim.getShips().spawn(
combatDef->id,
QVector2D(stationCenter.x() - 1.0f, stationCenter.y()),
/*isEnemy=*/false);
@@ -259,7 +259,7 @@ TEST_CASE("CombatSystem: player ship fires at enemy station in range", "[combat]
entt::entity stationEntity = entt::null;
QVector2D stationCenter;
sim.admin().forEach<StationBodyComponent, FactionComponent>(
sim.getAdmin().forEach<StationBodyComponent, FactionComponent>(
[&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f)
{
if (f.isEnemy && stationEntity == entt::null)
@@ -270,12 +270,12 @@ TEST_CASE("CombatSystem: player ship fires at enemy station in range", "[combat]
sb.anchor.y() + sb.footprint.height() / 2.0f);
}
});
REQUIRE(sim.admin().isValid(stationEntity));
REQUIRE(sim.getAdmin().isValid(stationEntity));
const ShipDef* combatDef = findCombatShip(sim.config());
const ShipDef* combatDef = findCombatShip(sim.getConfig());
REQUIRE(combatDef != nullptr);
const entt::entity playerShip = sim.ships().spawn(
const entt::entity playerShip = sim.getShips().spawn(
combatDef->id,
QVector2D(stationCenter.x() - 1.0f, stationCenter.y()),
/*isEnemy=*/false);
@@ -390,17 +390,17 @@ TEST_CASE("CombatSystem: dead ship is removed after tick step 9", "[combat]")
{
Simulation sim(loadConfig(), 42);
const ShipDef* combatDef = findCombatShip(sim.config());
const ShipDef* combatDef = findCombatShip(sim.getConfig());
REQUIRE(combatDef != nullptr);
const entt::entity ship = sim.ships().spawn(combatDef->id,
const entt::entity ship = sim.getShips().spawn(combatDef->id,
QVector2D(10.0f, 10.0f));
sim.admin().get<HealthComponent>(ship).hp = -1.0f;
sim.getAdmin().get<HealthComponent>(ship).hp = -1.0f;
sim.tick();
REQUIRE_FALSE(sim.admin().isValid(ship));
REQUIRE_FALSE(sim.getAdmin().isValid(ship));
}
TEST_CASE("CombatSystem: scrap is spawned on ship death", "[combat]")
@@ -411,15 +411,15 @@ TEST_CASE("CombatSystem: scrap is spawned on ship death", "[combat]")
// (REQ-RES-SCRAP-DROP): round(threat * scrap_per_threat). The interceptor's
// threat is 59.0 and the test config sets scrap_per_threat = 1.0, so it drops
// round(59.0 * 1.0) = 59 scrap.
const entt::entity ship = sim.ships().spawn("interceptor",
const entt::entity ship = sim.getShips().spawn("interceptor",
QVector2D(10.0f, 10.0f));
sim.admin().get<HealthComponent>(ship).hp = -1.0f;
sim.getAdmin().get<HealthComponent>(ship).hp = -1.0f;
sim.tick();
const std::vector<ScrapInfo> scraps = sim.scraps().allScrapInfo();
const std::vector<ScrapInfo> scraps = sim.getScraps().getAllScrapInfo();
REQUIRE(scraps.size() == 1);
CHECK(sim.admin().get<ScrapDataComponent>(scraps[0].entity).amount == 59);
CHECK(sim.getAdmin().get<ScrapDataComponent>(scraps[0].entity).amount == 59);
}
TEST_CASE("CombatSystem: HQ death sets game over", "[combat]")
@@ -427,7 +427,7 @@ TEST_CASE("CombatSystem: HQ death sets game over", "[combat]")
Simulation sim(loadConfig(), 42);
// Damage the HQ proxy entity (has HqProxy + Health).
sim.admin().forEach<HqProxyComponent, HealthComponent>(
sim.getAdmin().forEach<HqProxyComponent, HealthComponent>(
[](entt::entity /*e*/, const HqProxyComponent& /*hq*/, HealthComponent& h)
{
h.hp = -1.0f;

View File

@@ -52,7 +52,7 @@ TEST_CASE("apply(PlaceBuildingCommand) with recipe matches place-then-setRecipe"
viaCommand.apply(command);
const BuildingId id =
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
SimulationTestAccess::buildings(viaDirect).setRecipe(id, "mine_iron_ore");
REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum());
@@ -64,9 +64,9 @@ TEST_CASE("apply(DemolishCommand) matches direct demolish", "[command]")
Simulation viaDirect(loadConfig(), 99);
const BuildingId idA =
SimulationTestAccess::place(viaCommand, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
SimulationTestAccess::place(viaCommand, BuildingType::Miner, QPoint(-3, 0), Rotation::East).value();
const BuildingId idB =
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-3, 0), Rotation::East).value();
REQUIRE(idA == idB);
DemolishCommand command;

View File

@@ -88,6 +88,11 @@ TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[c
REQUIRE(*cfg.world.buildingBlocksTooltip ==
"Spend building blocks to build; deliver them to the HQ to gain more.");
// Optional header artifact tooltip (REQ-UI-ARTIFACTS-TOOLTIP).
REQUIRE(cfg.world.artifactTooltip.has_value());
REQUIRE(*cfg.world.artifactTooltip ==
"Choose the artifact reward when destroying enemy stations; collect enough to win.");
// Spot-check that a config-derived formula computes as expected.
// threat_rate_formula = "x": evaluates to the input value.
REQUIRE(cfg.world.waves.threatRateFormula.evaluate(1.0) == Approx(1.0));

View File

@@ -67,7 +67,7 @@ TEST_CASE("Hasher: identical inputs produce identical values", "[determinism]")
b.append(3.5f);
b.append(std::string("ore"));
REQUIRE(a.value() == b.value());
REQUIRE(a.getValue() == b.getValue());
}
TEST_CASE("Hasher: differing inputs produce differing values", "[determinism]")
@@ -77,7 +77,7 @@ TEST_CASE("Hasher: differing inputs produce differing values", "[determinism]")
a.append(42);
b.append(43);
REQUIRE(a.value() != b.value());
REQUIRE(a.getValue() != b.getValue());
}
TEST_CASE("Hasher: string concatenation does not collide", "[determinism]")
@@ -89,7 +89,7 @@ TEST_CASE("Hasher: string concatenation does not collide", "[determinism]")
b.append(std::string("a"));
b.append(std::string("bc"));
REQUIRE(a.value() != b.value());
REQUIRE(a.getValue() != b.getValue());
}
TEST_CASE("Hasher: negative and positive zero hash equally", "[determinism]")
@@ -99,7 +99,7 @@ TEST_CASE("Hasher: negative and positive zero hash equally", "[determinism]")
a.append(-0.0f);
b.append(0.0f);
REQUIRE(a.value() == b.value());
REQUIRE(a.getValue() == b.getValue());
}
// ---------------------------------------------------------------------------
@@ -124,7 +124,7 @@ TEST_CASE("Simulation::rngFingerprint is stable for equal seeds", "[determinism]
const Simulation a(loadConfig(), 777);
const Simulation b(loadConfig(), 777);
REQUIRE(a.rngFingerprint() == b.rngFingerprint());
REQUIRE(a.getRngFingerprint() == b.getRngFingerprint());
}
// ---------------------------------------------------------------------------

View File

@@ -37,7 +37,7 @@ TEST_CASE("Formula retains its source string", "[formula]")
const std::string source = "10 + x / 5";
const Formula f = Formula::compile(source);
REQUIRE(f.source() == source);
REQUIRE(f.getSource() == source);
}
TEST_CASE("Formula throws on malformed source", "[formula]")

View File

@@ -22,7 +22,7 @@ static GameConfig loadConfig()
// tickDeathsAndLoot fires, triggering the push and schematic choices.
static void killEnemyStations(Simulation& sim)
{
sim.admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity, StationBodyComponent&, FactionComponent& faction, HealthComponent& health)
{
if (faction.isEnemy)

View File

@@ -166,7 +166,7 @@ TEST_CASE("a recorded run replays to byte-identical state with no desync", "[rep
manager.drain();
for (int i = 0; i < 60; ++i) { rec.tick(); manager.recordTickCheckpoint(); }
replayPath = recorderPtr->currentFilePath();
replayPath = recorderPtr->getCurrentFilePath();
recordedFinalChecksum = rec.computeStateChecksum();
manager.setRecorder(nullptr); // close the file
}
@@ -185,11 +185,11 @@ TEST_CASE("a recorded run replays to byte-identical state with no desync", "[rep
while (!player.isFinished())
{
play.tick();
player.advanceTo(play.currentTick());
player.advanceTo(play.getCurrentTick());
}
REQUIRE_FALSE(player.getDesyncTick().has_value());
REQUIRE(play.currentTick() == 150);
REQUIRE(play.getCurrentTick() == 150);
REQUIRE(play.computeStateChecksum() == recordedFinalChecksum);
QFile::remove(QString::fromStdString(replayPath));
@@ -208,7 +208,7 @@ TEST_CASE("ReplayPlayer reports a desync when a checksum is corrupted", "[replay
ReplayRecorder* recorderPtr = recorder.get();
manager.setRecorder(std::move(recorder));
for (int i = 0; i < 60; ++i) { rec.tick(); manager.recordTickCheckpoint(); }
replayPath = recorderPtr->currentFilePath();
replayPath = recorderPtr->getCurrentFilePath();
manager.setRecorder(nullptr);
}
@@ -234,7 +234,7 @@ TEST_CASE("ReplayPlayer reports a desync when a checksum is corrupted", "[replay
while (!player.isFinished())
{
play.tick();
player.advanceTo(play.currentTick());
player.advanceTo(play.getCurrentTick());
}
REQUIRE(player.getDesyncTick().has_value());
@@ -267,7 +267,7 @@ TEST_CASE("a long recorded run (through waves and combat) replays with no desync
manager.drain();
for (int i = 0; i < 900; ++i) { rec.tick(); manager.recordTickCheckpoint(); }
replayPath = recorderPtr->currentFilePath();
replayPath = recorderPtr->getCurrentFilePath();
recordedFinalChecksum = rec.computeStateChecksum();
manager.setRecorder(nullptr);
}
@@ -281,11 +281,11 @@ TEST_CASE("a long recorded run (through waves and combat) replays with no desync
while (!player.isFinished())
{
play.tick();
player.advanceTo(play.currentTick());
player.advanceTo(play.getCurrentTick());
}
REQUIRE_FALSE(player.getDesyncTick().has_value());
REQUIRE(play.currentTick() == 2400);
REQUIRE(play.getCurrentTick() == 2400);
REQUIRE(play.computeStateChecksum() == recordedFinalChecksum);
QFile::remove(QString::fromStdString(replayPath));

View File

@@ -120,7 +120,7 @@ TEST_CASE("ReplayRecorder writes a well-formed file", "[replay]")
recorder.recordChecksum(30, 0x0ffffffffffffff0ull);
const std::string path = recorder.currentFilePath();
const std::string path = recorder.getCurrentFilePath();
REQUIRE_FALSE(path.empty());
recorder.close();
@@ -152,7 +152,7 @@ TEST_CASE("CommandManager records commands and an initial checksum on drain", "[
ReplayRecorder* recorderPtr = recorder.get();
// setRecorder opens the file and writes the header + the tick-0 checksum.
manager.setRecorder(std::move(recorder));
const std::string path = recorderPtr->currentFilePath();
const std::string path = recorderPtr->getCurrentFilePath();
REQUIRE_FALSE(path.empty());
std::shared_ptr<PlaceBuildingCommand> place = std::make_shared<PlaceBuildingCommand>();
@@ -175,10 +175,10 @@ TEST_CASE("ReplayRecorder startNewRun rolls to a new file", "[replay]")
ReplayRecorder recorder(CONFIG_DIR, tempOutputDir());
recorder.startNewRun(1u, 0ull);
const std::string first = recorder.currentFilePath();
const std::string first = recorder.getCurrentFilePath();
recorder.startNewRun(2u, 0ull);
const std::string second = recorder.currentFilePath();
const std::string second = recorder.getCurrentFilePath();
REQUIRE(first != second);
REQUIRE(second.find("_2.replay") != std::string::npos);
@@ -194,7 +194,7 @@ TEST_CASE("CommandManager rolls the replay file on a Reset command", "[replay]")
std::make_unique<ReplayRecorder>(CONFIG_DIR, tempOutputDir());
ReplayRecorder* recorderPtr = recorder.get();
manager.setRecorder(std::move(recorder));
const std::string firstPath = recorderPtr->currentFilePath();
const std::string firstPath = recorderPtr->getCurrentFilePath();
std::shared_ptr<ResetCommand> reset = std::make_shared<ResetCommand>();
reset->config = std::make_shared<GameConfig>(ConfigLoader::loadFromDirectory(CONFIG_DIR));
@@ -202,7 +202,7 @@ TEST_CASE("CommandManager rolls the replay file on a Reset command", "[replay]")
manager.enqueue(reset);
manager.drain();
const std::string secondPath = recorderPtr->currentFilePath();
const std::string secondPath = recorderPtr->getCurrentFilePath();
REQUIRE(firstPath != secondPath);
REQUIRE(secondPath.find("_999.replay") != std::string::npos);

View File

@@ -1,5 +1,6 @@
#include "catch.hpp"
#include <QSize>
#include <QVector2D>
#include <algorithm>
@@ -148,7 +149,7 @@ TEST_CASE("ScrapSystem: allScrapInfo returns all spawned scrap", "[scrap]")
ss.spawn(QVector2D(1.0f, 2.0f), 3, 100);
ss.spawn(QVector2D(4.0f, 5.0f), 6, 200);
const std::vector<ScrapInfo> info = ss.allScrapInfo();
const std::vector<ScrapInfo> info = ss.getAllScrapInfo();
REQUIRE(info.size() == 2);
}
@@ -160,7 +161,7 @@ TEST_CASE("ScrapSystem: allScrapInfo reports each pile's remaining amount", "[sc
const entt::entity a = ss.spawn(QVector2D(1.0f, 2.0f), 3, 100);
const entt::entity b = ss.spawn(QVector2D(4.0f, 5.0f), 6, 200);
const std::vector<ScrapInfo> info = ss.allScrapInfo();
const std::vector<ScrapInfo> info = ss.getAllScrapInfo();
REQUIRE(info.size() == 2);
for (const ScrapInfo& i : info)
{
@@ -226,3 +227,45 @@ TEST_CASE("scrapInBox returns exactly the piles inside the tile rectangle", "[sc
REQUIRE(contains(hit, inB));
REQUIRE_FALSE(contains(hit, outX));
}
TEST_CASE("actorsInBox returns living ships and stations, excluding scrap and dead actors",
"[actor]")
{
EntityAdmin admin;
// Two living ships inside the box: one player, one enemy.
const entt::entity playerShip = admin.spawnShip(
QVector2D(1.5f, 2.5f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
"fighter", false); // tile (1,2)
const entt::entity enemyShip = admin.spawnShip(
QVector2D(4.2f, 5.8f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
"raider", true); // tile (4,5)
// A dead ship inside the box is excluded.
const entt::entity deadShip = admin.spawnShip(
QVector2D(3.0f, 3.0f), 0.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
"fighter", false);
// A ship outside the box is excluded.
const entt::entity outsideShip = admin.spawnShip(
QVector2D(20.0f, 20.0f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
"fighter", false);
// A station is included when any body cell lies inside the box.
const std::vector<QPoint> stationCells{ QPoint(2, 2), QPoint(3, 2) };
const entt::entity station = admin.spawnStation(
QPoint(2, 2), QSize(2, 1), stationCells, 200.0f, 200.0f, true);
// Scrap and the HQ proxy are never actors.
admin.spawnScrap(QVector2D(1.0f, 1.0f), 5, Tick(1000));
admin.spawnHqProxy(QVector2D(0.5f, 0.5f), 500.0f, 500.0f);
const std::vector<entt::entity> hit = actorsInBox(admin, QPoint(5, 5), QPoint(0, 0));
REQUIRE(hit.size() == 3);
REQUIRE(contains(hit, playerShip));
REQUIRE(contains(hit, enemyShip));
REQUIRE(contains(hit, station));
REQUIRE_FALSE(contains(hit, deadShip));
REQUIRE_FALSE(contains(hit, outsideShip));
}

View File

@@ -87,7 +87,7 @@ static void fillMaterials(Simulation& sim, BuildingId yardId,
}
for (const PlacedModule& pm : layout.placedModules)
{
for (const ModuleDef& modDef : sim.config().modules.modules)
for (const ModuleDef& modDef : sim.getConfig().modules.modules)
{
if (modDef.id == pm.moduleId)
{
@@ -109,22 +109,22 @@ static void fillMaterials(Simulation& sim, BuildingId yardId,
TEST_CASE("Ship spawn: no modules leaves base stats unchanged", "[modules]")
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findSchematic(sim.config(), "interceptor");
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr);
const float expectedHp = static_cast<float>(def->health.hp);
const entt::entity e = sim.ships().spawn("interceptor",
const entt::entity e = sim.getShips().spawn("interceptor",
QVector2D(5.0f, 5.0f), false, std::nullopt);
REQUIRE(sim.admin().isValid(e));
CHECK(sim.admin().get<HealthComponent>(e).maxHp == Approx(expectedHp));
REQUIRE(sim.getAdmin().isValid(e));
CHECK(sim.getAdmin().get<HealthComponent>(e).maxHp == Approx(expectedHp));
}
TEST_CASE("Ship spawn: multiplicative HP module applies correctly", "[modules]")
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findSchematic(sim.config(), "interceptor");
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr);
const float baseHp = static_cast<float>(def->health.hp);
@@ -136,23 +136,23 @@ TEST_CASE("Ship spawn: multiplicative HP module applies correctly", "[modules]")
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
const entt::entity e = sim.ships().spawn("interceptor",
const entt::entity e = sim.getShips().spawn("interceptor",
QVector2D(5.0f, 5.0f), false, layout);
REQUIRE(sim.admin().isValid(e));
REQUIRE(sim.getAdmin().isValid(e));
// armor_plate has multiplied_hp_formula = "1.5"
// final = base * (1 + (1.5 - 1)) + 0 = base * 1.5
CHECK(sim.admin().get<HealthComponent>(e).maxHp == Approx(baseHp * 1.5f));
CHECK(sim.admin().get<HealthComponent>(e).hp == sim.admin().get<HealthComponent>(e).maxHp);
CHECK(sim.getAdmin().get<HealthComponent>(e).maxHp == Approx(baseHp * 1.5f));
CHECK(sim.getAdmin().get<HealthComponent>(e).hp == sim.getAdmin().get<HealthComponent>(e).maxHp);
}
TEST_CASE("Ship spawn: additive sensor module applies correctly", "[modules]")
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findSchematic(sim.config(), "interceptor");
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr);
const float tileSize = static_cast<float>(sim.config().world.tileSize_m);
const float tileSize = static_cast<float>(sim.getConfig().world.tileSize_m);
const float baseRange_tiles = static_cast<float>(def->sensor.sensorRange_m) / tileSize;
ShipLayoutConfig layout;
@@ -162,19 +162,19 @@ TEST_CASE("Ship spawn: additive sensor module applies correctly", "[modules]")
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
const entt::entity e = sim.ships().spawn("interceptor",
const entt::entity e = sim.getShips().spawn("interceptor",
QVector2D(5.0f, 5.0f), false, layout);
REQUIRE(sim.admin().isValid(e));
REQUIRE(sim.getAdmin().isValid(e));
// sensor_booster has added_sensor_range_m_formula = "100" m → 100/10 = 10 tiles
// final = baseRange_tiles * 1.0 + 10 = baseRange_tiles + 10
CHECK(sim.admin().get<SensorRangeComponent>(e).value_tiles == Approx(baseRange_tiles + 10.0f));
CHECK(sim.getAdmin().get<SensorRangeComponent>(e).value_tiles == Approx(baseRange_tiles + 10.0f));
}
TEST_CASE("Ship spawn: multiple modules stack correctly", "[modules]")
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findSchematic(sim.config(), "interceptor");
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr);
const float baseHp = static_cast<float>(def->health.hp);
@@ -189,14 +189,14 @@ TEST_CASE("Ship spawn: multiple modules stack correctly", "[modules]")
layout.placedModules.push_back(pm);
}
const entt::entity e = sim.ships().spawn("interceptor",
const entt::entity e = sim.getShips().spawn("interceptor",
QVector2D(5.0f, 5.0f), false, layout);
REQUIRE(sim.admin().isValid(e));
REQUIRE(sim.getAdmin().isValid(e));
// Two armor_plates: each 1.5 multiplier
// total_mult = 1 + (1.5 - 1) + (1.5 - 1) = 2.0
// final = base * 2.0
CHECK(sim.admin().get<HealthComponent>(e).maxHp == Approx(baseHp * 2.0f));
CHECK(sim.getAdmin().get<HealthComponent>(e).maxHp == Approx(baseHp * 2.0f));
}
// ---------------------------------------------------------------------------
@@ -207,7 +207,7 @@ TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
"[modules][shipyard]")
{
Simulation sim(loadConfig(), 42);
const BuildingDef* yardDef = findShipyardDef(sim.config());
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef);
@@ -222,7 +222,7 @@ TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
const Building* b = sim.buildings().findBuilding(yardId);
const Building* b = sim.getBuildings().findBuilding(yardId);
REQUIRE(b != nullptr);
// armor_plate needs 2 iron_ingot; interceptor needs 3 iron_ingot + 1 circuit_board
// Total iron_ingot = 5, buffer cap = 2 * 5 = 10
@@ -234,9 +234,9 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
"[modules][shipyard]")
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findSchematic(sim.config(), "interceptor");
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr);
const BuildingDef* yardDef = findShipyardDef(sim.config());
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef);
@@ -247,7 +247,7 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
fillMaterials(sim, yardId, *def, emptyLayout);
sim.tick();
const Building* b1 = sim.buildings().findBuilding(yardId);
const Building* b1 = sim.getBuildings().findBuilding(yardId);
REQUIRE(b1 != nullptr);
REQUIRE(b1->production.has_value());
@@ -261,7 +261,7 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
const Building* b2 = sim.buildings().findBuilding(yardId);
const Building* b2 = sim.getBuildings().findBuilding(yardId);
REQUIRE(b2 != nullptr);
CHECK_FALSE(b2->production.has_value());
}
@@ -270,7 +270,7 @@ TEST_CASE("Shipyard: builds a bare hull when no layout is configured",
"[modules][shipyard]")
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findSchematic(sim.config(), "interceptor");
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr);
// The schematic carries a weapon in its (wave-only) default loadout. This
// test pins that a player shipyard with no configured layout does NOT hand
@@ -278,7 +278,7 @@ TEST_CASE("Shipyard: builds a bare hull when no layout is configured",
// base-hull materials it was charged.
REQUIRE_FALSE(def->defaultModules.empty());
const BuildingDef* yardDef = findShipyardDef(sim.config());
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef);
@@ -297,23 +297,23 @@ TEST_CASE("Shipyard: builds a bare hull when no layout is configured",
// Locate the freshly built player ship.
entt::entity built = entt::null;
sim.admin().forEach<ShipIdentityComponent, FactionComponent>(
sim.getAdmin().forEach<ShipIdentityComponent, FactionComponent>(
[&](entt::entity e, const ShipIdentityComponent& si, const FactionComponent& fac)
{
if (!fac.isEnemy && si.schematicId == "interceptor") { built = e; }
});
REQUIRE(sim.admin().isValid(built));
REQUIRE(sim.getAdmin().isValid(built));
// Bare hull: the schematic's default weapon must NOT have been installed.
const bool hasWeapon =
findFirstWeaponChild(sim.admin(), built) != entt::null;
findFirstWeaponChild(sim.getAdmin(), built) != entt::null;
CHECK_FALSE(hasWeapon);
}
TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
{
Simulation sim(loadConfig(), 42);
const BuildingDef* yardDef = findShipyardDef(sim.config());
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef);
@@ -327,13 +327,13 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
layout.placedModules.push_back(pm);
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
const Building* b1 = sim.buildings().findBuilding(yardId);
const Building* b1 = sim.getBuildings().findBuilding(yardId);
REQUIRE(b1 != nullptr);
REQUIRE(b1->shipLayout.has_value());
SimulationTestAccess::buildings(sim).setRecipe(yardId,"destroyer");
const Building* b2 = sim.buildings().findBuilding(yardId);
const Building* b2 = sim.getBuildings().findBuilding(yardId);
REQUIRE(b2 != nullptr);
CHECK_FALSE(b2->shipLayout.has_value());
}
@@ -342,7 +342,7 @@ TEST_CASE("Shipyard: setRecipe with unchanged recipe keeps ship layout",
"[modules][shipyard]")
{
Simulation sim(loadConfig(), 42);
const BuildingDef* yardDef = findShipyardDef(sim.config());
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef);
@@ -356,14 +356,14 @@ TEST_CASE("Shipyard: setRecipe with unchanged recipe keeps ship layout",
layout.placedModules.push_back(pm);
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
const Building* b1 = sim.buildings().findBuilding(yardId);
const Building* b1 = sim.getBuildings().findBuilding(yardId);
REQUIRE(b1 != nullptr);
REQUIRE(b1->shipLayout.has_value());
// Re-selecting the same recipe must be a no-op and preserve the layout.
SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor");
const Building* b2 = sim.buildings().findBuilding(yardId);
const Building* b2 = sim.getBuildings().findBuilding(yardId);
REQUIRE(b2 != nullptr);
REQUIRE(b2->shipLayout.has_value());
REQUIRE(b2->shipLayout->placedModules.size() == 1);
@@ -377,7 +377,7 @@ TEST_CASE("Shipyard: setRecipe with unchanged recipe keeps ship layout",
TEST_CASE("Ship spawn: weapon_primer multiplies attack rate in simulation", "[modules]")
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findSchematic(sim.config(), "interceptor");
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr);
ShipLayoutConfig layout;
@@ -390,22 +390,22 @@ TEST_CASE("Ship spawn: weapon_primer multiplies attack rate in simulation", "[mo
layout.placedModules.push_back(pm);
}
const entt::entity ship = sim.ships().spawn("interceptor",
const entt::entity ship = sim.getShips().spawn("interceptor",
QVector2D(5.0f, 5.0f), false, layout);
const entt::entity weapon = findFirstWeaponChild(sim.admin(), ship);
REQUIRE(sim.admin().isValid(weapon));
const entt::entity weapon = findFirstWeaponChild(sim.getAdmin(), ship);
REQUIRE(sim.getAdmin().isValid(weapon));
// base rate = 2.0 hz; weapon_primer multiplier = 1.2 → 2.4 hz
CHECK(sim.admin().get<WeaponComponent>(weapon).fireRateHz == Approx(2.4f));
CHECK(sim.getAdmin().get<WeaponComponent>(weapon).fireRateHz == Approx(2.4f));
}
TEST_CASE("Ship spawn: weapon_stabilizer multiplies attack range in simulation", "[modules]")
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findSchematic(sim.config(), "interceptor");
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr);
const float tileSize = static_cast<float>(sim.config().world.tileSize_m);
const float tileSize = static_cast<float>(sim.getConfig().world.tileSize_m);
ShipLayoutConfig layout;
for (const std::string& id : {"laser_cannon", "weapon_stabilizer"})
@@ -417,22 +417,22 @@ TEST_CASE("Ship spawn: weapon_stabilizer multiplies attack range in simulation",
layout.placedModules.push_back(pm);
}
const entt::entity ship = sim.ships().spawn("interceptor",
const entt::entity ship = sim.getShips().spawn("interceptor",
QVector2D(5.0f, 5.0f), false, layout);
const entt::entity weapon = findFirstWeaponChild(sim.admin(), ship);
REQUIRE(sim.admin().isValid(weapon));
const entt::entity weapon = findFirstWeaponChild(sim.getAdmin(), ship);
REQUIRE(sim.getAdmin().isValid(weapon));
// base range = 50 m / tileSize = 5 tiles; weapon_stabilizer multiplier = 1.5 → 7.5 tiles
CHECK(sim.admin().get<WeaponComponent>(weapon).range_tiles == Approx(50.0f / tileSize * 1.5f));
CHECK(sim.getAdmin().get<WeaponComponent>(weapon).range_tiles == Approx(50.0f / tileSize * 1.5f));
}
TEST_CASE("Ship spawn: afterburner additive main_acceleration is converted m/s² to tiles/tick", "[modules]")
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findSchematic(sim.config(), "interceptor");
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr);
const float tileSize = static_cast<float>(sim.config().world.tileSize_m);
const float tileSize = static_cast<float>(sim.getConfig().world.tileSize_m);
const float tickRate = static_cast<float>(kTickRateHz);
const float base_mpss = static_cast<float>(def->movement.mainAcceleration_mpss);
@@ -443,21 +443,21 @@ TEST_CASE("Ship spawn: afterburner additive main_acceleration is converted m/s²
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
const entt::entity e = sim.ships().spawn("interceptor",
const entt::entity e = sim.getShips().spawn("interceptor",
QVector2D(5.0f, 5.0f), false, layout);
// added_main_acceleration_mpss = 60; same conversion as base: / tileSize / tickRate
const float expected = (base_mpss + 60.0f) / tileSize / tickRate;
CHECK(sim.admin().get<DynamicBodyComponent>(e).mainAcceleration_tptt == Approx(expected));
CHECK(sim.getAdmin().get<DynamicBodyComponent>(e).mainAcceleration_tptt == Approx(expected));
}
TEST_CASE("Ship spawn: maneuvering_thrusters additive maneuvering_acceleration is converted m/s² to tiles/tick", "[modules]")
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findSchematic(sim.config(), "interceptor");
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr);
const float tileSize = static_cast<float>(sim.config().world.tileSize_m);
const float tileSize = static_cast<float>(sim.getConfig().world.tileSize_m);
const float tickRate = static_cast<float>(kTickRateHz);
const float base_mpss = static_cast<float>(def->movement.maneuveringAcceleration_mpss);
@@ -468,12 +468,12 @@ TEST_CASE("Ship spawn: maneuvering_thrusters additive maneuvering_acceleration i
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
const entt::entity e = sim.ships().spawn("interceptor",
const entt::entity e = sim.getShips().spawn("interceptor",
QVector2D(5.0f, 5.0f), false, layout);
// added_maneuvering_acceleration_mpss = 10; same conversion as base: / tileSize / tickRate
const float expected = (base_mpss + 10.0f) / tileSize / tickRate;
CHECK(sim.admin().get<DynamicBodyComponent>(e).maneuveringAcceleration_tptt == Approx(expected));
CHECK(sim.getAdmin().get<DynamicBodyComponent>(e).maneuveringAcceleration_tptt == Approx(expected));
}
// ---------------------------------------------------------------------------

View File

@@ -224,7 +224,7 @@ TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]")
// Cargo capacity is now a ship-level pool (REQ-MOD-CARGO-CAPACITY).
REQUIRE(admin.get<CargoComponent>(e).maxCapacity == 10);
REQUIRE(admin.get<CargoComponent>(e).current == 0);
REQUIRE(admin.get<DeliverScrapBehavior>(e).deliveryBay == kInvalidBuildingId);
REQUIRE_FALSE(admin.get<DeliverScrapBehavior>(e).deliveryBay.has_value());
REQUIRE_FALSE(admin.get<SalvageScrapBehavior>(e).scrapTarget.has_value());
REQUIRE(admin.get<SalvageScrapBehavior>(e).maxCollectionRange_tiles == Approx(50.0f));
}

View File

@@ -56,7 +56,7 @@ static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
static int countShips(Simulation& sim)
{
int n = 0;
sim.admin().forEach<ShipIdentityComponent>(
sim.getAdmin().forEach<ShipIdentityComponent>(
[&n](entt::entity /*e*/, const ShipIdentityComponent& /*si*/) { ++n; });
return n;
}
@@ -85,9 +85,9 @@ TEST_CASE("Shipyard: spawns a player ship after production cycle completes",
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findAvailableSchematic(sim.config());
const ShipDef* def = findAvailableSchematic(sim.getConfig());
REQUIRE(def != nullptr);
const BuildingDef* yardDef = findShipyardDef(sim.config());
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
REQUIRE(yardDef != nullptr);
const int shipsBefore = countShips(sim);
@@ -115,7 +115,7 @@ TEST_CASE("Shipyard: spawns a player ship after production cycle completes",
REQUIRE(countShips(sim) == shipsBefore + 1);
bool foundPlayerShip = false;
sim.admin().forEach<ShipIdentityComponent, FactionComponent>(
sim.getAdmin().forEach<ShipIdentityComponent, FactionComponent>(
[&](entt::entity /*e*/, const ShipIdentityComponent& si, const FactionComponent& f)
{
if (!f.isEnemy && si.schematicId == def->id)
@@ -130,7 +130,7 @@ TEST_CASE("Shipyard: does not spawn without a schematic set", "[shipyard]")
{
Simulation sim(loadConfig(), 42);
const BuildingDef* yardDef = findShipyardDef(sim.config());
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
REQUIRE(yardDef != nullptr);
const int shipsBefore = countShips(sim);
@@ -146,9 +146,9 @@ TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]")
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findAvailableSchematic(sim.config());
const ShipDef* def = findAvailableSchematic(sim.getConfig());
REQUIRE(def != nullptr);
const BuildingDef* yardDef = findShipyardDef(sim.config());
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
REQUIRE(yardDef != nullptr);
const int shipsBefore = countShips(sim);
@@ -170,9 +170,9 @@ TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipya
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findAvailableSchematic(sim.config());
const ShipDef* def = findAvailableSchematic(sim.getConfig());
REQUIRE(def != nullptr);
const BuildingDef* yardDef = findShipyardDef(sim.config());
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef);
@@ -203,7 +203,7 @@ TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipya
// Verify the shipyard production field cleared (i.e. the cycle completed
// and is not still running).
bool productionCleared = false;
for (const Building& b : sim.buildings().allBuildings())
for (const Building& b : sim.getBuildings().getAllBuildings())
{
if (b.id == yardId)
{

View File

@@ -19,7 +19,7 @@ TEST_CASE("Simulation::currentTick starts at 0", "[simulation]")
{
const Simulation sim(loadConfig());
REQUIRE(sim.currentTick() == 0);
REQUIRE(sim.getCurrentTick() == 0);
}
TEST_CASE("Simulation::tick increments currentTick by 1", "[simulation]")
@@ -28,7 +28,7 @@ TEST_CASE("Simulation::tick increments currentTick by 1", "[simulation]")
sim.tick();
REQUIRE(sim.currentTick() == 1);
REQUIRE(sim.getCurrentTick() == 1);
}
TEST_CASE("Simulation::tick 10 times yields currentTick == 10", "[simulation]")
@@ -40,7 +40,7 @@ TEST_CASE("Simulation::tick 10 times yields currentTick == 10", "[simulation]")
sim.tick();
}
REQUIRE(sim.currentTick() == 10);
REQUIRE(sim.getCurrentTick() == 10);
}
TEST_CASE("Simulation::drainBeamFiredEvents returns empty initially", "[simulation]")
@@ -131,3 +131,26 @@ TEST_CASE("TickDriver::reset clears the accumulator", "[simulation]")
// Nothing in the accumulator: zero elapsed time should not fire.
REQUIRE(driver.advance(0.0, 1.0) == 0);
}
TEST_CASE("TickDriver::reset discards a large pending delta so a restart does not "
"fast-forward", "[simulation]")
{
// Regression guard for the "restart fast-forwards by the time spent in the
// Game Over / Win / escape dialog" bug: on restart the frame timer holds the
// whole wall-clock duration the modal was open. GameWorldView::resetForNewGame()
// now calls TickDriver::reset() to discard that pending delta; without it the
// fresh run would burst forward by the dialog duration on its first frame.
TickDriver driver;
// 30 seconds spent in the dialog would otherwise be ~900 ticks at 30 Hz.
const double dialogOpenMs = 30000.0;
driver.reset();
// A brand-new run's first normal frame (~16 ms) advances only its own ticks;
// the discarded dialog time contributes nothing.
REQUIRE(driver.advance(16.0, 1.0) == 0);
// Sanity: had the dialog delta not been discarded, it would have fired ~900 ticks.
TickDriver leaked;
REQUIRE(leaked.advance(dialogOpenMs, 1.0) > 800);
}

View File

@@ -1,5 +1,7 @@
#pragma once
#include <optional>
#include <QPoint>
#include "BuildingId.h"
@@ -22,11 +24,11 @@ class BuildingSystem;
// reached via buildings(sim)/belts(sim).
struct SimulationTestAccess
{
static BuildingSystem& buildings(Simulation& sim) { return sim.buildingsMutable(); }
static BeltSystem& belts(Simulation& sim) { return sim.beltsMutable(); }
static BuildingSystem& buildings(Simulation& sim) { return sim.getBuildingsMutable(); }
static BeltSystem& belts(Simulation& sim) { return sim.getBeltsMutable(); }
static BuildingId place(Simulation& sim, BuildingType type, QPoint anchor,
Rotation rotation)
static std::optional<BuildingId> place(Simulation& sim, BuildingType type,
QPoint anchor, Rotation rotation)
{
return sim.tryPlaceBuilding(type, anchor, rotation);
}

View File

@@ -67,7 +67,7 @@ RecipeDef& findRecipe(GameConfig& cfg, const std::string& id)
// tickDeathsAndLoot fires, triggering the push and schematic choices.
void killEnemyStations(Simulation& sim)
{
sim.admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity, StationBodyComponent&, FactionComponent& faction, HealthComponent& health)
{
if (faction.isEnemy)

View File

@@ -49,7 +49,7 @@ TEST_CASE("WaveSystem: threat accumulates at boss wave counter rate", "[wave]")
ws.tickThreatAccumulation();
}
REQUIRE(ws.threatLevel() == Approx(1.0));
REQUIRE(ws.getThreatLevel() == Approx(1.0));
}
TEST_CASE("WaveSystem: threatAccumulationRate matches the rate formula outside quiet windows",
@@ -60,7 +60,7 @@ TEST_CASE("WaveSystem: threatAccumulationRate matches the rate formula outside q
WaveSystem ws(cfg, rng);
// threat_rate_formula = "x", boss wave counter starts at 1 → rate = 1 threat/s.
REQUIRE(ws.threatAccumulationRate() == Approx(1.0));
REQUIRE(ws.getThreatAccumulationRate() == Approx(1.0));
}
TEST_CASE("WaveSystem: threatAccumulationRate is 0 during a quiet window", "[wave]")
@@ -71,14 +71,14 @@ TEST_CASE("WaveSystem: threatAccumulationRate is 0 during a quiet window", "[wav
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
REQUIRE(ws.threatAccumulationRate() == Approx(0.0));
REQUIRE(ws.getThreatAccumulationRate() == Approx(0.0));
const double before = ws.threatLevel();
const double before = ws.getThreatLevel();
for (int i = 0; i < static_cast<int>(secondsToTicks(1.0)); ++i)
{
ws.tickThreatAccumulation();
}
REQUIRE(ws.threatLevel() == Approx(before));
REQUIRE(ws.getThreatLevel() == Approx(before));
}
TEST_CASE("WaveSystem: generation starts at 0 and increments on station destruction", "[wave]")
@@ -87,11 +87,11 @@ TEST_CASE("WaveSystem: generation starts at 0 and increments on station destruct
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
REQUIRE(ws.generation() == 0);
REQUIRE(ws.getGeneration() == 0);
ws.onEnemyStationsDestroyed();
REQUIRE(ws.generation() == 1);
REQUIRE(ws.getGeneration() == 1);
ws.onEnemyStationsDestroyed();
REQUIRE(ws.generation() == 2);
REQUIRE(ws.getGeneration() == 2);
}
// ---------------------------------------------------------------------------
@@ -104,7 +104,7 @@ TEST_CASE("WaveSystem: Simulation pre-places HQ + 2 player + 2 enemy stations",
// HQ is still a Building (for belt integration).
int hqCount = 0;
for (const Building& b : sim.buildings().allBuildings())
for (const Building& b : sim.getBuildings().getAllBuildings())
{
if (b.type == BuildingType::Hq) { ++hqCount; }
}
@@ -112,7 +112,7 @@ TEST_CASE("WaveSystem: Simulation pre-places HQ + 2 player + 2 enemy stations",
// Stations are ECS entities.
int playerCount = 0;
int enemyCount = 0;
sim.admin().forEach<StationBodyComponent, FactionComponent>(
sim.getAdmin().forEach<StationBodyComponent, FactionComponent>(
[&](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f)
{
if (f.isEnemy) { ++enemyCount; }
@@ -129,10 +129,10 @@ TEST_CASE("WaveSystem: HQ has correct initial HP from config", "[wave]")
const Simulation sim(loadConfig(), 42);
const float expectedHp =
static_cast<float>(sim.config().stations.hq.hpFormula.evaluate(0.0));
static_cast<float>(sim.getConfig().stations.hq.hpFormula.evaluate(0.0));
bool found = false;
float actualHp = 0.0f;
sim.admin().forEach<HqProxyComponent, HealthComponent>(
sim.getAdmin().forEach<HqProxyComponent, HealthComponent>(
[&](entt::entity /*e*/, const HqProxyComponent& /*hq*/, const HealthComponent& h)
{
found = true;
@@ -147,7 +147,7 @@ TEST_CASE("WaveSystem: HQ anchor is at asteroid right edge", "[wave]")
{
const Simulation sim(loadConfig(), 42);
for (const Building& b : sim.buildings().allBuildings())
for (const Building& b : sim.getBuildings().getAllBuildings())
{
if (b.type != BuildingType::Hq) { continue; }
// Rightmost body cell must be at x = -1 (asteroid right edge).
@@ -165,11 +165,11 @@ TEST_CASE("WaveSystem: player stations have weapon set", "[wave]")
Simulation sim(loadConfig(), 42);
int armedPlayerStations = 0;
sim.admin().forEach<WeaponComponent, ModuleOwnerComponent>(
sim.getAdmin().forEach<WeaponComponent, ModuleOwnerComponent>(
[&](entt::entity /*e*/, const WeaponComponent& w, const ModuleOwnerComponent& mo)
{
if (!sim.admin().hasAll<StationBodyComponent>(mo.owner)) { return; }
const FactionComponent& f = sim.admin().get<FactionComponent>(mo.owner);
if (!sim.getAdmin().hasAll<StationBodyComponent>(mo.owner)) { return; }
const FactionComponent& f = sim.getAdmin().get<FactionComponent>(mo.owner);
if (!f.isEnemy)
{
++armedPlayerStations;
@@ -186,11 +186,11 @@ TEST_CASE("WaveSystem: enemy stations have weapon set", "[wave]")
Simulation sim(loadConfig(), 42);
int armedEnemyStations = 0;
sim.admin().forEach<WeaponComponent, ModuleOwnerComponent>(
sim.getAdmin().forEach<WeaponComponent, ModuleOwnerComponent>(
[&](entt::entity /*e*/, const WeaponComponent& w, const ModuleOwnerComponent& mo)
{
if (!sim.admin().hasAll<StationBodyComponent>(mo.owner)) { return; }
const FactionComponent& f = sim.admin().get<FactionComponent>(mo.owner);
if (!sim.getAdmin().hasAll<StationBodyComponent>(mo.owner)) { return; }
const FactionComponent& f = sim.getAdmin().get<FactionComponent>(mo.owner);
if (f.isEnemy)
{
++armedEnemyStations;
@@ -221,7 +221,7 @@ TEST_CASE("WaveSystem: enemy ships spawn after the initial gap elapses", "[wave]
sim.tick();
if (!foundEnemyShip)
{
sim.admin().forEach<ShipIdentityComponent, FactionComponent>(
sim.getAdmin().forEach<ShipIdentityComponent, FactionComponent>(
[&](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
const FactionComponent& f)
{
@@ -253,7 +253,7 @@ TEST_CASE("WaveSystem: destroying both enemy stations triggers a push", "[wave]"
Simulation sim(loadConfig(), 42);
// Damage both enemy stations to 0.
sim.admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
{
if (f.isEnemy) { h.hp = -1.0f; }
@@ -263,7 +263,7 @@ TEST_CASE("WaveSystem: destroying both enemy stations triggers a push", "[wave]"
// After push: should have 2 new enemy stations.
int enemyCount = 0;
sim.admin().forEach<StationBodyComponent, FactionComponent>(
sim.getAdmin().forEach<StationBodyComponent, FactionComponent>(
[&](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f)
{
if (f.isEnemy) { ++enemyCount; }
@@ -275,7 +275,7 @@ TEST_CASE("WaveSystem: push generates pending schematic choices", "[wave]")
{
Simulation sim(loadConfig(), 42);
sim.admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
{
if (f.isEnemy) { h.hp = -1.0f; }
@@ -293,7 +293,7 @@ TEST_CASE("WaveSystem: push schematic choices have valid ids", "[wave]")
{
Simulation sim(loadConfig(), 42);
sim.admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
{
if (f.isEnemy) { h.hp = -1.0f; }
@@ -306,20 +306,20 @@ TEST_CASE("WaveSystem: push schematic choices have valid ids", "[wave]")
for (const SchematicChoiceOption& opt : choices)
{
bool validId = false;
for (const ShipDef& def : sim.config().ships.ships)
for (const ShipDef& def : sim.getConfig().ships.ships)
{
if (def.id == opt.schematicId) { validId = true; break; }
}
if (!validId)
{
for (const ModuleDef& def : sim.config().modules.modules)
for (const ModuleDef& def : sim.getConfig().modules.modules)
{
if (def.id == opt.schematicId) { validId = true; break; }
}
}
if (!validId)
{
for (const RecipeDef& def : sim.config().recipes.recipes)
for (const RecipeDef& def : sim.getConfig().recipes.recipes)
{
if (def.id == opt.schematicId) { validId = true; break; }
}
@@ -332,7 +332,7 @@ TEST_CASE("WaveSystem: schematic choices have no duplicates", "[wave]")
{
Simulation sim(loadConfig(), 42);
sim.admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
{
if (f.isEnemy) { h.hp = -1.0f; }
@@ -352,7 +352,7 @@ TEST_CASE("WaveSystem: applySchematicChoice clears pending and applies", "[wave]
{
Simulation sim(loadConfig(), 42);
sim.admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
{
if (f.isEnemy) { h.hp = -1.0f; }
@@ -370,7 +370,7 @@ TEST_CASE("WaveSystem: push places new enemy stations further right", "[wave]")
// Record the X position of the initial enemy stations.
int initialX = std::numeric_limits<int>::min();
sim.admin().forEach<StationBodyComponent, FactionComponent>(
sim.getAdmin().forEach<StationBodyComponent, FactionComponent>(
[&](entt::entity /*e*/, const StationBodyComponent& sb, const FactionComponent& f)
{
if (f.isEnemy && sb.anchor.x() > initialX)
@@ -379,7 +379,7 @@ TEST_CASE("WaveSystem: push places new enemy stations further right", "[wave]")
}
});
sim.admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
{
if (f.isEnemy) { h.hp = -1.0f; }
@@ -387,7 +387,7 @@ TEST_CASE("WaveSystem: push places new enemy stations further right", "[wave]")
sim.tick();
int newX = std::numeric_limits<int>::min();
sim.admin().forEach<StationBodyComponent, FactionComponent>(
sim.getAdmin().forEach<StationBodyComponent, FactionComponent>(
[&](entt::entity /*e*/, const StationBodyComponent& sb, const FactionComponent& f)
{
if (f.isEnemy && sb.anchor.x() > newX)

View File

@@ -28,7 +28,6 @@ BlueprintPanel::BlueprintPanel(Simulation* sim, const GameConfig* config, QWidge
, m_sim(sim)
, m_config(config)
, m_currentBlocks(0)
, m_activeIndex(-1)
{
QVBoxLayout* layout = new QVBoxLayout(this);
layout->setContentsMargins(4, 4, 4, 4);
@@ -80,11 +79,11 @@ void BlueprintPanel::handleEvent(std::shared_ptr<const BuildingBlocksChangedEven
void BlueprintPanel::clearActiveBlueprintButton()
{
if (m_activeIndex >= 0 && m_activeIndex < static_cast<int>(m_blueprintButtons.size()))
if (m_activeIndex.has_value() && *m_activeIndex < static_cast<int>(m_blueprintButtons.size()))
{
m_blueprintButtons[static_cast<std::size_t>(m_activeIndex)]->setChecked(false);
m_blueprintButtons[static_cast<std::size_t>(*m_activeIndex)]->setChecked(false);
}
m_activeIndex = -1;
m_activeIndex = std::nullopt;
refreshButtonStates();
}
@@ -109,13 +108,13 @@ void BlueprintPanel::onDeleteBlueprintClicked(int index)
{
if (m_activeIndex == index)
{
m_activeIndex = -1;
m_activeIndex = std::nullopt;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExitBlueprintModeRequestedEvent>());
}
else if (m_activeIndex > index)
else if (m_activeIndex.has_value() && *m_activeIndex > index)
{
m_activeIndex--;
--*m_activeIndex;
}
m_blueprints.erase(m_blueprints.begin() + index);
rebuildButtons();
@@ -133,9 +132,9 @@ void BlueprintPanel::onBlueprintButtonClicked(int index)
return;
}
if (m_activeIndex >= 0 && m_activeIndex < static_cast<int>(m_blueprintButtons.size()))
if (m_activeIndex.has_value() && *m_activeIndex < static_cast<int>(m_blueprintButtons.size()))
{
m_blueprintButtons[static_cast<std::size_t>(m_activeIndex)]->setChecked(false);
m_blueprintButtons[static_cast<std::size_t>(*m_activeIndex)]->setChecked(false);
}
m_activeIndex = index;

View File

@@ -1,5 +1,6 @@
#pragma once
#include <optional>
#include <vector>
#include <QWidget>
@@ -56,7 +57,7 @@ private:
const GameConfig* m_config;
std::vector<BuildingId> m_selectedBuildingIds;
int m_currentBlocks;
int m_activeIndex;
std::optional<int> m_activeIndex; // nullopt = no blueprint selected
std::vector<Blueprint> m_blueprints;
std::vector<QPushButton*> m_blueprintButtons;
QPushButton* m_createBtn;

View File

@@ -12,12 +12,13 @@
#include "DisplayName.h"
#include "EventManager.h"
#include "ExitBuilderModeRequestedEvent.h"
#include "Simulation.h"
BuildButtonGrid::BuildButtonGrid(const GameConfig* config, QWidget* parent)
BuildButtonGrid::BuildButtonGrid(Simulation* sim, const GameConfig* config, QWidget* parent)
: QWidget(parent)
, m_sim(sim)
, m_config(config)
, m_activeIndex(-1)
{
QGridLayout* layout = new QGridLayout(this);
layout->setSpacing(4);
@@ -79,24 +80,42 @@ BuildButtonGrid::~BuildButtonGrid()
unregisterForEvents();
}
void BuildButtonGrid::updateAffordability(int buildingBlocks)
void BuildButtonGrid::updateAffordability()
{
const int buildingBlocks = m_sim->getBuildingBlocksStock();
// If the currently selected tool can no longer be afforded, exit builder mode
// before recomputing button states so it does not stay selected. Clearing the
// active index first lets the loop below disable the now-unaffordable button.
if (m_activeIndex)
{
const BuildingType activeType = m_types[*m_activeIndex];
const std::map<BuildingType, int>::const_iterator it = m_costs.find(activeType);
const int cost = (it != m_costs.end()) ? it->second : 0;
if (buildingBlocks < cost)
{
clearActiveButton();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExitBuilderModeRequestedEvent>());
}
}
for (std::size_t i = 0; i < m_buttons.size(); ++i)
{
const BuildingType type = m_types[i];
const std::map<BuildingType, int>::const_iterator it = m_costs.find(type);
const int cost = (it != m_costs.end()) ? it->second : 0;
m_buttons[i]->setEnabled(buildingBlocks >= cost || m_activeIndex == static_cast<int>(i));
m_buttons[i]->setEnabled(buildingBlocks >= cost || m_activeIndex == i);
}
}
void BuildButtonGrid::clearActiveButton()
{
if (m_activeIndex >= 0 && m_activeIndex < static_cast<int>(m_buttons.size()))
if (m_activeIndex)
{
m_buttons[static_cast<std::size_t>(m_activeIndex)]->setChecked(false);
m_buttons[*m_activeIndex]->setChecked(false);
}
m_activeIndex = -1;
m_activeIndex.reset();
}
void BuildButtonGrid::onBuildButton(int index)
@@ -105,8 +124,9 @@ void BuildButtonGrid::onBuildButton(int index)
{
return;
}
const std::size_t idx = static_cast<std::size_t>(index);
if (m_activeIndex == index)
if (m_activeIndex == idx)
{
clearActiveButton();
EventManager::getInstance()->sendEventImmediately(
@@ -114,15 +134,15 @@ void BuildButtonGrid::onBuildButton(int index)
return;
}
if (m_activeIndex >= 0 && m_activeIndex < static_cast<int>(m_buttons.size()))
if (m_activeIndex)
{
m_buttons[static_cast<std::size_t>(m_activeIndex)]->setChecked(false);
m_buttons[*m_activeIndex]->setChecked(false);
}
m_activeIndex = index;
m_buttons[static_cast<std::size_t>(index)]->setChecked(true);
m_activeIndex = idx;
m_buttons[idx]->setChecked(true);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BuildingTypeSelectedEvent>(m_types[static_cast<std::size_t>(index)]));
std::make_shared<BuildingTypeSelectedEvent>(m_types[idx]));
}
void BuildButtonGrid::handleEvent(std::shared_ptr<const BuilderModeExitedEvent> /*event*/)
@@ -130,6 +150,11 @@ void BuildButtonGrid::handleEvent(std::shared_ptr<const BuilderModeExitedEvent>
clearActiveButton();
}
void BuildButtonGrid::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> /*event*/)
{
updateAffordability();
}
void BuildButtonGrid::handleEvent(std::shared_ptr<const DemolishModeChangedEvent> event)
{
m_demolishButton->setChecked(event->active);

View File

@@ -1,46 +1,56 @@
#pragma once
#include <map>
#include <optional>
#include <vector>
#include <QWidget>
#include "BuilderModeExitedEvent.h"
#include "BuildHotkeyPressedEvent.h"
#include "BuildingBlocksChangedEvent.h"
#include "BuildingType.h"
#include "DemolishModeChangedEvent.h"
#include "EventHandler.h"
#include "GameConfig.h"
class QPushButton;
class Simulation;
class BuildButtonGrid : public QWidget,
public CombinedEventHandler<BuilderModeExitedEvent,
DemolishModeChangedEvent,
BuildHotkeyPressedEvent>
BuildHotkeyPressedEvent,
BuildingBlocksChangedEvent>
{
Q_OBJECT
public:
BuildButtonGrid(const GameConfig* config, QWidget* parent = nullptr);
BuildButtonGrid(Simulation* sim, const GameConfig* config, QWidget* parent = nullptr);
~BuildButtonGrid() override;
void updateAffordability(int buildingBlocks);
void clearActiveButton();
private:
// Re-evaluates which build buttons are enabled from the current building block
// stock (read from the simulation). If the currently selected tool can no longer
// be afforded, it exits builder mode so the button does not stay selected.
void updateAffordability();
void handleEvent(std::shared_ptr<const BuilderModeExitedEvent> event) override;
void handleEvent(std::shared_ptr<const DemolishModeChangedEvent> event) override;
void handleEvent(std::shared_ptr<const BuildHotkeyPressedEvent> event) override;
void handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) override;
private slots:
void onBuildButton(int index);
private:
Simulation* m_sim;
const GameConfig* m_config;
std::vector<BuildingType> m_types;
std::vector<QPushButton*> m_buttons;
std::map<BuildingType, int> m_costs;
int m_activeIndex;
std::optional<std::size_t> m_activeIndex;
QPushButton* m_demolishButton;
};

File diff suppressed because it is too large Load Diff

View File

@@ -36,7 +36,7 @@
#include "entt/entity/entity.hpp"
#include "CommandManager.h"
#include "EntitySelectedEvent.h"
#include "EntitySelectionChangedEvent.h"
#include "GameConfig.h"
#include "Rotation.h"
#include "Tick.h"
@@ -76,7 +76,7 @@ public:
const ParsedReplay* replay, QWidget* parent = nullptr);
~GameWorldView() override;
double gameSpeed() const;
double getGameSpeed() const;
bool isDebugDrawEnabled() const;
void resetFrameTimer();
void setGameSpeed(double multiplier);
@@ -131,43 +131,57 @@ private:
void drawBeams(QPainter& painter);
void drawOverlays(QPainter& painter);
void drawScreenSpace(QPainter& painter);
// Vignette-style border shown while the game is paused (speed 0x, REQ-UI-PAUSE-BORDER)
// to make the paused state hard to miss: a black frame whose alpha fades from 50% at
// the viewport edges to 0% toward the center.
void drawPauseBorder(QPainter& painter);
// Vignette-style border shown while demolish mode is active (REQ-UI-DEMOLISH-BORDER),
// tinted with the demolish overlay color (including its configured alpha) from
// visuals.toml instead of black.
void drawDemolishBorder(QPainter& painter);
// Shared drawing for the pause/demolish vignettes: a mitred picture-frame border
// whose alpha fades from `edgeColor` (with its own alpha) at the viewport edge to
// fully transparent toward the center.
void drawVignetteBorder(QPainter& painter, const QColor& edgeColor);
void drawReplayOverlay(QPainter& painter);
float tilePx() const;
float viewportWidthTiles() const;
float getTilePx() const;
float getViewportWidthTiles() const;
// World-X (tiles) at the left edge of the viewport. m_scrollXTiles stores the
// view center; this derives the left edge the world<->widget conversions need.
float viewLeftTiles() const;
float getViewLeftTiles() const;
QPointF worldToWidget(QVector2D worldPos) const;
QPointF tileToWidget(QPoint tile) const;
QPoint widgetToTile(QPoint widgetPt) const;
QRectF tileRect(QPoint tile) const;
QRect viewportRect() const;
QRect getViewportRect() const;
// Widget-space rectangle covering a building or construction site's footprint,
// or nullopt if the id resolves to neither. Shared by the selection highlight
// and the copy-settings feedback (REQ-BLD-COPY-CONFIG-FEEDBACK).
std::optional<QRectF> footprintWidgetRect(BuildingId id) const;
float asteroidLeftEdge() const;
float enemyStationRightEdge() const;
float getAsteroidLeftEdge() const;
float getEnemyStationRightEdge() const;
// Horizontal pan speed at a given view-center X, in tiles/s (REQ-UI-SCROLL-SPEED).
float panSpeedTilesPerSecondAt(float viewCenterXTiles) const;
void clampScroll();
bool isValidPlacement(BuildingType type, QPoint anchor, Rotation rot) const;
const BuildingDef* findBuildingDef(BuildingType type) const;
BuildingId buildingAtTile(QPoint tile) const;
BuildingId siteAtTile(QPoint tile) const;
std::optional<BuildingId> buildingAtTile(QPoint tile) const;
std::optional<BuildingId> siteAtTile(QPoint tile) const;
// Ids of all buildings and construction sites whose footprint intersects
// the tile box spanned by the two (unordered) corner tiles.
std::vector<BuildingId> buildingsInBox(QPoint cornerA, QPoint cornerB) const;
QVector2D widgetToWorld(QPoint widgetPt) const;
void drawPortGlyph(QPainter& painter, QPoint bodyTile,
Rotation direction, const QColor& color);
void drawPortGlyph(QPainter& painter, QPoint tile,
Rotation direction, const QColor& color,
bool centered);
void drawBuildingGhost(QPainter& painter, BuildingType type,
QPoint anchorTile, Rotation rotation, bool valid);
QPoint anchorTile, Rotation rotation, bool valid,
bool showPortTargetGlyphs);
void placeBlueprintAtTile(QPoint center);
@@ -179,6 +193,14 @@ private:
// Drops despawned or fully-collected piles from the scrap selection and re-emits
// when it changed (REQ-UI-SCRAP-CLICK-SELECT). Called each frame from onFrame().
void pruneDespawnedScrap();
// Clears the actor selection, emitting an empty EntitySelectionChangedEvent when it was
// non-empty (REQ-UI-ENTITY-CLICK-SELECT). Used when buildings take over.
void clearEntitySelection();
// Drops despawned or dead actors from the selection and re-emits when it changed
// (REQ-UI-ENTITY-CLICK-SELECT). Called each frame from onFrame().
void pruneDespawnedActors();
// True if the given actor is part of the current actor selection.
bool isEntitySelected(entt::entity entity) const;
void stepSpeed(int delta);
void placeAtTile(QPoint tile);
@@ -222,7 +244,7 @@ private:
std::mt19937 m_rng;
double m_gameSpeedMultiplier;
double m_prevNonZeroSpeed;
// World-X (tiles) at the center of the viewport (see viewLeftTiles()).
// World-X (tiles) at the center of the viewport (see getViewLeftTiles()).
float m_scrollXTiles;
QTimer* m_renderTimer;
@@ -256,11 +278,11 @@ private:
static constexpr qint64 kCopyFlashDurationMs = 300;
bool m_demolishMode;
BuildingId m_demolishHoverBuildingId;
std::optional<BuildingId> m_demolishHoverBuildingId;
bool m_debugDraw;
std::vector<BuildingId> m_selectedBuildingIds;
std::optional<entt::entity> m_selectedEntity;
std::vector<entt::entity> m_selectedEntities;
std::vector<entt::entity> m_selectedScrap;
bool m_boxSelecting;
QPoint m_boxStartTile;

View File

@@ -32,6 +32,11 @@ HeaderBar::HeaderBar(const GameConfig* config, QWidget* parent)
QString::fromStdString(*config->world.buildingBlocksTooltip));
}
m_artifactsLabel = new QLabel(tr("Artifacts: 0/?"), this);
if (config->world.artifactTooltip)
{
m_artifactsLabel->setToolTip(
QString::fromStdString(*config->world.artifactTooltip));
}
m_bossLabel = new QLabel(tr("Boss Wave #1 Next boss: 5:00"), this);
layout->addWidget(m_timeLabel);
layout->addWidget(m_blocksLabel);

View File

@@ -14,7 +14,6 @@
#include "BlueprintPanel.h"
#include "BuildButtonGrid.h"
#include "BuildingBlocksChangedEvent.h"
#include "BuildingSystem.h"
#include "Command.h"
#include "CommandRequestedEvent.h"
@@ -42,9 +41,9 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
setWindowTitle(tr("Dota Factory"));
resize(1280, 768);
m_headerBar = new HeaderBar(&sim->config(), this);
m_headerBar = new HeaderBar(&sim->getConfig(), this);
m_gameWorldView = new GameWorldView(sim, &sim->config(), &m_visuals, m_configDir,
m_gameWorldView = new GameWorldView(sim, &sim->getConfig(), &m_visuals, m_configDir,
m_replay.get(), this);
m_sidePanel = new QWidget(this);
@@ -52,9 +51,9 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
sideLayout->setContentsMargins(1, 1, 1, 1);
sideLayout->setSpacing(1);
m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->config(), m_sidePanel);
m_buildButtonGrid = new BuildButtonGrid(&sim->config(), m_sidePanel);
m_blueprintPanel = new BlueprintPanel(sim, &sim->config(), m_sidePanel);
m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->getConfig(), m_sidePanel);
m_buildButtonGrid = new BuildButtonGrid(sim, &sim->getConfig(), m_sidePanel);
m_blueprintPanel = new BlueprintPanel(sim, &sim->getConfig(), m_sidePanel);
sideLayout->addWidget(m_selectedBuildingPanel, 1);
sideLayout->addWidget(m_buildButtonGrid, 1);
@@ -151,18 +150,13 @@ void MainWindow::layoutPanels()
m_dimOverlay->setGeometry(0, 0, totalW, totalH);
}
void MainWindow::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event)
{
m_buildButtonGrid->updateAffordability(event->blocks);
}
void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEvent> event)
{
const double prevSpeed = m_gameWorldView->gameSpeed();
const double prevSpeed = m_gameWorldView->getGameSpeed();
m_gameWorldView->setGameSpeed(0.0);
ModalDimScope dim(*m_dimOverlay);
SchematicChoiceDialog dialog(event->choices, m_sim->config().recipes, this);
SchematicChoiceDialog dialog(event->choices, m_sim->getConfig().recipes, this);
dialog.exec();
std::shared_ptr<ApplySchematicChoiceCommand> command =
@@ -177,7 +171,7 @@ void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEven
void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*event*/)
{
const double prevSpeed = m_gameWorldView->gameSpeed();
const double prevSpeed = m_gameWorldView->getGameSpeed();
m_gameWorldView->setGameSpeed(0.0);
ModalDimScope dim(*m_dimOverlay);
@@ -232,11 +226,11 @@ void MainWindow::openShipLayoutDialog(BuildingId shipyardId,
const std::string& schematicId,
const ShipLayoutConfig& currentLayout)
{
const double prevSpeed = m_gameWorldView->gameSpeed();
const double prevSpeed = m_gameWorldView->getGameSpeed();
m_gameWorldView->setGameSpeed(0.0);
std::set<std::string> unlockedModuleIds;
for (const ModuleDef& def : m_sim->config().modules.modules)
for (const ModuleDef& def : m_sim->getConfig().modules.modules)
{
if (m_sim->isModuleSchematicUnlocked(def.id))
{
@@ -245,17 +239,17 @@ void MainWindow::openShipLayoutDialog(BuildingId shipyardId,
}
ModalDimScope dim(*m_dimOverlay);
ShipLayoutDialog dialog(&m_sim->config(), schematicId, currentLayout,
ShipLayoutDialog dialog(&m_sim->getConfig(), schematicId, currentLayout,
m_layoutBlueprints,
std::move(unlockedModuleIds),
m_gameWorldView->isDebugDrawEnabled(),
this);
if (dialog.exec() == QDialog::Accepted && dialog.result().has_value())
if (dialog.exec() == QDialog::Accepted && dialog.getResult().has_value())
{
std::shared_ptr<SetShipLayoutCommand> command =
std::make_shared<SetShipLayoutCommand>();
command->id = shipyardId;
command->layout = *dialog.result();
command->layout = *dialog.getResult();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
@@ -268,9 +262,9 @@ void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> e
{
// A construction site has no Building yet; fall back to its site record so
// the shipyard layout can be configured before it is built (REQ-BLD-SITE-CONFIG).
const Building* b = m_sim->buildings().findBuilding(event->shipyardId);
const Building* b = m_sim->getBuildings().findBuilding(event->shipyardId);
const ConstructionSite* s =
b ? nullptr : m_sim->buildings().findSite(event->shipyardId);
b ? nullptr : m_sim->getBuildings().findSite(event->shipyardId);
if (!b && !s)
{
return;
@@ -291,14 +285,14 @@ void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> e
void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event)
{
const double prevSpeed = m_gameWorldView->gameSpeed();
const double prevSpeed = m_gameWorldView->getGameSpeed();
m_gameWorldView->setGameSpeed(0.0);
// A construction site has no Building yet; fall back to its site record so
// the recipe/schematic can be chosen before it is built (REQ-BLD-SITE-CONFIG).
const Building* b = m_sim->buildings().findBuilding(event->buildingId);
const Building* b = m_sim->getBuildings().findBuilding(event->buildingId);
const ConstructionSite* s =
b ? nullptr : m_sim->buildings().findSite(event->buildingId);
b ? nullptr : m_sim->getBuildings().findSite(event->buildingId);
if (!b && !s)
{
m_gameWorldView->setGameSpeed(prevSpeed);
@@ -316,7 +310,7 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
// dereferenced after dialog.exec() returns.
const std::string oldSchematic = b ? b->recipeId : s->recipeId;
const std::vector<RecipeSelectionOption> options =
buildRecipeSelectionOptions(type, *m_sim, m_sim->config());
buildRecipeSelectionOptions(type, *m_sim, m_sim->getConfig());
const QString title = (type == BuildingType::Shipyard)
? tr("Select Schematic")
: tr("Select Recipe");
@@ -356,7 +350,7 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
{
const Tick tick = m_sim->currentTick();
const Tick tick = m_sim->getCurrentTick();
const int totalSeconds = static_cast<int>(ticksToSeconds(tick));
const int minutes = totalSeconds / 60;
const int seconds = totalSeconds % 60;
@@ -403,7 +397,7 @@ void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
void MainWindow::handleEvent(std::shared_ptr<const WinEvent> /*event*/)
{
const Tick tick = m_sim->currentTick();
const Tick tick = m_sim->getCurrentTick();
const int totalSeconds = static_cast<int>(ticksToSeconds(tick));
const int minutes = totalSeconds / 60;
const int seconds = totalSeconds % 60;

View File

@@ -6,7 +6,6 @@
#include <QWidget>
#include "BuildingBlocksChangedEvent.h"
#include "BuildingId.h"
#include "EscapeMenuRequestedEvent.h"
#include "EventHandler.h"
@@ -32,8 +31,7 @@ class QCloseEvent;
class QResizeEvent;
class MainWindow : public QWidget,
public CombinedEventHandler<BuildingBlocksChangedEvent,
SchematicChoicesAvailableEvent,
public CombinedEventHandler<SchematicChoicesAvailableEvent,
GameOverEvent,
WinEvent,
EscapeMenuRequestedEvent,
@@ -52,7 +50,6 @@ protected:
void closeEvent(QCloseEvent* event) override;
private:
void handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) override;
void handleEvent(std::shared_ptr<const SchematicChoicesAvailableEvent> event) override;
void handleEvent(std::shared_ptr<const GameOverEvent> event) override;
void handleEvent(std::shared_ptr<const WinEvent> event) override;

View File

@@ -9,14 +9,16 @@
#include <QLabel>
#include <QListWidget>
#include <QPushButton>
#include <QStringList>
#include <QVBoxLayout>
#include "BeltSystem.h"
#include "Command.h"
#include "CommandRequestedEvent.h"
#include "DisplayName.h"
#include "DynamicBodyComponent.h"
#include "EntityAdmin.h"
#include "EntitySelectedEvent.h"
#include "EntitySelectionChangedEvent.h"
#include "EventManager.h"
#include "FactionComponent.h"
#include "HealthComponent.h"
@@ -131,7 +133,6 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
: QWidget(parent)
, m_sim(sim)
, m_config(config)
, m_singleBuildingId(kInvalidBuildingId)
, m_splitterTile(0, 0)
{
m_layout = new QVBoxLayout(this);
@@ -170,10 +171,10 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
connect(m_clearBeltBtn, &QPushButton::clicked,
this, &SelectedBuildingPanel::onClearBelt);
connect(m_configureLayoutBtn, &QPushButton::clicked, this, [this]() {
if (m_singleBuildingId != kInvalidBuildingId)
if (m_singleBuildingId.has_value())
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<LayoutDialogRequestedEvent>(m_singleBuildingId));
std::make_shared<LayoutDialogRequestedEvent>(*m_singleBuildingId));
}
});
connect(m_filterAList, &QListWidget::itemChanged,
@@ -197,6 +198,11 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
m_layout->addWidget(m_stationStatsLabel);
m_stationStatsLabel->hide();
m_entitySummaryLabel = new QLabel(this);
m_entitySummaryLabel->setWordWrap(true);
m_layout->addWidget(m_entitySummaryLabel);
m_entitySummaryLabel->hide();
m_scrapLabel = new QLabel(this);
m_layout->addWidget(m_scrapLabel);
m_scrapLabel->hide();
@@ -216,8 +222,9 @@ void SelectedBuildingPanel::onSelectionChanged(const std::vector<BuildingId>& id
m_selectedBuildingIds = ids;
if (!ids.empty())
{
// A building selection is exclusive: it supersedes any field selection —
// actors and scrap (REQ-UI-SELECTION-CATEGORIES).
clearEntityDisplay();
// A building selection supersedes any scrap selection (REQ-UI-SCRAP-CLICK-SELECT).
m_selectedScrap.clear();
m_scrapLabel->hide();
}
@@ -257,7 +264,7 @@ void SelectedBuildingPanel::hideAllWidgets()
void SelectedBuildingPanel::clearContent()
{
m_singleBuildingId = kInvalidBuildingId;
m_singleBuildingId = std::nullopt;
hideAllWidgets();
}
@@ -267,6 +274,7 @@ void SelectedBuildingPanel::buildEmpty()
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
m_entitySummaryLabel->hide();
}
void SelectedBuildingPanel::buildSingle(BuildingId id)
@@ -274,8 +282,8 @@ void SelectedBuildingPanel::buildSingle(BuildingId id)
m_singleBuildingId = id;
hideAllWidgets();
const Building* b = m_sim->buildings().findBuilding(id);
const ConstructionSite* s = b ? nullptr : m_sim->buildings().findSite(id);
const Building* b = m_sim->getBuildings().findBuilding(id);
const ConstructionSite* s = b ? nullptr : m_sim->getBuildings().findSite(id);
if (!b && !s)
{
buildEmpty();
@@ -353,12 +361,12 @@ void SelectedBuildingPanel::buildSingle(BuildingId id)
std::optional<BeltSystem::SplitterInfo> info;
if (m_singleIsSite)
{
info = m_sim->buildings().getSiteSplitterInfo(id);
info = m_sim->getBuildings().getSiteSplitterInfo(id);
}
else
{
m_splitterTile = anchor;
info = m_sim->belts().getSplitterInfo(m_splitterTile);
info = m_sim->getBelts().getSplitterInfo(m_splitterTile);
}
buildSplitterFilters(info);
}
@@ -397,7 +405,7 @@ void SelectedBuildingPanel::refreshSiteProgress(const ConstructionSite* s)
if (def && def->constructionTimeSeconds > 0)
{
const Tick duration = secondsToTicks(def->constructionTimeSeconds);
const Tick elapsed = m_sim->currentTick() - (s->completesAt - duration);
const Tick elapsed = m_sim->getCurrentTick() - (s->completesAt - duration);
const int pct = static_cast<int>(
std::max(Tick(0), std::min(duration, elapsed)) * 100 / duration);
progress = tr("%1% complete").arg(pct);
@@ -554,7 +562,7 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
{
const Tick cycleTicks = secondsToTicks(durationSeconds);
const Tick completesAt = b->production->completesAt;
const Tick currentTick = m_sim->currentTick();
const Tick currentTick = m_sim->getCurrentTick();
const Tick elapsed = currentTick - (completesAt - cycleTicks);
const int pct = static_cast<int>(
std::max(Tick(0), std::min(cycleTicks, elapsed)) * 100 / cycleTicks);
@@ -659,21 +667,29 @@ void SelectedBuildingPanel::handleEvent(
void SelectedBuildingPanel::refreshSelectionDisplay(RefreshReason reason)
{
if (!m_selectedScrap.empty())
if (!m_selectedEntities.empty() || !m_selectedScrap.empty())
{
// The total shrinks live as piles are collected or despawn (REQ-UI-SCRAP-PANEL).
refreshScrapTotal();
// Field selection. Keep the live values current: the single-actor stats panel,
// the standalone scrap total, or the count summary (whose scrap line shrinks as
// piles are collected) — matching the layout chosen by buildFieldSelection()
// (REQ-UI-SHIP-STATS-PANEL, REQ-UI-SCRAP-PANEL).
if (m_selectedEntities.size() == 1 && m_selectedScrap.empty())
{
refreshEntityStats();
}
else if (m_selectedEntities.empty())
{
refreshScrapTotal();
}
else
{
buildEntitySummary();
}
return;
}
if (m_selectedEntity.has_value())
{
refreshEntityStats();
return;
}
if (m_singleBuildingId == kInvalidBuildingId) { return; }
const Building* b = m_sim->buildings().findBuilding(m_singleBuildingId);
if (!m_singleBuildingId.has_value()) { return; }
const Building* b = m_sim->getBuildings().findBuilding(*m_singleBuildingId);
if (b)
{
if (m_titleLabel->text().startsWith(tr("(Building) ")))
@@ -686,7 +702,7 @@ void SelectedBuildingPanel::refreshSelectionDisplay(RefreshReason reason)
}
return;
}
const ConstructionSite* s = m_sim->buildings().findSite(m_singleBuildingId);
const ConstructionSite* s = m_sim->getBuildings().findSite(*m_singleBuildingId);
if (s)
{
// A periodic tick only advances construction progress, so update just the
@@ -708,7 +724,7 @@ void SelectedBuildingPanel::refreshSelectionDisplay(RefreshReason reason)
void SelectedBuildingPanel::buildMulti(const std::vector<BuildingId>& ids)
{
m_singleBuildingId = kInvalidBuildingId;
m_singleBuildingId = std::nullopt;
m_recipeSelectButton->hide();
m_clearBeltBtn->hide();
m_filterALabel->hide();
@@ -720,13 +736,13 @@ void SelectedBuildingPanel::buildMulti(const std::vector<BuildingId>& ids)
std::map<BuildingType, int> counts;
for (BuildingId id : ids)
{
const Building* b = m_sim->buildings().findBuilding(id);
const Building* b = m_sim->getBuildings().findBuilding(id);
if (b)
{
counts[b->type]++;
continue;
}
const ConstructionSite* s = m_sim->buildings().findSite(id);
const ConstructionSite* s = m_sim->getBuildings().findSite(id);
if (s)
{
counts[s->type]++;
@@ -738,7 +754,7 @@ void SelectedBuildingPanel::buildMulti(const std::vector<BuildingId>& ids)
QString text;
for (const std::pair<const BuildingType, int>& entry : counts)
{
text += buildingTypeName(entry.first) + ": "
text += buildingTypeName(entry.first) + " x "
+ QString::number(entry.second) + "\n";
if (isBeltLike(entry.first))
{
@@ -764,7 +780,7 @@ void SelectedBuildingPanel::buildMulti(const std::vector<BuildingId>& ids)
void SelectedBuildingPanel::onSelectRecipeClicked()
{
if (m_singleBuildingId == kInvalidBuildingId)
if (!m_singleBuildingId.has_value())
{
return;
}
@@ -775,7 +791,7 @@ void SelectedBuildingPanel::onSelectRecipeClicked()
// refreshBuffers() path picks up the new schematic (and shows the layout
// preview + Configure Layout button) once the command has been applied.
EventManager::getInstance()->sendEventImmediately(
std::make_shared<RecipeSelectionRequestedEvent>(m_singleBuildingId));
std::make_shared<RecipeSelectionRequestedEvent>(*m_singleBuildingId));
rebuild();
}
@@ -791,7 +807,7 @@ void SelectedBuildingPanel::buildSplitterFilters(
return;
}
const std::vector<std::string> items = allItemIds();
const std::vector<std::string> items = getAllItemIds();
auto populateList = [&](QListWidget* list, QLabel* label,
const QString& dirLabel,
@@ -825,7 +841,7 @@ void SelectedBuildingPanel::buildSplitterFilters(
void SelectedBuildingPanel::onSplitterFilterChanged()
{
if (m_singleBuildingId == kInvalidBuildingId)
if (!m_singleBuildingId.has_value())
{
return;
}
@@ -848,7 +864,7 @@ void SelectedBuildingPanel::onSplitterFilterChanged()
{
std::shared_ptr<SetSiteSplitterFiltersCommand> command =
std::make_shared<SetSiteSplitterFiltersCommand>();
command->id = m_singleBuildingId;
command->id = *m_singleBuildingId;
command->filterA = collectFilter(m_filterAList);
command->filterB = collectFilter(m_filterBList);
EventManager::getInstance()->sendEventImmediately(
@@ -866,7 +882,7 @@ void SelectedBuildingPanel::onSplitterFilterChanged()
}
}
std::vector<std::string> SelectedBuildingPanel::allItemIds() const
std::vector<std::string> SelectedBuildingPanel::getAllItemIds() const
{
std::set<std::string> seen;
for (const RecipeDef& recipe : m_config->recipes.recipes)
@@ -888,7 +904,7 @@ void SelectedBuildingPanel::onClearBelt()
std::vector<QPoint> tiles;
for (BuildingId id : m_selectedBuildingIds)
{
const Building* b = m_sim->buildings().findBuilding(id);
const Building* b = m_sim->getBuildings().findBuilding(id);
if (b && isBeltLike(b->type))
{
for (const QPoint& cell : b->bodyCells)
@@ -907,44 +923,144 @@ void SelectedBuildingPanel::onClearBelt()
}
}
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const EntitySelectedEvent> event)
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event)
{
if (event->entity.has_value())
m_selectedEntities = event->entities;
if (!m_selectedEntities.empty())
{
m_selectedEntity = event->entity;
// A field selection supersedes any building selection (REQ-UI-SELECTION-CATEGORIES).
m_selectedBuildingIds.clear();
// An entity selection supersedes any scrap selection (REQ-UI-SCRAP-CLICK-SELECT).
m_selectedScrap.clear();
}
buildFieldSelection();
}
void SelectedBuildingPanel::buildFieldSelection()
{
if (m_selectedEntities.empty() && m_selectedScrap.empty())
{
// Nothing in the field category. Fall back to empty unless buildings own the panel.
clearEntityDisplay();
m_scrapLabel->hide();
clearContent();
EntityAdmin& admin = m_sim->admin();
entt::entity entity = *m_selectedEntity;
if (!admin.isValid(entity))
if (m_selectedBuildingIds.empty())
{
clearEntityDisplay();
return;
buildEmpty();
}
return;
}
if (admin.hasAll<ShipIdentityComponent>(entity))
// A field selection owns the panel: drop any building content.
clearContent();
EntityAdmin& admin = m_sim->getAdmin();
// Full single-actor stats are shown only for a lone actor with no scrap. As soon as
// the selection holds more than one object (multiple actors, or an actor plus scrap),
// the panel switches to the compact count summary (REQ-UI-FIELD-MULTI-SELECTION).
if (m_selectedEntities.size() == 1 && m_selectedScrap.empty())
{
m_entitySummaryLabel->hide();
m_scrapLabel->hide();
const entt::entity entity = m_selectedEntities.front();
if (admin.isValid(entity) && admin.hasAll<ShipIdentityComponent>(entity))
{
buildEntityShip(entity);
}
else if (admin.hasAll<StationBodyComponent>(entity))
else if (admin.isValid(entity) && admin.hasAll<StationBodyComponent>(entity))
{
buildEntityStation(entity);
}
else
{
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
}
return;
}
else
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
if (m_selectedEntities.empty())
{
clearEntityDisplay();
// Scrap only: a single "Scrap: N" line.
m_entitySummaryLabel->hide();
refreshScrapTotal();
m_scrapLabel->show();
return;
}
// Actor counts, with the scrap total appended into the same label so every line
// shares the same spacing.
m_scrapLabel->hide();
buildEntitySummary();
}
void SelectedBuildingPanel::buildEntitySummary()
{
EntityAdmin& admin = m_sim->getAdmin();
// Group actors by faction + kind + ship schematic, preserving first-seen order
// (REQ-UI-FIELD-MULTI-SELECTION).
std::vector<QString> keys;
std::map<QString, int> counts;
std::map<QString, QString> labels;
for (entt::entity entity : m_selectedEntities)
{
if (!admin.isValid(entity)) { continue; }
const bool isEnemy = admin.hasAll<FactionComponent>(entity)
&& admin.get<FactionComponent>(entity).isEnemy;
QString key;
QString label;
if (admin.hasAll<ShipIdentityComponent>(entity))
{
const std::string& id = admin.get<ShipIdentityComponent>(entity).schematicId;
const QString name = QString::fromStdString(toDisplayName(id));
key = (isEnemy ? QStringLiteral("ship:enemy:") : QStringLiteral("ship:player:"))
+ QString::fromStdString(id);
label = isEnemy ? tr("Enemy %1").arg(name) : name;
}
else if (admin.hasAll<StationBodyComponent>(entity))
{
key = isEnemy ? QStringLiteral("station:enemy") : QStringLiteral("station:player");
label = isEnemy ? tr("Enemy Defence Station") : tr("Player Defence Station");
}
else
{
continue;
}
if (counts.find(key) == counts.end())
{
keys.push_back(key);
labels[key] = label;
}
counts[key] += 1;
}
// One "<type> x <count>" line per group (matching the recipe tooltip and the building
// multi-selection). No total-count header, consistent with the building panel. The
// scrap total, when present, is appended as another line in the same label so the
// line spacing is uniform (REQ-UI-FIELD-MULTI-SELECTION, REQ-UI-SCRAP-PANEL).
QStringList lines;
for (const QString& key : keys)
{
lines << tr("%1 x %2").arg(labels[key]).arg(counts[key]);
}
if (!m_selectedScrap.empty())
{
lines << scrapTotalText();
}
m_entitySummaryLabel->setText(lines.join('\n'));
m_entitySummaryLabel->show();
}
void SelectedBuildingPanel::buildEntityShip(entt::entity entity)
{
EntityAdmin& admin = m_sim->admin();
EntityAdmin& admin = m_sim->getAdmin();
const ShipIdentityComponent& identity = admin.get<ShipIdentityComponent>(entity);
const HealthComponent& health = admin.get<HealthComponent>(entity);
@@ -976,7 +1092,7 @@ void SelectedBuildingPanel::buildEntityShip(entt::entity entity)
void SelectedBuildingPanel::buildEntityStation(entt::entity entity)
{
EntityAdmin& admin = m_sim->admin();
EntityAdmin& admin = m_sim->getAdmin();
const HealthComponent& health = admin.get<HealthComponent>(entity);
const bool isEnemy = admin.hasAll<FactionComponent>(entity)
@@ -1017,23 +1133,17 @@ void SelectedBuildingPanel::buildEntityStation(entt::entity entity)
void SelectedBuildingPanel::refreshEntityStats()
{
if (!m_selectedEntity.has_value()) { return; }
// Only the single-actor stats panel needs a live refresh; the multi-actor summary is
// static counts, and GameWorldView prunes dead/despawned actors and re-emits the
// selection (REQ-UI-ENTITY-CLICK-SELECT), so the panel does not mutate it here.
if (m_selectedEntities.size() != 1) { return; }
EntityAdmin& admin = m_sim->admin();
entt::entity entity = *m_selectedEntity;
if (!admin.isValid(entity))
{
clearEntityDisplay();
return;
}
EntityAdmin& admin = m_sim->getAdmin();
const entt::entity entity = m_selectedEntities.front();
if (!admin.isValid(entity) || !admin.hasAll<HealthComponent>(entity)) { return; }
const HealthComponent& health = admin.get<HealthComponent>(entity);
if (health.hp <= 0.0f)
{
clearEntityDisplay();
return;
}
if (health.hp <= 0.0f) { return; }
if (admin.hasAll<ShipIdentityComponent>(entity))
{
@@ -1050,10 +1160,11 @@ void SelectedBuildingPanel::refreshEntityStats()
void SelectedBuildingPanel::clearEntityDisplay()
{
m_selectedEntity = std::nullopt;
m_selectedEntities.clear();
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
m_entitySummaryLabel->hide();
}
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> event)
@@ -1067,38 +1178,18 @@ void SelectedBuildingPanel::handleEvent(
m_selectedScrap = event->scrap;
if (!m_selectedScrap.empty())
{
// Scrap is its own selection category, mutually exclusive with buildings and
// entities (REQ-UI-SCRAP-CLICK-SELECT).
// Scrap is a field object: it supersedes any building selection but coexists
// with actors (REQ-UI-SELECTION-CATEGORIES).
m_selectedBuildingIds.clear();
clearContent();
clearEntityDisplay();
buildScrap();
}
else
{
m_scrapLabel->hide();
if (m_selectedBuildingIds.empty() && !m_selectedEntity.has_value())
{
buildEmpty();
}
}
buildFieldSelection();
}
void SelectedBuildingPanel::buildScrap()
{
clearContent();
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
refreshScrapTotal();
m_scrapLabel->show();
}
void SelectedBuildingPanel::refreshScrapTotal()
QString SelectedBuildingPanel::scrapTotalText() const
{
// Sum the remaining amounts of the still-living selected piles (REQ-UI-SCRAP-PANEL).
int total = 0;
for (const ScrapInfo& info : m_sim->scraps().allScrapInfo())
for (const ScrapInfo& info : m_sim->getScraps().getAllScrapInfo())
{
if (std::find(m_selectedScrap.begin(), m_selectedScrap.end(), info.entity)
!= m_selectedScrap.end())
@@ -1106,7 +1197,12 @@ void SelectedBuildingPanel::refreshScrapTotal()
total += info.amount;
}
}
m_scrapLabel->setText(tr("Scrap: %1").arg(total));
return tr("Scrap x %1").arg(total);
}
void SelectedBuildingPanel::refreshScrapTotal()
{
m_scrapLabel->setText(scrapTotalText());
}
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event)

View File

@@ -13,7 +13,7 @@
#include "Building.h"
#include "BuildingId.h"
#include "DebugDrawToggledEvent.h"
#include "EntitySelectedEvent.h"
#include "EntitySelectionChangedEvent.h"
#include "EventHandler.h"
#include "GameConfig.h"
#include "PlayerCommandsAppliedEvent.h"
@@ -36,7 +36,7 @@ class QVBoxLayout;
class SelectedBuildingPanel : public QWidget,
public CombinedEventHandler<TickAdvancedEvent,
PlayerCommandsAppliedEvent,
EntitySelectedEvent,
EntitySelectionChangedEvent,
SelectionChangedEvent,
ScrapSelectionChangedEvent,
DebugDrawToggledEvent>
@@ -51,7 +51,7 @@ public:
private:
void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override;
void handleEvent(std::shared_ptr<const PlayerCommandsAppliedEvent> event) override;
void handleEvent(std::shared_ptr<const EntitySelectedEvent> event) override;
void handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const ScrapSelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event) override;
@@ -80,8 +80,9 @@ private:
void buildEmpty();
void buildSingle(BuildingId id);
void buildMulti(const std::vector<BuildingId>& ids);
void buildScrap();
void refreshScrapTotal();
// "Scrap: N" for the summed remaining amount of the selected piles (REQ-UI-SCRAP-PANEL).
QString scrapTotalText() const;
void refreshBuffers(const Building* b);
void refreshSiteProgress(const ConstructionSite* s);
void updateShipyardLayoutWidgets(BuildingType type,
@@ -90,7 +91,7 @@ private:
void buildSplitterFilters(const std::optional<BeltSystem::SplitterInfo>& info);
const RecipeDef* findRecipe(const Building* b) const;
const ShipDef* findShipDef(const std::string& id) const;
std::vector<std::string> allItemIds() const;
std::vector<std::string> getAllItemIds() const;
Simulation* m_sim;
const GameConfig* m_config;
@@ -109,22 +110,30 @@ private:
ShipLayoutPreview* m_layoutPreview;
QPushButton* m_configureLayoutBtn;
BuildingId m_singleBuildingId;
std::optional<BuildingId> m_singleBuildingId;
bool m_singleIsSite = false; // selected single entity is a construction site
QPoint m_splitterTile;
std::string m_currentRecipeId;
bool m_debugDraw = false;
std::optional<entt::entity> m_selectedEntity;
// The selected ships/defence stations. Shares the "field" selection category with
// scrap (m_selectedScrap): both can be non-empty at once (REQ-UI-SELECTION-CATEGORIES).
std::vector<entt::entity> m_selectedEntities;
ShipStatsPanel* m_entityStatsPanel;
QLabel* m_entityTitleLabel;
QLabel* m_stationStatsLabel;
QLabel* m_entitySummaryLabel;
std::vector<entt::entity> m_selectedScrap;
QLabel* m_scrapLabel;
// Renders the combined field selection (actors + scrap): a single-actor stats panel
// or a multi-actor summary, plus the scrap total when scrap is also selected
// (REQ-UI-FIELD-MULTI-SELECTION).
void buildFieldSelection();
void buildEntityShip(entt::entity entity);
void buildEntityStation(entt::entity entity);
void buildEntitySummary();
void refreshEntityStats();
void clearEntityDisplay();
};

View File

@@ -83,7 +83,7 @@ public:
setFixedSize(cols * kCellSize + 1, rows * kCellSize + 1);
}
void setGhostData(int moduleIndex, Rotation rotation)
void setGhostData(std::optional<int> moduleIndex, Rotation rotation)
{
m_ghostModuleIdx = moduleIndex;
m_ghostRotation = rotation;
@@ -111,9 +111,9 @@ protected:
{
painter.fillRect(cellRect, QColor(30, 30, 30));
}
else if (cell.moduleIndex >= 0)
else if (cell.moduleIndex.has_value())
{
const PlacedModule& pm = (*m_placed)[cell.moduleIndex];
const PlacedModule& pm = (*m_placed)[*cell.moduleIndex];
const ModuleDef* def = findModule(pm.moduleId);
QColor color(Qt::gray);
QString glyph;
@@ -140,9 +140,9 @@ protected:
}
// Draw ghost
if (m_ghostModuleIdx >= 0 && m_hoverCell.x() >= 0 && m_config)
if (m_ghostModuleIdx.has_value() && m_hoverCell.x() >= 0 && m_config)
{
const ModuleDef& def = m_config->modules.modules[m_ghostModuleIdx];
const ModuleDef& def = m_config->modules.modules[*m_ghostModuleIdx];
const std::vector<std::string> mask = rotateMask(def.surfaceMask, m_ghostRotation);
QColor ghostColor(QString::fromStdString(def.fillColor));
ghostColor.setAlpha(100);
@@ -238,7 +238,7 @@ private:
int m_cols = 0;
const std::vector<PlacedModule>* m_placed = nullptr;
const GameConfig* m_config = nullptr;
int m_ghostModuleIdx = -2;
std::optional<int> m_ghostModuleIdx;
Rotation m_ghostRotation = Rotation::East;
QPoint m_hoverCell = QPoint(-1, -1);
};
@@ -374,7 +374,6 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
, m_rows(0)
, m_cols(0)
, m_placedModules(currentLayout.placedModules)
, m_activeModuleIndex(-2)
, m_currentRotation(Rotation::East)
, m_removeButton(nullptr)
, m_gridWidget(nullptr)
@@ -406,7 +405,7 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
}
// Initialize grid.
m_grid.assign(m_rows, std::vector<CellInfo>(m_cols, {false, -1}));
m_grid.assign(m_rows, std::vector<CellInfo>(m_cols, {false, std::nullopt}));
for (int r = 0; r < m_rows; ++r)
{
for (int c = 0; c < static_cast<int>(m_shipLayout[r].size()); ++c)
@@ -491,9 +490,9 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
}
buttonGrid->addWidget(m_removeButton, row, 0, 1, kCols);
connect(m_removeButton, &QPushButton::clicked, this, [this]() {
if (m_activeModuleIndex == -1)
if (m_removeMode)
{
m_activeModuleIndex = -2;
m_removeMode = false;
m_removeButton->setChecked(false);
}
else
@@ -502,7 +501,8 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
{
if (btn) { btn->setChecked(false); }
}
m_activeModuleIndex = -1;
m_activeModuleIndex = std::nullopt;
m_removeMode = true;
m_removeButton->setChecked(true);
}
updateGridWidget();
@@ -540,20 +540,20 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
// Grid click handler.
connect(this, &ShipLayoutDialog::gridCellClicked, this, [this](QPoint cell) {
if (m_activeModuleIndex == -2)
if (!m_removeMode && !m_activeModuleIndex.has_value())
{
return;
}
if (m_activeModuleIndex == -1)
if (m_removeMode)
{
// Remove mode: find and remove module at cell.
if (cell.y() >= 0 && cell.y() < m_rows && cell.x() >= 0 && cell.x() < m_cols)
{
const int idx = m_grid[cell.y()][cell.x()].moduleIndex;
if (idx >= 0)
const std::optional<int> idx = m_grid[cell.y()][cell.x()].moduleIndex;
if (idx.has_value())
{
m_placedModules.erase(m_placedModules.begin() + idx);
m_placedModules.erase(m_placedModules.begin() + *idx);
rebuildOccupancy();
updateGridWidget();
updateStats();
@@ -563,7 +563,7 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
}
// Place module.
const ModuleDef& def = m_config->modules.modules[m_activeModuleIndex];
const ModuleDef& def = m_config->modules.modules[*m_activeModuleIndex];
if (canPlaceModule(def, cell, m_currentRotation))
{
PlacedModule pm;
@@ -578,7 +578,7 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
});
}
std::optional<ShipLayoutConfig> ShipLayoutDialog::result() const
std::optional<ShipLayoutConfig> ShipLayoutDialog::getResult() const
{
return m_result;
}
@@ -622,7 +622,7 @@ void ShipLayoutDialog::onModuleButtonClicked(int index)
if (m_activeModuleIndex == index)
{
if (m_moduleButtons[index]) { m_moduleButtons[index]->setChecked(false); }
m_activeModuleIndex = -2;
m_activeModuleIndex = std::nullopt;
}
else
{
@@ -631,6 +631,7 @@ void ShipLayoutDialog::onModuleButtonClicked(int index)
if (m_moduleButtons[i]) { m_moduleButtons[i]->setChecked(i == index); }
}
m_removeButton->setChecked(false);
m_removeMode = false;
m_activeModuleIndex = index;
}
updateGridWidget();
@@ -656,7 +657,7 @@ void ShipLayoutDialog::rebuildOccupancy()
{
for (int c = 0; c < m_cols; ++c)
{
m_grid[r][c].moduleIndex = -1;
m_grid[r][c].moduleIndex = std::nullopt;
}
}
@@ -726,7 +727,7 @@ bool ShipLayoutDialog::canPlaceModule(const ModuleDef& def, QPoint position,
{
return false;
}
if (m_grid[gr][gc].moduleIndex >= 0)
if (m_grid[gr][gc].moduleIndex.has_value())
{
return false;
}

View File

@@ -30,7 +30,7 @@ public:
bool debugDraw,
QWidget* parent = nullptr);
std::optional<ShipLayoutConfig> result() const;
std::optional<ShipLayoutConfig> getResult() const;
protected:
void keyPressEvent(QKeyEvent* event) override;
@@ -47,7 +47,7 @@ public:
struct CellInfo
{
bool buildable;
int moduleIndex; // -1 if empty
std::optional<int> moduleIndex; // nullopt if empty
};
private:
@@ -69,7 +69,12 @@ private:
std::vector<PlacedModule> m_placedModules;
std::vector<std::vector<CellInfo>> m_grid;
int m_activeModuleIndex; // -1 = remove mode, -2 = no selection
// The module to place, as an index into config modules; nullopt when no
// module is selected for placement. m_removeMode is a separate mode in which
// clicking a cell removes the module there (mutually exclusive with a
// selected module).
std::optional<int> m_activeModuleIndex;
bool m_removeMode = false;
Rotation m_currentRotation;
std::vector<QPushButton*> m_moduleButtons;

View File

@@ -129,7 +129,7 @@ void ShipLayoutPreview::setShipAndLayout(const std::vector<std::string>& shipLay
}
}
m_grid.assign(m_rows, std::vector<CellInfo>(m_cols, {false, -1}));
m_grid.assign(m_rows, std::vector<CellInfo>(m_cols, {false, std::nullopt}));
for (int r = 0; r < m_rows; ++r)
{
for (int c = 0; c < static_cast<int>(shipLayout[r].size()); ++c)
@@ -205,9 +205,9 @@ void ShipLayoutPreview::paintEvent(QPaintEvent* /*event*/)
{
painter.fillRect(cellRect, Qt::black);
}
else if (cell.moduleIndex >= 0)
else if (cell.moduleIndex.has_value())
{
const PlacedModule& pm = m_placedModules[cell.moduleIndex];
const PlacedModule& pm = m_placedModules[*cell.moduleIndex];
const ModuleDef* def = findModuleDef(*m_modules, pm.moduleId);
QColor color(Qt::gray);
if (def)

View File

@@ -1,5 +1,6 @@
#pragma once
#include <optional>
#include <string>
#include <vector>
@@ -31,7 +32,7 @@ private:
struct CellInfo
{
bool buildable;
int moduleIndex; // -1 if empty
std::optional<int> moduleIndex; // nullopt if empty
};
std::vector<std::vector<CellInfo>> m_grid;