Compare commits
19 Commits
0.1.1
...
a8a6a04f1e
| Author | SHA1 | Date | |
|---|---|---|---|
| a8a6a04f1e | |||
| a75222f111 | |||
| 9b63af6ccb | |||
| 4ca5b332cd | |||
| 0c4eb480be | |||
| b2c1ea34fd | |||
| 1cbc695bc5 | |||
| c7ecce6ac4 | |||
| be475e2836 | |||
| 9622fa4345 | |||
| 1cdafb7bcd | |||
| f205136e21 | |||
| b708e1b29d | |||
| c76ba07bc7 | |||
| b2e7e4897a | |||
| c21af63e84 | |||
| a4267a4760 | |||
| d412c69f82 | |||
| 08752aeced |
@@ -16,7 +16,7 @@ surface_mask = ["<A>"]
|
||||
|
||||
[[building]]
|
||||
id = "tunnel_entry"
|
||||
tooltip = "Sends items underground to a matching tunnel exit, letting belts cross."
|
||||
tooltip = "Sends items underground so belts can cross. Places an entry, or an exit when it would connect to a matching entry under the cursor."
|
||||
cost = 5
|
||||
player_placeable = true
|
||||
construction_time_seconds = 0.5
|
||||
|
||||
@@ -351,6 +351,7 @@ selected_outline = "#ffff00" # outline drawn around currently-selected buildin
|
||||
copy_config = "#33ccff66" # copy-settings eligible-target tint + copy/paste flash (REQ-BLD-COPY-CONFIG-FEEDBACK)
|
||||
locked_asteroid = "#0000007f" # tint over the asteroid left of the buildable edge (not yet unlocked by expansion)
|
||||
modal_dim = "#00000099" # semi-transparent black dim behind modal dialogs/menus (REQ-UI-MODAL-DIM)
|
||||
tunnel_preview = "#00ff0055" # tunnel connection preview: matched end + tiles between (REQ-BLD-TUNNEL-MODE)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Schematic-drop toasts (REQ-UI-SCHEMATIC-TOAST)
|
||||
@@ -360,3 +361,17 @@ modal_dim = "#00000099" # semi-transparent black dim behind modal dialogs
|
||||
bg = "#000000cc"
|
||||
fg = "#ffffff"
|
||||
font_size = 14
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Building status light (REQ-UI-STATUS-LIGHT)
|
||||
#
|
||||
# Fill color per production state, drawn as a small circle in the building's
|
||||
# upper-right corner, plus the constant outline color.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
[status_light]
|
||||
grey = "#808080" # no recipe/schematic selected
|
||||
green = "#33cc33" # producing (Salvage Bay: holding scrap)
|
||||
red = "#cc3333" # idle, input missing (Salvage Bay: empty)
|
||||
yellow = "#e6c619" # idle, output buffer full
|
||||
outline = "#000000"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
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).
|
||||
- **modules.toml** — per module type: id, surface mask, materials list, production time, fill color, glyph, an optional tooltip description string (shown as the module selection button's hover tooltip, REQ-MOD-UI-MODULE-TOOLTIP; omitted when unset), the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the module schematic already unlocked), an optional `unlock_requires` prerequisite list (REQ-LOCK-PREREQ), and an optional capability section and/or stat modifier formulas. A module with a capability section (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat formulas is a **capability module** that grants the ship a weapon, salvage bay, or repair tool per instance (see REQ-MOD-CONFIG for the full list of formulas per capability type). A module with only `added_*`/`multiplied_*` formulas is a **passive module** that modifies stats on the ship or on capability module instances (see REQ-MOD-STAT-CALC).
|
||||
- **stations.toml** — HP, damage, range, fire rate, and scrap drop for player and enemy defence stations, defined as formulas of station level.
|
||||
- **visuals.toml** — rendering-only config (not game parameters): fill and outline colors and glyphs for every building type, item type, ship schematic, and station type; a distinct beam color per tool type (weapon, repair, salvage) and beam width; overlay and toast colors. Loaded by the UI at startup; the simulation does not read it.
|
||||
- **visuals.toml** — rendering-only config (not game parameters): fill and outline colors and glyphs for every building type, item type, ship schematic, and station type; a distinct beam color per tool type (weapon, repair, salvage) and beam width; overlay and toast colors; and building status light colors (grey, green, red, and yellow fills plus the outline color, REQ-UI-STATUS-LIGHT). Loaded by the UI at startup; the simulation does not read it.
|
||||
- **ship_layouts.toml** — named layout blueprints per ship type; written and read by the application to persist the layout blueprint panel (REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD). Not a game parameter file; the simulation does not read it.
|
||||
|
||||
- REQ-CFG-RELOAD: When the player triggers a Restart (REQ-UI-GAME-MENU), all config files are reloaded from disk before the simulation is reset to its initial state. Formula strings are recompiled at that point. This allows config edits made while the application is running to take effect without a full application restart.
|
||||
@@ -100,14 +100,25 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
- REQ-BLD-COST: The player places buildings from a build menu. Placement costs building blocks from the global stock. The cost per building type is read from `buildings.toml [[building]].cost`.
|
||||
- REQ-BLD-QUEUE: Placed buildings enter a construction queue and are built one at a time. Each building takes a duration defined in `buildings.toml [[building]].construction_time_seconds` to construct.
|
||||
- REQ-BLD-ASTEROID-ONLY: Buildings can only be placed on asteroid tiles (per surface_mask; tiles marked `S` may extend into space).
|
||||
- REQ-BLD-BUILDER-MODE: Clicking a build button activates builder mode for that building type. Builder mode is exited by right-clicking in the game world or clicking the same build button again.
|
||||
- REQ-BLD-BUILDER-MODE: Clicking a build button activates builder mode for that building type. Builder mode is exited by right-clicking in the game world or clicking the same build button again. (Exception: while a belt drag placement is in progress, right-clicking cancels that drag instead of exiting, and builder mode stays active — REQ-BLD-BELT-DRAG.)
|
||||
- REQ-BLD-GHOST: While in builder mode, a ghost of the building is rendered at the tile under the cursor, showing where it would be placed. The ghost is drawn semi-transparently in the building type's own visuals — its `fill` and `outline` colors and `glyph` from `visuals.toml` — so that different building types are visually distinguishable in builder mode rather than all looking alike. When the current cursor position is invalid, the ghost instead uses the distinct "invalid" color (REQ-BLD-PLACE-VALID), which overrides the per-building coloring.
|
||||
- REQ-BLD-ROTATE: While in builder mode, pressing Shift+R rotates the ghost 90° clockwise and R rotates it 90° counter-clockwise. Rotation affects the direction of the output port.
|
||||
- REQ-BLD-PLACE: Clicking a valid tile in builder mode places a construction site and adds it to the build queue, consuming building blocks from the global stock.
|
||||
- REQ-BLD-PLACE: Clicking a valid tile in builder mode places a construction site and adds it to the build queue, consuming building blocks from the global stock. (For belts, placement is instead deferred to a drag gesture and happens on mouse release — REQ-BLD-BELT-DRAG.)
|
||||
- REQ-BLD-PLACE-VALID: A placement position is valid only if (a) every footprint cell in the rotated `surface_mask` is satisfied by the underlying terrain — `A` cells coincide with asteroid tiles, `S` cells coincide with space tiles — (b) no footprint cell overlaps an existing placed building or construction site, except as allowed by REQ-BLD-ROTATE-IN-PLACE, and (c) the player has enough building blocks to afford the building. The ghost (REQ-BLD-GHOST) is rendered in a distinct "invalid" color — overriding its per-building coloring (REQ-BLD-GHOST) — when the current cursor position fails any of these conditions.
|
||||
- REQ-BLD-ROTATE-IN-PLACE: If the ghost's footprint exactly coincides with the footprint of an existing placed building or construction site of the same building type, clicking places no new construction site and consumes no building blocks. Instead, the existing building or site is rotated to match the ghost's rotation. If the target is a construction site, its construction progress is preserved. This applies in both normal builder mode and blueprint placement mode; in blueprint placement mode it is evaluated per building in the blueprint independently — buildings in the blueprint whose footprint coincides with an existing same-type building or site are rotated in place, while the remaining buildings in the blueprint are placed as normal construction sites (subject to the usual validity checks and total cost).
|
||||
- REQ-BLD-BELT-DRAG: For belts, the player can click and drag across multiple tiles to place a construction site on each tile in one gesture.
|
||||
- REQ-BLD-TUNNEL-AUTO-SWITCH: After the player successfully places a Tunnel Entry construction site, builder mode automatically switches to Tunnel Exit (and vice versa), preserving the current ghost rotation. This makes it easy to immediately place the paired end without manually selecting the complementary type.
|
||||
- REQ-BLD-ROTATE-IN-PLACE: If the ghost's footprint exactly coincides with the footprint of an existing placed building or construction site of the same building type, clicking places no new construction site and consumes no building blocks. Instead, the existing building or site is rotated to match the ghost's rotation. If the target is a construction site, its construction progress is preserved. **Exception:** Tunnel Entries and Tunnel Exits are never rotated in place — re-orienting a tunnel requires demolishing and re-placing it (REQ-BLD-TUNNEL-MODE). A tunnel ghost whose footprint coincides with an existing tunnel is therefore treated as an ordinary occupied-tile placement (invalid in normal builder mode; skipped in blueprint placement mode). This applies in both normal builder mode and blueprint placement mode; in blueprint placement mode it is evaluated per building in the blueprint independently — buildings in the blueprint whose footprint coincides with an existing same-type building or site are rotated in place, while the remaining buildings in the blueprint are placed as normal construction sites (subject to the usual validity checks and total cost).
|
||||
- REQ-BLD-BELT-DRAG: **Belt drag placement.** For belts, placement is a deferred drag gesture rather than immediate per-tile placement: construction sites are not placed while the cursor hovers new tiles, but only once the player releases the left mouse button. Pressing the left mouse button in the game world while in belt builder mode starts a drag anchored at the tile under the cursor. As the cursor moves, a **rectilinear (L-shaped) path** of belt tiles is computed from the anchor tile to the tile under the cursor: the path first runs along the axis **parallel to the belt's current orientation** (REQ-BLD-ROTATE) — stepping toward the cursor's coordinate on that axis to a corner tile — and then runs along the orthogonal axis to the cursor tile. When the cursor shares the anchor's row or column the path degenerates to a straight line, and when it is on the anchor tile the path is a single tile.
|
||||
- **Snapping to a building.** When the tile under the cursor is occupied by a non-belt building or construction site (the **target**), the path does not end on that occupied tile. Instead the end tile is the tile **closest to the cursor** (by distance from the cursor position to the tile) among the tiles orthogonally adjacent to the target across one of its **input-capable edges** — any footprint edge that is not one of the target's output ports, i.e. an edge on which the target can accept an incoming item (REQ-MAT-INPUT-PORTS for buildings, REQ-MAT-ACCEPT-DIR for splitters and tunnels). The geometrically closest such tile is **always** used, even if it turns out not to be a valid belt endpoint — in that case it is previewed and applied by the ordinary rules below (invalid color and skipped if occupied by a non-belt building or invalid terrain; re-oriented if it already holds a belt). The rest of the L-shaped path is computed from the anchor to this end tile exactly as above. The end tile's belt direction points **toward the target** (across the shared input edge), overriding the "final tile keeps its incoming step" rule; this applies whether the end tile is a newly placed belt or an existing belt re-oriented in place, and is reflected both in the ghost preview and in the placement on release.
|
||||
- **Rotating during the drag.** Rotating the belt with R / Shift+R (REQ-BLD-ROTATE) while a drag is in progress re-picks the path's primary axis immediately from the new orientation and re-derives the whole path from the anchor to the current cursor tile, without waiting for the next cursor movement.
|
||||
- **Ghosts.** While dragging, a belt ghost (REQ-BLD-GHOST) is rendered on every path tile that would be acted on, instead of a single ghost under the cursor. Each ghost is oriented to point toward the next tile along the path toward the cursor, so the path forms one connected belt run that turns at the corner (curved belts along the path auto-derive per REQ-BLD-BELT); the final tile keeps the direction of its incoming step (unless the end tile is snapped to a building, in which case it points into the target — see **Snapping to a building**), and a single-tile path keeps the belt's current orientation. A tile occupied by only an existing belt or belt construction site is a valid target — its belt is re-oriented to follow the path — and shows a normal belt ghost. A tile occupied by a non-belt building or construction site, or otherwise an invalid belt position (REQ-BLD-PLACE-VALID), is drawn in the distinct invalid color, overriding the belt coloring. A tile whose new belt is unaffordable — the cumulative cost of the belts newly placed up to and including it exceeds the global stock — shows **no ghost at all**.
|
||||
- **Placement on release.** No construction site is placed while dragging. On releasing the left mouse button, the path is applied in order (anchor to cursor): each cell occupied by only an existing belt or belt construction site has that belt re-oriented in place to its path direction, consuming no building blocks and preserving any construction progress (REQ-BLD-ROTATE-IN-PLACE); each empty, valid cell gets a new belt construction site, consuming building blocks from the global stock (REQ-BLD-COST). Cells occupied by a non-belt building or construction site, cells that are otherwise invalid (REQ-BLD-PLACE-VALID), and cells whose new belt can no longer be afforded once the running total has been spent are skipped. This supersedes the click-to-place of REQ-BLD-PLACE for belts, including both the single-tile case and multi-tile drags that pass over existing belts.
|
||||
- **Right-click cancels the drag.** Right-clicking while a belt drag is in progress cancels it: the path is discarded, no construction site is placed, and belt builder mode stays active (the exception to REQ-BLD-BUILDER-MODE). Right-clicking when no drag is in progress exits builder mode as usual (REQ-BLD-BUILDER-MODE).
|
||||
- REQ-BLD-TUNNEL-MODE: **Unified tunnel build mode.** The build button grid contains a single **Tunnel** button rather than separate Tunnel Entry and Tunnel Exit buttons (REQ-UI-BUILD-GRID), activated by that button or by hotkey 3 (REQ-UI-HOTKEYS). This one builder mode places either a Tunnel Entry or a Tunnel Exit construction site depending on the hovered position, so the player never manually chooses between the two ends. Both remain distinct building types (REQ-BLD-TUNNEL-ENTRY, REQ-BLD-TUNNEL-EXIT) with their own costs and construction; only their build-menu entry point is unified.
|
||||
- **Default type.** The ghost (REQ-BLD-GHOST) is a **Tunnel Entry** by default; clicking places a Tunnel Entry construction site (REQ-BLD-PLACE). Rotation (REQ-BLD-ROTATE) sets the ghost's facing direction as for any building.
|
||||
- **Exit-completion match.** While the ghost is at a valid position, the game tests whether placing a **Tunnel Exit** at the hovered tile with the current ghost rotation would pair — per the pairing rules of REQ-BLD-TUNNEL-PAIR (same facing direction, within `tunnel_max_distance`, first same-direction building along the search, nearest-claim semantics) — with an existing Tunnel Entry. If so, that Entry is the **exit-completion match** and the ghost turns into a **Tunnel Exit**; clicking then places a Tunnel Exit construction site.
|
||||
- **Entry-completion match.** The game also tests whether placing a **Tunnel Entry** at the hovered tile with the current ghost rotation would pair — again per REQ-BLD-TUNNEL-PAIR — with an existing Tunnel Exit. If so, that Exit is the **entry-completion match** and the ghost stays a Tunnel Entry.
|
||||
- **Resolving the type.** If neither match exists, the ghost is a Tunnel Entry (the default). If only one kind of match exists, the ghost is the kind that produces it (Tunnel Exit for an exit-completion match, Tunnel Entry for an entry-completion match). If **both** an exit-completion match (an existing Entry) and an entry-completion match (an existing Exit) exist, the mode resolves to the completion whose **existing partner building is closer to the mouse cursor position** — the actual sub-tile cursor position, not the hovered tile's center — and the ghost becomes the corresponding type (a Tunnel Exit to complete the nearer Entry, or a Tunnel Entry to complete the nearer Exit). Because the comparison uses the sub-tile cursor position, when the two partners are at the same tile distance the player can move the cursor within the hovered tile to switch which end is placed. When more than one candidate qualifies on a side, the nearest qualifying partner on that side is used.
|
||||
- **Connection preview (green).** Whenever a completion match is resolved, the matched existing partner building is highlighted green, and every tile strictly between that partner and the hovered ghost tile (along the tunnel's straight run) is marked green, previewing the connection that placing the ghost would create.
|
||||
- **Invalid positions.** The completion tests, type switch, and green preview apply only while the hovered position is a valid placement (REQ-BLD-PLACE-VALID). At an invalid position the ordinary invalid-colored ghost is shown (REQ-BLD-GHOST) with no green preview and no switch away from the default Tunnel Entry.
|
||||
- REQ-BLD-DEMOLISH: The player can demolish a placed factory building. Demolition returns `world.toml [world].refund_percentage` percent of the original building block cost (default 75%) to the global stock. Exception: if the building is still in the construction queue (not yet fully built, including the one currently being constructed), it is removed from the queue and the **full** building block cost is refunded. The HQ and player defence stations cannot be demolished.
|
||||
- REQ-BLD-DEMOLISH-CLICK: While in demolish mode (REQ-UI-HOTKEYS, REQ-UI-DEMOLISH-BUTTON), left-clicking a placed factory building or construction site in the game world demolishes it, following the refund rules of REQ-BLD-DEMOLISH — the partial refund for built buildings and the full refund for still-queued construction sites. Clicking a building that cannot be demolished (the HQ or a player defence station, per REQ-BLD-DEMOLISH), or clicking empty world space, has no effect. Demolish mode stays active after a demolition so the player can demolish further buildings without re-entering the mode; it is exited via the Q toggle (REQ-UI-HOTKEYS) or the Demolish button (REQ-UI-DEMOLISH-BUTTON).
|
||||
- REQ-BLD-DEMOLISH-BOX: While in demolish mode (REQ-UI-HOTKEYS, REQ-UI-DEMOLISH-BUTTON), the player can click and drag a selection box in the game world. A selection rectangle is drawn while dragging, using the same box-drag gesture and coverage semantics as the multi-select box (REQ-UI-MULTI-SELECT). On mouse up, every placed factory building and construction site covered by the box is demolished, each following the refund rules of REQ-BLD-DEMOLISH — the partial refund for built buildings and the full refund for still-queued construction sites. Buildings that cannot be demolished (the HQ and player defence stations, per REQ-BLD-DEMOLISH) are excluded from the box demolition; ships and defence stations are never affected.
|
||||
@@ -143,6 +154,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
- Pairing is one-to-one: each Tunnel Entry pairs with at most one Tunnel Exit, and vice versa. A Tunnel Exit is claimed by the nearest Tunnel Entry that can validly reach it; all other entries for which it would otherwise qualify are unpaired.
|
||||
- When one end of a pair is demolished, the pair is dissolved and any items currently in transit are discarded.
|
||||
- REQ-BLD-TUNNEL-TRANSIT: **Tunnel transit.** Items inside a tunnel are not rendered (they travel invisibly). Transit time equals the tile-coordinate distance between entry and exit divided by `world.toml [world].belt_speed_tiles_per_second`, matching the time a chain of belt tiles of equivalent length would take. Multiple items may be in transit simultaneously, spaced as they would be on a belt chain of the same length. Clearing a tunnel entry or exit tile (REQ-UI-BELT-CLEAR) also discards all items currently in transit through that tunnel.
|
||||
- REQ-BLD-TUNNEL-SELECT-HIGHLIGHT: **Selected-tunnel connection highlight.** While a Tunnel Entry or Tunnel Exit — operational building or construction site — is part of the current selection (single selection or multi-selection, REQ-UI-MULTI-SELECT), its tunnel connection is marked green in the game world, using the same `visuals.toml [overlays].tunnel_preview` green as the placement connection preview (REQ-BLD-TUNNEL-MODE). The matching end is found by applying the pairing scan of REQ-BLD-TUNNEL-PAIR over both built tunnels **and** construction-site tunnels (site-inclusive, matching the placement preview): for a selected entry, the first same-direction tunnel within `tunnel_max_distance` along its facing direction, if it is a Tunnel Exit; for a selected exit, the first same-direction tunnel within `tunnel_max_distance` opposite its facing direction, if it is a Tunnel Entry. When a matching end is found, the entry tile, the exit tile, and every tile strictly between them (along the tunnel's straight run) are marked green. A selected tunnel with no matching end shows no green highlight (it still receives the normal selection outline). In multi-selection each selected tunnel end that has a matching end contributes its connection, and a given connection is shown whenever either of its ends is selected. The highlight is presentation-only and has no effect on the simulation.
|
||||
|
||||
## Material Transport & Buffers
|
||||
|
||||
@@ -287,6 +299,8 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
- **Right** (below the grid): The layout blueprint panel (see REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD).
|
||||
- **Bottom**: A "Confirm" button and a "Cancel" button. Cancel discards all changes made in this dialog session and closes the dialog. Confirm applies the changes: the shipyard's configured layout is updated, the required materials and cycle time displayed in the selected building panel are recalculated, and the ship layout preview is refreshed.
|
||||
|
||||
- REQ-MOD-UI-EMPTY-PULSE: While a module is selected for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG), the empty buildable cells of the layout grid pulse smoothly around their normal fill shade, oscillating between a slightly darker and a slightly brighter shade at approximately 1 Hz (one full cycle per second), to draw the player's attention to where the module can be placed. All empty buildable cells pulse in phase. When no module is selected for placement (including remove mode), empty buildable cells render at their normal static shade. Non-buildable cells and cells occupied by a placed module do not pulse.
|
||||
|
||||
- REQ-MOD-UI-AUTO-DIALOG: When the player selects a schematic for a shipyard (operational building or construction site) through the schematic selection dialog (REQ-UI-SELECT-BUTTON), and the chosen schematic **differs** from the shipyard's current schematic, the layout configuration dialog (REQ-MOD-UI-DIALOG) opens automatically and immediately once the selection dialog closes — exactly as if the player had then clicked "Configure". Re-selecting the schematic already set does not reopen the dialog. This auto-open applies only to the manual schematic selection dialog; schematic changes applied via the copy-settings gesture (REQ-BLD-COPY-CONFIG) or blueprint placement (REQ-UI-BLUEPRINT-PLACE) do **not** auto-open the dialog. The player may still cancel the auto-opened dialog (REQ-MOD-UI-DIALOG), which leaves the newly selected schematic in place with its default empty layout; the "Configure" button (REQ-MOD-UI-PREVIEW) remains available to open the dialog again later.
|
||||
|
||||
- REQ-MOD-UI-MODULE-TOOLTIP: Each module selection button in the layout configuration dialog (REQ-MOD-UI-DIALOG) shows a hover tooltip with the descriptive text defined for that module type in `modules.toml` (the optional per-module tooltip field). If a module type defines no tooltip text, its button shows no tooltip. The "Remove" button is not a module type and has no config-defined tooltip.
|
||||
@@ -424,8 +438,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 +459,21 @@ 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-STATUS-LIGHT: Every operational production building — Miner, Smelter, Assembler, Reprocessing Plant, Shipyard, and Salvage Bay — renders a small **status light**: a filled circle with a black outline drawn in the building's upper-right corner, letting the player read a building's production state without selecting it. The light is anchored to the footprint corner that is the upper-right corner in the building's default orientation and rotates with the building — like the output-port glyph (REQ-UI-PORT-GLYPH) — so it stays on the same physical corner of the building as it is rotated. The status light is rendered only for operational buildings; construction sites (which instead show construction progress, REQ-UI-CONSTRUCTION-PROGRESS) and the builder-mode ghost do not render it. Buildings that are not production buildings — belts, splitters, tunnel entries/exits, and the HQ — have no status light. The black outline is constant; the fill color reflects the building's current production state.
|
||||
- For the five production buildings (Miner, Smelter, Assembler, Reprocessing Plant, Shipyard), the fill color is determined by evaluating, in order:
|
||||
- **Grey** — no recipe or schematic is selected. This applies only to buildings with a player-facing selection (Miner, Assembler, Shipyard); the Smelter and Reprocessing Plant always run an implicit recipe (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) and are never grey.
|
||||
- **Green** — the building is currently producing: a production cycle is active (REQ-MAT-CYCLE; for the Shipyard, an in-progress production cycle per REQ-BLD-SHIPYARD).
|
||||
- **Red** — the building is idle because a required input is missing from its input buffers, so it cannot start a cycle. Missing input takes precedence over a full output buffer: if any required input is missing the light is red even when the output buffer is also full.
|
||||
- **Yellow** — the building is idle with all required inputs present but its output buffer full, so no new cycle can start (REQ-MAT-OUTPUT-BUFFER, REQ-MAT-CYCLE).
|
||||
- A configured building that is momentarily idle yet blocked by neither condition (all inputs present and the output buffer has room — a transient state that resolves into a started cycle on the same or the next tick per REQ-MAT-CYCLE) shows green.
|
||||
- The Salvage Bay has no recipe and no production cycle (REQ-BLD-SALVAGE-BAY); its status light uses only two states: **green** while its output buffer holds at least one unit of scrap, and **red** while its output buffer is empty. The Salvage Bay's status light is never grey or yellow.
|
||||
- The four fill colors (grey, green, red, yellow) and the outline color are read from `visuals.toml [status_light]`, consistent with the other rendering-only colors. The status light is presentation-only and has no effect on the simulation.
|
||||
- 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.)
|
||||
@@ -454,7 +481,7 @@ The screen is divided into two columns: a main column (75% width) containing the
|
||||
- **T** — create a temporary blueprint from the current selection and enter its placement mode (REQ-UI-BLUEPRINT-TEMP).
|
||||
- **Escape** — opens the escape menu (REQ-UI-GAME-MENU).
|
||||
- **Build mode selection** — pressing a build hotkey activates builder mode for the corresponding building type, equivalent to clicking its build button (REQ-BLD-BUILDER-MODE):
|
||||
- **1** — Belt, **2** — Splitter, **3** — Tunnel Entry, **4** — Tunnel Exit.
|
||||
- **1** — Belt, **2** — Splitter, **3** — Tunnel (the unified tunnel build mode, REQ-BLD-TUNNEL-MODE). Hotkey 4 is unused.
|
||||
- **Shift+1** — Miner, **Shift+2** — Smelter, **Shift+3** — Assembler, **Shift+4** — Shipyard, **Shift+5** — Salvage Bay, **Shift+6** — Reprocessing Plant.
|
||||
|
||||
### Debug Draw
|
||||
@@ -479,9 +506,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 +517,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,14 +527,15 @@ 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
|
||||
|
||||
- REQ-UI-BUILD-GRID: All placeable building types are shown as a flat grid of buttons with no grouping.
|
||||
- REQ-UI-BUILD-GRID: All placeable building types are shown as a flat grid of buttons with no grouping. Tunnel Entry and Tunnel Exit share a single **Tunnel** button (REQ-BLD-TUNNEL-MODE) rather than one button each.
|
||||
- REQ-UI-BUILD-COST: Each button caption shows the building name and its building block cost, e.g. "Belt: 2 Blocks".
|
||||
- REQ-UI-BUILD-TOOLTIP: Each building-type button shows a hover tooltip with the descriptive text defined for that building type in `buildings.toml` (the optional per-building tooltip field). This tooltip is distinct from the recipe/schematic selection tooltip (REQ-UI-SELECT-TOOLTIP). If a building type defines no tooltip text, its button shows no tooltip. The Demolish button (REQ-UI-DEMOLISH-BUTTON) is not a building type and has no config-defined tooltip.
|
||||
- REQ-UI-BUILD-DISABLED: Buttons for buildings the player cannot currently afford are shown as disabled.
|
||||
@@ -560,7 +589,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 +606,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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
94
src/lib/core/BeltDragPath.cpp
Normal file
94
src/lib/core/BeltDragPath.cpp
Normal file
@@ -0,0 +1,94 @@
|
||||
#include "BeltDragPath.h"
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace
|
||||
{
|
||||
int signOf(int value)
|
||||
{
|
||||
if (value > 0) { return 1; }
|
||||
if (value < 0) { return -1; }
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Direction stepping from one tile to an orthogonally adjacent tile.
|
||||
Rotation directionBetween(QPoint from, QPoint to)
|
||||
{
|
||||
const QPoint delta = to - from;
|
||||
if (delta.x() > 0) { return Rotation::East; }
|
||||
if (delta.x() < 0) { return Rotation::West; }
|
||||
if (delta.y() > 0) { return Rotation::South; }
|
||||
return Rotation::North;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<BeltPathTile> computeBeltDragPath(QPoint anchor, QPoint cursor,
|
||||
Rotation orientation)
|
||||
{
|
||||
const bool horizontalFirst =
|
||||
(orientation == Rotation::East || orientation == Rotation::West);
|
||||
|
||||
// Build the ordered tile coordinates: first leg along the primary axis to the
|
||||
// corner, then the orthogonal leg to the cursor (no duplicated corner tile).
|
||||
std::vector<QPoint> coords;
|
||||
if (horizontalFirst)
|
||||
{
|
||||
const int stepX = signOf(cursor.x() - anchor.x());
|
||||
for (int x = anchor.x(); ; x += stepX)
|
||||
{
|
||||
coords.push_back(QPoint(x, anchor.y()));
|
||||
if (x == cursor.x() || stepX == 0) { break; }
|
||||
}
|
||||
const int stepY = signOf(cursor.y() - anchor.y());
|
||||
if (stepY != 0)
|
||||
{
|
||||
for (int y = anchor.y() + stepY; ; y += stepY)
|
||||
{
|
||||
coords.push_back(QPoint(cursor.x(), y));
|
||||
if (y == cursor.y()) { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
const int stepY = signOf(cursor.y() - anchor.y());
|
||||
for (int y = anchor.y(); ; y += stepY)
|
||||
{
|
||||
coords.push_back(QPoint(anchor.x(), y));
|
||||
if (y == cursor.y() || stepY == 0) { break; }
|
||||
}
|
||||
const int stepX = signOf(cursor.x() - anchor.x());
|
||||
if (stepX != 0)
|
||||
{
|
||||
for (int x = anchor.x() + stepX; ; x += stepX)
|
||||
{
|
||||
coords.push_back(QPoint(x, cursor.y()));
|
||||
if (x == cursor.x()) { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assign each tile the direction toward the next tile; the last tile keeps its
|
||||
// incoming step direction, and a single-tile path keeps the belt orientation.
|
||||
std::vector<BeltPathTile> path;
|
||||
path.reserve(coords.size());
|
||||
const std::size_t count = coords.size();
|
||||
for (std::size_t index = 0; index < count; ++index)
|
||||
{
|
||||
Rotation rotation;
|
||||
if (count == 1)
|
||||
{
|
||||
rotation = orientation;
|
||||
}
|
||||
else if (index + 1 < count)
|
||||
{
|
||||
rotation = directionBetween(coords[index], coords[index + 1]);
|
||||
}
|
||||
else
|
||||
{
|
||||
rotation = directionBetween(coords[index - 1], coords[index]);
|
||||
}
|
||||
path.push_back(BeltPathTile{ coords[index], rotation });
|
||||
}
|
||||
return path;
|
||||
}
|
||||
27
src/lib/core/BeltDragPath.h
Normal file
27
src/lib/core/BeltDragPath.h
Normal file
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include <QPoint>
|
||||
|
||||
#include "Rotation.h"
|
||||
|
||||
// One tile of a belt drag-placement path: the tile coordinate and the belt
|
||||
// orientation it should be given (REQ-BLD-BELT-DRAG).
|
||||
struct BeltPathTile
|
||||
{
|
||||
QPoint tile;
|
||||
Rotation rotation;
|
||||
};
|
||||
|
||||
// Computes the rectilinear (L-shaped) belt path from `anchor` to `cursor` for a
|
||||
// belt whose current orientation is `orientation` (REQ-BLD-BELT-DRAG). The path
|
||||
// first runs along the axis parallel to `orientation` (horizontal for East/West,
|
||||
// vertical for North/South), stepping toward the cursor's coordinate on that axis
|
||||
// to the corner tile, then runs along the orthogonal axis to the cursor tile. Each
|
||||
// tile is oriented to point toward the next tile along the path; the final tile
|
||||
// keeps the direction of its incoming step, and a single-tile path keeps
|
||||
// `orientation`. Returned tiles are ordered from anchor to cursor with no duplicate
|
||||
// corner tile.
|
||||
std::vector<BeltPathTile> computeBeltDragPath(QPoint anchor, QPoint cursor,
|
||||
Rotation orientation);
|
||||
@@ -11,6 +11,8 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/Port.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceOption.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.h
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
@@ -19,6 +21,8 @@ SET(SRCS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingType.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/EntityAdmin.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.cpp
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
|
||||
155
src/lib/core/TunnelCompletion.cpp
Normal file
155
src/lib/core/TunnelCompletion.cpp
Normal file
@@ -0,0 +1,155 @@
|
||||
#include "TunnelCompletion.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
QPoint stepTile(QPoint tile, Rotation dir)
|
||||
{
|
||||
switch (dir)
|
||||
{
|
||||
case Rotation::North: return {tile.x(), tile.y() - 1};
|
||||
case Rotation::East: return {tile.x() + 1, tile.y() };
|
||||
case Rotation::South: return {tile.x(), tile.y() + 1};
|
||||
case Rotation::West: return {tile.x() - 1, tile.y() };
|
||||
}
|
||||
return tile;
|
||||
}
|
||||
|
||||
Rotation oppositeRotation(Rotation dir)
|
||||
{
|
||||
switch (dir)
|
||||
{
|
||||
case Rotation::North: return Rotation::South;
|
||||
case Rotation::East: return Rotation::West;
|
||||
case Rotation::South: return Rotation::North;
|
||||
case Rotation::West: return Rotation::East;
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
float tileCenterDistanceSq(QPoint tile, QVector2D cursorWorldPos)
|
||||
{
|
||||
const QVector2D center(static_cast<float>(tile.x()) + 0.5f,
|
||||
static_cast<float>(tile.y()) + 0.5f);
|
||||
return (center - cursorWorldPos).lengthSquared();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<QPoint> firstTunnelFacing(const TunnelLookup& lookup, QPoint start,
|
||||
Rotation stepDir, Rotation targetFacing,
|
||||
int maxDistance)
|
||||
{
|
||||
QPoint probe = start;
|
||||
for (int distance = 1; distance <= maxDistance; ++distance)
|
||||
{
|
||||
probe = stepTile(probe, stepDir);
|
||||
const std::optional<TunnelTileInfo> info = lookup(probe);
|
||||
if (info.has_value() && info->rotation == targetFacing)
|
||||
{
|
||||
return probe;
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
TunnelCompletion resolveTunnelCompletion(const TunnelLookup& lookup, QPoint hoverTile,
|
||||
Rotation rotation, int maxDistance,
|
||||
QVector2D cursorWorldPos)
|
||||
{
|
||||
// Entry-completion: a hypothetical entry at the hovered tile searches ahead (its
|
||||
// facing direction) for a same-direction exit to pair with. The first
|
||||
// same-direction tunnel ahead completes the entry only if it is an exit.
|
||||
std::optional<QPoint> exitPartner;
|
||||
if (const std::optional<QPoint> ahead =
|
||||
firstTunnelFacing(lookup, hoverTile, rotation, rotation, maxDistance);
|
||||
ahead.has_value())
|
||||
{
|
||||
const std::optional<TunnelTileInfo> info = lookup(*ahead);
|
||||
if (info.has_value() && info->type == BuildingType::TunnelExit)
|
||||
{
|
||||
exitPartner = ahead;
|
||||
}
|
||||
}
|
||||
|
||||
// Exit-completion: a hypothetical exit at the hovered tile pairs with an existing
|
||||
// entry behind it — one whose forward search (its facing direction) reaches the
|
||||
// hovered tile as its first same-direction tunnel. Scanning backwards, the first
|
||||
// same-direction tunnel completes the exit only if it is an entry.
|
||||
std::optional<QPoint> entryPartner;
|
||||
if (const std::optional<QPoint> behind =
|
||||
firstTunnelFacing(lookup, hoverTile, oppositeRotation(rotation), rotation,
|
||||
maxDistance);
|
||||
behind.has_value())
|
||||
{
|
||||
const std::optional<TunnelTileInfo> info = lookup(*behind);
|
||||
if (info.has_value() && info->type == BuildingType::TunnelEntry)
|
||||
{
|
||||
entryPartner = behind;
|
||||
}
|
||||
}
|
||||
|
||||
// Default: a TunnelEntry with no partner.
|
||||
if (!entryPartner.has_value() && !exitPartner.has_value())
|
||||
{
|
||||
return TunnelCompletion{BuildingType::TunnelEntry, std::nullopt};
|
||||
}
|
||||
if (entryPartner.has_value() && !exitPartner.has_value())
|
||||
{
|
||||
return TunnelCompletion{BuildingType::TunnelExit, entryPartner};
|
||||
}
|
||||
if (exitPartner.has_value() && !entryPartner.has_value())
|
||||
{
|
||||
return TunnelCompletion{BuildingType::TunnelEntry, exitPartner};
|
||||
}
|
||||
|
||||
// Both apply: complete whichever existing partner is closer to the sub-tile
|
||||
// cursor position (REQ-BLD-TUNNEL-MODE).
|
||||
const float entryDistanceSq = tileCenterDistanceSq(*entryPartner, cursorWorldPos);
|
||||
const float exitDistanceSq = tileCenterDistanceSq(*exitPartner, cursorWorldPos);
|
||||
if (entryDistanceSq <= exitDistanceSq)
|
||||
{
|
||||
return TunnelCompletion{BuildingType::TunnelExit, entryPartner};
|
||||
}
|
||||
return TunnelCompletion{BuildingType::TunnelEntry, exitPartner};
|
||||
}
|
||||
|
||||
std::optional<QPoint> findTunnelPartner(const TunnelLookup& lookup, QPoint tile,
|
||||
BuildingType type, Rotation rotation,
|
||||
int maxDistance)
|
||||
{
|
||||
if (type == BuildingType::TunnelEntry)
|
||||
{
|
||||
// An entry pairs with the first same-direction tunnel ahead, if it is an exit.
|
||||
const std::optional<QPoint> ahead =
|
||||
firstTunnelFacing(lookup, tile, rotation, rotation, maxDistance);
|
||||
if (ahead.has_value())
|
||||
{
|
||||
const std::optional<TunnelTileInfo> info = lookup(*ahead);
|
||||
if (info.has_value() && info->type == BuildingType::TunnelExit)
|
||||
{
|
||||
return ahead;
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
if (type == BuildingType::TunnelExit)
|
||||
{
|
||||
// An exit is claimed by the first same-direction tunnel behind it, if it is an
|
||||
// entry (its forward search reaches this exit as its first same-direction tunnel).
|
||||
const std::optional<QPoint> behind =
|
||||
firstTunnelFacing(lookup, tile, oppositeRotation(rotation), rotation, maxDistance);
|
||||
if (behind.has_value())
|
||||
{
|
||||
const std::optional<TunnelTileInfo> info = lookup(*behind);
|
||||
if (info.has_value() && info->type == BuildingType::TunnelEntry)
|
||||
{
|
||||
return behind;
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return std::nullopt;
|
||||
}
|
||||
66
src/lib/core/TunnelCompletion.h
Normal file
66
src/lib/core/TunnelCompletion.h
Normal file
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
|
||||
#include <QPoint>
|
||||
#include <QVector2D>
|
||||
|
||||
#include "BuildingType.h"
|
||||
#include "Rotation.h"
|
||||
|
||||
// A tunnel building occupying a single tile: whether it is an entry or an exit and
|
||||
// the direction it faces. Used by the tunnel pairing scan (REQ-BLD-TUNNEL-PAIR) and
|
||||
// the unified tunnel build mode (REQ-BLD-TUNNEL-MODE).
|
||||
struct TunnelTileInfo
|
||||
{
|
||||
BuildingType type; // TunnelEntry or TunnelExit
|
||||
Rotation rotation; // facing direction
|
||||
};
|
||||
|
||||
// Given a tile, returns the tunnel building on it (entry or exit) with its facing
|
||||
// direction, or std::nullopt when the tile holds no tunnel building.
|
||||
using TunnelLookup = std::function<std::optional<TunnelTileInfo>(QPoint)>;
|
||||
|
||||
// Steps from `start` in `stepDir` over the tiles at distance 1..maxDistance and
|
||||
// returns the first tile whose tunnel faces `targetFacing`. Tunnel buildings facing
|
||||
// any other direction are skipped, mirroring the "stop at the first same-direction
|
||||
// tunnel" rule of REQ-BLD-TUNNEL-PAIR. Returns std::nullopt if none is found in range.
|
||||
std::optional<QPoint> firstTunnelFacing(const TunnelLookup& lookup, QPoint start,
|
||||
Rotation stepDir, Rotation targetFacing,
|
||||
int maxDistance);
|
||||
|
||||
// Result of resolving which tunnel end the ghost should become at a hovered tile
|
||||
// (REQ-BLD-TUNNEL-MODE): the type to place and the existing tunnel it would complete.
|
||||
struct TunnelCompletion
|
||||
{
|
||||
BuildingType resolvedType; // TunnelEntry (default) or TunnelExit
|
||||
std::optional<QPoint> partnerTile; // matched existing tunnel, if any
|
||||
};
|
||||
|
||||
// Resolves the tunnel ghost type for a hover at `hoverTile` with the ghost facing
|
||||
// `rotation` (REQ-BLD-TUNNEL-MODE):
|
||||
// - exit-completion: placing an exit here would pair with an existing entry behind
|
||||
// it (the entry's forward search reaches this tile as its first same-direction
|
||||
// tunnel) — the ghost becomes a TunnelExit;
|
||||
// - entry-completion: placing an entry here would pair with an existing exit ahead
|
||||
// of it — the ghost stays a TunnelEntry.
|
||||
// When both apply, the end whose existing partner is closer to `cursorWorldPos` (the
|
||||
// sub-tile cursor position in world tile units) wins, so nudging the cursor within a
|
||||
// single tile can flip the target. With no match the ghost stays a TunnelEntry with
|
||||
// no partner.
|
||||
TunnelCompletion resolveTunnelCompletion(const TunnelLookup& lookup, QPoint hoverTile,
|
||||
Rotation rotation, int maxDistance,
|
||||
QVector2D cursorWorldPos);
|
||||
|
||||
// Finds the matching end of an existing tunnel building at `tile` of the given `type`
|
||||
// (TunnelEntry or TunnelExit) facing `rotation`, applying the pairing scan of
|
||||
// REQ-BLD-TUNNEL-PAIR over `lookup`:
|
||||
// - for an entry, the first same-direction tunnel along its facing direction, if it
|
||||
// is an exit;
|
||||
// - for an exit, the first same-direction tunnel opposite its facing direction, if it
|
||||
// is an entry.
|
||||
// Returns the partner tile, or std::nullopt when the tunnel is unpaired.
|
||||
std::optional<QPoint> findTunnelPartner(const TunnelLookup& lookup, QPoint tile,
|
||||
BuildingType type, Rotation rotation,
|
||||
int maxDistance);
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
25
src/lib/eventsystem/event/EntitySelectionChangedEvent.h
Normal file
25
src/lib/eventsystem/event/EntitySelectionChangedEvent.h
Normal 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
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include "StateChecksum.h"
|
||||
#include "Tick.h"
|
||||
#include "TunnelCompletion.h"
|
||||
#include "tracing.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -177,55 +178,57 @@ void BeltSystem::reevaluateTunnelPairing()
|
||||
std::vector<TunnelLink> oldLinks;
|
||||
std::swap(oldLinks, m_tunnelLinks);
|
||||
|
||||
// Tunnel index over this system's own (completed) tunnel tiles, shared with the
|
||||
// scan primitive so the pairing rule lives in one place (REQ-BLD-TUNNEL-PAIR).
|
||||
const TunnelLookup lookup = [this](QPoint tile) -> std::optional<TunnelTileInfo>
|
||||
{
|
||||
const std::map<std::pair<int, int>, TunnelEntryTile>::const_iterator teIt =
|
||||
m_tunnelEntries.find(key(tile));
|
||||
if (teIt != m_tunnelEntries.end())
|
||||
{
|
||||
return TunnelTileInfo{BuildingType::TunnelEntry, teIt->second.direction};
|
||||
}
|
||||
const std::map<std::pair<int, int>, TunnelExitTile>::const_iterator txIt =
|
||||
m_tunnelExits.find(key(tile));
|
||||
if (txIt != m_tunnelExits.end())
|
||||
{
|
||||
return TunnelTileInfo{BuildingType::TunnelExit, txIt->second.direction};
|
||||
}
|
||||
return std::nullopt;
|
||||
};
|
||||
|
||||
for (const std::pair<const std::pair<int, int>, TunnelEntryTile>& entry : m_tunnelEntries)
|
||||
{
|
||||
const QPoint entryPos(entry.first.first, entry.first.second);
|
||||
const Rotation dir = entry.second.direction;
|
||||
const int maxDist = entry.second.maxDistance;
|
||||
|
||||
for (int d = 1; d <= maxDist; ++d)
|
||||
// The first same-direction tunnel ahead forms a pair only when it is an exit;
|
||||
// a same-direction entry blocks (firstTunnelFacing stops at it either way).
|
||||
const std::optional<QPoint> target =
|
||||
firstTunnelFacing(lookup, entryPos, dir, dir, maxDist);
|
||||
if (!target.has_value() || m_tunnelExits.find(key(*target)) == m_tunnelExits.end())
|
||||
{
|
||||
QPoint probe = entryPos;
|
||||
for (int step = 0; step < d; ++step)
|
||||
{
|
||||
probe = adjacentTile(probe, dir);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if a same-direction tunnel entry is here (blocks pairing)
|
||||
const std::map<std::pair<int, int>, TunnelEntryTile>::const_iterator teIt =
|
||||
m_tunnelEntries.find(key(probe));
|
||||
if (teIt != m_tunnelEntries.end() && teIt->second.direction == dir)
|
||||
TunnelLink link;
|
||||
link.entryTile = entryPos;
|
||||
link.exitTile = *target;
|
||||
// The exit is colinear with the entry along `dir`, so the tile-coordinate
|
||||
// distance is the Manhattan distance.
|
||||
link.length = static_cast<double>((*target - entryPos).manhattanLength());
|
||||
|
||||
for (const TunnelLink& old : oldLinks)
|
||||
{
|
||||
if (old.entryTile == entryPos && old.exitTile == *target)
|
||||
{
|
||||
link.items = old.items;
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if a same-direction tunnel exit is here (forms pair)
|
||||
const std::map<std::pair<int, int>, TunnelExitTile>::const_iterator txIt =
|
||||
m_tunnelExits.find(key(probe));
|
||||
if (txIt != m_tunnelExits.end())
|
||||
{
|
||||
if (txIt->second.direction == dir)
|
||||
{
|
||||
TunnelLink link;
|
||||
link.entryTile = entryPos;
|
||||
link.exitTile = probe;
|
||||
link.length = static_cast<double>(d);
|
||||
|
||||
for (const TunnelLink& old : oldLinks)
|
||||
{
|
||||
if (old.entryTile == entryPos && old.exitTile == probe)
|
||||
{
|
||||
link.items = old.items;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
m_tunnelLinks.push_back(std::move(link));
|
||||
break;
|
||||
}
|
||||
// Different direction exit — skip, keep searching
|
||||
}
|
||||
}
|
||||
|
||||
m_tunnelLinks.push_back(std::move(link));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -267,16 +267,23 @@ void BuildingSystem::initSalvageBayBuffer(Building& b) const
|
||||
}
|
||||
|
||||
std::vector<Port> BuildingSystem::computeInputPorts(const Building& b) const
|
||||
{
|
||||
return computeInputPorts(b.bodyCells, b.outputPorts);
|
||||
}
|
||||
|
||||
std::vector<Port> BuildingSystem::computeInputPorts(
|
||||
const std::vector<QPoint>& bodyCells,
|
||||
const std::vector<Port>& outputPorts) const
|
||||
{
|
||||
// Build lookup sets for quick membership checks.
|
||||
std::set<std::pair<int, int>> bodySet;
|
||||
for (const QPoint& cell : b.bodyCells)
|
||||
for (const QPoint& cell : bodyCells)
|
||||
{
|
||||
bodySet.insert({cell.x(), cell.y()});
|
||||
}
|
||||
|
||||
std::set<std::pair<int, int>> outputPortTiles;
|
||||
for (const Port& port : b.outputPorts)
|
||||
for (const Port& port : outputPorts)
|
||||
{
|
||||
outputPortTiles.insert({port.tile.x(), port.tile.y()});
|
||||
}
|
||||
@@ -294,7 +301,7 @@ std::vector<Port> BuildingSystem::computeInputPorts(const Building& b) const
|
||||
std::set<std::pair<int, int>> seen;
|
||||
std::vector<Port> inputPorts;
|
||||
|
||||
for (const QPoint& cell : b.bodyCells)
|
||||
for (const QPoint& cell : bodyCells)
|
||||
{
|
||||
for (int i = 0; i < 4; ++i)
|
||||
{
|
||||
@@ -317,6 +324,30 @@ std::vector<Port> BuildingSystem::computeInputPorts(const Building& b) const
|
||||
return inputPorts;
|
||||
}
|
||||
|
||||
std::vector<Port> BuildingSystem::getInputPorts(BuildingId id) const
|
||||
{
|
||||
if (const Building* building = findBuilding(id))
|
||||
{
|
||||
return building->inputPorts;
|
||||
}
|
||||
if (const ConstructionSite* site = findSite(id))
|
||||
{
|
||||
// A site stores no ports; derive its output ports from the mask (absolute)
|
||||
// and run the same input-edge scan (REQ-BLD-BELT-DRAG, REQ-MAT-INPUT-PORTS).
|
||||
const BuildingDef* def = findBuildingDef(site->type);
|
||||
if (def == nullptr) { return {}; }
|
||||
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, site->rotation);
|
||||
std::vector<Port> outputPortsAbsolute;
|
||||
outputPortsAbsolute.reserve(mask.outputPorts.size());
|
||||
for (const Port& port : mask.outputPorts)
|
||||
{
|
||||
outputPortsAbsolute.push_back(Port{ site->anchor + port.tile, port.direction });
|
||||
}
|
||||
return computeInputPorts(site->bodyCells, outputPortsAbsolute);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::vector<Item> BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe)
|
||||
{
|
||||
std::vector<const RecipeOutput*> eligible;
|
||||
@@ -346,7 +377,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 +387,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();
|
||||
@@ -951,43 +982,13 @@ void BuildingSystem::tickProduction(Tick currentTick)
|
||||
// recipe of their type in config order, running the first whose inputs
|
||||
// are satisfied (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING). Other buildings
|
||||
// try only their selected recipe.
|
||||
std::vector<const RecipeDef*> candidates;
|
||||
if (autoRecipe)
|
||||
{
|
||||
for (const RecipeDef& r : m_config.recipes.recipes)
|
||||
{
|
||||
if (r.building == building.type && !r.inputs.empty())
|
||||
{
|
||||
candidates.push_back(&r);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
const RecipeDef* recipe = findRecipe(building.recipeId, building.type);
|
||||
if (recipe)
|
||||
{
|
||||
candidates.push_back(recipe);
|
||||
}
|
||||
}
|
||||
const std::vector<const RecipeDef*> candidates =
|
||||
gatherCandidateRecipes(building);
|
||||
|
||||
for (const RecipeDef* recipe : candidates)
|
||||
{
|
||||
// 1. All required inputs present?
|
||||
bool inputsOk = true;
|
||||
for (const RecipeIngredient& ing : recipe->inputs)
|
||||
{
|
||||
const ItemType type{ing.item};
|
||||
const std::map<ItemType, int>::const_iterator it =
|
||||
building.inputBuffer.counts.find(type);
|
||||
const int have = (it != building.inputBuffer.counts.end()) ? it->second : 0;
|
||||
if (have < ing.amount)
|
||||
{
|
||||
inputsOk = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!inputsOk)
|
||||
if (!recipeInputsAvailable(building, *recipe))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -1014,7 +1015,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)
|
||||
{
|
||||
@@ -1082,26 +1083,8 @@ void BuildingSystem::tickShipyardProduction(Tick currentTick)
|
||||
}
|
||||
|
||||
// Build combined materials list (base + modules).
|
||||
std::map<std::string, int> requiredMaterials;
|
||||
for (const RecipeIngredient& ing : shipDef->schematic.materials)
|
||||
{
|
||||
requiredMaterials[ing.item] += ing.amount;
|
||||
}
|
||||
if (building.shipLayout.has_value())
|
||||
{
|
||||
for (const PlacedModule& pm : building.shipLayout->placedModules)
|
||||
{
|
||||
const ModuleDef* modDef = findModuleDef(pm.moduleId);
|
||||
if (!modDef)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for (const RecipeIngredient& ing : modDef->materials)
|
||||
{
|
||||
requiredMaterials[ing.item] += ing.amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
const std::map<std::string, int> requiredMaterials =
|
||||
computeShipyardRequiredMaterials(building);
|
||||
|
||||
// Idle: check if all combined materials are available.
|
||||
bool inputsOk = true;
|
||||
@@ -1279,12 +1262,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 +1291,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 +1301,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 +1311,148 @@ int BuildingSystem::activeProductionBuildingCount() const
|
||||
return count;
|
||||
}
|
||||
|
||||
std::vector<BuildingSystem::BeltTileInfo> BuildingSystem::allBeltTiles() const
|
||||
std::vector<const RecipeDef*>
|
||||
BuildingSystem::gatherCandidateRecipes(const Building& b) const
|
||||
{
|
||||
std::vector<const RecipeDef*> candidates;
|
||||
if (isAutoRecipeBuildingType(b.type))
|
||||
{
|
||||
for (const RecipeDef& r : m_config.recipes.recipes)
|
||||
{
|
||||
if (r.building == b.type && !r.inputs.empty())
|
||||
{
|
||||
candidates.push_back(&r);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
const RecipeDef* recipe = findRecipe(b.recipeId, b.type);
|
||||
if (recipe)
|
||||
{
|
||||
candidates.push_back(recipe);
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
bool BuildingSystem::recipeInputsAvailable(const Building& b,
|
||||
const RecipeDef& recipe) const
|
||||
{
|
||||
for (const RecipeIngredient& ing : recipe.inputs)
|
||||
{
|
||||
const std::map<ItemType, int>::const_iterator it =
|
||||
b.inputBuffer.counts.find(ItemType{ing.item});
|
||||
const int have = (it != b.inputBuffer.counts.end()) ? it->second : 0;
|
||||
if (have < ing.amount)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::map<std::string, int>
|
||||
BuildingSystem::computeShipyardRequiredMaterials(const Building& b) const
|
||||
{
|
||||
std::map<std::string, int> requiredMaterials;
|
||||
const ShipDef* shipDef = findShipDef(b.recipeId);
|
||||
if (!shipDef)
|
||||
{
|
||||
return requiredMaterials;
|
||||
}
|
||||
for (const RecipeIngredient& ing : shipDef->schematic.materials)
|
||||
{
|
||||
requiredMaterials[ing.item] += ing.amount;
|
||||
}
|
||||
if (b.shipLayout.has_value())
|
||||
{
|
||||
for (const PlacedModule& pm : b.shipLayout->placedModules)
|
||||
{
|
||||
const ModuleDef* modDef = findModuleDef(pm.moduleId);
|
||||
if (!modDef)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for (const RecipeIngredient& ing : modDef->materials)
|
||||
{
|
||||
requiredMaterials[ing.item] += ing.amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
return requiredMaterials;
|
||||
}
|
||||
|
||||
bool BuildingSystem::hasInputsToStart(const Building& b) const
|
||||
{
|
||||
if (b.type == BuildingType::Shipyard)
|
||||
{
|
||||
const std::map<std::string, int> required =
|
||||
computeShipyardRequiredMaterials(b);
|
||||
for (const std::pair<const std::string, int>& req : required)
|
||||
{
|
||||
const std::map<ItemType, int>::const_iterator it =
|
||||
b.inputBuffer.counts.find(ItemType{req.first});
|
||||
const int have = (it != b.inputBuffer.counts.end()) ? it->second : 0;
|
||||
if (have < req.second)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Recipe buildings: startable if any candidate recipe's inputs are satisfied.
|
||||
// A Miner recipe has no inputs, so an idle Miner is always startable and its
|
||||
// only idle reason is a full output buffer.
|
||||
for (const RecipeDef* recipe : gatherCandidateRecipes(b))
|
||||
{
|
||||
if (recipeInputsAvailable(b, *recipe))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::optional<ProductionStatus>
|
||||
BuildingSystem::getProductionStatus(const Building& building) const
|
||||
{
|
||||
// Salvage Bay has no recipe or production cycle (REQ-BLD-SALVAGE-BAY): it is
|
||||
// "producing" while it holds scrap to push out, and starved when empty.
|
||||
if (building.type == BuildingType::SalvageBay)
|
||||
{
|
||||
return building.getOutputItemCount() >= 1 ? ProductionStatus::Producing
|
||||
: ProductionStatus::Starved;
|
||||
}
|
||||
|
||||
// Only the five recipe/cycle production types show a status light besides the
|
||||
// Salvage Bay; belts, splitters, tunnels, HQ, and stations show none.
|
||||
if (!isProductionBuildingType(building.type))
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// Grey only applies to player-configured types; auto-recipe buildings
|
||||
// (Smelter, Reprocessing Plant) always run an implicit recipe.
|
||||
if (!isAutoRecipeBuildingType(building.type) && building.recipeId.empty())
|
||||
{
|
||||
return ProductionStatus::Unconfigured;
|
||||
}
|
||||
|
||||
if (building.production.has_value())
|
||||
{
|
||||
return ProductionStatus::Producing;
|
||||
}
|
||||
|
||||
// Idle: missing inputs (red) take precedence over a full output buffer
|
||||
// (yellow). If inputs are present yet the building is idle, the only remaining
|
||||
// reason it could not start a cycle is a full output buffer (REQ-MAT-CYCLE).
|
||||
return hasInputsToStart(building) ? ProductionStatus::Blocked
|
||||
: ProductionStatus::Starved;
|
||||
}
|
||||
|
||||
std::vector<BuildingSystem::BeltTileInfo> BuildingSystem::getAllBeltTiles() const
|
||||
{
|
||||
std::vector<BeltTileInfo> result;
|
||||
for (const Building& b : m_buildings)
|
||||
@@ -1368,6 +1492,13 @@ bool BuildingSystem::isTileOccupied(QPoint tile) const
|
||||
std::optional<BuildingId> BuildingSystem::findRotateInPlaceTarget(
|
||||
BuildingType type, QPoint anchor, Rotation rot) const
|
||||
{
|
||||
// Tunnel Entries and Tunnel Exits cannot be rotated in place; re-orienting a
|
||||
// tunnel requires demolishing and re-placing it (REQ-BLD-ROTATE-IN-PLACE).
|
||||
if (type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const BuildingDef* def = findBuildingDef(type);
|
||||
if (!def) { return std::nullopt; }
|
||||
|
||||
@@ -1518,7 +1649,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;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,18 @@
|
||||
|
||||
class Hasher;
|
||||
|
||||
// Production state of a building for the UI status light (REQ-UI-STATUS-LIGHT).
|
||||
// The simulation owns the classification so it stays in sync with the
|
||||
// production-cycle predicates (REQ-MAT-CYCLE); the UI maps each value to a fill
|
||||
// color.
|
||||
enum class ProductionStatus
|
||||
{
|
||||
Unconfigured, // no recipe/schematic selected (grey)
|
||||
Producing, // a production cycle is active (green)
|
||||
Starved, // idle: a required input is missing / Salvage Bay empty (red)
|
||||
Blocked, // idle: output buffer full, inputs otherwise present (yellow)
|
||||
};
|
||||
|
||||
// Manages building placement, construction queuing, and the per-tick
|
||||
// production loop (belt→building pull, production, building→belt push).
|
||||
// All types including Belt and Splitter are stored as Building instances;
|
||||
@@ -43,13 +55,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 +124,23 @@ 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;
|
||||
|
||||
// Production state for the UI status light (REQ-UI-STATUS-LIGHT). Returns
|
||||
// nullopt for building types that show no light (belts, splitters, tunnels,
|
||||
// HQ, defence stations). The Salvage Bay is a two-state special case:
|
||||
// Producing while its output buffer holds scrap, Starved when empty.
|
||||
std::optional<ProductionStatus> getProductionStatus(const Building& building) const;
|
||||
std::vector<BeltTileInfo> getAllBeltTiles() const;
|
||||
bool isTileOccupied(QPoint tile) const;
|
||||
|
||||
// Visits every item currently emerging from a building output port on its
|
||||
@@ -153,6 +171,12 @@ public:
|
||||
// Find nearest operational building of the given type; nullptr if none.
|
||||
const Building* findNearestBuilding(QVector2D worldPos, BuildingType type) const;
|
||||
|
||||
// Input-capable adjacent tiles for a building or construction site
|
||||
// (REQ-BLD-BELT-DRAG, REQ-MAT-INPUT-PORTS): each returned Port.tile is the
|
||||
// outside adjacent tile and Port.direction is the belt facing that points into
|
||||
// the target. Output-port edges are excluded. Empty for an unknown id.
|
||||
std::vector<Port> getInputPorts(BuildingId id) const;
|
||||
|
||||
// Register / unregister tile occupancy for ECS station entities.
|
||||
void registerTileOccupancy(const std::vector<QPoint>& cells, BuildingId ownerPlaceholder);
|
||||
void unregisterTileOccupancy(const std::vector<QPoint>& cells);
|
||||
@@ -200,6 +224,22 @@ private:
|
||||
const Port& outputPort,
|
||||
const Item& item);
|
||||
|
||||
// Candidate recipes an idle building would try this tick: an auto-recipe
|
||||
// building (Smelter, Reprocessing Plant) offers every recipe of its type with
|
||||
// inputs; other buildings offer only their selected recipe. Shared by
|
||||
// tickProduction and the status classifier (REQ-MAT-CYCLE, REQ-UI-STATUS-LIGHT).
|
||||
std::vector<const RecipeDef*> gatherCandidateRecipes(const Building& b) const;
|
||||
// True if every input of `recipe` is present in `b`'s input buffers in the
|
||||
// required per-cycle amount (REQ-MAT-CYCLE input check).
|
||||
bool recipeInputsAvailable(const Building& b,
|
||||
const RecipeDef& recipe) const;
|
||||
// Combined base + module materials a shipyard needs per ship (REQ-BLD-SHIPYARD).
|
||||
std::map<std::string, int> computeShipyardRequiredMaterials(const Building& b) const;
|
||||
// True if the building currently has all inputs/materials to start a cycle
|
||||
// (ignoring output-buffer space); drives the Starved/Blocked distinction of
|
||||
// the status light (REQ-UI-STATUS-LIGHT).
|
||||
bool hasInputsToStart(const Building& b) const;
|
||||
|
||||
const BuildingDef* findBuildingDef(BuildingType type) const;
|
||||
const RecipeDef* findRecipe(const std::string& id, BuildingType type) const;
|
||||
const ShipDef* findShipDef(const std::string& id) const;
|
||||
@@ -212,6 +252,9 @@ private:
|
||||
void initShipyardBuffers(Building& b) const;
|
||||
void initSalvageBayBuffer(Building& b) const;
|
||||
std::vector<Port> computeInputPorts(const Building& b) const;
|
||||
// Core input-edge scan shared by operational buildings and construction sites.
|
||||
std::vector<Port> computeInputPorts(const std::vector<QPoint>& bodyCells,
|
||||
const std::vector<Port>& outputPorts) const;
|
||||
std::vector<Item> rollReprocessingOutput(const RecipeDef& recipe);
|
||||
bool bodyCellsWithinWorldBounds(
|
||||
const std::vector<QPoint>& bodyCells,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
157
src/test/BeltDragPathTest.cpp
Normal file
157
src/test/BeltDragPathTest.cpp
Normal file
@@ -0,0 +1,157 @@
|
||||
#include "catch.hpp"
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include <QPoint>
|
||||
|
||||
#include "BeltDragPath.h"
|
||||
#include "Rotation.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static std::vector<QPoint> tilesOf(const std::vector<BeltPathTile>& path)
|
||||
{
|
||||
std::vector<QPoint> tiles;
|
||||
for (const BeltPathTile& entry : path) { tiles.push_back(entry.tile); }
|
||||
return tiles;
|
||||
}
|
||||
|
||||
static std::vector<Rotation> rotationsOf(const std::vector<BeltPathTile>& path)
|
||||
{
|
||||
std::vector<Rotation> rotations;
|
||||
for (const BeltPathTile& entry : path) { rotations.push_back(entry.rotation); }
|
||||
return rotations;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single tile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("Single-tile path keeps the belt orientation")
|
||||
{
|
||||
for (Rotation orientation : { Rotation::North, Rotation::East,
|
||||
Rotation::South, Rotation::West })
|
||||
{
|
||||
const std::vector<BeltPathTile> path =
|
||||
computeBeltDragPath(QPoint(3, 4), QPoint(3, 4), orientation);
|
||||
REQUIRE(path.size() == 1);
|
||||
REQUIRE(path[0].tile == QPoint(3, 4));
|
||||
REQUIRE(path[0].rotation == orientation);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Straight runs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("Straight horizontal run faces along the row toward the cursor")
|
||||
{
|
||||
const std::vector<BeltPathTile> path =
|
||||
computeBeltDragPath(QPoint(0, 0), QPoint(3, 0), Rotation::East);
|
||||
REQUIRE(tilesOf(path) == std::vector<QPoint>{
|
||||
QPoint(0, 0), QPoint(1, 0), QPoint(2, 0), QPoint(3, 0) });
|
||||
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
|
||||
Rotation::East, Rotation::East, Rotation::East, Rotation::East });
|
||||
}
|
||||
|
||||
TEST_CASE("Straight horizontal run toward the left faces West")
|
||||
{
|
||||
const std::vector<BeltPathTile> path =
|
||||
computeBeltDragPath(QPoint(0, 0), QPoint(-2, 0), Rotation::East);
|
||||
REQUIRE(tilesOf(path) == std::vector<QPoint>{
|
||||
QPoint(0, 0), QPoint(-1, 0), QPoint(-2, 0) });
|
||||
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
|
||||
Rotation::West, Rotation::West, Rotation::West });
|
||||
}
|
||||
|
||||
TEST_CASE("Straight vertical run faces along the column toward the cursor")
|
||||
{
|
||||
const std::vector<BeltPathTile> path =
|
||||
computeBeltDragPath(QPoint(0, 0), QPoint(0, 3), Rotation::South);
|
||||
REQUIRE(tilesOf(path) == std::vector<QPoint>{
|
||||
QPoint(0, 0), QPoint(0, 1), QPoint(0, 2), QPoint(0, 3) });
|
||||
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
|
||||
Rotation::South, Rotation::South, Rotation::South, Rotation::South });
|
||||
}
|
||||
|
||||
// A vertical target with a horizontal orientation still yields a straight vertical
|
||||
// line (the parallel-axis leg is zero-length).
|
||||
TEST_CASE("Vertical target with horizontal orientation is a straight vertical line")
|
||||
{
|
||||
const std::vector<BeltPathTile> path =
|
||||
computeBeltDragPath(QPoint(2, 0), QPoint(2, 2), Rotation::East);
|
||||
REQUIRE(tilesOf(path) == std::vector<QPoint>{
|
||||
QPoint(2, 0), QPoint(2, 1), QPoint(2, 2) });
|
||||
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
|
||||
Rotation::South, Rotation::South, Rotation::South });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// L-shaped paths — horizontal-first (East/West orientation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("East orientation goes horizontal then vertical (down-right)")
|
||||
{
|
||||
const std::vector<BeltPathTile> path =
|
||||
computeBeltDragPath(QPoint(0, 0), QPoint(2, 2), Rotation::East);
|
||||
// Leg 1 East to the corner (2,0), then Leg 2 South to the cursor (2,2).
|
||||
REQUIRE(tilesOf(path) == std::vector<QPoint>{
|
||||
QPoint(0, 0), QPoint(1, 0), QPoint(2, 0), QPoint(2, 1), QPoint(2, 2) });
|
||||
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
|
||||
Rotation::East, Rotation::East, Rotation::South, Rotation::South,
|
||||
Rotation::South });
|
||||
}
|
||||
|
||||
TEST_CASE("East orientation with cursor up-left goes horizontal (West) then vertical (North)")
|
||||
{
|
||||
const std::vector<BeltPathTile> path =
|
||||
computeBeltDragPath(QPoint(0, 0), QPoint(-2, -2), Rotation::East);
|
||||
REQUIRE(tilesOf(path) == std::vector<QPoint>{
|
||||
QPoint(0, 0), QPoint(-1, 0), QPoint(-2, 0), QPoint(-2, -1),
|
||||
QPoint(-2, -2) });
|
||||
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
|
||||
Rotation::West, Rotation::West, Rotation::North, Rotation::North,
|
||||
Rotation::North });
|
||||
}
|
||||
|
||||
TEST_CASE("West orientation is horizontal-first as well (up-right cursor)")
|
||||
{
|
||||
const std::vector<BeltPathTile> path =
|
||||
computeBeltDragPath(QPoint(0, 0), QPoint(2, -2), Rotation::West);
|
||||
// Horizontal axis first: East toward the cursor to the corner (2,0), then North.
|
||||
REQUIRE(tilesOf(path) == std::vector<QPoint>{
|
||||
QPoint(0, 0), QPoint(1, 0), QPoint(2, 0), QPoint(2, -1), QPoint(2, -2) });
|
||||
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
|
||||
Rotation::East, Rotation::East, Rotation::North, Rotation::North,
|
||||
Rotation::North });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// L-shaped paths — vertical-first (North/South orientation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("South orientation goes vertical then horizontal (down-right)")
|
||||
{
|
||||
const std::vector<BeltPathTile> path =
|
||||
computeBeltDragPath(QPoint(0, 0), QPoint(2, 2), Rotation::South);
|
||||
// Leg 1 South to the corner (0,2), then Leg 2 East to the cursor (2,2).
|
||||
REQUIRE(tilesOf(path) == std::vector<QPoint>{
|
||||
QPoint(0, 0), QPoint(0, 1), QPoint(0, 2), QPoint(1, 2), QPoint(2, 2) });
|
||||
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
|
||||
Rotation::South, Rotation::South, Rotation::East, Rotation::East,
|
||||
Rotation::East });
|
||||
}
|
||||
|
||||
TEST_CASE("North orientation is vertical-first (down-left cursor)")
|
||||
{
|
||||
const std::vector<BeltPathTile> path =
|
||||
computeBeltDragPath(QPoint(0, 0), QPoint(-2, 2), Rotation::North);
|
||||
// Vertical axis first: South toward the cursor to the corner (0,2), then West.
|
||||
REQUIRE(tilesOf(path) == std::vector<QPoint>{
|
||||
QPoint(0, 0), QPoint(0, 1), QPoint(0, 2), QPoint(-1, 2), QPoint(-2, 2) });
|
||||
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
|
||||
Rotation::South, Rotation::South, Rotation::West, Rotation::West,
|
||||
Rotation::West });
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
#include <map>
|
||||
#include <random>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <QPoint>
|
||||
@@ -119,7 +121,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 +140,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 +153,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 +164,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 +178,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 +189,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 +239,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 +258,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 +279,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 +291,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 +314,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 +334,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 +353,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 +379,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 +408,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 +441,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 +459,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 +481,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 +521,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 +538,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 +565,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 +605,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 +647,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 +688,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 +734,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 +788,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 +828,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 +868,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 +884,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 +906,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 +918,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 +926,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 +950,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 +982,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 +1058,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 +1081,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);
|
||||
@@ -1113,6 +1115,32 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when building
|
||||
bs.findRotateInPlaceTarget(BuildingType::Splitter, QPoint(0, 0), Rotation::East).has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("BuildingSystem: findRotateInPlaceTarget never rotates a tunnel in place",
|
||||
"[building][rotate-in-place]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
BeltSystem belts(cfg.world.beltSpeed_tps);
|
||||
int stock = 0;
|
||||
std::mt19937 rng(0);
|
||||
BuildingId nextBuildingId = 1;
|
||||
BuildingSystem bs(cfg, belts,
|
||||
[&nextBuildingId]() { return nextBuildingId++; },
|
||||
[&stock](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
[](const std::string&) -> bool { return true; },
|
||||
rng);
|
||||
|
||||
// Even with a coincident same-type tunnel under the ghost, rotate-in-place is
|
||||
// never offered for tunnels (REQ-BLD-ROTATE-IN-PLACE exception).
|
||||
bs.place(BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::East, 0);
|
||||
bs.place(BuildingType::TunnelExit, QPoint(-2, 0), Rotation::East, 0);
|
||||
|
||||
REQUIRE_FALSE(
|
||||
bs.findRotateInPlaceTarget(BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::North).has_value());
|
||||
REQUIRE_FALSE(
|
||||
bs.findRotateInPlaceTarget(BuildingType::TunnelExit, QPoint(-2, 0), Rotation::North).has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when footprints only partially overlap",
|
||||
"[building][rotate-in-place]")
|
||||
{
|
||||
@@ -1154,7 +1182,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 +1209,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 +1232,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 +1256,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 +1287,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 +1304,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 +1323,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);
|
||||
@@ -1308,3 +1336,219 @@ TEST_CASE("BuildingSystem: splitter filters configured on a construction site ca
|
||||
REQUIRE(builtInfo->filterA == filterA);
|
||||
REQUIRE(builtInfo->filterB.empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Production status classifier (REQ-UI-STATUS-LIGHT)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[building]")
|
||||
{
|
||||
PlacementFixture f;
|
||||
|
||||
// Pick representative config ids so the test survives content edits.
|
||||
std::string minerRecipeId;
|
||||
for (const RecipeDef& r : f.cfg.recipes.recipes)
|
||||
{
|
||||
if (r.building == BuildingType::Miner) { minerRecipeId = r.id; break; }
|
||||
}
|
||||
REQUIRE_FALSE(minerRecipeId.empty());
|
||||
|
||||
const RecipeDef* assemblerRecipe = nullptr;
|
||||
for (const RecipeDef& r : f.cfg.recipes.recipes)
|
||||
{
|
||||
if (r.building == BuildingType::Assembler && !r.inputs.empty())
|
||||
{
|
||||
assemblerRecipe = &r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
REQUIRE(assemblerRecipe != nullptr);
|
||||
|
||||
std::string shipId;
|
||||
for (const ShipDef& s : f.cfg.ships.ships)
|
||||
{
|
||||
if (!s.schematic.materials.empty()) { shipId = s.id; break; }
|
||||
}
|
||||
REQUIRE_FALSE(shipId.empty());
|
||||
|
||||
const auto statusOf = [&f](const Building& b) { return f.bs.getProductionStatus(b); };
|
||||
|
||||
SECTION("non-production buildings show no status light")
|
||||
{
|
||||
Building belt; belt.type = BuildingType::Belt;
|
||||
Building hq; hq.type = BuildingType::Hq;
|
||||
REQUIRE_FALSE(statusOf(belt).has_value());
|
||||
REQUIRE_FALSE(statusOf(hq).has_value());
|
||||
}
|
||||
|
||||
SECTION("Miner: unconfigured, producing, output-blocked")
|
||||
{
|
||||
Building miner; miner.type = BuildingType::Miner;
|
||||
REQUIRE(statusOf(miner) == ProductionStatus::Unconfigured); // no recipe -> grey
|
||||
|
||||
miner.recipeId = minerRecipeId;
|
||||
miner.production = Production{};
|
||||
REQUIRE(statusOf(miner) == ProductionStatus::Producing); // active cycle -> green
|
||||
|
||||
// A miner has no inputs, so its only idle reason is a full output buffer.
|
||||
miner.production = std::nullopt;
|
||||
miner.outputBuffer.capacity = 2;
|
||||
miner.outputBuffer.items = { makeItem("iron_ore"), makeItem("iron_ore") };
|
||||
REQUIRE(statusOf(miner) == ProductionStatus::Blocked); // -> yellow
|
||||
}
|
||||
|
||||
SECTION("Assembler: starved vs blocked, input-missing takes precedence")
|
||||
{
|
||||
Building assembler; assembler.type = BuildingType::Assembler;
|
||||
assembler.recipeId = assemblerRecipe->id;
|
||||
|
||||
// Idle with inputs missing -> red.
|
||||
REQUIRE(statusOf(assembler) == ProductionStatus::Starved);
|
||||
|
||||
// Inputs present but idle -> the only remaining reason is a full output
|
||||
// buffer -> yellow.
|
||||
for (const RecipeIngredient& ing : assemblerRecipe->inputs)
|
||||
{
|
||||
assembler.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
|
||||
}
|
||||
REQUIRE(statusOf(assembler) == ProductionStatus::Blocked);
|
||||
|
||||
// Inputs missing AND output full -> red wins over yellow.
|
||||
assembler.inputBuffer.counts.clear();
|
||||
assembler.outputBuffer.capacity = 2;
|
||||
assembler.outputBuffer.items = { makeItem("x"), makeItem("x") };
|
||||
REQUIRE(statusOf(assembler) == ProductionStatus::Starved);
|
||||
}
|
||||
|
||||
SECTION("Smelter (auto-recipe) is never grey")
|
||||
{
|
||||
Building smelter; smelter.type = BuildingType::Smelter;
|
||||
// No player-selectable recipe and empty inputs -> red, not grey.
|
||||
REQUIRE(statusOf(smelter) == ProductionStatus::Starved);
|
||||
}
|
||||
|
||||
SECTION("Shipyard: unconfigured, then starved without materials, then producing")
|
||||
{
|
||||
Building yard; yard.type = BuildingType::Shipyard;
|
||||
REQUIRE(statusOf(yard) == ProductionStatus::Unconfigured); // no schematic -> grey
|
||||
|
||||
yard.recipeId = shipId;
|
||||
REQUIRE(statusOf(yard) == ProductionStatus::Starved); // no materials -> red
|
||||
|
||||
yard.production = Production{};
|
||||
REQUIRE(statusOf(yard) == ProductionStatus::Producing); // active cycle -> green
|
||||
}
|
||||
|
||||
SECTION("Salvage Bay: red when empty, green when holding scrap")
|
||||
{
|
||||
Building bay; bay.type = BuildingType::SalvageBay;
|
||||
bay.outputBuffer.capacity = 20;
|
||||
REQUIRE(statusOf(bay) == ProductionStatus::Starved); // empty -> red
|
||||
|
||||
bay.outputBuffer.items = { makeItem("scrap") };
|
||||
REQUIRE(statusOf(bay) == ProductionStatus::Producing); // holding scrap -> green
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getInputPorts (REQ-BLD-BELT-DRAG snapping, REQ-MAT-INPUT-PORTS)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
namespace
|
||||
{
|
||||
QPoint directionDelta(Rotation direction)
|
||||
{
|
||||
switch (direction)
|
||||
{
|
||||
case Rotation::North: return QPoint(0, -1);
|
||||
case Rotation::East: return QPoint(1, 0);
|
||||
case Rotation::South: return QPoint(0, 1);
|
||||
case Rotation::West: return QPoint(-1, 0);
|
||||
}
|
||||
return QPoint(0, 0);
|
||||
}
|
||||
|
||||
bool hasInputPort(const std::vector<Port>& ports, QPoint tile, Rotation direction)
|
||||
{
|
||||
for (const Port& port : ports)
|
||||
{
|
||||
if (port.tile == tile && port.direction == direction) { return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Advances the sim until the given site becomes an operational building, or a
|
||||
// safety cap is reached.
|
||||
void buildToCompletion(BuildingSystem& bs, BeltSystem& belts, BuildingId id,
|
||||
Tick& tick)
|
||||
{
|
||||
for (int i = 0; i < 20000 && bs.findBuilding(id) == nullptr; ++i)
|
||||
{
|
||||
runTicks(bs, belts, 1, tick);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("BuildingSystem: getInputPorts on a miner site lists every input edge", "[building]")
|
||||
{
|
||||
PlacementFixture f;
|
||||
// Miner mask ["AA","A>"] East → body (0,0),(1,0),(0,1); output tile (1,1) East.
|
||||
const BuildingId id = f.bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
|
||||
|
||||
const std::vector<Port> ports = f.bs.getInputPorts(id);
|
||||
|
||||
// Every perimeter edge except the output-port edge at (1,1), each pointing in.
|
||||
REQUIRE(ports.size() == 6);
|
||||
REQUIRE(hasInputPort(ports, QPoint(-1, 0), Rotation::East));
|
||||
REQUIRE(hasInputPort(ports, QPoint(0, -1), Rotation::South));
|
||||
REQUIRE(hasInputPort(ports, QPoint(2, 0), Rotation::West));
|
||||
REQUIRE(hasInputPort(ports, QPoint(1, -1), Rotation::South));
|
||||
REQUIRE(hasInputPort(ports, QPoint(-1, 1), Rotation::East));
|
||||
REQUIRE(hasInputPort(ports, QPoint(0, 2), Rotation::North));
|
||||
|
||||
// The output-port tile is never an input port.
|
||||
REQUIRE_FALSE(hasInputPort(ports, QPoint(1, 1), Rotation::North));
|
||||
REQUIRE_FALSE(hasInputPort(ports, QPoint(1, 1), Rotation::West));
|
||||
}
|
||||
|
||||
TEST_CASE("BuildingSystem: getInputPorts matches between a site and the built building",
|
||||
"[building]")
|
||||
{
|
||||
PlacementFixture f;
|
||||
Tick tick = 0;
|
||||
const BuildingId id = f.bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
|
||||
|
||||
const std::vector<Port> sitePorts = f.bs.getInputPorts(id);
|
||||
buildToCompletion(f.bs, f.belts, id, tick);
|
||||
REQUIRE(f.bs.findBuilding(id) != nullptr);
|
||||
const std::vector<Port> builtPorts = f.bs.getInputPorts(id);
|
||||
|
||||
// The operational path (stored inputPorts) agrees with the site path (mask-derived).
|
||||
REQUIRE(builtPorts.size() == sitePorts.size());
|
||||
for (const Port& port : sitePorts)
|
||||
{
|
||||
REQUIRE(hasInputPort(builtPorts, port.tile, port.direction));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("BuildingSystem: getInputPorts invariants hold for a rotated site", "[building]")
|
||||
{
|
||||
PlacementFixture f;
|
||||
const BuildingId id = f.bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::South, 0).value();
|
||||
const ConstructionSite* site = f.bs.findSite(id);
|
||||
REQUIRE(site != nullptr);
|
||||
|
||||
std::set<std::pair<int, int>> bodySet;
|
||||
for (const QPoint& cell : site->bodyCells) { bodySet.insert({cell.x(), cell.y()}); }
|
||||
|
||||
const std::vector<Port> ports = f.bs.getInputPorts(id);
|
||||
REQUIRE_FALSE(ports.empty());
|
||||
for (const Port& port : ports)
|
||||
{
|
||||
// Each port tile is outside the footprint...
|
||||
REQUIRE(bodySet.count({port.tile.x(), port.tile.y()}) == 0);
|
||||
// ...and its direction points into an adjacent body cell.
|
||||
const QPoint into = port.tile + directionDelta(port.direction);
|
||||
REQUIRE(bodySet.count({into.x(), into.y()}) == 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ add_files(
|
||||
SimulationTest.cpp
|
||||
BeltSystemTest.cpp
|
||||
SurfaceMaskTest.cpp
|
||||
BeltDragPathTest.cpp
|
||||
TunnelCompletionTest.cpp
|
||||
BuildingTest.cpp
|
||||
BuildingConfigTest.cpp
|
||||
ShipTest.cpp
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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]")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
248
src/test/TunnelCompletionTest.cpp
Normal file
248
src/test/TunnelCompletionTest.cpp
Normal file
@@ -0,0 +1,248 @@
|
||||
#include "catch.hpp"
|
||||
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <utility>
|
||||
|
||||
#include <QPoint>
|
||||
#include <QVector2D>
|
||||
|
||||
#include "BuildingType.h"
|
||||
#include "Rotation.h"
|
||||
#include "TunnelCompletion.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
using TunnelMap = std::map<std::pair<int, int>, TunnelTileInfo>;
|
||||
|
||||
TunnelLookup makeLookup(const TunnelMap& tunnels)
|
||||
{
|
||||
return [&tunnels](QPoint tile) -> std::optional<TunnelTileInfo>
|
||||
{
|
||||
const TunnelMap::const_iterator it = tunnels.find({tile.x(), tile.y()});
|
||||
if (it == tunnels.end()) { return std::nullopt; }
|
||||
return it->second;
|
||||
};
|
||||
}
|
||||
|
||||
// Cursor at the centre of a tile.
|
||||
QVector2D tileCentre(QPoint tile)
|
||||
{
|
||||
return QVector2D(static_cast<float>(tile.x()) + 0.5f,
|
||||
static_cast<float>(tile.y()) + 0.5f);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// No match
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("No tunnels: ghost defaults to a Tunnel Entry with no partner")
|
||||
{
|
||||
const TunnelMap tunnels;
|
||||
const TunnelCompletion result = resolveTunnelCompletion(
|
||||
makeLookup(tunnels), QPoint(5, 5), Rotation::East, 10, tileCentre(QPoint(5, 5)));
|
||||
|
||||
REQUIRE(result.resolvedType == BuildingType::TunnelEntry);
|
||||
REQUIRE(!result.partnerTile.has_value());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry-completion: an existing exit ahead keeps the ghost an entry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("Existing exit ahead completes as an entry")
|
||||
{
|
||||
TunnelMap tunnels;
|
||||
tunnels[{8, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East};
|
||||
|
||||
const TunnelCompletion result = resolveTunnelCompletion(
|
||||
makeLookup(tunnels), QPoint(5, 5), Rotation::East, 10, tileCentre(QPoint(5, 5)));
|
||||
|
||||
REQUIRE(result.resolvedType == BuildingType::TunnelEntry);
|
||||
REQUIRE(result.partnerTile.has_value());
|
||||
REQUIRE(*result.partnerTile == QPoint(8, 5));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exit-completion: an existing entry behind flips the ghost to an exit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("Existing entry behind completes as an exit")
|
||||
{
|
||||
TunnelMap tunnels;
|
||||
tunnels[{2, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East};
|
||||
|
||||
const TunnelCompletion result = resolveTunnelCompletion(
|
||||
makeLookup(tunnels), QPoint(5, 5), Rotation::East, 10, tileCentre(QPoint(5, 5)));
|
||||
|
||||
REQUIRE(result.resolvedType == BuildingType::TunnelExit);
|
||||
REQUIRE(result.partnerTile.has_value());
|
||||
REQUIRE(*result.partnerTile == QPoint(2, 5));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Both matches: the sub-tile cursor position breaks the tie
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("Both matches resolve to the partner nearer the sub-tile cursor")
|
||||
{
|
||||
TunnelMap tunnels;
|
||||
tunnels[{3, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East}; // behind
|
||||
tunnels[{7, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East}; // ahead
|
||||
|
||||
const TunnelLookup lookup = makeLookup(tunnels);
|
||||
|
||||
SECTION("cursor near the left edge completes the entry (ghost becomes exit)")
|
||||
{
|
||||
const QVector2D cursor(5.1f, 5.5f);
|
||||
const TunnelCompletion result =
|
||||
resolveTunnelCompletion(lookup, QPoint(5, 5), Rotation::East, 10, cursor);
|
||||
REQUIRE(result.resolvedType == BuildingType::TunnelExit);
|
||||
REQUIRE(*result.partnerTile == QPoint(3, 5));
|
||||
}
|
||||
|
||||
SECTION("cursor near the right edge completes the exit (ghost stays entry)")
|
||||
{
|
||||
const QVector2D cursor(5.9f, 5.5f);
|
||||
const TunnelCompletion result =
|
||||
resolveTunnelCompletion(lookup, QPoint(5, 5), Rotation::East, 10, cursor);
|
||||
REQUIRE(result.resolvedType == BuildingType::TunnelEntry);
|
||||
REQUIRE(*result.partnerTile == QPoint(7, 5));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A same-direction tunnel between the ghost and a candidate blocks the pairing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("A same-direction entry between the ghost and an exit blocks completion")
|
||||
{
|
||||
TunnelMap tunnels;
|
||||
tunnels[{7, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East}; // blocker
|
||||
tunnels[{9, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East};
|
||||
|
||||
const TunnelCompletion result = resolveTunnelCompletion(
|
||||
makeLookup(tunnels), QPoint(5, 5), Rotation::East, 10, tileCentre(QPoint(5, 5)));
|
||||
|
||||
// The forward scan stops at the same-direction entry, which is not an exit, so no
|
||||
// entry-completion. Nothing behind, so no exit-completion either.
|
||||
REQUIRE(result.resolvedType == BuildingType::TunnelEntry);
|
||||
REQUIRE(!result.partnerTile.has_value());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Differently-facing tunnels are skipped by the scan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("A differently-facing tunnel ahead is skipped, not treated as a match")
|
||||
{
|
||||
TunnelMap tunnels;
|
||||
tunnels[{6, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::North}; // skipped
|
||||
tunnels[{8, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East}; // match
|
||||
|
||||
const TunnelCompletion result = resolveTunnelCompletion(
|
||||
makeLookup(tunnels), QPoint(5, 5), Rotation::East, 10, tileCentre(QPoint(5, 5)));
|
||||
|
||||
REQUIRE(result.resolvedType == BuildingType::TunnelEntry);
|
||||
REQUIRE(*result.partnerTile == QPoint(8, 5));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Distance limit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("A candidate beyond the max distance is not matched")
|
||||
{
|
||||
TunnelMap tunnels;
|
||||
tunnels[{8, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East}; // distance 3
|
||||
|
||||
const TunnelCompletion result = resolveTunnelCompletion(
|
||||
makeLookup(tunnels), QPoint(5, 5), Rotation::East, 2, tileCentre(QPoint(5, 5)));
|
||||
|
||||
REQUIRE(result.resolvedType == BuildingType::TunnelEntry);
|
||||
REQUIRE(!result.partnerTile.has_value());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findTunnelPartner: the matching end of an existing selected tunnel
|
||||
// (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("findTunnelPartner: a selected entry finds its exit ahead")
|
||||
{
|
||||
TunnelMap tunnels;
|
||||
tunnels[{2, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East};
|
||||
tunnels[{6, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East};
|
||||
|
||||
const std::optional<QPoint> partner = findTunnelPartner(
|
||||
makeLookup(tunnels), QPoint(2, 5), BuildingType::TunnelEntry, Rotation::East, 10);
|
||||
|
||||
REQUIRE(partner.has_value());
|
||||
REQUIRE(*partner == QPoint(6, 5));
|
||||
}
|
||||
|
||||
TEST_CASE("findTunnelPartner: a selected exit finds its entry behind")
|
||||
{
|
||||
TunnelMap tunnels;
|
||||
tunnels[{2, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East};
|
||||
tunnels[{6, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East};
|
||||
|
||||
const std::optional<QPoint> partner = findTunnelPartner(
|
||||
makeLookup(tunnels), QPoint(6, 5), BuildingType::TunnelExit, Rotation::East, 10);
|
||||
|
||||
REQUIRE(partner.has_value());
|
||||
REQUIRE(*partner == QPoint(2, 5));
|
||||
}
|
||||
|
||||
TEST_CASE("findTunnelPartner: an unpaired tunnel has no partner")
|
||||
{
|
||||
TunnelMap tunnels;
|
||||
tunnels[{2, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East};
|
||||
|
||||
REQUIRE(!findTunnelPartner(makeLookup(tunnels), QPoint(2, 5),
|
||||
BuildingType::TunnelEntry, Rotation::East, 10).has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("findTunnelPartner: a same-direction tunnel between blocks the pairing")
|
||||
{
|
||||
TunnelMap tunnels;
|
||||
tunnels[{2, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East}; // selected
|
||||
tunnels[{4, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East}; // blocker
|
||||
tunnels[{6, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East};
|
||||
|
||||
// The scan from the selected entry stops at the same-direction entry, which is not
|
||||
// an exit, so the exit beyond it is not paired.
|
||||
REQUIRE(!findTunnelPartner(makeLookup(tunnels), QPoint(2, 5),
|
||||
BuildingType::TunnelEntry, Rotation::East, 10).has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("findTunnelPartner: a differently-facing tunnel is skipped")
|
||||
{
|
||||
TunnelMap tunnels;
|
||||
tunnels[{2, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East};
|
||||
tunnels[{4, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::North}; // skipped
|
||||
tunnels[{6, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East}; // match
|
||||
|
||||
const std::optional<QPoint> partner = findTunnelPartner(
|
||||
makeLookup(tunnels), QPoint(2, 5), BuildingType::TunnelEntry, Rotation::East, 10);
|
||||
|
||||
REQUIRE(partner.has_value());
|
||||
REQUIRE(*partner == QPoint(6, 5));
|
||||
}
|
||||
|
||||
TEST_CASE("findTunnelPartner: a partner beyond the max distance is not matched")
|
||||
{
|
||||
TunnelMap tunnels;
|
||||
tunnels[{2, 5}] = TunnelTileInfo{BuildingType::TunnelEntry, Rotation::East};
|
||||
tunnels[{6, 5}] = TunnelTileInfo{BuildingType::TunnelExit, Rotation::East}; // distance 4
|
||||
|
||||
REQUIRE(!findTunnelPartner(makeLookup(tunnels), QPoint(2, 5),
|
||||
BuildingType::TunnelEntry, Rotation::East, 3).has_value());
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -34,11 +35,21 @@ BuildButtonGrid::BuildButtonGrid(const GameConfig* config, QWidget* parent)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Tunnel Entry and Tunnel Exit share a single "Tunnel" button; the exit is
|
||||
// reached through the unified tunnel build mode, not its own button
|
||||
// (REQ-BLD-TUNNEL-MODE, REQ-UI-BUILD-GRID). Both stay player-placeable so
|
||||
// blueprints and cost totals still account for exits.
|
||||
if (def.type == BuildingType::TunnelExit)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
m_types.push_back(def.type);
|
||||
m_costs[def.type] = def.cost;
|
||||
|
||||
const QString label = QString::fromStdString(toDisplayName(def.id))
|
||||
+ "\n" + tr("%1 Building Blocks").arg(def.cost);
|
||||
const QString name = (def.type == BuildingType::TunnelEntry)
|
||||
? tr("Tunnel")
|
||||
: QString::fromStdString(toDisplayName(def.id));
|
||||
const QString label = name + "\n" + tr("%1 Building Blocks").arg(def.cost);
|
||||
QPushButton* btn = new QPushButton(label, this);
|
||||
btn->setCheckable(true);
|
||||
btn->setFixedHeight(48);
|
||||
@@ -79,24 +90,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 +134,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 +144,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 +160,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);
|
||||
|
||||
@@ -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
@@ -1,9 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <random>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <QElapsedTimer>
|
||||
@@ -35,12 +37,14 @@
|
||||
#include "SpeedChangeRequestedEvent.h"
|
||||
|
||||
#include "entt/entity/entity.hpp"
|
||||
#include "BeltDragPath.h"
|
||||
#include "CommandManager.h"
|
||||
#include "EntitySelectedEvent.h"
|
||||
#include "EntitySelectionChangedEvent.h"
|
||||
#include "GameConfig.h"
|
||||
#include "Rotation.h"
|
||||
#include "Tick.h"
|
||||
#include "TickDriver.h"
|
||||
#include "TunnelCompletion.h"
|
||||
#include "VisualsConfig.h"
|
||||
|
||||
struct Command;
|
||||
@@ -76,7 +80,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 +135,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,9 +197,51 @@ 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);
|
||||
|
||||
// Unified tunnel build mode (REQ-BLD-TUNNEL-MODE). Active while the builder type
|
||||
// is TunnelEntry; the ghost then resolves to an entry or exit by hovered position.
|
||||
bool inTunnelMode() const;
|
||||
// The building type the ghost currently represents: the position-resolved tunnel
|
||||
// type in tunnel mode, otherwise the plain builder type.
|
||||
BuildingType effectiveBuilderType() const;
|
||||
// Recomputes m_tunnelGhostType and m_tunnelPartnerTile from the current ghost
|
||||
// tile, rotation, and sub-tile cursor position. Only meaningful in tunnel mode.
|
||||
void updateTunnelGhost();
|
||||
// Indexes every tunnel entry/exit — built or still a construction site — by its
|
||||
// single-cell tile. Shared by the placement preview and the selection highlight.
|
||||
std::map<std::pair<int, int>, TunnelTileInfo> collectTunnelTiles() const;
|
||||
// Draws the green connection highlight for every selected tunnel end that has a
|
||||
// matching end (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT).
|
||||
void drawSelectedTunnelConnections(QPainter& painter);
|
||||
|
||||
// Belt drag placement (REQ-BLD-BELT-DRAG).
|
||||
// Per-path-tile decision, shared by ghost drawing and release-time placement.
|
||||
enum class BeltTileAction { PlaceNew, RotateInPlace, Invalid };
|
||||
struct BeltDragResolved
|
||||
{
|
||||
BeltTileAction action;
|
||||
bool affordable; // meaningful only for PlaceNew
|
||||
std::optional<BuildingId> rotateId; // set only for RotateInPlace
|
||||
};
|
||||
// Recomputes m_beltDragPath from m_beltDragAnchor to cursorTile using the
|
||||
// current ghost orientation.
|
||||
void recomputeBeltDragPath(QPoint cursorTile);
|
||||
// Classifies each path tile against the current sim state, applying cumulative
|
||||
// affordability to the PlaceNew tiles.
|
||||
std::vector<BeltDragResolved> resolveBeltDragPath() const;
|
||||
// Enqueues placements and rotate-in-place commands for the resolved path.
|
||||
void applyBeltDragPath();
|
||||
|
||||
// Copy-settings gesture (REQ-BLD-COPY-CONFIG): Shift+right-click copies a
|
||||
// building's configuration into m_copiedConfig; Shift+left-click applies it to
|
||||
// another building of the same type via the existing configuration commands.
|
||||
@@ -222,7 +282,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;
|
||||
@@ -233,7 +293,20 @@ private:
|
||||
Rotation m_ghostRotation;
|
||||
QPoint m_ghostTile;
|
||||
bool m_ghostValid;
|
||||
std::set<QPoint, QPointCompare> m_beltDragTiles;
|
||||
// Unified tunnel build mode (REQ-BLD-TUNNEL-MODE): while m_builderType is
|
||||
// TunnelEntry the ghost resolves to an entry or an exit based on the hovered
|
||||
// position; m_tunnelPartnerTile is the existing tunnel it would complete (drawn
|
||||
// as the green connection preview), or unset when there is no completion match.
|
||||
BuildingType m_tunnelGhostType = BuildingType::TunnelEntry;
|
||||
std::optional<QPoint> m_tunnelPartnerTile;
|
||||
// Deferred belt drag placement (REQ-BLD-BELT-DRAG): while dragging, the
|
||||
// rectilinear anchor->cursor path is recomputed on each move and only applied
|
||||
// on release. Empty unless a belt drag is in progress.
|
||||
std::vector<BeltPathTile> m_beltDragPath;
|
||||
QPoint m_beltDragAnchor;
|
||||
// Last known cursor position in world (tile) units; used to pick the belt-drag
|
||||
// end tile closest to the cursor when snapping to a building (REQ-BLD-BELT-DRAG).
|
||||
QVector2D m_cursorWorldPos;
|
||||
bool m_dragging;
|
||||
|
||||
std::optional<Blueprint> m_blueprintMode;
|
||||
@@ -256,11 +329,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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "ShipLayoutDialog.h"
|
||||
#include "ShipStatsPanel.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
|
||||
#include "DisplayName.h"
|
||||
@@ -12,8 +13,10 @@
|
||||
#include <QMouseEvent>
|
||||
#include <QPainter>
|
||||
#include <QPushButton>
|
||||
#include <QElapsedTimer>
|
||||
#include <QScrollArea>
|
||||
#include <QSignalMapper>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
namespace
|
||||
@@ -21,6 +24,13 @@ namespace
|
||||
|
||||
const int kCellSize = 32;
|
||||
|
||||
// Empty ship tiles pulse between this grey and white at 1 Hz while a module is
|
||||
// selected, to draw the player's attention to where modules can be placed.
|
||||
const double kTwoPi = 6.283185307179586;
|
||||
const int kPulseIntervalMs = 33;
|
||||
const int kEmptyCellGrey = 240;
|
||||
const int kPulseAmplitude = 15;
|
||||
|
||||
std::vector<std::string> rotateMaskCW(const std::vector<std::string>& grid)
|
||||
{
|
||||
if (grid.empty())
|
||||
@@ -68,6 +78,10 @@ public:
|
||||
, m_dialog(dialog)
|
||||
{
|
||||
setMouseTracking(true);
|
||||
m_pulseClock.start();
|
||||
m_pulseTimer = new QTimer(this);
|
||||
m_pulseTimer->setInterval(kPulseIntervalMs);
|
||||
connect(m_pulseTimer, &QTimer::timeout, this, [this]() { update(); });
|
||||
}
|
||||
|
||||
void setGridData(const std::vector<std::vector<ShipLayoutDialog::CellInfo>>* grid,
|
||||
@@ -83,10 +97,23 @@ 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;
|
||||
// Only animate the empty-tile pulse while a module is selected.
|
||||
if (moduleIndex.has_value())
|
||||
{
|
||||
if (!m_pulseTimer->isActive())
|
||||
{
|
||||
m_pulseClock.restart();
|
||||
m_pulseTimer->start();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
m_pulseTimer->stop();
|
||||
}
|
||||
}
|
||||
|
||||
protected:
|
||||
@@ -100,6 +127,19 @@ protected:
|
||||
QPainter painter(this);
|
||||
painter.setRenderHint(QPainter::Antialiasing, false);
|
||||
|
||||
// While a module is selected, pulse empty tiles at 1 Hz around the base
|
||||
// grey (kEmptyCellGrey +/- kPulseAmplitude). All empty tiles share the
|
||||
// same phase, so compute it once.
|
||||
QColor emptyCellColor(kEmptyCellGrey, kEmptyCellGrey, kEmptyCellGrey);
|
||||
if (m_ghostModuleIdx.has_value())
|
||||
{
|
||||
const double seconds = m_pulseClock.elapsed() / 1000.0;
|
||||
const double oscillation = std::sin(seconds * kTwoPi);
|
||||
const int channel = kEmptyCellGrey
|
||||
+ static_cast<int>(std::lround(oscillation * kPulseAmplitude));
|
||||
emptyCellColor = QColor(channel, channel, channel);
|
||||
}
|
||||
|
||||
for (int r = 0; r < m_rows; ++r)
|
||||
{
|
||||
for (int c = 0; c < m_cols; ++c)
|
||||
@@ -111,9 +151,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;
|
||||
@@ -131,7 +171,7 @@ protected:
|
||||
}
|
||||
else
|
||||
{
|
||||
painter.fillRect(cellRect, QColor(240, 240, 240));
|
||||
painter.fillRect(cellRect, emptyCellColor);
|
||||
}
|
||||
|
||||
painter.setPen(QColor(100, 100, 100));
|
||||
@@ -140,9 +180,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,9 +278,11 @@ 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);
|
||||
QTimer* m_pulseTimer = nullptr;
|
||||
QElapsedTimer m_pulseClock;
|
||||
};
|
||||
|
||||
|
||||
@@ -374,7 +416,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 +447,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 +532,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 +543,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 +582,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 +605,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 +620,7 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
|
||||
});
|
||||
}
|
||||
|
||||
std::optional<ShipLayoutConfig> ShipLayoutDialog::result() const
|
||||
std::optional<ShipLayoutConfig> ShipLayoutDialog::getResult() const
|
||||
{
|
||||
return m_result;
|
||||
}
|
||||
@@ -622,7 +664,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 +673,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 +699,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 +769,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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -51,6 +51,7 @@ struct OverlayVisuals
|
||||
QColor copyConfig;
|
||||
QColor lockedAsteroid;
|
||||
QColor modalDim;
|
||||
QColor tunnelPreview; // tunnel connection preview highlight (REQ-BLD-TUNNEL-MODE)
|
||||
};
|
||||
|
||||
struct ToastVisuals
|
||||
@@ -60,6 +61,17 @@ struct ToastVisuals
|
||||
int fontSize;
|
||||
};
|
||||
|
||||
// Fill colors for the building status light (REQ-UI-STATUS-LIGHT), plus its
|
||||
// constant outline color.
|
||||
struct StatusLightVisuals
|
||||
{
|
||||
QColor grey; // no recipe/schematic selected
|
||||
QColor green; // producing
|
||||
QColor red; // idle, input missing (or Salvage Bay empty)
|
||||
QColor yellow; // idle, output buffer full
|
||||
QColor outline;
|
||||
};
|
||||
|
||||
struct VisualsConfig
|
||||
{
|
||||
TileVisuals asteroid;
|
||||
@@ -69,7 +81,8 @@ struct VisualsConfig
|
||||
std::map<std::string, ItemVisuals> items;
|
||||
std::map<std::string, ShipVisuals> ships;
|
||||
|
||||
BeamVisuals beams;
|
||||
OverlayVisuals overlays;
|
||||
ToastVisuals toast;
|
||||
BeamVisuals beams;
|
||||
OverlayVisuals overlays;
|
||||
ToastVisuals toast;
|
||||
StatusLightVisuals statusLight;
|
||||
};
|
||||
|
||||
@@ -227,6 +227,7 @@ VisualsConfig VisualsLoader::load(const std::string& path)
|
||||
cfg.overlays.copyConfig = parseColor(requireString(ov, "copy_config", "overlays"), "overlays.copy_config");
|
||||
cfg.overlays.lockedAsteroid = parseColor(requireString(ov, "locked_asteroid", "overlays"), "overlays.locked_asteroid");
|
||||
cfg.overlays.modalDim = parseColor(requireString(ov, "modal_dim", "overlays"), "overlays.modal_dim");
|
||||
cfg.overlays.tunnelPreview = parseColor(requireString(ov, "tunnel_preview", "overlays"), "overlays.tunnel_preview");
|
||||
}
|
||||
|
||||
// Toast
|
||||
@@ -237,5 +238,15 @@ VisualsConfig VisualsLoader::load(const std::string& path)
|
||||
cfg.toast.fontSize = requireInt(t, "font_size", "toast");
|
||||
}
|
||||
|
||||
// Status light (REQ-UI-STATUS-LIGHT)
|
||||
{
|
||||
toml::table& sl = requireSubtable(tbl, "status_light", "root");
|
||||
cfg.statusLight.grey = parseColor(requireString(sl, "grey", "status_light"), "status_light.grey");
|
||||
cfg.statusLight.green = parseColor(requireString(sl, "green", "status_light"), "status_light.green");
|
||||
cfg.statusLight.red = parseColor(requireString(sl, "red", "status_light"), "status_light.red");
|
||||
cfg.statusLight.yellow = parseColor(requireString(sl, "yellow", "status_light"), "status_light.yellow");
|
||||
cfg.statusLight.outline = parseColor(requireString(sl, "outline", "status_light"), "status_light.outline");
|
||||
}
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user