29 Commits

Author SHA1 Message Date
a93f7c3897 Arena round 6: revert railgun_m damage, correct playtest record
Arena re-run after the playtest-1 adjustments:

- spam-cruiser arena landed at par (default +9%) - the range buff alone
  priced out the small-gun-spam meta, so revert railgun_m damage 16 -> 14
  (range stays 80). The buff had regressed drone-swarm-vs-cruisers to
  +47%, largely via a hit-count breakpoint (60 HP drone: 5 hits at
  14 dmg, 4 at 16).
- battleship +30% / dreadnought +29% vs pure railgun_s fleets accepted
  as reach-doctrine texture (the l gun's 130 m standoff working as
  intended); repair kept at 4 HP/s (below par in-fight is the correct
  price for free between-wave sustain).

Also corrects the playtest-1 record: it was two FULL playthroughs won in
~40 min each with cruisers only (not two pushes) - a 2.5-3x run-length
gap. Pacing knobs deliberately deferred to playtest 2, since those runs
predate the repair nerf and the l-gun siege range.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-06 22:10:06 +02:00
f8028a6ca2 Adjust combat stats after playtest 1
Playtest 1 (first two pushes, ~40 min game time — pacing on target)
found the railgun_s-spam-cruiser meta and confirmed the repair tool as
overpowered (second signal after the persistent arena escort margin):

- repair_tool 9 -> 4 HP/s: the arena only prices in-fight sustain; real
  runs add free between-wave top-offs across the whole swarm
- railgun_l range 100 -> 130: now outranges stations (120), buying the
  siege role the capital ladder promises
- railgun_m 14 -> 16 dmg, range 70 -> 80: concentration tax softened so
  the ladder reads "pay some DPS efficiency for reach", not
  "strictly worse"
- new tracked arena: railgun_s-spam cruisers vs default cruisers

Module costs untouched, so all threat values stay valid. Docs updated
(derived.md, history.md playtest record, README status).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-06 22:00:34 +02:00
b4fa3e6dff Restructure balancing docs into docs/balancing/
Split the cluttered progression_design.md and content_design.md into
separated documents by role:

- docs/balancing/rules.md    - design rules and principles (moved from
                               progression_design.md, which is removed)
- docs/balancing/targets.md  - the chosen base numbers: run shape,
                               factory curve, threat ladder (achieved
                               values adopted), combat anchors, pacing
                               anchors
- docs/balancing/derived.md  - current tuned state of all derived
                               numbers, mirroring the configs
- docs/balancing/process.md  - pass order, tuning discipline learned in
                               arena rounds 1-5, tools, next-round
                               checklist
- docs/balancing/history.md  - chronological record: decisions, numbers
                               pass, calculator bugs, arena rounds,
                               pacing pass
- docs/balancing/README.md   - index, status, open action items (moved
                               from progression_design.md)

content_design.md slims back down to actual content: footprint gating,
hull grids, gating matrix (module names updated to railguns), and the
production tree structure with its fiction - numbers, anchors, arena
logs, and pacing all moved to docs/balancing/. Stale references in the
config comments updated to the new locations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-06 17:15:08 +02:00
dcc6af123f implement cost formula for asteroid expansion 2026-07-04 19:59:29 +02:00
f62b7bb78e Pacing pass: unlock ladder, threat rate, economy values
Replace the everything-at--1 test setup with the real progression:
starting set is exactly drone/frigate/railgun_s/salvager; the ladder
runs destroyer+repair+armor at level 0, supports at 1, the quartz gate
(cruiser, railgun_m, afterburner, chip/hardened shortcuts) at 2,
battlecruiser at 4, battleship+railgun_l at 6, dreadnought at 8,
carrier+drone_hangar at 9, with unlock_requires edges cruiser ->
battlecruiser -> battleship -> dreadnought/carrier and railgun_m ->
railgun_l.

world.toml: threat_rate_formula 2*x + 0.15*x*x (below the player's
achievable military output early, crossing at the late boundary,
overwhelming by cycle ~24), starting_building_blocks 1000 -> 200,
expansion cost 200 -> 400 flat (placeholder until action item 4),
artifact_win_count 3 -> 5 (winning means choosing the artifact over a
schematic five times across ~7 cumulative offers).

Bug fix found during the pass: the building_block recipe carried no
unlock_at_station_level, and since building blocks appear in no
schematic's materials, implicit unlocking could never reach it - the
recipe was silently locked at game start. Now explicitly -1.

All values and derivations documented in content_design.md, "Pacing
pass - first values".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-04 12:49:35 +02:00
27c3a7b65b Combat stats round 6: combat pass converged
Round 5 (with fight durations): TTK anchor validated - mirrors run
22.7/71.2/94.6 s (drone/cruiser/battleship), most fights within
22-72 s, destroyers-vs-dreadnought the accepted 214 s outlier. All
matchups converged: mirrors 5-10%, swarm vs cruisers +15%, frigates
vs battleship +13% (dropped on its own, confirming the noise floor),
dreadnought +3%, glass vs armored +10%, two-to-one 78%, stations 44%.

Two two-round signals actioned per the convergence policy:
battlecruiser hull 2200 -> 2400 (mixed fleet won +20%/+18% twice) and
repair_amount 12 -> 9 (~0.7 HP/s per threat; escorted team held +24%
twice with all frigates surviving). Everything else frozen; further
refinement moves to real-game playtests. Removed the arena
end-condition action item (fixed upstream).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-04 12:43:05 +02:00
9ddd622870 fix but where balancing matches did not finish until enemy hq was destroyed 2026-07-04 09:29:13 +02:00
df9f0d5ccd show total time in balancing target arenas 2026-07-04 09:17:32 +02:00
db7a03dc1f Combat stats round 5: final nudges and convergence policy
Round 4: mirrors 0-3%, swarm vs cruisers +7%, glass vs armored
resolved at +3% for armored (armor 1200 confirmed). Noise floor
established at ~+/-10% per single run (battleship margin ignored a
-10% EHP cut; repair drifted 14->24% with no repair changes).

Round-5 nudges for the armor-coupling overshoots: battlecruiser hull
2000 -> 2200, dreadnought/carrier 22500 -> 24000 (two-round
persistent deficit vs destroyers). The battleship's +23% over a pure
frigate fleet is accepted as doctrine texture rather than chased: the
edge is mechanical (range) and the fair anti-capital answer is the
mixed fleet, whose arena is balanced. Convergence policy documented:
act only on two-round signals, +/-20% counts as converged for v1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-04 07:56:52 +02:00
67054f432f Combat stats round 4 from arena round 3 margins
Round 3 (narrow lanes; arena geometry is part of the fixture from
here): dreadnought closed to -11%, battleship improved to +22%, the
drone swarm flipped to +14% over cruisers under full engagement, glass
beat armored for the third consecutive round, mirrors/repair/
two-to-one healthy, stations cracked at 51% by the 3x swarm under the
new geometry (watch, not touched).

Round-4 knobs: armor_plates 1000 -> 1200 (~37 HP/threat; persistent
glass signal, also lifts armor-carrying cruiser/BC/destroyer/DN
loadouts), battlecruiser hull 2500 -> 2000 (net -300 EHP after its
armor gain), battleship 7000 -> 6300 (loadout has no armor, clean
-10%), dreadnought/carrier 19000 -> 22500 (opposing destroyers gain
armor too, so the larger step). Anchors doc notes the emerging shape:
the arena prices capitals as tanks with taxed guns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-04 07:50:50 +02:00
7a9a7a35ad make arenas more narrow to avoid fleets passing each other 2026-07-04 07:47:08 +02:00
f822047b4e Combat stats round 3 from arena round 2 margins
Round 2 (with the new EHP-margin logging): mirrors healthy and the
round-1 side bias was noise; drones-vs-cruisers (10%) and
mixed-vs-battlecruisers (14%) near parity; battleship over at +33%
while dreadnought under at -37%; glass-vs-armored narrowed to 11%
(watch); repair escort fair at 11%; two-to-one decisive; the 3x swarm
now cracks the station set at 13%.

Round-3 knobs: weapon_stabilizer range multiplier 1.5 -> 1.3 (range is
the strongest stat under the orbit AI, and the stabilized battleship
was the one loadout still overperforming), plus per-hull HP trims with
the 15 HP/threat anchor demoted to a prior and per-hull HP documented
as the empirical trim knob: battlecruiser 2700 -> 2500, battleship
7500 -> 7000, dreadnought/carrier 15500 -> 19000.

Action items: mirror-bias and EHP-logging items resolved (the latter
implemented upstream); new item to verify the arena end condition seen
idling in round 1 with all ships dead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-04 07:34:47 +02:00
d9e7dd0fe8 show team EHP in balancing target 2026-07-04 07:27:11 +02:00
4d5b218fec implement logging of arena states 2026-07-04 07:27:11 +02:00
97bafc95c3 Combat stats round 2 from arena round 1 findings
Round 1 findings: concentrated fleets won all four equal-threat
cross-tier matchups flawlessly; glass destroyers beat armored; repair
escort beat raw numbers flawlessly; two stations annihilated a 3x
threat swarm through 200 m approach fire; team 1 won all three mirror
matches (possible sim bias, recorded as action item 6, plus action
item 7 for HP-margin logging).

Knob changes, deliberately few for clean attribution: concentration
tax on m/l gun DPS (railgun_m damage 17 -> 14, railgun_l 70 -> 52),
armor_plates 640 -> 1000 HP (~31 HP/threat: killing removes enemy DPS
while surviving merely delays, so HP must be cheaper than DPS), repair
25 -> 12 HP per cycle (~1 HP/s per threat: in-combat sustain removes
enemy DPS and must be priced like DPS), station range 200 -> 120. Gun
ranges untouched; if concentration still wins flawlessly in round 2,
the range ladder is the next suspect. Anchors and findings documented
in content_design.md; fitted threat values are unaffected by stat
changes, so the arena suite counts stay valid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-04 06:55:43 +02:00
5aa0544cf2 Combat stats pass: anchor-derived stats and canonical arena suite
Derive all combat stats from documented anchors (content_design.md,
'Combat stat anchors'): weapon DPS ~0.6 per threat with a mild size
discount traded for range (railgun m/l damage 17/70), hull HP at 15
per hull threat (drone 60 up to capitals 15500), armor at a 20/threat
premium (armor_plates 640 HP), repair at ~2 HP/s per threat (25 HP per
cycle), salvager range fixed to 60 m with cargo 20, and a monotone
mobility ladder (the placeholder drone was the least agile ship).
Stations anchored to 'a fresh station holds one early parity wave';
enemy level 0 matches the player station and scales per push. HQ 5000
HP. scrap_despawn_seconds 30 -> 120 so capital-sized scrap drops are
collectable.

Rewrite balancing.toml as the canonical 12-arena suite: class mirrors,
equal-threat cross-tier matchups with counts from the verified threat
values, a two-to-one decisiveness check, armored-vs-glass and
repair-escort doctrine matchups, and station assault. The carrier
arena is expected to lose until drone launching exists (hangar is dead
threat) - documented, not stat-compensated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-03 20:19:24 +02:00
38fd2e4e89 Fix ThreatCostCalculator: per-unit division, scrap fallback, fixpoint, staggered-recipe max (action items 6-9)
Four algorithm fixes to bring ThreatCostCalculator.cpp into agreement with
tools/threat_report.py and the newly amended REQ-THREAT-ITEM semantics:

6. Scrap-consuming recipes as threat fallback only. Non-reprocessing recipes
   that take scrap as an input are excluded from an item's threat computation
   whenever at least one scrap-free recipe (miner/smelter/assembler) produces
   that item. Previously the scrap_smelting recipe (1 scrap → 1 iron_ingot)
   would have inflated iron_ingot's threat via the max rule.

7. Per-unit item threat. computeRecipeThreatPerUnit() now divides by the
   recipe's output amount, so multi-output recipes price each unit correctly.
   Example: copper_wire (1 copper_ingot, 1 s, output 2) is now 1.5, not 3.

8. Fixpoint resolution. The resolution loop now alternates the non-reprocessing
   pass and the reprocessing pass until neither makes progress, rather than
   running the reprocessing pass once at the end. Items downstream of
   reprocessing-only items (voidsteel_plate, capital_core, capital hulls,
   drone_hangar_module) now resolve correctly.

9. Max rule across staggered recipes. An item is committed only once every
   eligible recipe producing it is computable, so a shallow shortcut recipe
   (e.g. shortcut_steel_plate: 3 iron_ore → 1 steel_plate, resolvable one
   iteration earlier) cannot undercut the expensive base path. A deadlock
   fallback (require_all_recipes=False) handles potential recipe cycles.

docs/requirements.md: REQ-THREAT-ITEM amended for per-unit division, the
scrap-fallback rule, and order-independence via fixpoint.

docs/progression_design.md: action items 6-9 removed (completed); remaining
items 1-5 renumbered unchanged.

tools/threat_report.py: NOTE updated — C++ now matches Python semantics.

bin/test/data/config/recipes.toml: four minimal test recipes added (one per
fix: scrap_iron, dual_wire, downstream_product, staggered_item_{cheap,expensive}).

src/test/ThreatCostCalculatorTest.cpp: four new TEST_CASEs covering each fix.

Expected values with the live config (bin/app/data/config) verified by
threat_report.py: iron_ingot 2, copper_wire 1.5, steel_plate 7, control_chip
12, voidsteel_plate 141, capital_core 240; fitted ships 10.5/47/99/233.5/
354.5/722.5/1491.5/1436.5. All 378 test cases pass.
2026-07-03 18:45:39 +02:00
d889b79658 Write the v2 production tree into the config files
recipes.toml: full v2 rewrite - iron/copper/quartz mining, smelting
incl. the value-losing scrap sink, reprocessing (4 scrap / 4 s,
voidsteel at 20% of the full pool), tiered intermediates, hulls,
module prefabs, and the three drop-only shortcut recipes.

modules.toml: lasers renamed to railguns (implementation unchanged),
prefab materials and numbers-pass production times; combat stats stay
placeholders for the arena pass.

ships.toml: hull-item materials, numbers-pass base production times,
and geometry-validated default_modules loadouts for every ship (waves
now spawn armed).

visuals.toml: item entries for the new palette (quartz, silicon,
copper_coil, control_chip, capacitor_bank, hardened_steel,
ceramic_plate, voidsteel, voidsteel_plate, railgun modules); retired
titanium/alloy/laser items removed.

world.toml: scrap_per_threat = 0.25 per the numbers pass.

threat_report.py: commit an item's threat only once every eligible
recipe is computable - the previous first-resolved-wins behavior let
shortcut recipes underprice items (same flaw exists in
ThreatCostCalculator, recorded as action item 9). Verified: all
default_modules placements valid, verify_recipes and verify_layouts
pass, and the report reproduces the numbers-pass tables exactly
(fitted: 10.5/47/99/233.5/354.5/722.5/1491.5/1436.5). Doc tables
updated for the three geometry-corrected loadouts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-03 17:05:19 +02:00
b9e70ba83a Add tools/threat_report.py (action item 7)
Reads recipes/ships/modules/world.toml and reports per-item threat
values, module contributions, hull-only and fitted ship threats
(default_modules), producer:consumer ratio tables, and belt
feasibility against the single-belt cap.

Implements the agreed design semantics: per-unit threat (recipe threat
divided by output amount), the scrap-fallback rule, and fixpoint
resolution through reprocessing-only items. Running it against the
current configs surfaced two ThreatCostCalculator deviations, recorded
as new action items: multi-output recipes are double-priced (no
per-unit division), and items downstream of reprocessing-only items
never resolve, underestimating capital hull threat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-03 16:47:40 +02:00
3aefc05118 Scrap-consuming recipes as threat fallback, not excluded
Refine the REQ-THREAT-ITEM fix per review: recipes taking scrap as
input participate in an item threat computation only when no
scrap-free recipe produces that item, mirroring the existing
reprocessing-path rule instead of a blanket exclusion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-03 16:34:06 +02:00
fe7703d5c3 Add first numbers pass for the v2 production tree
content_design.md: full recipe quantities and durations computed with
a recursive threat calculator - economy constants (scrap_per_threat
0.25, reprocessing 4 scrap / 4 s, voidsteel at threat 100), per-item
threat values, module contributions, and fitted ship threats vs the
ladder (96-124%, smooth ~x2-per-class curve). Checks recorded: ratio
curve realized (t1 1:1, t2 2:3, t3 strange), belt feasibility under
the single-belt cap, block at 4 threat implying ~18 blocks average
building cost for the 4-minute doubling target, and a small-end
deviation note recommending ladder adjustment over chain thinning.

progression_design.md: two new action items - amend REQ-THREAT-ITEM to
exclude scrap-consuming recipes from item threat (the max rule would
otherwise inflate basic materials via the scrap smelting recipe), and
port the calculator to tools/threat_report.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-03 15:05:40 +02:00
72b38df0dd Draft the v2 production tree structure
Add the tree structure draft to the v2 decisions section: mined and
smelted items, reprocessing pool, intermediates per tier with ratio
classes and archetypes, hull and module recipes as input lists, the
resolved m+ hull gate (hardened_steel plus control_chip), shortcut
recipe candidates, and a refactorability spot-check. Quantities and
durations are deferred to the numbers pass against the threat ladder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-03 14:26:29 +02:00
c7a168f4a9 Record production tree v2 decisions and resource deposit rules
progression_design.md: rewrite the resource phases around the four
decided inputs (two mined everywhere, one deposit-gated mid resource,
one scrap-only late input), add the Resource deposits rule set
(freedom first / geography later, deposits only in expansions, patch
area as throughput cap, deterministic content, binary mining), note
the dual gating of the mid resource, and add the deposit-layer action
item.

content_design.md: mark the first-pass tree as superseded and record
the v2 decisions — iron/copper/quartz/voidsteel with their fiction
(M-type asteroid, quartz geodes, battle-forged voidsteel), titanium
dropped with its gating role moved to electronics and possibly a
quality-steel step, the material palette fingerprints, and the
laser-to-railgun rename with lasers reserved as a future weapon type.
Update the balancing target phase boundaries accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-03 13:54:24 +02:00
a6451f2bdf Add balancing targets section to content_design.md
Record the six root numbers for the balancing pass: run length (win
within ~2 h game time, cycle 20-24), phase boundaries, factory size
curve, fitted threat-cost ladder, swarm fleet size (~25 ships), and
block economy roots (bootstrap, doubling time, expansion cadence).
Derived values are always re-derived from these roots, never patched
directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-03 11:30:43 +02:00
493208d230 Add growth curve rules to the building block economy
Diagnose that player output equals building count, so the growth
curve must be shaped by the block and space economy: escalating
expansion costs (formula of expansions purchased) as the long-run
curve, a designed doubling time for the block feedback loop, and an
explicit rule that growth is limited by economy, never by
construction waiting. Note the HQ intake ceiling and add the
condensed-building-block idea to future work, plus an action item
for the expansion cost formula.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-03 10:50:42 +02:00
4098e40c8c Sync progression_design.md with implemented action items
Prune completed action items 1-4 and 8 (scrap-from-threat, schematic
upgrade and ship level removal, balancing tool cleanup, unlock
prerequisites), renumber the remaining three, and update stale
cross-references: cite REQ-LOCK-PREREQ and unlock_requires, reflect
that REQ-THREAT-SCRAP is now the 1/scrap_per_threat constant, and
drop the obsolete level-up wording from the artifact rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-03 09:44:53 +02:00
730cd3bdf3 refinement 2026-07-03 08:36:33 +02:00
437ac97355 Rewrite progression_design.md as rules/principles document
Turn the rough draft into structured rules for the production tree,
progression pacing, and balancing: resource phases, ratio curve,
shortcut recipes, refactorability, cost archetypes, threat model,
drop pacing, scrap/block economies, and numeric guardrails.

Includes agreed decisions: ship scrap derived from threat via a
scrap_per_threat key, no duplicate schematic drops (Mk2 upgrade
recipes deferred to future work), removal of ship levels, and an
action-item list for the follow-up requirements/code changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-03 08:36:33 +02:00
41081620f0 add first draft 2026-07-03 08:36:33 +02:00
122 changed files with 1778 additions and 5166 deletions

View File

@@ -4,20 +4,10 @@ message(STATUS "Using CMake ${CMAKE_VERSION}")
include(cmake/add_files.cmake)
include(cmake/create_source_groups.cmake)
include(cmake/version.cmake)
# Project ----------------------------------------------------------------------
# Product identity — anything that depends on the product/project name is defined
# here so it lives in a single place and can change in the future. These values
# feed the build targets in src/CMakeLists.txt and the Windows version resource
# (see cmake/version.rc.in).
set(PRODUCT_NAME "DotaFactory") # internal name and executable base name
set(PRODUCT_DISPLAY_NAME "Dota Factory") # human-readable product / file description
set(PRODUCT_COMPANY "TODO: company") # placeholder
set(PRODUCT_COPYRIGHT "TODO: copyright") # placeholder
project(${PRODUCT_NAME})
project(DotaFactory)
set(CMAKE_BUILD_TYPE_INIT "Release")

View File

@@ -1,6 +1,5 @@
[[building]]
id = "belt"
tooltip = "Transports items one tile at a time in the direction it faces."
cost = 2
player_placeable = true
construction_time_seconds = 0.2
@@ -8,7 +7,6 @@ surface_mask = ["A>"]
[[building]]
id = "splitter"
tooltip = "Splits an incoming item stream between two outputs, with optional per-output filters."
cost = 3
player_placeable = true
construction_time_seconds = 0.5
@@ -16,7 +14,6 @@ surface_mask = ["<A>"]
[[building]]
id = "tunnel_entry"
tooltip = "Sends items underground to a matching tunnel exit, letting belts cross."
cost = 5
player_placeable = true
construction_time_seconds = 0.5
@@ -24,7 +21,6 @@ surface_mask = ["A>"]
[[building]]
id = "tunnel_exit"
tooltip = "Receives items from a matching tunnel entry and pushes them onward."
cost = 5
player_placeable = true
construction_time_seconds = 0.5
@@ -32,7 +28,6 @@ surface_mask = ["A>"]
[[building]]
id = "miner"
tooltip = "Extracts a selected ore from the asteroid; every tile yields any ore."
cost = 15
player_placeable = true
construction_time_seconds = 1
@@ -42,7 +37,6 @@ surface_mask = [
[[building]]
id = "smelter"
tooltip = "Melts ore or scrap into basic materials. No recipe selection needed."
cost = 20
player_placeable = true
construction_time_seconds = 1
@@ -53,7 +47,6 @@ surface_mask = [
[[building]]
id = "assembler"
tooltip = "Crafts a selected recipe from the production tree into intermediate or final parts."
cost = 35
player_placeable = true
construction_time_seconds = 1
@@ -64,7 +57,6 @@ surface_mask = [
[[building]]
id = "reprocessing_plant"
tooltip = "Consumes scrap and yields one random higher-tier product per cycle."
cost = 40
player_placeable = true
construction_time_seconds = 1
@@ -76,7 +68,6 @@ surface_mask = [
[[building]]
id = "shipyard"
tooltip = "Builds autonomous combat ships from a selected schematic and module layout."
cost = 60
player_placeable = true
construction_time_seconds = 1
@@ -87,12 +78,10 @@ surface_mask = [
[[building]]
id = "salvage_bay"
tooltip = "Drop-off point where salvage ships unload collected scrap onto belts."
cost = 25
player_placeable = true
construction_time_seconds = 1
output_buffer_capacity = 20
surface_mask = [
"<AAS",
" AAS",
"SAA",
"SAA>",
]

View File

@@ -32,7 +32,6 @@
[[module]]
id = "railgun_s"
tooltip = "Small railgun. Fast-firing, short range, low damage; fits any hull."
unlock_at_station_level = -1
surface_mask = ["O"]
materials = [{item = "railgun_s_module", amount = 1}]
@@ -48,7 +47,6 @@ attack_rate_hz = 2.0
[[module]]
id = "railgun_m"
tooltip = "Medium railgun. Higher damage at longer range; needs a 2x2 slot."
unlock_at_station_level = 2
surface_mask = [
"OO",
@@ -58,15 +56,17 @@ production_time_seconds = 3
fill_color = "#FF8040"
glyph = "Rm"
# damage 14 keeps a 60 HP drone at 5 hits — 15+ crosses a breakpoint that
# silently adds ~25% effective DPS vs drones (docs/balancing/history.md,
# round 6).
[module.weapon]
damage = 14
attack_range_m = 70
attack_range_m = 80
attack_rate_hz = 1.5
[[module]]
id = "railgun_l"
tooltip = "Large railgun. Heavy damage at long range; needs a 3x3 slot."
unlock_at_station_level = 6
unlock_requires = ["railgun_m"]
surface_mask = [
@@ -78,9 +78,12 @@ production_time_seconds = 4
fill_color = "#FF8040"
glyph = "Rl"
# attack_range_m 130 deliberately exceeds the station range of 120
# (stations.toml) — the l gun is the only weapon that can besiege stations
# without tanking their fire (docs/balancing/history.md, playtest 1).
[module.weapon]
damage = 52
attack_range_m = 100
attack_range_m = 130
attack_rate_hz = 0.8
# -----------------------------------------------------------------------------
@@ -89,7 +92,6 @@ attack_rate_hz = 0.8
[[module]]
id = "salvager"
tooltip = "Collects scrap from wrecks and stores it in the ship's cargo hold."
unlock_at_station_level = -1
surface_mask = ["O"]
materials = [{item = "salvager_module", amount = 1}]
@@ -105,7 +107,6 @@ collection_rate_hz = 0.5
[[module]]
id = "repair_tool"
tooltip = "Repairs damaged friendly ships and defence stations within range."
unlock_at_station_level = 0
surface_mask = ["O"]
materials = [{item = "repair_tool_module", amount = 1}]
@@ -124,7 +125,6 @@ repair_range_m = 80
[[module]]
id = "afterburner"
tooltip = "Greatly boosts top speed and forward acceleration."
unlock_at_station_level = 2
surface_mask = ["OOO"]
materials = [{item = "afterburner_module", amount = 1}]
@@ -139,7 +139,6 @@ added_main_acceleration_mpss = 60
[[module]]
id = "maneuvering_thrusters"
tooltip = "Improves top speed and lateral/braking acceleration."
unlock_at_station_level = 1
surface_mask = ["OO"]
materials = [{item = "maneuvering_thrusters_module", amount = 1}]
@@ -157,7 +156,6 @@ added_maneuvering_acceleration_mpss = 10
[[module]]
id = "armor_plates"
tooltip = "Adds a large flat bonus to the ship's hit points."
unlock_at_station_level = 0
surface_mask = ["OO"]
materials = [{item = "armor_plates_module", amount = 1}]
@@ -171,7 +169,6 @@ added_hp = 1200
[[module]]
id = "sensor_booster"
tooltip = "Extends the ship's sensor range."
unlock_at_station_level = 1
surface_mask = ["OO"]
materials = [{item = "sensor_booster_module", amount = 1}]
@@ -188,7 +185,6 @@ added_sensor_range_m = 50
[[module]]
id = "weapon_upgrade"
tooltip = "Increases the damage of all weapons on the ship."
unlock_at_station_level = 4
surface_mask = [
"OO",
@@ -205,7 +201,6 @@ multiplied_damage = 1.2
[[module]]
id = "weapon_primer"
tooltip = "Increases the fire rate of all weapons on the ship."
unlock_at_station_level = 4
surface_mask = [
"OO",
@@ -222,7 +217,6 @@ multiplied_attack_rate_hz = 1.2
[[module]]
id = "weapon_stabilizer"
tooltip = "Extends weapon range at the cost of some fire rate."
unlock_at_station_level = 3
surface_mask = [
"OO",
@@ -246,7 +240,6 @@ multiplied_attack_rate_hz = 0.8
[[module]]
id = "drone_bay"
tooltip = "Drone launch bay (capability not yet implemented)."
unlock_at_station_level = 5
surface_mask = [
"OO",
@@ -259,7 +252,6 @@ glyph = "Db"
[[module]]
id = "drone_hangar"
tooltip = "Large drone hangar (capability not yet implemented)."
unlock_at_station_level = 9
surface_mask = [
"OOOOOO",

View File

@@ -348,9 +348,6 @@ demolish_tint = "#ff000033" # demolish-mode hover tint
selection_rect = "#00ff00" # box-drag selection rectangle (REQ-UI-MULTI-SELECT)
tile_highlight = "#ffffff22" # tile under cursor
selected_outline = "#ffff00" # outline drawn around currently-selected building(s)
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)
# -----------------------------------------------------------------------------
# Schematic-drop toasts (REQ-UI-SCHEMATIC-TOAST)

View File

@@ -10,8 +10,6 @@ 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 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
@@ -19,13 +17,6 @@ player_buffer_width_tiles = 20
contest_zone_width_tiles = 60
enemy_buffer_width_tiles = 20
[scroll]
# View pan speed (REQ-UI-SCROLL-SPEED): slow near the asteroid, fast across the
# contest zone, with a linear ramp of the given width straddling each boundary.
pan_speed_slow_tiles_per_second = 16.0
pan_speed_fast_tiles_per_second = 32.0
pan_ramp_band_width_tiles = 16
[expansion]
columns_per_expansion_tiles = 10
# x = expansions already purchased; ~1 per cycle mid-game, decelerating

View File

@@ -4,7 +4,8 @@
# using the verified fitted threat values from tools/threat_report.py:
# drone 10.5, frigate 47, destroyer 99, cruiser 233.5,
# battlecruiser 354.5, battleship 722.5, dreadnought 1491.5,
# carrier 1436.5, glass destroyer (8 small guns) 92, repair drone 17.
# carrier 1436.5, glass destroyer (8 small guns) 92, repair drone 17,
# railgun_s-spam cruiser (12 small guns) 178.
# Module arrays mirror the ships' default_modules loadouts unless a
# doctrine variant is the point of the arena.
#
@@ -348,6 +349,50 @@ enemy_buffer_width_tiles = 10
{type = "railgun_s", x = 4, y = 1, rotation = "east"},
]
[[arena]]
name = "Railgun_s-spam cruisers vs default cruisers (1424 vs 1401)"
# Tracks the playtest-1 meta: cruiser hulls filled with 12 small guns
# (max DPS/threat, no armor, range 50) against the default m-gun fit.
# The concentration tax means the spam side SHOULD win a brawl somewhat;
# this arena bounds its margin — a blowout here means the small-gun
# premium or the m-gun range edge needs retuning.
height_tiles = 10
player_buffer_width_tiles = 10
contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10
[[arena.team]]
name = "Spam"
[[arena.team.ship]]
schematic = "cruiser"
count = 8
modules = [
{type = "railgun_s", x = 1, y = 0, rotation = "east"},
{type = "railgun_s", x = 2, y = 0, rotation = "east"},
{type = "railgun_s", x = 0, y = 1, rotation = "east"},
{type = "railgun_s", x = 1, y = 1, rotation = "east"},
{type = "railgun_s", x = 2, y = 1, rotation = "east"},
{type = "railgun_s", x = 3, y = 1, rotation = "east"},
{type = "railgun_s", x = 0, y = 2, rotation = "east"},
{type = "railgun_s", x = 1, y = 2, rotation = "east"},
{type = "railgun_s", x = 2, y = 2, rotation = "east"},
{type = "railgun_s", x = 3, y = 2, rotation = "east"},
{type = "railgun_s", x = 1, y = 3, rotation = "east"},
{type = "railgun_s", x = 2, y = 3, rotation = "east"},
]
[[arena.team]]
name = "Default"
[[arena.team.ship]]
schematic = "cruiser"
count = 6
modules = [
{type = "railgun_m", x = 0, y = 1, rotation = "east"},
{type = "railgun_m", x = 2, y = 1, rotation = "east"},
{type = "armor_plates", x = 1, y = 0, rotation = "east"},
{type = "maneuvering_thrusters", x = 1, y = 3, rotation = "east"},
]
[[arena]]
name = "Repair escort vs raw numbers (444 vs 444)"
height_tiles = 10

View File

@@ -83,8 +83,6 @@ id = "salvage_bay"
cost = 25
player_placeable = true
construction_time_seconds = 15
output_buffer_capacity = 20
tooltip = "Drop-off point for salvage ships."
surface_mask = [
"SAA",
"SAA>",

View File

@@ -1,6 +1,5 @@
[[module]]
id = "armor_plate"
tooltip = "Adds a large flat bonus to hit points."
unlock_at_station_level = -1
surface_mask = ["OO"]
materials = [{item = "iron_ingot", amount = 2}]

View File

@@ -10,8 +10,6 @@ tunnel_max_distance_tiles = 10
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
@@ -19,11 +17,6 @@ player_buffer_width_tiles = 10
contest_zone_width_tiles = 30
enemy_buffer_width_tiles = 15
[scroll]
pan_speed_slow_tiles_per_second = 8.0
pan_speed_fast_tiles_per_second = 24.0
pan_ramp_band_width_tiles = 16
[expansion]
columns_per_expansion_tiles = 10
cost_building_blocks_formula = "400 * 2^x"

View File

@@ -1,60 +0,0 @@
# simple check for a git repo
if(EXISTS "${CMAKE_SOURCE_DIR}/.git")
find_package(Git)
execute_process(
COMMAND ${GIT_EXECUTABLE} rev-parse --abbrev-ref HEAD
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
OUTPUT_VARIABLE GIT_BRANCH
OUTPUT_STRIP_TRAILING_WHITESPACE
)
execute_process(
COMMAND ${GIT_EXECUTABLE} log -1 --format=%h
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
OUTPUT_VARIABLE GIT_COMMIT_HASH
OUTPUT_STRIP_TRAILING_WHITESPACE
)
execute_process(
COMMAND ${GIT_EXECUTABLE} log -1 --format=%ci
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
OUTPUT_VARIABLE GIT_COMMIT_TIME
OUTPUT_STRIP_TRAILING_WHITESPACE
)
execute_process(
COMMAND ${GIT_EXECUTABLE} describe --long --match "[0-9]*" HEAD
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
OUTPUT_VARIABLE GIT_VERSION_NUMBER
OUTPUT_STRIP_TRAILING_WHITESPACE
)
string(REGEX REPLACE "^([0-9]+)\\..*" "\\1" VERSION_MAJOR "${GIT_VERSION_NUMBER}")
string(REGEX REPLACE "^[0-9]+\\.([0-9]+).*" "\\1" VERSION_MINOR "${GIT_VERSION_NUMBER}")
string(REGEX REPLACE "^[0-9]+\\.[0-9]+\\.([0-9]+).*" "\\1" VERSION_PATCH "${GIT_VERSION_NUMBER}")
string(REGEX REPLACE "^[0-9]+\\.[0-9]+\\.[0-9]+-([0-9]+).*" "\\1" VERSION_COMMIT "${GIT_VERSION_NUMBER}")
else(EXISTS "${CMAKE_SOURCE_DIR}/.git")
set(GIT_BRANCH "")
set(GIT_COMMIT_HASH "")
set(GIT_VERSION_NUMBER "")
set(VERSION_MAJOR "0")
set(VERSION_MINOR "0")
set(VERSION_PATCH "0")
set(VERSION_COMMIT "0")
set(BUILD_TYPE "")
endif(EXISTS "${CMAKE_SOURCE_DIR}/.git")
set(VERSION_STRING "${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}.${VERSION_COMMIT}")
message(STATUS "Version: ${VERSION_STRING}")
# message(STATUS "Git current branch: ${GIT_BRANCH}")
# message(STATUS "Git version number: " ${GIT_VERSION_NUMBER} )
# message(STATUS "Git commit hash: ${GIT_COMMIT_HASH}")
# message(STATUS "Git commit time: ${GIT_COMMIT_TIME}")
# message(STATUS "Version major: ${VERSION_MAJOR}")
# message(STATUS "Version minor: ${VERSION_MINOR}")
# message(STATUS "Version patch: ${VERSION_PATCH}")
# message(STATUS "Version commit: ${VERSION_COMMIT}")

View File

@@ -1,35 +0,0 @@
// Windows version resource. Generated by CMake via configure_file() from this
// template; @VAR@ placeholders are filled from cmake/version.cmake (version
// numbers) and the product identity variables in the top-level CMakeLists.txt.
// Shows up on the executable's Details tab (right-click -> Properties).
#include <windows.h>
VS_VERSION_INFO VERSIONINFO
FILEVERSION @VERSION_MAJOR@,@VERSION_MINOR@,@VERSION_PATCH@,@VERSION_COMMIT@
PRODUCTVERSION @VERSION_MAJOR@,@VERSION_MINOR@,@VERSION_PATCH@,@VERSION_COMMIT@
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
FILEFLAGS 0x0L
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
FILESUBTYPE VFT2_UNKNOWN
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904b0" // US English (0x0409), Unicode (0x04b0)
BEGIN
VALUE "CompanyName", "@PRODUCT_COMPANY@"
VALUE "FileDescription", "@PRODUCT_DISPLAY_NAME@"
VALUE "FileVersion", "@VERSION_STRING@"
VALUE "InternalName", "@PRODUCT_NAME@"
VALUE "OriginalFilename", "@PRODUCT_NAME@.exe"
VALUE "ProductName", "@PRODUCT_DISPLAY_NAME@"
VALUE "ProductVersion", "@VERSION_STRING@"
VALUE "LegalCopyright", "@PRODUCT_COPYRIGHT@"
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1200 // 0x409 = en-US, 1200 = Unicode code page
END
END

View File

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

View File

@@ -21,8 +21,12 @@ REQ-* ids in [../requirements.md](../requirements.md).
First full balancing round complete (2026-07-06): targets → tree →
numbers → threat-calculator parity → combat stats (arena-converged) →
pacing. Next step: full-game playtests against the run-shape targets in
`targets.md`.
pacing. Playtesting in progress: playtest 1 (two full ~40-min wins,
cruisers only) found the railgun_s-spam-cruiser meta, confirmed repair
as overpowered, and exposed a 2.53× run-length gap; combat stats
adjusted and re-checked in arena round 6 (see `history.md`). Next:
playtest 2 with the new stats — pacing knobs (station scaling, threat
rate, win pacing) wait for its result.
## Open action items

View File

@@ -94,10 +94,16 @@ geometry-validated against the hull grids)
## Combat stats
(arena-converged, 2026-07; see `history.md` rounds 15)
(arena-converged 2026-07, rounds 15; playtest-1 adjustments 2026-07-06 —
see `history.md`)
**Weapons:** railgun_s 2 dmg × 2.0 Hz (4.0 DPS), range 50 m;
railgun_m 14 × 1.5 (21), range 70; railgun_l 52 × 0.8 (41.6), range 100.
railgun_m 14 × 1.5 (21), range 80; railgun_l 52 × 0.8 (41.6), range 130.
DPS per threat: s 0.62, m 0.48, l 0.41 — the concentration tax stands;
reach is the bigger guns' compensation (ranges raised after playtest 1,
which alone priced out the small-gun-spam meta). railgun_l deliberately
outranges stations (120 m) to buy the siege role. railgun_m damage is
breakpoint-sensitive: 15+ drops a 60 HP drone from 5 hits to 4.
**Hull HP** (15/threat prior + empirical trims): drone 60, frigate 300,
destroyer 550, cruiser 1500, battlecruiser 2400, battleship 6300,
@@ -109,8 +115,9 @@ destroyer 30|35|18|6|3, cruiser 24|25|12|4|2, battlecruiser 20|20|10|3|1.5,
battleship 15|14|7|2|1, dreadnought/carrier 10|8|4|1|0.5.
Sensors: 150/200/220/250/260/280/300/350 m.
**Other modules:** armor_plates +1200 HP; repair_tool 9 HP × 1 Hz,
range 80; salvager range 60, cargo 20, 0.5 collections/s; afterburner
**Other modules:** armor_plates +1200 HP; repair_tool 4 HP × 1 Hz,
range 80 (halved after playtest 1 — free between-wave top-offs were never
priced by the arena escort test); salvager range 60, cargo 20, 0.5 collections/s; afterburner
×1.6 speed +60 accel; maneuvering_thrusters ×1.2 speed +10 maneuvering;
sensor_booster +50 m; weapon_upgrade ×1.2 damage; weapon_primer ×1.2
rate; weapon_stabilizer ×1.3 range ×0.8 rate.

View File

@@ -94,3 +94,68 @@ range 200).
— ~1 expansion per cycle mid-game, 23 cycles apart late.
- **First full balancing round complete.** Next: full-game playtests
against the run-shape targets.
## 2026-07-06 — playtest 1 (two full playthroughs)
Two complete runs, WON in ~40 min each with cruiser fleets only — never
needing capitals. (Initially misread as "two pushes in 40 min, pacing on
target"; corrected in round 6.) That is roughly cycle 8 against the
win-cycle target of 2024: a 2.53× pacing gap. Pacing knobs deliberately
untouched this round — the runs rode 9 HP/s repair and stations nothing
outranged, both nerfed below; playtest 2 measures the remaining gap.
- **Meta finding: cruisers filled with 12× railgun_s dominate.** Predicted
by the numbers in hindsight: the concentration tax makes railgun_s the
best DPS/threat (0.62 vs m 0.48, l 0.41), range is the big guns' only
mechanical edge (armor is added HP, not damage reduction — no anti-swarm
mechanic), repair sustain covers the closing distance, and stations
outranged every ship gun (120 vs railgun_l's 100), so even capitals had
to tank-and-brawl. The cruiser compounds it: first hull with a large
1×1 canvas (12 cells) and a nearly quartz-free chain.
- **Repair tool confirmed overpowered** (second signal after the
persistent +24% arena escort margin): the arena only prices in-fight
sustain; real runs add free full top-offs in every 1545 s wave gap
across the whole swarm. The 0.7 HP/s-per-threat prior is wrong for
wave defence.
- **Changes:** repair_tool 9→4 HP/s; railgun_l range 100→130 (now
outranges stations — buys the siege role the capital ladder promises);
railgun_m 14→16 dmg and range 70→80 (tax softened: m sits at 0.55
DPS/threat, between s and l). Module threats unchanged (costs
untouched), so no ladder recalculation needed.
- New tracked arena added: railgun_s-spam cruisers (8× 178) vs default
cruisers (6× 233.5) — the spam side should win a brawl somewhat, but a
blowout means the small-gun premium needs retuning.
- **Open:** re-run the arena suite to check the range/damage changes
against the round 15 results; next playtest should verify big guns now
feel worth climbing to and repair is merely good.
## 2026-07-06 — arena round 6 (checking the playtest-1 adjustments)
Mirrors healthy (58% margins, durations 24/66/91 s). Results:
- **Spam-cruiser arena: default cruisers +9% — the meta is priced out**,
and the range buff alone did the work.
- **Regression: drone swarm vs cruisers +47% for cruisers** (was +14%
swarm in round 3). Besides the wider range gap, the damage buff crossed
a breakpoint: 14 dmg kills a 60 HP drone in 5 hits, 16 in 4 — a hidden
~25% effective-DPS gain vs drones. Change: **railgun_m damage 16→14**
(range stays 80); the tax stands, reach is the compensation.
- Battleship +30% and dreadnought +29% vs pure railgun_s fleets:
**accepted as reach-doctrine texture** (BS was already accepted at
+23%) — the l gun's 130 m standoff is exactly what the range buff
bought; the counter is your own reach or 2:1 numbers, not equal-threat
small guns. Watch, don't tune.
- Repair escort flipped to raw +16%: **kept at 4 HP/s deliberately**
the arena cannot price the free between-wave top-offs, so slightly
below par in-fight is the correct price for a module whose run-value
includes them. Playtest 2 decides; 6 is the fallback if repair feels
dead.
- Station assault: the 3× swarm cracked the fortified position keeping
45% EHP. No knob this round touched it; together with playtest 1's
trivially easy pushes it flags **station strength as the first pacing
lever** for the next pass.
**Pacing deferred:** playtest 1's 40-min wins predate the repair nerf
and the l-gun siege range. If playtest 2 still wins by ~cycle 10, the
levers are enemy station scaling (`3000 + 1500*x` likely too shallow),
the threat rate, and possibly `artifact_win_count`.

View File

@@ -4,11 +4,11 @@
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), 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).
- **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, and artifact win count.
- **buildings.toml** — building block cost and construction time per building type.
- **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities. Assembler recipe entries may optionally define `unlock_at_station_level` (integer): -1 means the recipe is explicitly unlocked at game start; a value ≥ 0 means the recipe starts locked and a schematic for it can be awarded via defence station destruction (see REQ-LOCK-EXPLICIT, REQ-DEF-SCHEMATIC-DROP). 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).
- **modules.toml** — per module type: id, surface mask, materials list, production time, fill color, glyph, 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.
- **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.
@@ -68,7 +68,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
## Game World
- REQ-GW-COORDS: Tile coordinates are integer `(x, y)`. The origin `(0, 0)` is the first column of space — the tile immediately to the right of the asteroid's right edge at game start, at the top of the world. X grows right; Y grows down. All asteroid tiles have `x < 0`; asteroid left-expansions add tiles at increasingly negative X. The origin never shifts.
- REQ-GW-TILE-SIZE: Tiles are square. The tile size in pixels is derived automatically so that the world height (in tiles) exactly fills the game world view's height in pixels. Items on belts are rendered at half-tile size; when multiple items occupy the same tile they are spaced quarter-tile apart along the direction of travel and overlap, rendered in ascending order of progress — the least-progressed item is drawn first (bottom) and the furthest-progressed item is drawn last (on top). Items emerging from a building's output port are rendered by these same rules on that port's output belt (REQ-MAT-OUTPUT-EMERGE).
- REQ-GW-TILE-SIZE: Tiles are square. The tile size in pixels is derived automatically so that the world height (in tiles) exactly fills the game world view's height in pixels. Items on belts are rendered at half-tile size; when multiple items occupy the same tile they are spaced quarter-tile apart along the direction of travel and overlap, rendered in ascending order of progress — the least-progressed item is drawn first (bottom) and the furthest-progressed item is drawn last (on top).
- REQ-GW-BELT-CAPACITY: Belt tiles and tunnel entry/exit tiles each hold up to four items simultaneously, queued one behind the other in the direction of travel. Splitter tiles hold up to four items: two unassigned items (progress < 0.5, not yet routed to an output) and one item per output slot (progress ≥ 0.5, committed to a specific output direction). Output-slot items are rendered on top of unassigned items; when both output slots are occupied, their rendering order follows the clockwise port order starting from East.
- REQ-GW-BELT-SPEED: Items on belts move at `world.toml [world].belt_speed_tiles_per_second` tiles per second (default 2).
- REQ-GW-HEIGHT: The world height (in tiles) is read from `world.toml [world].height_tiles`.
@@ -109,15 +109,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- 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-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.
- REQ-BLD-SITE-CONFIG: A construction site — a building that has been placed but is still queued or under construction (REQ-BLD-QUEUE) — can be selected and configured exactly like the equivalent operational building, before it finishes building. Whatever configuration the building type supports is available on the site: the recipe for a Miner or Assembler (REQ-UI-SELECT-BUTTON), the produced-ship schematic and its module layout for a Shipyard (REQ-UI-SELECT-BUTTON, REQ-MOD-UI-PREVIEW, REQ-MOD-UI-DIALOG), and the output filters for a Splitter (REQ-BLD-SPLITTER) — all set through the same Selected Building Panel controls (REQ-UI-CONFIG-INLINE). Only currently unlocked recipes and schematics are offered, exactly as for operational buildings (REQ-LOCK-UI-RECIPE, REQ-LOCK-UI-SCHEMATIC, REQ-LOCK-UI-SPLITTER). The configuration is stored on the construction site and carries over unchanged when construction completes, so the building becomes operational already configured. A construction site has no input/output buffers and runs no production cycle, so the buffer and production-progress portions of the panel (REQ-UI-SINGLE-SELECTION, REQ-UI-PRODUCTION-PROGRESS) are not shown for it; only its construction progress (REQ-UI-CONSTRUCTION-PROGRESS) and its configuration controls appear. (Blueprint placement already applies a stored recipe or schematic to a construction site on placement per REQ-UI-BLUEPRINT-PLACE; this requirement additionally lets the player set or change that configuration directly on an existing site.)
- REQ-BLD-COPY-CONFIG: **Copy building settings (hold Shift).** While the Shift key is held, the player can copy one building's settings onto other buildings of the same type, so several identical machines can be set up without opening each one's panel. This gesture is available only in the default selection mode; while a builder, blueprint placement, or demolish mode is active it is disabled, so it never clashes with placement or demolition clicks.
- **Shift + right-click** a building copies its current settings into a temporary cache, along with the building's type. The settings copied are whatever that building type supports: the selected recipe (Miner, Assembler), the selected schematic together with its module layout (Shipyard), or the two output filters (Splitter, REQ-BLD-SPLITTER). Copying succeeds only when there is something to copy — a Miner or Assembler with a recipe selected, a Shipyard with a schematic selected, or any Splitter (whose output filters, even when empty/accept-all, always constitute valid settings). Shift + right-clicking a configurable building with nothing yet selected, a building type that has no settings at all (Smelter, Reprocessing Plant, Salvage Bay, belt/tunnel tiles, the HQ), or empty world space, has no effect and leaves any existing cache unchanged.
- **Shift + left-click** a building of the **same type** as the cached one applies the cached settings to it, exactly as if the player had made that selection through the selected building panel — with the same effects as a normal selection change (buffer clearing per REQ-MAT-INPUT-BUFFER and REQ-MAT-OUTPUT-BUFFER, and, for a Shipyard, in-progress cycle cancellation per REQ-BLD-SHIPYARD). This can be repeated on any number of same-type buildings while Shift stays held. Shift + left-clicking a building of a different type than the cached one, any building while the cache is empty, or empty world space, has no effect.
- Both operational buildings and construction sites take part as source and target (REQ-BLD-SITE-CONFIG); settings applied to a construction site carry over unchanged when it finishes building.
- **Releasing Shift clears the temporary cache.** It is never persisted and does not survive Shift being released; the next copy starts fresh.
- Because the cached settings were already valid on a same-type source building, they remain valid and available on the target (a selected recipe/schematic stays unlocked per REQ-LOCK-UI-RECIPE and REQ-LOCK-UI-SCHEMATIC; splitter filter item types stay unlocked per REQ-LOCK-UI-SPLITTER).
## Building Types
@@ -126,15 +118,15 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- REQ-BLD-ASSEMBLER: **Assembler** (3×3): The player selects a recipe from the config-defined crafting tree. Produces the selected output item at the rate defined in the corresponding `recipes.toml [[recipe]]` entry with `building = "assembler"`. Only implicitly unlocked recipes are available for selection (REQ-LOCK-UI-RECIPE).
- REQ-BLD-REPROCESSING: **Reprocessing Plant** (3×3): Consumes scrap per cycle and produces exactly one higher-level intermediate product per cycle via weighted random pick. The input quantity, possible output items, per-output weights, and amounts are defined in `recipes.toml [[recipe]]` entries with `building = "reprocessing_plant"` (`inputs`, `outputs[].item`, `outputs[].amount`, `outputs[].weight`). Weights are normalized at load time; their sum does not need to equal 1. The output is rolled at cycle start (see REQ-MAT-CYCLE); the pool of eligible outputs is restricted to implicitly unlocked item types (REQ-LOCK-REPROCESSING-POOL). The output buffer holds at most one cycle's output — see REQ-MAT-OUTPUT-BUFFER-REPROCESSING.
- REQ-BLD-SHIPYARD: **Shipyard** (4×2): The player selects a schematic. When all required materials — the ship's base materials (`[ship.schematic].materials`) plus the materials of all modules in the configured layout (REQ-MOD-MATERIALS) — are present in its input buffer, the shipyard consumes them and begins a production cycle lasting the ship's base `[ship.schematic].production_time_seconds` plus the sum of production times contributed by all module instances in the configured layout (REQ-MOD-PRODUCTION-TIME). One ship of that type is spawned with the configured modules when the cycle completes. The shipyard cannot start a new cycle while one is in progress. If the player confirms a layout change (REQ-MOD-UI-DIALOG) while a production cycle is in progress, the current cycle is cancelled and all consumed materials are discarded; the shipyard returns to idle with the new layout configuration.
- REQ-BLD-SALVAGE-BAY: **Salvage Bay** (3×2): A dedicated drop-off point for salvage ships. It has an output buffer whose holding capacity is defined by the `output_buffer_capacity` field of the `salvage_bay` entry in `buildings.toml` (rather than by a production cycle, since the Salvage Bay has no recipe). A ship at the bay hands over one unit of scrap per tick while the buffer has free space; a full buffer blocks further drop-off until space frees up (consistent with the buffer-full semantics of REQ-MAT-OUTPUT-BUFFER). Held scrap is pushed onto connected output belts.
- REQ-BLD-BELT: **Belt** (1×1): Transports items. A belt tile has one direction (N, S, E, W) set at placement (modified by rotation). Curved belts are auto-derived: when a belt tile's outgoing direction leads into another belt whose direction is orthogonal, the downstream belt is rendered and behaves as a curve. Belt speed is defined in `world.toml [world].belt_speed_tiles_per_second` (REQ-GW-BELT-SPEED). A belt accepts items only through a non-output edge (REQ-MAT-ACCEPT-DIR).
- REQ-BLD-SPLITTER: **Splitter** (1×1): Distributes incoming items between two output directions. Incoming items are accepted only through the splitter's non-output edges (REQ-MAT-ACCEPT-DIR). Each output can optionally have a filter (a list of item types), configurable via the selected building panel; only implicitly unlocked item types are available as filter options (REQ-LOCK-UI-SPLITTER). Routing rules:
- REQ-BLD-SALVAGE-BAY: **Salvage Bay** (3×2): A dedicated drop-off point for salvage ships. Scrap delivered here is placed onto connected output belts.
- REQ-BLD-BELT: **Belt** (1×1): Transports items. A belt tile has one direction (N, S, E, W) set at placement (modified by rotation). Curved belts are auto-derived: when a belt tile's outgoing direction leads into another belt whose direction is orthogonal, the downstream belt is rendered and behaves as a curve. Belt speed is defined in `world.toml [world].belt_speed_tiles_per_second` (REQ-GW-BELT-SPEED).
- REQ-BLD-SPLITTER: **Splitter** (1×1): Distributes incoming items between two output directions. Each output can optionally have a filter (a list of item types), configurable via the selected building panel; only implicitly unlocked item types are available as filter options (REQ-LOCK-UI-SPLITTER). Routing rules:
- An item matching only one output's filter is routed to that output.
- An item matching both outputs' filters is distributed by strict alternation between those outputs.
- An item matching neither output's filter is routed to the unfiltered output. If both outputs have a filter and the item matches neither, the splitter stalls and moves no items until the situation is resolved.
- If neither output has a filter, items are distributed by strict alternation.
- In all alternation cases, if one output is blocked the item goes to the other output until it unblocks.
- REQ-BLD-TUNNEL-ENTRY: **Tunnel Entry** (1×1): The sending end of a tunnel pair. The player sets a direction (N, S, E, W) at placement, rotatable with R/Shift+R. Items arriving from an adjacent belt tile on a non-output edge (i.e. not the mouth edge in the entry's facing direction — see REQ-MAT-ACCEPT-DIR) whose direction points into the entry are forwarded through the tunnel to the paired Tunnel Exit (see REQ-BLD-TUNNEL-PAIR, REQ-BLD-TUNNEL-TRANSIT). If the entry is unpaired, or if the paired exit's output is blocked, the entry blocks like a full belt tile.
- REQ-BLD-TUNNEL-ENTRY: **Tunnel Entry** (1×1): The sending end of a tunnel pair. The player sets a direction (N, S, E, W) at placement, rotatable with R/Shift+R. Items arriving from an adjacent belt tile whose direction points into the entry are forwarded through the tunnel to the paired Tunnel Exit (see REQ-BLD-TUNNEL-PAIR, REQ-BLD-TUNNEL-TRANSIT). If the entry is unpaired, or if the paired exit's output is blocked, the entry blocks like a full belt tile.
- REQ-BLD-TUNNEL-EXIT: **Tunnel Exit** (1×1): The receiving end of a tunnel pair. The player sets a direction at placement, rotatable with R/Shift+R. Items received from the paired Tunnel Entry emerge from the output side of the exit tile — the tile adjacent in the exit's facing direction — continuing in that direction. If the exit is unpaired or its output is blocked, it holds received items until they can advance.
- REQ-BLD-TUNNEL-PAIR: **Tunnel pairing rules.** Pairing is re-evaluated for all Tunnel Entries whenever any Tunnel Entry or Tunnel Exit is placed or demolished.
- A Tunnel Entry searches tile-by-tile in its facing direction for a partner. Any tunnel building (entry or exit) that faces a *different* direction is ignored and skipped. The search stops at the first tunnel building that faces the *same* direction as the searching entry.
@@ -146,27 +138,9 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
## Material Transport & Buffers
- REQ-MAT-BELT-ONLY: Materials are transported exclusively via belts, splitters, and tunnels, with one exception: two directly adjacent buildings whose output and input ports meet transfer items straight between them without an intervening transport tile (REQ-MAT-DIRECT-COUPLE).
- REQ-MAT-INPUT-PORTS: A building accepts items from any adjacent belt tile on any edge of its footprint (excluding cells occupied by output port(s)) whose direction points toward the building, provided the item is an input required by the currently selected recipe and the matching per-material input buffer has free space. An accepted item does not enter the building instantly; it is removed from the belt and travels inward across the input port's footprint cell on that port's own input belt before being added to the buffer (REQ-MAT-INPUT-INTAKE).
- REQ-MAT-INPUT-INTAKE: Accepted input items travel into a building as an animation rather than vanishing off the belt instantly — the input-side mirror of REQ-MAT-OUTPUT-EMERGE. Each input port has its own **input belt** — a virtual belt tile occupying the input port's footprint cell (the body cell the feeding belt points into), oriented in the port's inward flow direction, with progress 0.0 at the outer edge adjacent to the feeding belt and 0.5 at the tile centre. It reuses the belt subsystem: movement at belt speed (REQ-GW-BELT-SPEED), item rendering and spacing (REQ-GW-TILE-SIZE), and capacity/packing (REQ-GW-BELT-CAPACITY), but restricted to the 0.0→0.5 half of the tile. This applies to every building that pulls items from adjacent belts into an input buffer (Smelter, Assembler, Reprocessing Plant, Shipyard); a building may run several input belts at once when belts feed it from more than one side. The HQ is included with the one difference noted below.
- **Acceptance & reservation.** The acceptance test of REQ-MAT-INPUT-PORTS is unchanged — an item is accepted only if it is a required input whose per-material input buffer has space — except that "has space" now counts both the items already buffered **and** the items of that material currently travelling on the building's input belts (reserved but not yet arrived), so the total (buffered + in-transit) never exceeds that material's buffer cap (REQ-MAT-INPUT-BUFFER). An item that fails this test is not placed on an input belt and stays on the feeding belt exactly as before, so items that are not required inputs never enter the building.
- **Feeding.** An accepted item is removed from the feeding belt on the same tick it would have been taken without this animation, and placed on the input belt at progress 0.0, reserving a slot in its per-material buffer. (An input belt may also be fed directly by an adjacent producer's output belt rather than by a real belt — see REQ-MAT-DIRECT-COUPLE — with the same reservation and entry rules.) A new item is placed only when the input belt's entry slot at progress 0.0 is free (per REQ-GW-BELT-CAPACITY spacing — no in-transit item within a quarter tile of 0.0). The 0.0→0.5 span holds at most three in-transit items (progress 0.0, 0.25, 0.5); the reservation limit above may permit fewer.
- **Travel & arrival.** An in-transit item advances from progress 0.0 to 0.5 at belt speed. On reaching progress 0.5 it leaves the input belt and is added to its per-material input buffer, turning its reservation into buffered stock; only then does it count toward starting a production cycle (REQ-MAT-CYCLE). Because the slot was reserved on entry, arrival always succeeds — there is no deadlock.
- **Reservation may delay production.** A reserved item occupies buffer capacity for its whole 0.0→0.5 travel without yet being consumable, so an input-starved building may briefly wait for an in-transit item to arrive before it can start a cycle. This is accepted.
- **Clearing.** Clearing the input buffers on a recipe or schematic change (REQ-MAT-INPUT-BUFFER) also discards any items currently travelling on the input belts and releases their reservations.
- **HQ.** The HQ has no input buffer (REQ-HQ-BELT-INPUT); a building block accepted at an HQ input port travels its input belt the same way but reserves nothing, and is added to the global building blocks stock (REQ-MAT-GLOBAL-STOCK) on reaching progress 0.5.
- **Intake rendering (no pop-out).** Mirror of the emergence rendering in REQ-MAT-OUTPUT-EMERGE: the building is rendered over the input belt, so an in-transit item is occluded while inside the footprint and is only visible as it crosses the outer edge — appearing to sink into the port. The portion inside the footprint is hidden, and the item disappears at the tile centre (progress 0.5) as it enters the buffer.
- REQ-MAT-OUTPUT-PORT: Each building has one or more fixed output port(s) defined by its surface_mask (direction determined by rotation). Produced items do not appear on the outgoing belt instantly; each item leaves the building by first emerging across the output port tile on that port's own output belt and then transferring onto the adjacent real belt tile (REQ-MAT-OUTPUT-EMERGE). The adjacent belt's direction is otherwise unconstrained (it may flow away from the building or perpendicular to it), except that a belt oriented with its own output edge facing back into the building refuses the transfer and the item stays stuck at the port (REQ-MAT-ACCEPT-DIR, REQ-MAT-OUTPUT-EMERGE).
- REQ-MAT-OUTPUT-EMERGE: Items emerge from a building output port as an animation rather than popping directly onto the outgoing belt. Each output port has its own **output belt** — a virtual belt tile occupying the output port tile, oriented in the port's facing direction, with progress 0.0 at the tile's inner edge and 1.0 at the outer (port) edge adjacent to the next real belt tile. It reuses the belt subsystem: movement at belt speed (REQ-GW-BELT-SPEED), item rendering and spacing (REQ-GW-TILE-SIZE), and capacity/packing (REQ-GW-BELT-CAPACITY), but restricted to the 0.5→1.0 half of the tile. This applies to every building that outputs items onto belts (Miner, Smelter, Assembler, Reprocessing Plant, Salvage Bay); it does not apply to the Shipyard, which spawns a ship rather than a belt item (REQ-SHP-SPAWN-PLAYER).
- **Feeding.** While the output buffer (REQ-MAT-OUTPUT-BUFFER) holds an item that has not yet begun emerging and the output belt's entry slot at progress 0.5 is free (per REQ-GW-BELT-CAPACITY spacing — no emerging item within a quarter tile of progress 0.5), the next buffered item is placed on the output belt at progress 0.5. Because only the 0.5→1.0 span is used, the output belt holds at most three emerging items (progress 0.5, 0.75, 1.0); once that span is full the building places no further items on it even if the output buffer still holds more.
- **Cosmetic hold.** An emerging item still counts as residing in the output buffer (REQ-MAT-GLOBAL-STOCK) for the whole animation; it only leaves the building when it transfers onto a real belt tile at progress 1.0. The output belt therefore adds no inventory capacity beyond the output buffer, and clearing the output buffer on a recipe or schematic change (REQ-MAT-OUTPUT-BUFFER) also removes any items currently emerging.
- **Travel & handoff.** An emerging item advances from progress 0.5 to 1.0 at belt speed. At progress 1.0 it attempts to transfer onto the adjacent real belt tile using the normal belt hand-off and accept-direction rules (REQ-MAT-OUTPUT-PORT, REQ-MAT-ACCEPT-DIR): the transfer succeeds only if a transport tile exists there, is not oriented with its output edge facing back into the building, and has free space. On success the item leaves the output buffer and becomes an ordinary item on that belt tile. If instead the output port tile is a directly adjacent building's input edge, the item transfers straight into that building (REQ-MAT-DIRECT-COUPLE).
- **Stuck items.** If there is no next real belt tile and no directly-coupled building (REQ-MAT-DIRECT-COUPLE), or the transfer is refused or blocked, the emerging item stops at progress 1.0 and is rendered there (still counted in the output buffer). Following items pile up behind it at progress 0.75 and 0.5 per the packing above, and once the 0.5→1.0 span is full no further items emerge until the front item transfers.
- **Emergence rendering (no pop-in).** An emerging item must not simply appear at progress 0.5. The output port tile's building is rendered over the output belt, so an emerging item is occluded while inside the footprint and is revealed progressively as it slides past the port edge — appearing to physically emerge from the building. The portion of the item still within the output port tile is hidden; the portion past the outer edge is drawn.
- REQ-MAT-DIRECT-COUPLE: **Direct port coupling.** Two directly adjacent buildings whose ports meet transfer items between them with no intervening transport tile. A direct coupling exists at a shared edge where a producer building's output port tile (the tile it pushes toward, REQ-MAT-OUTPUT-PORT) is a body cell of a consumer building, and the producer's output direction carries the item across that edge into the consumer through one of the consumer's input edges (any perimeter edge other than the consumer's own output port, per REQ-MAT-INPUT-PORTS). Over a direct coupling the two virtual belts chain end to end: an item that reaches progress 1.0 on the producer's output belt at the shared edge (REQ-MAT-OUTPUT-EMERGE) is handed, instead of onto a real belt tile, directly onto the consumer's input belt at progress 0.0 (REQ-MAT-INPUT-INTAKE) and continues inward to the consumer's buffer — so the item appears to slide continuously across the shared edge from one building into the next.
- **Acceptance.** The hand-off obeys the consumer's normal input rules (REQ-MAT-INPUT-PORTS, REQ-MAT-INPUT-INTAKE): it succeeds only if the item is a required input of the consumer whose per-material buffer has space (reservation-aware — buffered + in-transit below the cap) and the consumer's input belt entry at progress 0.0 is free. On success the item leaves the producer's output buffer and reserves a slot in the consumer's input buffer, exactly as a belt-fed intake would. If the consumer does not accept the item — it is not one of its inputs, or the buffer is full, or the input-belt entry is occupied — the item stays stuck at the producer's output port at progress 1.0, exactly as when a downstream belt is blocked (REQ-MAT-OUTPUT-EMERGE stuck items).
- **Scope.** Direct coupling is the only case in which materials move between buildings without a belt, splitter, or tunnel (REQ-MAT-BELT-ONLY); it bridges only two buildings that are directly adjacent with meeting output/input ports. Transport tiles feeding a building (belt, splitter, or tunnel exit) continue to work through the normal pull, and a producer still hands off to a transport tile placed in the gap as before; a single such tile between two buildings is unaffected by this requirement.
- REQ-MAT-ACCEPT-DIR: A transport tile (belt, splitter, tunnel entry, or tunnel exit) accepts an incoming item only through a non-output edge; an item that would enter through one of the tile's output edges is refused. For a belt or a tunnel entry/exit the sole output edge is the one in its facing direction; for a splitter either of its two output directions is an output edge. This applies both to items pushed from an adjacent transport tile and to items deposited by a building's output port (REQ-MAT-OUTPUT-PORT).
- REQ-MAT-BELT-ONLY: Materials are transported exclusively via belts, splitters, and tunnels.
- REQ-MAT-INPUT-PORTS: A building accepts items from any adjacent belt tile on any edge of its footprint (excluding cells occupied by output port(s)) whose direction points toward the building, provided the item is an input required by the currently selected recipe and the matching per-material input buffer has free space.
- REQ-MAT-OUTPUT-PORT: Each building has one or more fixed output port(s) defined by its surface_mask (direction determined by rotation). Produced items are placed onto the belt at the output port tile regardless of that belt's direction.
- REQ-MAT-INPUT-BUFFER: Each building has one input buffer per required input material. Each per-material buffer holds up to twice that material's per-cycle requirement. When the player selects a new recipe or schematic, all items in all input buffers are cleared.
- REQ-MAT-OUTPUT-BUFFER: Each building has an output buffer that holds up to twice the quantity produced by one production cycle. If the output buffer is full, production stops until space is available. When the player selects a new recipe or schematic, all items in the output buffer are cleared (relevant when the adjacent belt is jammed and items have accumulated).
- REQ-MAT-OUTPUT-BUFFER-REPROCESSING: Exception to REQ-MAT-OUTPUT-BUFFER — the Reprocessing Plant's output buffer holds at most one cycle's output. This prevents exploits where the player stalls the output belt to force the plant to reroll.
@@ -277,7 +251,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
### Module UI
- REQ-MOD-UI-PREVIEW: For a selected shipyard (operational building or construction site), the selected building panel always shows a small non-interactive **ship layout preview** widget below the schematic selection button (REQ-UI-SELECT-BUTTON) and a "Configure" button below the preview. Both are **disabled while no schematic is selected**, and enabled once one is; the preview then shows an empty placeholder in place of a layout grid. When a schematic is selected, the preview renders the ship's layout grid at a reduced scale: buildable cells without a module are shown as white, non-buildable cells are shown as black, and cells occupied by a module are shown in that module's `fill_color` with the module's `glyph` character. For non-shipyard buildings, neither the preview nor the "Configure" button is shown.
- REQ-MOD-UI-PREVIEW: When a schematic is selected in a shipyard's selected building panel, a small non-interactive **ship layout preview** widget is shown below the schematic selection button (REQ-UI-SELECT-BUTTON). The preview renders the ship's layout grid at a reduced scale: buildable cells without a module are shown as white, non-buildable cells are shown as black, and cells occupied by a module are shown in that module's `fill_color` with the module's `glyph` character. Below the preview, a "Configure" button is shown.
- REQ-MOD-UI-DIALOG: Clicking the "Configure" button opens the **layout configuration dialog** as a modal. While the dialog is open, the game is paused (speed set to 0×). On close, the game speed is restored to what it was before the dialog was opened.
The dialog contains:
@@ -287,10 +261,6 @@ 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-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.
- REQ-MOD-UI-STATS-PANEL: The **ship stats panel** in the layout configuration dialog shows the stats of the currently configured ship layout as they would be computed, incorporating all passive module modifiers per REQ-MOD-STAT-CALC. The panel updates in real time whenever modules are placed or removed in the layout grid.
The panel always shows all hull stats as final computed values:
@@ -341,13 +311,13 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
In addition to the conditions above, a schematic is included in the eligible drop pool only when every prerequisite in its optional `unlock_requires` list is currently satisfied (REQ-LOCK-PREREQ). Because the pool is rebuilt for each drop, a schematic gated behind prerequisites first appears only after all of its prerequisites have themselves been unlocked.
Each option in the dialog displays: the schematic name (ship `id` from `ships.toml`, module `id` from `modules.toml`, or assembler recipe `id` from `recipes.toml`) and the schematic type (ship, module, or assembler recipe). The artifact option (if present) is displayed as a distinct entry with the name "Artifact".
Each option in the dialog displays: the schematic name (ship `display_name` from `ships.toml`, module `id` from `modules.toml`, or the output item type for assembler recipes) and the schematic type (ship, module, or assembler recipe). The artifact option (if present) is displayed as a distinct entry with the name "Artifact".
Each option additionally displays a vertical list of recipe names labeled "Unlocks recipes:", showing which miner and assembler recipes would newly become implicitly unlocked (REQ-LOCK-IMPLICIT) if this option were selected — specifically, the miner recipes and assembler recipes (without `unlock_at_station_level`) that are not currently implicitly unlocked but would become so after applying this option's effect:
Each option additionally displays a vertical list of item names labeled "Unlocks recipes for:", showing which recipes would newly become implicitly unlocked (REQ-LOCK-IMPLICIT) if this option were selected — specifically, the output items of miner recipes and assembler recipes (without `unlock_at_station_level`) that are not currently implicitly unlocked but would become so after applying this option's effect:
- For a ship or module schematic, its `materials` are added to the base set per REQ-LOCK-IMPLICIT step 1a before recomputation.
- For an assembler recipe schematic, its output item is added to the base set per REQ-LOCK-IMPLICIT step 1b before recomputation.
Each recipe is listed by its `id` (using the same display convention as the assembler recipe-selection dialog), sorted alphabetically. Hovering a recipe in this list displays the recipe info tooltip described for a recipe in REQ-UI-SELECT-TOOLTIP (the recipe name; the name and quantity of each input item; the completion time; and the name and quantity of the produced output item). If no recipes would be newly unlocked, the list shows "None".
Item names are deduplicated and sorted alphabetically. If no recipes would be newly unlocked, the list shows "None".
The player selects one option by clicking it. If the player selects the artifact option, the player's artifact count is incremented by 1 (REQ-WIN-ARTIFACT-COUNT) and the dialog closes; no schematic is applied. Otherwise, the selected schematic is applied and the dialog closes:
@@ -423,39 +393,26 @@ 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 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-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-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).
- REQ-UI-MODAL-DIM: While a modal dialog, menu, or full-screen state screen is open on top of the game, a transparent black overlay (a dim/scrim) is drawn over the **entire game window** — the header bar, the game world view, and the side panel column — behind that modal, so the game reads as inactive while the modal holds focus. The overlay is shown for every modal that auto-pauses the simulation — the escape menu (REQ-UI-GAME-MENU), the recipe/schematic selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), and the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP) — as well as the game-over screen (REQ-HQ-GAME-OVER) and the win screen (REQ-WIN-SCREEN), which end rather than pause the game. When modals are nested (for example the Create Blueprint name dialog (REQ-MOD-UI-BLUEPRINT-CREATE) opened from the layout configuration dialog), only a single dim is shown over the game window; nested modals do not stack additional overlays. The dim color and opacity are read from `visuals.toml [overlays]` (a semi-transparent black modal-dim color), consistent with the other overlay colors. The overlay is presentation-only and has no effect on the simulation.
### Game World
- REQ-UI-SCROLL: The player scrolls the view horizontally across the scrollable area by pressing A (scroll left) and D (scroll right). The pan speed is not constant; it varies with the view's position per REQ-UI-SCROLL-SPEED.
- REQ-UI-SCROLL-SPEED: The horizontal pan speed varies with position so the player crosses the empty middle of the world quickly while retaining fine control near the asteroid and near the front line. Two pan speeds are read from `world.toml [scroll]`: `pan_speed_slow_tiles_per_second` (the base speed, used while the view is over the asteroid and player buffer zone) and `pan_speed_fast_tiles_per_second` (the faster speed, used while the view is over the contest zone). Both are expressed in tiles per second and apply equally to the A and D scroll directions. The current pan speed is a function of the view's horizontal center X (REQ-GW-REGIONS defines the regions):
- **Flat regions:** while the view center is left of the contest zone (over the asteroid or player buffer zone) and outside any ramp band, the pan speed is the slow speed; while the view center is inside the contest zone and outside any ramp band, the pan speed is the fast speed.
- **Ramp bands:** a transition ramp band of width `world.toml [scroll].pan_ramp_band_width_tiles` tiles straddles each contest-zone boundary (the player defence stations on the left, the enemy defence stations on the right), centered on the boundary with half the band width on each side. While the view center is within a ramp band, the pan speed is linearly interpolated between the slow speed at the band's outer (non-contest-zone) edge and the fast speed at the band's inner (contest-zone) edge, by the view center's fractional position across the band. This produces smooth speed changes when entering and exiting the fast contest-zone range rather than an abrupt jump.
- **Narrow contest zone:** should the two ramp bands overlap (a contest zone narrower than the band width), each ramp is clamped at the contest-zone center so the bands do not cross; the fast plateau then reduces to a single point at the center and the peak speed there may be below the fast speed.
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-SCROLL: The player scrolls the view horizontally across the scrollable area by pressing A (scroll left) and D (scroll right).
- REQ-UI-CONSTRUCTION-PROGRESS: Construction sites display the building's glyph centered on the footprint (same as an operational building). Below the glyph — or centered on the footprint if the building has no glyph — a construction progress percentage is shown (integer, e.g. `42%`), increasing from 0% to 100% as construction completes.
- REQ-UI-PORT-GLYPH: Every output port of every building is indicated by a directional glyph drawn on the port's tile. The glyph is a `>` rotated to face the port's exit direction (`>` for East, `^` for North, `<` for West, `v` for South). It is drawn at the midpoint between the tile center and the tile edge that the port exits through (i.e. halfway from center toward the exit edge). The indicator is rendered for all building states: operational buildings, construction sites, and the builder-mode ghost. Buildings with multiple output ports (e.g. splitters) show one indicator per port.
- REQ-UI-PORT-TARGET-GLYPH: While in builder mode (REQ-BLD-BUILDER-MODE), the builder-mode ghost additionally shows, for each of the building's output ports, a directional glyph drawn centered in the port's **target cell** — the cell immediately outside the footprint that the port pushes into, i.e. the cell the surface-mask output-port indicator occupies (see Surface Mask Format). As in REQ-UI-PORT-GLYPH the glyph is a `>` rotated to face the port's exit direction (`>` East, `^` North, `<` West, `v` South), previewing where the port's output will go before placement. This is in addition to the on-tile port glyph of REQ-UI-PORT-GLYPH, and — unlike that indicator — is shown only for the builder-mode ghost, not for operational buildings, construction sites, or the blueprint-placement ghost (REQ-UI-BLUEPRINT-PLACE). A building with multiple output ports (e.g. a splitter) shows one target-cell glyph per port. The target-cell glyph is drawn larger than the on-tile port glyph so it stands out as the flow-direction preview. Exceptions: the Tunnel Entry shows no target-cell glyph, because it receives items (which may arrive from any of its non-mouth edges, REQ-BLD-TUNNEL-ENTRY) rather than emitting into a single adjacent cell; the Shipyard shows none either, because its output port is a ship-spawn point (REQ-SHP-SPAWN-PLAYER) rather than a belt-item output (REQ-MAT-OUTPUT-EMERGE).
- REQ-UI-HP-BARS: All entities with HP — the HQ, player and enemy defence stations, and player and enemy ships — render an HP bar below them. The bar is always visible regardless of current HP. The bar's filled portion represents the fraction of current HP to maximum HP.
- REQ-UI-NO-ZOOM: The view has a fixed zoom level; the player cannot zoom in or out.
- REQ-UI-HOTKEYS: Global keyboard shortcuts:
- **Space** — toggles pause. Pressing Space pauses (sets speed to 0×) and stores the previously selected non-zero speed; pressing Space again restores that speed.
- **W** — increases game speed by one step in the sequence 0×, 0.5×, 1×, 2×, 10× (no wrap-around past 10×).
- **W** — increases game speed by one step in the sequence 0×, 0.5×, 1×, 2×, 4× (no wrap-around past 4×).
- **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.)
- **R / Shift+R** — in builder mode, rotate the ghost counter-clockwise / clockwise (REQ-BLD-ROTATE).
- **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.
@@ -482,51 +439,35 @@ 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-EMPTY-SELECTION: When no building is selected, the panel is empty.
- 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 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-MULTI-SELECT: The player selects multiple buildings by box-drag or by Ctrl+clicking individual buildings to add or remove them from the selection.
- 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.
- 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).
- REQ-UI-SELECT-TOOLTIP: **Selection info tooltip.** Hovering an option button in the selection dialog (REQ-UI-SELECT-BUTTON), and hovering the selection button in the selected building panel when a selection is set, displays an info tooltip:
- 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. 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).
- **Salvaging** — the ship is executing salvage navigation: seeking scrap, collecting, or delivering to a Salvage Bay (REQ-SHP-SALVAGE).
- **Repairing** — the ship is navigating to a repair target (REQ-SHP-REPAIR).
- **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 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).
- 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 empty world space (no building, ship, or defence station) 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-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.
### 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-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.
- REQ-UI-DEMOLISH-BUTTON: A dedicated **Demolish** button is shown in the build button grid. Clicking it toggles demolish mode on and off, equivalent to the Q demolish toggle (REQ-UI-HOTKEYS). The button is shown in a visually active/pressed state while demolish mode is active.
### Blueprint Panel
- REQ-UI-BLUEPRINT-PANEL: The blueprint panel is shown to the right of the build button grid. It contains, from top to bottom: a "Create Blueprint" button, and a list of blueprint entries (one per saved blueprint, in creation order). The panel has no Save or Load buttons; blueprints are persisted automatically (REQ-UI-BLUEPRINT-SAVE) and restored at startup (REQ-UI-BLUEPRINT-LOAD).
- REQ-UI-BLUEPRINT-PANEL: The blueprint panel is shown to the right of the build button grid. It contains, from top to bottom: a "Create Blueprint" button, and a list of blueprint entries (one per saved blueprint, in creation order).
- REQ-UI-BLUEPRINT-CREATE: The "Create Blueprint" button is enabled only when at least one player-placeable building (i.e. a building with a button in the build button grid) is currently selected; non-player-placeable buildings (HQ, defence stations) in the selection do not count toward this condition. A selected player-placeable building may be either an operational building or a construction site (a building placed but not yet fully built, REQ-BLD-SITE-CONFIG); both count toward this condition and are captured identically (REQ-UI-BLUEPRINT-STORAGE). When clicked, a modal dialog appears prompting the player to enter a name. The dialog has Confirm and Cancel buttons. Clicking Cancel closes the dialog with no effect. Clicking Confirm with a non-empty name creates a blueprint from the current selection, silently excluding any non-player-placeable buildings, and appends its button to the blueprint list.
- REQ-UI-BLUEPRINT-CREATE: The "Create Blueprint" button is enabled only when at least one player-placeable building (i.e. a building with a button in the build button grid) is currently selected; non-player-placeable buildings (HQ, defence stations) in the selection do not count toward this condition. When clicked, a modal dialog appears prompting the player to enter a name. The dialog has Confirm and Cancel buttons. Clicking Cancel closes the dialog with no effect. Clicking Confirm with a non-empty name creates a blueprint from the current selection, silently excluding any non-player-placeable buildings, and appends its button to the blueprint list.
- REQ-UI-BLUEPRINT-TEMP: Pressing the **T** key (REQ-UI-HOTKEYS) creates a **temporary blueprint** from the current selection and immediately enters blueprint placement mode for it, without opening the naming dialog. It has effect only when at least one player-placeable building is currently selected — the same condition as REQ-UI-BLUEPRINT-CREATE; pressing T with an empty selection, or a selection containing only non-player-placeable buildings (HQ, defence stations), does nothing. Entering this mode replaces any currently active build, blueprint placement, or demolish mode. The temporary blueprint is captured exactly as a saved blueprint (REQ-UI-BLUEPRINT-STORAGE), silently excluding any non-player-placeable buildings from the selection, but it is never named, never shown in the blueprint panel (REQ-UI-BLUEPRINT-PANEL), and never persisted to `blueprints.toml` (REQ-UI-BLUEPRINT-SAVE). Placement behaves identically to a saved blueprint's placement mode (REQ-UI-BLUEPRINT-MODE, REQ-UI-BLUEPRINT-PLACE): a ghost is rendered per building, R / Shift+R rotate the entire constellation, placement follows the same per-building validity and total-cost rules, and after a successful placement the mode stays active so the blueprint can be placed again. Right-clicking in the game world exits placement mode, at which point the temporary blueprint is discarded.
- REQ-UI-BLUEPRINT-STORAGE: A blueprint stores its name and, for each building in the selection, the building type, its rotation, its tile offset (integer dx, dy) from the center of the bounding box of all selected buildings' footprints, and — where applicable — the selected recipe ID (miners and assemblers) or schematic ID (shipyards), and for splitters the two output filters (each a list of item types; an empty list means accept-all), at the time of capture. A source building may be either an operational building or a construction site (REQ-BLD-SITE-CONFIG); a construction site is captured identically, storing whatever configuration it currently holds and never any buffer or construction-progress state. If no recipe or schematic was selected at capture time, none is stored; for a splitter with no filters set, no filter lists are stored. This structure maps directly to a TOML representation (e.g. one `[[building]]` array entry per constituent building, with the splitter filters as `filter_a`/`filter_b` arrays of item-type ids).
- REQ-UI-BLUEPRINT-STORAGE: A blueprint stores its name and, for each building in the selection, the building type, its rotation, its tile offset (integer dx, dy) from the center of the bounding box of all selected buildings' footprints, and — where applicable — the selected recipe ID (miners and assemblers) or schematic ID (shipyards), and for splitters the two output filters (each a list of item types; an empty list means accept-all), at the time of capture. If no recipe or schematic was selected at capture time, none is stored; for a splitter with no filters set, no filter lists are stored. This structure maps directly to a TOML representation (e.g. one `[[building]]` array entry per constituent building, with the splitter filters as `filter_a`/`filter_b` arrays of item-type ids).
- REQ-UI-BLUEPRINT-BUTTON: Each blueprint entry consists of a blueprint button and a dedicated delete icon ("×") placed to the right of the button. The blueprint button displays the blueprint name and, below it, the total building block cost of the blueprint (sum of the individual costs of all constituent buildings). A blueprint button is disabled when the player cannot afford the total cost. Clicking an enabled blueprint button enters blueprint placement mode for that blueprint. The delete icon is always enabled regardless of whether the player can afford the blueprint.
@@ -536,9 +477,9 @@ The screen is divided into two columns: a main column (75% width) containing the
- REQ-UI-BLUEPRINT-DELETE: Clicking the delete icon ("×") on a blueprint entry immediately removes that blueprint from the list. If the deleted blueprint was active in blueprint placement mode, that mode is exited.
- REQ-UI-BLUEPRINT-SAVE: On application shutdown, all current blueprints are serialized to a file named `blueprints.toml` located in the same directory as the application executable. The TOML structure matches REQ-UI-BLUEPRINT-STORAGE. Write errors are silently ignored on shutdown (no button, no dialog).
- REQ-UI-BLUEPRINT-SAVE: A "Save" button is shown at the bottom of the blueprint panel. Clicking it serializes all current blueprints to a file named `blueprints.toml` located in the same directory as the application executable. The TOML structure matches REQ-UI-BLUEPRINT-STORAGE. If writing fails, a modal error dialog is shown describing the failure.
- REQ-UI-BLUEPRINT-LOAD: At application startup, blueprints are loaded from `blueprints.toml` in the same directory as the application executable, populating the blueprint list (in the order they appear in the file). If the file does not exist, the blueprint list starts empty with no error. If the file exists but cannot be parsed (malformed TOML), a modal error dialog describes the failure and the blueprint list starts empty. There is no Load button and no runtime reload.
- REQ-UI-BLUEPRINT-LOAD: A "Load" button is shown at the bottom of the blueprint panel, to the right of the "Save" button. Clicking it shows a confirmation dialog ("Load blueprints? This will replace all current blueprints.") with Confirm and Cancel buttons. Clicking Cancel closes the dialog with no effect. Clicking Confirm reads `blueprints.toml` from the same directory as the application executable, replaces all current blueprints with those from the file (in the order they appear in the file), and exits any active blueprint-related mode (blueprint placement mode, delete mode). If the file does not exist or cannot be parsed, a modal error dialog is shown describing the failure and the current blueprint list is left unchanged.
## Balancing Tool
@@ -566,7 +507,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×, 10×) 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×, 4×) 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.
@@ -583,6 +524,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×, 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-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-VIEW: The arena view renders all tiles of the arena and displays ships, HQs, defence stations, and laser beams using the same visual elements and `visuals.toml` colors as the main game. Team 1 uses player visual styles; team 2 uses enemy visual styles. The view has a fixed zoom level — no zoom or scroll is possible. The tile size is derived so that the full arena (all tiles) fits within the view.
- REQ-BAL-UI-INSPECT-CLOSE: Closing the inspect window (via the window's close button) aborts the inspected arena's simulation. The arena widget's border returns to grey and its surviving counts are left as they were at the moment of closing. All main window buttons and controls are re-enabled.

View File

@@ -1,4 +1,4 @@
set(TARGET_BASE_NAME "${PRODUCT_NAME}")
set(TARGET_BASE_NAME "DotaFactory")
set(TARGET_APP_NAME "${TARGET_BASE_NAME}")
set(TARGET_LIB_NAME "${TARGET_BASE_NAME}_lib")
@@ -183,19 +183,6 @@ target_compile_definitions(${TARGET_APP_NAME} PRIVATE
)
target_link_libraries(${TARGET_APP_NAME} ${TARGET_UI_NAME})
# Embed the Windows version resource so the version shows on the executable's
# Details tab (right-click -> Properties). Values come from cmake/version.cmake
# (version numbers) and the product identity variables in the top-level
# CMakeLists.txt. MSVC compiles the .rc automatically once it is a target source.
if (WIN32)
configure_file(
"${CMAKE_SOURCE_DIR}/cmake/version.rc.in"
"${CMAKE_CURRENT_BINARY_DIR}/version.rc"
@ONLY
)
target_sources(${TARGET_APP_NAME} PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/version.rc")
endif ()
unset(APP_FILES)
unset(RELATIVE_HDRS)
unset(RELATIVE_SRCS)

View File

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

View File

@@ -46,19 +46,19 @@ struct ArenaStatus
double threatLevel = 0.0; // accumulated threat of the team's configured ships
// Remaining durability of the team's ships and defence stations (HQ
// excluded). currentEhp is summed live; maxEhp is the fixed full-HP
// baseline. See getEhpPercentText() for the displayed value.
// baseline. See ehpPercentText() 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 getEhpPercentText() const;
std::string ehpPercentText() const;
};
TeamStatus teams[2];
bool finished = false;
std::optional<int> winnerTeam; // 0 or 1 when finished; nullopt while running
int winnerTeam = -1; // 0 or 1 when finished; -1 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 getStatus() const;
ArenaStatus status() const;
bool isFinished() const;
std::optional<int> getWinnerTeam() const;
Tick getCurrentTick() const;
int winnerTeam() const;
Tick currentTick() const;
const ArenaConfig& getArenaConfig() const;
const BuildingSystem& getBuildings() const;
const ShipSystem& getShips() const;
const ScrapSystem& getScraps() const;
EntityAdmin& getAdmin();
const EntityAdmin& getAdmin() const;
const ArenaConfig& arenaConfig() const;
const BuildingSystem& buildings() const;
const ShipSystem& ships() const;
const ScrapSystem& scraps() const;
EntityAdmin& admin();
const EntityAdmin& admin() const;
private:
BuildingId allocateBuildingId();
@@ -122,7 +122,7 @@ private:
entt::entity m_team2HqEntity;
bool m_finished;
std::optional<int> m_winnerTeam;
int m_winnerTeam;
std::atomic<bool> m_stopRequested;
// Static accumulated threat per team, computed once from the configured roster.

View File

@@ -15,7 +15,7 @@
#include "Building.h"
#include "BuildingSystem.h"
#include "EntityHitTest.h"
#include "EntitySelectionChangedEvent.h"
#include "EntitySelectedEvent.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::getGameSpeed() const
double ArenaView::gameSpeed() 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->getCurrentTick();
const Tick now = m_sim->currentTick();
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->getAdmin().isValid(event->target)
&& m_sim->getAdmin().hasAll<StationBodyComponent>(event->target))
if (m_sim->admin().isValid(event->target)
&& m_sim->admin().hasAll<StationBodyComponent>(event->target))
{
const StationBodyComponent& sb = m_sim->getAdmin().get<StationBodyComponent>(event->target);
const StationBodyComponent& sb = m_sim->admin().get<StationBodyComponent>(event->target);
const int shorter = std::min(sb.footprint.width(),
sb.footprint.height());
maxRadius = shorter / 2.0f;
}
else if (m_sim->getAdmin().isValid(event->target)
&& m_sim->getAdmin().hasAll<ScrapDataComponent>(event->target))
else if (m_sim->admin().isValid(event->target)
&& m_sim->admin().hasAll<ScrapDataComponent>(event->target))
{
maxRadius = 0.1f;
}
@@ -192,9 +192,9 @@ void ArenaView::paintGL()
// Coordinate helpers
// ---------------------------------------------------------------------------
float ArenaView::getTilePx() const
float ArenaView::tilePx() const
{
const ArenaConfig& ac = m_sim->getArenaConfig();
const ArenaConfig& ac = m_sim->arenaConfig();
const int totalWidth = ac.playerBufferWidth_tiles
+ ac.contestZoneWidth_tiles
+ ac.enemyBufferWidth_tiles;
@@ -209,8 +209,8 @@ float ArenaView::getTilePx() const
QPointF ArenaView::worldToWidget(QVector2D worldPos) const
{
return QPointF(
static_cast<qreal>(worldPos.x() * getTilePx()),
static_cast<qreal>(worldPos.y() * getTilePx()));
static_cast<qreal>(worldPos.x() * tilePx()),
static_cast<qreal>(worldPos.y() * tilePx()));
}
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>(getTilePx()), static_cast<qreal>(getTilePx()));
static_cast<qreal>(tilePx()), static_cast<qreal>(tilePx()));
}
std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const
{
if (!m_sim->getAdmin().isValid(entity) || !m_sim->getAdmin().hasAll<PositionComponent>(entity))
if (!m_sim->admin().isValid(entity) || !m_sim->admin().hasAll<PositionComponent>(entity))
{
return std::nullopt;
}
return m_sim->getAdmin().get<PositionComponent>(entity).value;
return m_sim->admin().get<PositionComponent>(entity).value;
}
QVector2D ArenaView::widgetToWorld(QPoint widgetPt) const
{
const float px = getTilePx();
const float px = tilePx();
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->getAdmin(), worldPos);
entt::entity hit = entityAtWorldPos(m_sim->admin(), worldPos);
if (hit != entt::null)
{
@@ -259,14 +259,8 @@ 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<EntitySelectionChangedEvent>(selection));
std::make_shared<EntitySelectedEvent>(m_selectedEntity));
}
QOpenGLWidget::mousePressEvent(event);
@@ -288,7 +282,7 @@ void ArenaView::keyPressEvent(QKeyEvent* event)
void ArenaView::drawTiles(QPainter& painter)
{
const ArenaConfig& ac = m_sim->getArenaConfig();
const ArenaConfig& ac = m_sim->arenaConfig();
const int totalWidth = ac.playerBufferWidth_tiles
+ ac.contestZoneWidth_tiles
+ ac.enemyBufferWidth_tiles;
@@ -306,7 +300,7 @@ void ArenaView::drawTiles(QPainter& painter)
void ArenaView::drawBuildings(QPainter& painter)
{
for (const Building& b : m_sim->getBuildings().getAllBuildings())
for (const Building& b : m_sim->buildings().allBuildings())
{
const std::map<BuildingType, BuildingVisuals>::const_iterator it =
m_visuals->buildings.find(b.type);
@@ -321,8 +315,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>(getTilePx()),
b.footprint.height() * static_cast<qreal>(getTilePx()));
b.footprint.width() * static_cast<qreal>(tilePx()),
b.footprint.height() * static_cast<qreal>(tilePx()));
painter.setPen(QPen(bv.outline, 1));
painter.setBrush(Qt::NoBrush);
@@ -338,8 +332,8 @@ void ArenaView::drawBuildings(QPainter& painter)
void ArenaView::drawScrap(QPainter& painter)
{
const float r = getTilePx() * 0.2f;
for (const ScrapInfo& scrap : m_sim->getScraps().getAllScrapInfo())
const float r = tilePx() * 0.2f;
for (const ScrapInfo& scrap : m_sim->scraps().allScrapInfo())
{
const QPointF center = worldToWidget(scrap.position);
painter.setBrush(QColor(128, 110, 90));
@@ -351,7 +345,7 @@ void ArenaView::drawScrap(QPainter& painter)
void ArenaView::drawStations(QPainter& painter)
{
m_sim->getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
m_sim->admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f, const HealthComponent& h)
{
const BuildingType visType = f.isEnemy
@@ -370,8 +364,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>(getTilePx()),
sb.footprint.height() * static_cast<qreal>(getTilePx()));
sb.footprint.width() * static_cast<qreal>(tilePx()),
sb.footprint.height() * static_cast<qreal>(tilePx()));
painter.setPen(QPen(bv.outline, 1));
painter.setBrush(Qt::NoBrush);
@@ -380,7 +374,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>(getTilePx()) * 0.12;
const qreal barH = static_cast<qreal>(tilePx()) * 0.12;
const qreal barY = bboxRect.bottom() + 1.0;
const qreal barW = bboxRect.width();
painter.fillRect(QRectF(bboxRect.left(), barY, barW, barH),
@@ -400,7 +394,7 @@ void ArenaView::drawStations(QPainter& painter)
void ArenaView::drawShips(QPainter& painter)
{
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
FactionComponent, HealthComponent>(
[&](entt::entity e, const ShipIdentityComponent& si,
const PositionComponent& pos, const FacingComponent& facing,
@@ -414,8 +408,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 = getTilePx() * 0.45f;
const float side = getTilePx() * 0.25f;
const float fwd = tilePx() * 0.45f;
const float side = tilePx() * 0.25f;
QPolygonF tri;
tri << QPointF(center.x() + static_cast<qreal>(dir.x() * fwd),
@@ -433,7 +427,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>(getTilePx()) * 0.12;
const qreal barH = static_cast<qreal>(tilePx()) * 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));
@@ -443,7 +437,7 @@ void ArenaView::drawShips(QPainter& painter)
if (m_selectedEntity.has_value() && *m_selectedEntity == e)
{
const qreal radius = static_cast<qreal>(getTilePx()) * 0.55;
const qreal radius = static_cast<qreal>(tilePx()) * 0.55;
painter.setPen(QPen(QColor(255, 255, 0), 2));
painter.setBrush(Qt::NoBrush);
painter.drawEllipse(center, radius, radius);
@@ -454,7 +448,7 @@ void ArenaView::drawShips(QPainter& painter)
void ArenaView::drawDebugSensorRanges(QPainter& painter)
{
painter.setBrush(Qt::NoBrush);
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, SensorRangeComponent>(
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent, SensorRangeComponent>(
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
const PositionComponent& pos, const SensorRangeComponent& sensor)
{
@@ -464,7 +458,7 @@ void ArenaView::drawDebugSensorRanges(QPainter& painter)
const QPointF center = worldToWidget(pos.value);
const qreal radiusPx = static_cast<qreal>(sensor.value_tiles)
* static_cast<qreal>(getTilePx());
* static_cast<qreal>(tilePx());
QColor circleColor = it->second.outline;
circleColor.setAlpha(77);
painter.setPen(QPen(circleColor, 1));
@@ -493,7 +487,7 @@ void ArenaView::drawDebugTargetLines(QPainter& painter)
painter.drawLine(worldToWidget(from), worldToWidget(to));
};
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent,
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent,
FactionComponent, AttackBehavior>(
[&](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
const PositionComponent& pos, const FactionComponent& fac,
@@ -508,7 +502,7 @@ void ArenaView::drawDebugTargetLines(QPainter& painter)
drawTargetLine(fac.isEnemy, pos.value, *targetPos);
});
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent,
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent,
FactionComponent, RepairBehavior>(
[&](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
const PositionComponent& pos, const FactionComponent& fac,
@@ -523,7 +517,7 @@ void ArenaView::drawDebugTargetLines(QPainter& painter)
drawTargetLine(fac.isEnemy, pos.value, *targetPos);
});
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent,
m_sim->admin().forEach<ShipIdentityComponent, PositionComponent,
FactionComponent, SalvageScrapBehavior>(
[&](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
const PositionComponent& pos, const FactionComponent& fac,

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,6 @@
#include "HealthComponent.h"
#include "InspectWindowClosedEvent.h"
#include "ModuleOwnerComponent.h"
#include "SelectedBehaviorComponent.h"
#include "ShipIdentityComponent.h"
#include "ShipStatsCalculator.h"
#include "ShipStatsPanel.h"
@@ -199,7 +198,7 @@ void InspectWindow::handleEvent(std::shared_ptr<const GameSpeedChangedEvent> eve
void InspectWindow::pollStatus()
{
const ArenaStatus status = m_sim->getStatus();
const ArenaStatus status = m_sim->status();
updateInfoPanel(status);
refreshEntityStats();
}
@@ -228,7 +227,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.getEhpPercentText())));
ehp->setText(tr("EHP: %1").arg(QString::fromStdString(team.ehpPercentText())));
QString lines;
for (const ArenaStatus::Entry& entry : team.entries)
@@ -250,14 +249,13 @@ void InspectWindow::updateInfoPanel(const ArenaStatus& status)
}
}
void InspectWindow::handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event)
void InspectWindow::handleEvent(std::shared_ptr<const EntitySelectedEvent> event)
{
if (!event->entities.empty())
if (event->entity.has_value())
{
// The arena is single-select, so only the first entity is inspected.
m_selectedEntity = event->entities.front();
m_selectedEntity = event->entity;
EntityAdmin& admin = m_sim->getAdmin();
EntityAdmin& admin = m_sim->admin();
entt::entity entity = *m_selectedEntity;
if (!admin.isValid(entity))
@@ -280,8 +278,6 @@ void InspectWindow::handleEvent(std::shared_ptr<const EntitySelectionChangedEven
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
m_entityStatsPanel->refreshFromLive(stats, health.hp);
m_entityStatsPanel->setBehavior(
admin.get<SelectedBehaviorComponent>(entity).winner);
m_entityStatsPanel->show();
m_stationStatsLabel->hide();
}
@@ -333,7 +329,7 @@ void InspectWindow::refreshEntityStats()
{
if (!m_selectedEntity.has_value()) { return; }
EntityAdmin& admin = m_sim->getAdmin();
EntityAdmin& admin = m_sim->admin();
entt::entity entity = *m_selectedEntity;
if (!admin.isValid(entity))
@@ -359,8 +355,6 @@ void InspectWindow::refreshEntityStats()
{
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
m_entityStatsPanel->refreshFromLive(stats, health.hp);
m_entityStatsPanel->setBehavior(
admin.get<SelectedBehaviorComponent>(entity).winner);
}
else if (admin.hasAll<StationBodyComponent>(entity))
{

View File

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

View File

@@ -1,6 +1,5 @@
#pragma once
#include <optional>
#include <string>
#include <vector>
@@ -19,30 +18,9 @@ struct BuildingDef
// Stored as raw strings here; parsing into per-cell tiles + output ports
// happens when buildings are placed, not at load time.
std::vector<std::string> surfaceMask;
// Output-buffer holding size for buildings without a recipe-driven buffer.
// Only the Salvage Bay sets this (REQ-BLD-SALVAGE-BAY).
std::optional<int> outputBufferCapacity;
// Optional hover-tooltip text for the build button (REQ-UI-BUILD-TOOLTIP).
std::optional<std::string> tooltip;
};
struct BuildingsConfig
{
std::vector<BuildingDef> buildings;
// Returns the definition for the given building type, or nullptr if the
// type has no entry in buildings.toml.
const BuildingDef* findBuildingDef(BuildingType type) const
{
for (const BuildingDef& def : buildings)
{
if (def.type == type)
{
return &def;
}
}
return nullptr;
}
};

View File

@@ -273,18 +273,6 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path)
cfg.orbitFactor = requireDouble(tbl["world"]["orbit_factor"], file, "world.orbit_factor");
cfg.rallyOrbitRadius_tiles = requireDouble(tbl["world"]["rally_orbit_radius_tiles"], file, "world.rally_orbit_radius_tiles");
if (const std::optional<std::string> tip =
tbl["world"]["building_blocks_tooltip"].value<std::string>())
{
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"));
@@ -317,10 +305,6 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path)
cfg.artifacts.artifactChanceFormula = requireFormula(tbl["artifacts"]["artifact_chance_formula"], file, "artifacts.artifact_chance_formula");
cfg.artifacts.artifactWinCount = static_cast<int>(requireInt(tbl["artifacts"]["artifact_win_count"], file, "artifacts.artifact_win_count"));
cfg.scroll.panSpeedSlow_tps = requireDouble(tbl["scroll"]["pan_speed_slow_tiles_per_second"], file, "scroll.pan_speed_slow_tiles_per_second");
cfg.scroll.panSpeedFast_tps = requireDouble(tbl["scroll"]["pan_speed_fast_tiles_per_second"], file, "scroll.pan_speed_fast_tiles_per_second");
cfg.scroll.panRampBandWidth_tiles = static_cast<int>(requireInt(tbl["scroll"]["pan_ramp_band_width_tiles"], file, "scroll.pan_ramp_band_width_tiles"));
return cfg;
}
@@ -349,17 +333,6 @@ BuildingsConfig ConfigLoader::loadBuildings(const std::string& path)
def.constructionTimeSeconds = requireDouble(mt["construction_time_seconds"], file, elemPath + ".construction_time_seconds");
def.surfaceMask = requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask");
if (mt.contains("output_buffer_capacity"))
{
def.outputBufferCapacity = static_cast<int>(
requireInt(mt["output_buffer_capacity"], file, elemPath + ".output_buffer_capacity"));
}
if (mt.contains("tooltip"))
{
def.tooltip = requireString(mt["tooltip"], file, elemPath + ".tooltip");
}
const std::optional<BuildingType> parsedType = parseBuildingType(def.id);
if (!parsedType)
{
@@ -620,11 +593,6 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
def.fillColor = requireString(mt["fill_color"], file, elemPath + ".fill_color");
def.glyph = requireString(mt["glyph"], file, elemPath + ".glyph");
if (mt.contains("tooltip"))
{
def.tooltip = requireString(mt["tooltip"], file, elemPath + ".tooltip");
}
// Materials
{
const toml::array& materials = requireArray(mt["materials"], file, elemPath + ".materials");

View File

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

View File

@@ -54,10 +54,6 @@ struct ModuleDef
std::optional<ModuleWeaponCapability> weaponCapability;
std::optional<ModuleSalvageCapability> salvageCapability;
std::optional<ModuleRepairCapability> repairCapability;
// Optional hover-tooltip text for the module selection button
// (REQ-MOD-UI-MODULE-TOOLTIP).
std::optional<std::string> tooltip;
};
struct ModulesConfig

View File

@@ -1,8 +1,5 @@
#pragma once
#include <optional>
#include <string>
#include "Formula.h"
// Region widths are in tiles (REQ-GW-REGIONS).
@@ -56,14 +53,6 @@ struct WorldArtifacts
int artifactWinCount;
};
// View pan speed (REQ-UI-SCROLL-SPEED). Presentation-only; the simulation ignores these.
struct WorldScroll
{
double panSpeedSlow_tps; // tiles/s, used outside the contest zone
double panSpeedFast_tps; // tiles/s, used inside the contest zone
int panRampBandWidth_tiles; // full width of the ramp band straddling each contest-zone boundary
};
struct WorldConfig
{
int heightTiles; // REQ-GW-HEIGHT
@@ -78,19 +67,10 @@ struct WorldConfig
double orbitFactor; // REQ-SHP-ORBIT (multiplies tool range for orbit radius)
double rallyOrbitRadius_tiles; // REQ-SHP-ORBIT (fixed orbit radius around the rally point)
// Optional hover-tooltip for the header building blocks stock display
// (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;
WorldWaves waves;
WorldTargeting targeting;
WorldArtifacts artifacts;
WorldScroll scroll;
};

View File

@@ -20,8 +20,8 @@ struct SchematicChoiceOption
SchematicType type;
std::string displayName;
// Ids of miner/assembler recipes that would newly become implicitly
// unlocked (REQ-LOCK-IMPLICIT) if this option is selected. Sorted
// alphabetically by display name; empty if none.
std::vector<std::string> newlyUnlockedRecipeIds;
// Display names of items produced by recipes that would newly become
// implicitly unlocked (REQ-LOCK-IMPLICIT) if this option is selected.
// Deduplicated and sorted alphabetically; empty if none.
std::vector<std::string> newlyUnlockedItemNames;
};

View File

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

View File

@@ -30,7 +30,7 @@ void SalvagerSystem::tick(Tick currentTick, ScrapSystem& scraps, BuildingSystem&
// Apply collections whose mid-beam delay has elapsed (cycles started earlier).
applyPendingCollections(currentTick, scraps);
const std::vector<ScrapInfo> allScrap = scraps.getAllScrapInfo();
const std::vector<ScrapInfo> allScrap = scraps.allScrapInfo();
// 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.has_value()) { return; }
const Building* bay = buildings.findBuilding(*deliver.deliveryBay);
if (deliver.deliveryBay == kInvalidBuildingId) { return; }
const Building* bay = buildings.findBuilding(deliver.deliveryBay);
if (!bay) { return; }
const QVector2D bayCenter(bay->anchor.x() + bay->footprint.width() / 2.0f,
@@ -100,7 +100,7 @@ void SalvagerSystem::tick(Tick currentTick, ScrapSystem& scraps, BuildingSystem&
if (!m_admin.hasAll<CargoComponent>(ship)) { return; }
CargoComponent& cargo = m_admin.get<CargoComponent>(ship);
if (cargo.current <= 0) { return; }
if (buildings.deliverScrapToSalvageBay(*deliver.deliveryBay))
if (buildings.deliverScrapToSalvageBay(deliver.deliveryBay))
{
--cargo.current;
}

View File

@@ -65,13 +65,13 @@ bool ScrapSystem::collectOne(entt::entity entity)
return true;
}
std::vector<ScrapInfo> ScrapSystem::getAllScrapInfo() const
std::vector<ScrapInfo> ScrapSystem::allScrapInfo() const
{
std::vector<ScrapInfo> result;
m_admin.forEach<ScrapDataComponent>(
[&result, this](entt::entity e, const ScrapDataComponent& sd)
[&result, this](entt::entity e, const ScrapDataComponent& /*sd*/)
{
result.push_back(ScrapInfo{e, m_admin.get<PositionComponent>(e).value, sd.amount});
result.push_back(ScrapInfo{e, m_admin.get<PositionComponent>(e).value});
});
return result;
}

View File

@@ -15,7 +15,6 @@ struct ScrapInfo
{
entt::entity entity;
QVector2D position;
int amount;
};
class ScrapSystem
@@ -35,7 +34,7 @@ public:
bool collectOne(entt::entity entity);
// Lightweight snapshot for callers that need to iterate all scrap.
std::vector<ScrapInfo> getAllScrapInfo() const;
std::vector<ScrapInfo> allScrapInfo() const;
private:
EntityAdmin& m_admin;

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/TickAdvancedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingBlocksChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/ExpansionCostChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/EntitySelectionChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/EntitySelectedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/GameSpeedChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BossWaveUpdatedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoicesAvailableEvent.h
@@ -22,7 +22,6 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/DemolishModeToggleRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPlacementRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/ExitBlueprintModeRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/TemporaryBlueprintRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/SpeedChangeRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/LayoutDialogRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionRequestedEvent.h
@@ -32,7 +31,6 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/BeamFiredEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/CommandRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/PlayerCommandsAppliedEvent.h
PARENT_SCOPE
)

View File

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

View File

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

View File

@@ -1,13 +0,0 @@
#pragma once
#include "Event.h"
// Emitted by GameWorldView once per frame after queued player commands have been
// drained and applied to the simulation. It lets presentation widgets refresh
// even while the game is paused (no tick advances, so no TickAdvancedEvent), for
// example so a shipyard's layout preview appears immediately after its schematic
// is chosen. It is a UI notification only and never feeds back into the command
// queue, so it has no effect on replay recording or determinism.
class PlayerCommandsAppliedEvent : public Event
{
};

View File

@@ -1,18 +0,0 @@
#pragma once
#include <vector>
#include "entt/entity/entity.hpp"
#include "Event.h"
// The set of currently selected scrap piles (REQ-UI-SCRAP-CLICK-SELECT,
// REQ-UI-SCRAP-MULTI-SELECT). An empty list means no scrap is selected. Scrap forms
// its own selection category, mutually exclusive with buildings and entities.
class ScrapSelectionChangedEvent : public Event
{
public:
explicit ScrapSelectionChangedEvent(std::vector<entt::entity> scrap)
: scrap(std::move(scrap)) {}
const std::vector<entt::entity> scrap;
};

View File

@@ -1,9 +0,0 @@
#pragma once
#include "Event.h"
// Emitted when the player presses the temporary-blueprint hotkey (REQ-UI-BLUEPRINT-TEMP).
// Carries no payload: the blueprint is built from the current selection by the receiver.
class TemporaryBlueprintRequestedEvent : public Event
{
};

View File

@@ -1,45 +0,0 @@
#include "BeltSlot.h"
#include <cstddef>
void advanceBeltSlots(std::vector<BeltItemSlot>& slots, double progressPerTick)
{
for (std::size_t i = 0; i < slots.size(); ++i)
{
slots[i].progress += progressPerTick;
// Absolute cap: slot i cannot exceed 1.0 - i * 0.25.
const double absoluteCap = 1.0 - static_cast<double>(i) * 0.25;
if (slots[i].progress > absoluteCap)
{
slots[i].progress = absoluteCap;
}
// Gap constraint: must stay 0.25 behind the slot ahead.
if (i > 0)
{
const double gapCap = slots[i - 1].progress - 0.25;
if (slots[i].progress > gapCap)
{
slots[i].progress = (gapCap < 0.0 ? 0.0 : gapCap);
}
}
}
}
QPointF beltSlotWorldPos(QPoint tile, Rotation dir, double progress)
{
// Map progress [0, 1] along the belt direction to a fractional tile-unit position.
// Progress 0 = entered from opposite side; 1 = at output edge.
const double baseX = tile.x() + 0.5;
const double baseY = tile.y() + 0.5;
switch (dir)
{
case Rotation::North: return {baseX, baseY - (progress - 0.5)};
case Rotation::East: return {baseX + (progress - 0.5), baseY};
case Rotation::South: return {baseX, baseY + (progress - 0.5)};
case Rotation::West: return {baseX - (progress - 0.5), baseY};
}
return {baseX, baseY};
}

View File

@@ -1,30 +0,0 @@
#pragma once
#include <vector>
#include <QPoint>
#include <QPointF>
#include "Item.h"
#include "Rotation.h"
// A single item on a belt-like lane: an item plus its fractional progress along
// the lane's travel direction. Shared by BeltSystem's belt/tunnel tiles and by a
// building's virtual output belt (REQ-MAT-OUTPUT-EMERGE) so the packing and
// geometry live in exactly one place.
struct BeltItemSlot
{
Item item;
double progress; // [0.0, 1.0]: 0 = just entered, 1 = at output edge
};
// Advances every slot in `slots` by `progressPerTick`, applying the standard belt
// packing: the front (index 0) carries the highest progress; each following slot
// stays at least 0.25 behind the slot ahead and is capped at 1.0 - i * 0.25.
// `slots` must be ordered front (highest progress) first. This is the per-tile
// advance shared by belts, tunnel entries, and tunnel exits.
void advanceBeltSlots(std::vector<BeltItemSlot>& slots, double progressPerTick);
// World-space centre (in tile units) of a slot at `progress` on a lane occupying
// `tile` and flowing in `dir`. Progress 0 = entry edge, 1 = output edge.
QPointF beltSlotWorldPos(QPoint tile, Rotation dir, double progress);

View File

@@ -27,54 +27,21 @@ QPoint BeltSystem::adjacentTile(QPoint tile, Rotation dir)
return tile;
}
Rotation BeltSystem::oppositeRotation(Rotation dir)
QPointF BeltSystem::slotWorldPos(QPoint tile, Rotation dir, double progress)
{
// Map progress [0, 1] along the belt direction to a fractional tile-unit position.
// Progress 0 = entered from opposite side; 1 = at output edge.
double baseX = tile.x() + 0.5;
double baseY = tile.y() + 0.5;
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;
case Rotation::North: return {baseX, baseY - (progress - 0.5)};
case Rotation::East: return {baseX + (progress - 0.5), baseY};
case Rotation::South: return {baseX, baseY + (progress - 0.5)};
case Rotation::West: return {baseX - (progress - 0.5), baseY};
}
return dir;
}
bool BeltSystem::entersThroughOutputEdge(QPoint tile, Rotation travelDir) const
{
// An item travelling in travelDir crosses into the tile through the edge
// opposite that direction. If that entry edge is one of the tile's output
// edges, the tile must refuse the item (REQ-MAT-ACCEPT-DIR).
const Rotation entryEdge = oppositeRotation(travelDir);
const std::map<std::pair<int, int>, BeltTile>::const_iterator beltIt =
m_belts.find(key(tile));
if (beltIt != m_belts.end())
{
return entryEdge == beltIt->second.direction;
}
const std::map<std::pair<int, int>, SplitterTile>::const_iterator splIt =
m_splitters.find(key(tile));
if (splIt != m_splitters.end())
{
return entryEdge == splIt->second.outputA || entryEdge == splIt->second.outputB;
}
const std::map<std::pair<int, int>, TunnelEntryTile>::const_iterator teIt =
m_tunnelEntries.find(key(tile));
if (teIt != m_tunnelEntries.end())
{
return entryEdge == 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 entryEdge == txIt->second.direction;
}
return false;
return {baseX, baseY};
}
// ---------------------------------------------------------------------------
@@ -235,12 +202,6 @@ void BeltSystem::reevaluateTunnelPairing()
bool BeltSystem::tryPutItem(QPoint tile, Item item, Rotation fromDir)
{
// Refuse items that would enter through the tile's output edge (REQ-MAT-ACCEPT-DIR).
if (entersThroughOutputEdge(tile, fromDir))
{
return false;
}
const std::map<std::pair<int, int>, BeltTile>::iterator bIt = m_belts.find(key(tile));
if (bIt != m_belts.end())
{
@@ -450,7 +411,29 @@ void BeltSystem::advanceProgress()
for (std::map<std::pair<int, int>, BeltTile>::iterator it = m_belts.begin();
it != m_belts.end(); ++it)
{
advanceBeltSlots(it->second.itemSlots, m_progressPerTick_tpt);
BeltTile& bt = it->second;
for (std::size_t i = 0; i < bt.itemSlots.size(); ++i)
{
bt.itemSlots[i].progress += m_progressPerTick_tpt;
// Absolute cap: slot i cannot exceed 1.0 - i * 0.25.
const double absoluteCap = 1.0 - i * 0.25;
if (bt.itemSlots[i].progress > absoluteCap)
{
bt.itemSlots[i].progress = absoluteCap;
}
// Gap constraint: must stay 0.25 behind the slot ahead.
if (i > 0)
{
const double gapCap = bt.itemSlots[i - 1].progress - 0.25;
if (bt.itemSlots[i].progress > gapCap)
{
bt.itemSlots[i].progress = (gapCap < 0.0 ? 0.0 : gapCap);
}
}
}
}
for (std::map<std::pair<int, int>, SplitterTile>::iterator it = m_splitters.begin();
@@ -506,13 +489,53 @@ void BeltSystem::advanceTunnelProgress()
for (std::map<std::pair<int, int>, TunnelEntryTile>::iterator it = m_tunnelEntries.begin();
it != m_tunnelEntries.end(); ++it)
{
advanceBeltSlots(it->second.itemSlots, m_progressPerTick_tpt);
TunnelEntryTile& te = it->second;
for (std::size_t i = 0; i < te.itemSlots.size(); ++i)
{
te.itemSlots[i].progress += m_progressPerTick_tpt;
const double absoluteCap = 1.0 - i * 0.25;
if (te.itemSlots[i].progress > absoluteCap)
{
te.itemSlots[i].progress = absoluteCap;
}
if (i > 0)
{
const double gapCap = te.itemSlots[i - 1].progress - 0.25;
if (te.itemSlots[i].progress > gapCap)
{
te.itemSlots[i].progress = (gapCap < 0.0 ? 0.0 : gapCap);
}
}
}
}
for (std::map<std::pair<int, int>, TunnelExitTile>::iterator it = m_tunnelExits.begin();
it != m_tunnelExits.end(); ++it)
{
advanceBeltSlots(it->second.itemSlots, m_progressPerTick_tpt);
TunnelExitTile& tx = it->second;
for (std::size_t i = 0; i < tx.itemSlots.size(); ++i)
{
tx.itemSlots[i].progress += m_progressPerTick_tpt;
const double absoluteCap = 1.0 - i * 0.25;
if (tx.itemSlots[i].progress > absoluteCap)
{
tx.itemSlots[i].progress = absoluteCap;
}
if (i > 0)
{
const double gapCap = tx.itemSlots[i - 1].progress - 0.25;
if (tx.itemSlots[i].progress > gapCap)
{
tx.itemSlots[i].progress = (gapCap < 0.0 ? 0.0 : gapCap);
}
}
}
}
for (TunnelLink& link : m_tunnelLinks)
@@ -556,13 +579,6 @@ void BeltSystem::moveItemsToNextTile()
const QPoint here = QPoint(it->first.first, it->first.second);
const QPoint next = adjacentTile(here, bt.direction);
// Refuse to hand off into a downstream tile's output edge (REQ-MAT-ACCEPT-DIR);
// the item stays blocked at progress 1.0.
if (entersThroughOutputEdge(next, bt.direction))
{
continue;
}
const std::map<std::pair<int, int>, BeltTile>::iterator nextBelt = m_belts.find(key(next));
const std::map<std::pair<int, int>, SplitterTile>::iterator nextSplitter = m_splitters.find(key(next));
@@ -788,12 +804,6 @@ bool BeltSystem::tryPlaceOnBelt(QPoint tile, Item item)
bool BeltSystem::tryPushToTile(QPoint dest, Item item, Rotation fromDir)
{
// Refuse items that would enter through the tile's output edge (REQ-MAT-ACCEPT-DIR).
if (entersThroughOutputEdge(dest, fromDir))
{
return false;
}
if (tryPlaceOnBelt(dest, item))
{
return true;
@@ -861,7 +871,7 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles,
{
VisualItem vi;
vi.type = bt.itemSlots[i].item.type;
vi.worldPos = beltSlotWorldPos(tile, bt.direction, bt.itemSlots[i].progress);
vi.worldPos = slotWorldPos(tile, bt.direction, bt.itemSlots[i].progress);
visit(vi);
}
}
@@ -881,7 +891,7 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles,
{
VisualItem vi;
vi.type = st.back[i].item.type;
vi.worldPos = beltSlotWorldPos(tile, st.backDir[i], st.back[i].progress);
vi.worldPos = slotWorldPos(tile, st.backDir[i], st.back[i].progress);
visit(vi);
}
@@ -907,7 +917,7 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles,
{
VisualItem vi;
vi.type = slot->item.type;
vi.worldPos = beltSlotWorldPos(tile, dir, slot->progress);
vi.worldPos = slotWorldPos(tile, dir, slot->progress);
visit(vi);
}
};
@@ -937,7 +947,7 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles,
{
VisualItem vi;
vi.type = te.itemSlots[i].item.type;
vi.worldPos = beltSlotWorldPos(tile, te.direction, te.itemSlots[i].progress);
vi.worldPos = slotWorldPos(tile, te.direction, te.itemSlots[i].progress);
visit(vi);
}
}
@@ -955,7 +965,7 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles,
{
VisualItem vi;
vi.type = tx.itemSlots[i].item.type;
vi.worldPos = beltSlotWorldPos(tile, tx.direction, tx.itemSlots[i].progress);
vi.worldPos = slotWorldPos(tile, tx.direction, tx.itemSlots[i].progress);
visit(vi);
}
}

View File

@@ -10,7 +10,6 @@
#include <QPointF>
#include <QRect>
#include "BeltSlot.h"
#include "Item.h"
#include "ItemType.h"
#include "Port.h"
@@ -75,10 +74,8 @@ public:
// port.direction = direction items flow on that tile
//
// tryPutItem: place item onto tile.
// Returns false if the tile is not a belt/splitter/tunnel entry, tile full,
// or the item would enter through the tile's output edge (REQ-MAT-ACCEPT-DIR).
// fromDir: travel direction of the item (used for splitter animation and for
// the output-edge check).
// Returns false if the tile is not a belt/splitter, or tile full.
// fromDir: travel direction of the item (used for splitter animation).
bool tryPutItem(QPoint tile, Item item, Rotation fromDir = Rotation::West);
// tryTakeItem: remove and return the leading item from port.tile.
@@ -89,11 +86,6 @@ public:
// Returns nullopt if tile is not a belt, direction mismatches, or tile empty.
std::optional<ItemType> peekItem(Port port) const;
// Progress advanced per tick at the configured belt speed (tile fraction per
// tick). Shared with building output belts so emerging items travel at exactly
// the same speed as real belts (REQ-MAT-OUTPUT-EMERGE).
double getProgressPerTick_tpt() const { return m_progressPerTick_tpt; }
// -- Maintenance ---------------------------------------------------------
void clearTiles(const std::vector<QPoint>& tiles); // REQ-UI-BELT-CLEAR
void tick();
@@ -125,12 +117,15 @@ private:
static std::pair<int, int> key(QPoint tile);
static QPoint adjacentTile(QPoint tile, Rotation dir);
static Rotation oppositeRotation(Rotation dir);
// True if an item travelling in travelDir would enter the transport tile at
// `tile` through one of that tile's output edges (and must therefore be
// refused). Returns false if no transport tile occupies `tile`.
bool entersThroughOutputEdge(QPoint tile, Rotation travelDir) const;
// Returns the world-space centre of a slot given tile origin and progress.
static QPointF slotWorldPos(QPoint tile, Rotation dir, double progress);
struct BeltItemSlot
{
Item item;
double progress; // [0.0, 1.0]: 0 = just entered, 1 = at output edge
};
struct BeltTile
{

View File

@@ -12,7 +12,6 @@
#include "BuildingId.h"
#include "entt/entity/entity.hpp"
#include "BeltSlot.h"
#include "Item.h"
#include "ItemType.h"
#include "Port.h"
@@ -76,51 +75,6 @@ struct Building
OutputBuffer outputBuffer;
std::optional<Production> production;
// Items currently emerging from each output port on its virtual output belt
// (REQ-MAT-OUTPUT-EMERGE); one lane per output port, parallel to outputPorts.
// Each lane holds slots at progress [0.5, 1.0], front (highest progress) first.
// An emerging item still counts as residing in the output buffer until it hands
// off onto a real belt at progress 1.0.
std::vector<std::vector<BeltItemSlot>> emergingItems;
// 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 getOutputItemCount() const
{
int count = static_cast<int>(outputBuffer.items.size());
for (const std::vector<BeltItemSlot>& lane : emergingItems)
{
count += static_cast<int>(lane.size());
}
return count;
}
// Items currently travelling inward on each input port's virtual input belt
// (REQ-MAT-INPUT-INTAKE); one lane per input port, parallel to inputPorts. Each
// lane holds slots at progress [0.0, 0.5], front (highest progress) first. An
// in-transit item has reserved a slot in its per-material input buffer but is
// not yet consumable — it enters the buffer only on reaching progress 0.5.
std::vector<std::vector<BeltItemSlot>> incomingItems;
// Buffered plus in-transit count of one input material. The acceptance/space
// test (REQ-MAT-INPUT-PORTS, REQ-MAT-INPUT-INTAKE) counts in-transit items, so
// buffered + reserved never exceeds the material's cap (REQ-MAT-INPUT-BUFFER).
int pendingInputCount(const ItemType& type) const
{
int count = 0;
const std::map<ItemType, int>::const_iterator it = inputBuffer.counts.find(type);
if (it != inputBuffer.counts.end()) { count = it->second; }
for (const std::vector<BeltItemSlot>& lane : incomingItems)
{
for (const BeltItemSlot& slot : lane)
{
if (slot.item.type == type) { ++count; }
}
}
return count;
}
// Pre-computed from surface mask at placement; in absolute world coordinates.
std::vector<QPoint> bodyCells;
std::vector<Port> outputPorts;

View File

@@ -1,167 +0,0 @@
#include "BuildingConfig.h"
#include <algorithm>
#include <climits>
#include "BeltSystem.h"
#include "Building.h"
#include "BuildingSystem.h"
#include "Simulation.h"
namespace
{
// The blueprint-relevant geometry shared by operational buildings and construction
// sites. Resolved from whichever of the two a selected id refers to.
struct SelectedBuilding
{
BuildingId id;
BuildingType type;
Rotation rotation;
QPoint anchor;
const std::vector<QPoint>* bodyCells;
};
// Resolves a selected id to a player-placeable building or construction site, if it
// is one. Returns std::nullopt for an unknown id or a non-player-placeable building
// (the HQ and defence stations, per REQ-UI-BLUEPRINT-CREATE).
std::optional<SelectedBuilding> resolvePlaceable(const Simulation& sim, BuildingId 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.getConfig().buildings.findBuildingDef(type);
if (!def || !def->playerPlaceable)
{
return std::nullopt;
}
SelectedBuilding resolved;
resolved.id = id;
resolved.type = type;
resolved.rotation = building ? building->rotation : site->rotation;
resolved.anchor = building ? building->anchor : site->anchor;
resolved.bodyCells = building ? &building->bodyCells : &site->bodyCells;
return resolved;
}
} // namespace
std::optional<BuildingConfig> readBuildingConfig(const Simulation& sim, BuildingId id)
{
const Building* building = sim.getBuildings().findBuilding(id);
const ConstructionSite* site = building ? nullptr : sim.getBuildings().findSite(id);
if (!building && !site)
{
return std::nullopt;
}
BuildingConfig config;
config.type = building ? building->type : site->type;
const std::string& recipeId = building ? building->recipeId : site->recipeId;
if (!recipeId.empty())
{
config.recipeId = recipeId;
}
config.shipLayout = building ? building->shipLayout : site->shipLayout;
if (config.type == BuildingType::Splitter)
{
config.isSplitter = true;
if (building)
{
// Operational splitter filters live in the BeltSystem, keyed by tile.
const std::optional<BeltSystem::SplitterInfo> info =
sim.getBelts().getSplitterInfo(building->anchor);
if (info.has_value())
{
config.splitterFilterA = info->filterA;
config.splitterFilterB = info->filterB;
}
}
else
{
// A site keeps its pre-completion filters on the ConstructionSite.
config.splitterFilterA = site->splitterFilterA;
config.splitterFilterB = site->splitterFilterB;
}
}
return config;
}
Blueprint captureBlueprintFromSelection(const Simulation& sim,
const std::vector<BuildingId>& selectedIds)
{
std::vector<SelectedBuilding> entries;
entries.reserve(selectedIds.size());
for (const BuildingId id : selectedIds)
{
const std::optional<SelectedBuilding> resolved = resolvePlaceable(sim, id);
if (resolved.has_value())
{
entries.push_back(*resolved);
}
}
if (entries.empty())
{
return Blueprint{};
}
int minX = INT_MAX, maxX = INT_MIN;
int minY = INT_MAX, maxY = INT_MIN;
for (const SelectedBuilding& e : entries)
{
for (const QPoint& cell : *e.bodyCells)
{
minX = std::min(minX, cell.x());
maxX = std::max(maxX, cell.x());
minY = std::min(minY, cell.y());
maxY = std::max(maxY, cell.y());
}
}
const QPoint center((minX + maxX) / 2, (minY + maxY) / 2);
Blueprint blueprint;
blueprint.buildings.reserve(entries.size());
for (const SelectedBuilding& e : entries)
{
BlueprintBuilding building;
building.type = e.type;
building.rotation = e.rotation;
building.offset = e.anchor - center;
// Recipe / schematic / layout / splitter-filter capture is shared with the
// copy-settings gesture (REQ-BLD-COPY-CONFIG) via readBuildingConfig, which
// handles operational buildings and construction sites alike.
const std::optional<BuildingConfig> config = readBuildingConfig(sim, e.id);
if (config.has_value())
{
building.recipeId = config->recipeId.value_or(std::string());
building.shipLayout = config->shipLayout;
building.splitterFilterA = config->splitterFilterA;
building.splitterFilterB = config->splitterFilterB;
}
blueprint.buildings.push_back(building);
}
return blueprint;
}
bool selectionHasPlaceableBuilding(const Simulation& sim,
const std::vector<BuildingId>& selectedIds)
{
for (const BuildingId id : selectedIds)
{
if (resolvePlaceable(sim, id).has_value())
{
return true;
}
}
return false;
}

View File

@@ -1,54 +0,0 @@
#pragma once
#include <optional>
#include <string>
#include <vector>
#include "Blueprint.h"
#include "BuildingId.h"
#include "BuildingType.h"
#include "ItemType.h"
#include "ShipLayout.h"
class Simulation;
// The user-configurable settings of a single building or construction site: the
// selected recipe / ship schematic, the shipyard module layout, and (for
// splitters) the two output filters. Shared by the copy-settings gesture
// (REQ-BLD-COPY-CONFIG) and blueprint capture (REQ-UI-BLUEPRINT-STORAGE).
struct BuildingConfig
{
BuildingType type = BuildingType::Miner;
// Selected recipe (Miner / Assembler) or ship schematic id (Shipyard); unset
// when nothing is selected.
std::optional<std::string> recipeId;
// Shipyard module layout (REQ-MOD-LAYOUT).
std::optional<ShipLayoutConfig> shipLayout;
// Splitter output filters (empty = accept all). isSplitter distinguishes an
// empty-filter splitter (a valid accept-all configuration) from a building
// type that has no splitter filters at all.
bool isSplitter = false;
std::vector<ItemType> splitterFilterA;
std::vector<ItemType> splitterFilterB;
};
// Reads the current configuration of the building or construction site identified
// by id, handling operational buildings and sites alike. Returns std::nullopt if
// no such building or site exists.
std::optional<BuildingConfig> readBuildingConfig(const Simulation& sim, BuildingId id);
// Captures a blueprint from a selection of building / construction-site ids, keeping
// only player-placeable buildings and recording each one's type, rotation, offset
// from the selection's bounding-box center, and configuration. Operational buildings
// and construction sites are treated identically (REQ-UI-BLUEPRINT-CREATE,
// REQ-UI-BLUEPRINT-STORAGE). The returned blueprint is unnamed.
Blueprint captureBlueprintFromSelection(const Simulation& sim,
const std::vector<BuildingId>& selectedIds);
// True if any selected id refers to a player-placeable building or construction site
// (the enable condition for the Create Blueprint button, REQ-UI-BLUEPRINT-CREATE).
bool selectionHasPlaceableBuilding(const Simulation& sim,
const std::vector<BuildingId>& selectedIds);

View File

@@ -1,6 +1,5 @@
#include "BuildingSystem.h"
#include <algorithm>
#include <cassert>
#include <limits>
#include <random>
@@ -10,57 +9,6 @@
#include "SurfaceMask.h"
#include "tracing.h"
namespace
{
// Smelter and Reprocessing Plant have no player-selected recipe
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING). They auto-process whatever inputs
// they receive, matching against every recipe of their building type.
bool isAutoRecipeBuildingType(BuildingType type)
{
return type == BuildingType::Smelter
|| type == BuildingType::ReprocessingPlant;
}
// The building body tile that owns an output port, given the port's outside tile
// (port.tile) and its facing direction. The virtual output belt occupies this tile
// and flows toward port.tile (REQ-MAT-OUTPUT-EMERGE).
QPoint outputBodyTile(QPoint portTile, Rotation direction)
{
switch (direction)
{
case Rotation::East: return portTile + QPoint(-1, 0);
case Rotation::West: return portTile + QPoint( 1, 0);
case Rotation::North: return portTile + QPoint( 0, 1);
case Rotation::South: return portTile + QPoint( 0, -1);
}
return portTile;
}
// The building body tile an input port feeds into, given the port's outside belt
// tile (port.tile) and its inward flow direction. The virtual input belt occupies
// this tile and flows from the outer edge (progress 0.0) to the centre (0.5)
// (REQ-MAT-INPUT-INTAKE).
QPoint inputBodyTile(QPoint portTile, Rotation inwardDirection)
{
switch (inwardDirection)
{
case Rotation::East: return portTile + QPoint( 1, 0);
case Rotation::West: return portTile + QPoint(-1, 0);
case Rotation::North: return portTile + QPoint( 0, -1);
case Rotation::South: return portTile + QPoint( 0, 1);
}
return portTile;
}
// An input belt accepts a new item at progress 0.0 only when it holds fewer than
// three items and the entry slot is clear (nothing within a quarter tile of 0.0),
// matching the belt packing used elsewhere (REQ-GW-BELT-CAPACITY).
bool inputLaneEntryFree(const std::vector<BeltItemSlot>& lane)
{
return lane.size() < 3 && (lane.empty() || lane.back().progress >= 0.25);
}
} // namespace
BuildingSystem::BuildingSystem(const GameConfig& config,
BeltSystem& belts,
std::function<BuildingId()> allocateBuildingId,
@@ -170,56 +118,6 @@ void BuildingSystem::initBuffers(Building& b, const RecipeDef& recipe) const
}
}
void BuildingSystem::initAutoBuffers(Building& b) const
{
b.inputBuffer.counts.clear();
b.inputBuffer.caps.clear();
// Union the inputs of every recipe of this building type; the cap for each
// item is twice the largest per-cycle requirement across those recipes.
// Output capacity follows the same rules as initBuffers: the Reprocessing
// Plant holds one cycle's max output (REQ-MAT-OUTPUT-BUFFER-REPROCESSING),
// other auto buildings hold twice the largest per-cycle output.
int outputCapacity = 0;
for (const RecipeDef& recipe : m_config.recipes.recipes)
{
if (recipe.building != b.type)
{
continue;
}
for (const RecipeIngredient& ing : recipe.inputs)
{
const ItemType type{ing.item};
b.inputBuffer.counts[type] = 0;
b.inputBuffer.caps[type] =
std::max(b.inputBuffer.caps[type], 2 * ing.amount);
}
if (b.type == BuildingType::ReprocessingPlant)
{
int maxAmount = 0;
for (const RecipeOutput& out : recipe.outputs)
{
maxAmount = std::max(maxAmount, out.amount);
}
outputCapacity = std::max(outputCapacity, maxAmount);
}
else
{
int totalAmount = 0;
for (const RecipeOutput& out : recipe.outputs)
{
totalAmount += out.amount;
}
outputCapacity = std::max(outputCapacity, 2 * totalAmount);
}
}
b.outputBuffer.items.clear();
b.outputBuffer.capacity = outputCapacity;
}
void BuildingSystem::initShipyardBuffers(Building& b) const
{
b.inputBuffer.counts.clear();
@@ -256,16 +154,6 @@ void BuildingSystem::initShipyardBuffers(Building& b) const
}
}
void BuildingSystem::initSalvageBayBuffer(Building& b) const
{
// Salvage Bay has no recipe-driven buffer; its output-buffer holding size for
// ship drop-off is config-defined (REQ-BLD-SALVAGE-BAY).
b.outputBuffer.items.clear();
const BuildingDef* def = findBuildingDef(BuildingType::SalvageBay);
b.outputBuffer.capacity =
(def && def->outputBufferCapacity) ? *def->outputBufferCapacity : 0;
}
std::vector<Port> BuildingSystem::computeInputPorts(const Building& b) const
{
// Build lookup sets for quick membership checks.
@@ -346,7 +234,7 @@ std::vector<Item> BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe
// Placement
// ---------------------------------------------------------------------------
std::optional<BuildingId> BuildingSystem::place(BuildingType type, QPoint anchor,
BuildingId BuildingSystem::place(BuildingType type, QPoint anchor,
Rotation rotation, Tick currentTick)
{
const BuildingDef* def = findBuildingDef(type);
@@ -356,7 +244,7 @@ std::optional<BuildingId> BuildingSystem::place(BuildingType type, QPoint anchor
// Reject placements that fall outside the world (REQ-BLD-PLACE-VALID).
if (!bodyCellsWithinWorldBounds(mask.bodyCells, anchor))
{
return std::nullopt;
return kInvalidBuildingId;
}
const BuildingId id = m_allocateBuildingId();
@@ -521,18 +409,6 @@ void BuildingSystem::setRecipe(BuildingId id, const std::string& recipeId)
{
if (site.id == id)
{
// Auto-recipe buildings have no player-selected recipe
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING); ignore any attempt to set one.
if (isAutoRecipeBuildingType(site.type))
{
return;
}
// No-op if the recipe is unchanged, so a redundant selection does
// not wipe an already-configured ship layout.
if (site.recipeId == recipeId)
{
return;
}
site.recipeId = recipeId;
site.shipLayout = std::nullopt;
return;
@@ -544,30 +420,12 @@ void BuildingSystem::setRecipe(BuildingId id, const std::string& recipeId)
{
if (building.id == id)
{
// Auto-recipe buildings have no player-selected recipe
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING); ignore any attempt to set one.
if (isAutoRecipeBuildingType(building.type))
{
return;
}
// No-op if the recipe is unchanged, so a redundant selection does
// not wipe an already-configured ship layout or reset buffers.
if (building.recipeId == recipeId)
{
return;
}
building.recipeId = recipeId;
building.shipLayout = std::nullopt;
building.inputBuffer.counts.clear();
building.inputBuffer.caps.clear();
building.outputBuffer.items.clear();
building.outputBuffer.capacity = 0;
// Emerging items are part of the output buffer, so clearing it on a
// recipe change discards them too (REQ-MAT-OUTPUT-EMERGE); in-transit
// input items are discarded and their reservations released
// (REQ-MAT-INPUT-INTAKE).
for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
for (std::vector<BeltItemSlot>& lane : building.incomingItems) { lane.clear(); }
building.production = std::nullopt;
if (!recipeId.empty())
@@ -614,8 +472,6 @@ void BuildingSystem::setShipLayout(BuildingId id, const ShipLayoutConfig& layout
building.inputBuffer.caps.clear();
building.outputBuffer.items.clear();
building.outputBuffer.capacity = 0;
for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
for (std::vector<BeltItemSlot>& lane : building.incomingItems) { lane.clear(); }
if (!building.recipeId.empty() && building.type == BuildingType::Shipyard)
{
initShipyardBuffers(building);
@@ -719,21 +575,9 @@ void BuildingSystem::tickConstruction(Tick currentTick)
absPort.direction = port.direction;
building.outputPorts.push_back(absPort);
}
building.emergingItems.resize(building.outputPorts.size());
building.inputPorts = computeInputPorts(building);
building.incomingItems.assign(building.inputPorts.size(), {});
if (building.type == BuildingType::SalvageBay)
{
initSalvageBayBuffer(building);
}
else if (isAutoRecipeBuildingType(building.type))
{
// Smelter/Reprocessing Plant need no recipe selection; buffers are set
// up from all recipes of the type (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING).
initAutoBuffers(building);
}
else if (!building.recipeId.empty())
if (!building.recipeId.empty())
{
if (building.type == BuildingType::Shipyard)
{
@@ -794,119 +638,79 @@ void BuildingSystem::tickConstruction(Tick currentTick)
void BuildingSystem::tickBeltPull()
{
TRACE();
// Same per-tick step as the belts, so items travel inward at belt speed
// (REQ-GW-BELT-SPEED, REQ-MAT-INPUT-INTAKE).
const double progressPerTick = m_belts.getProgressPerTick_tpt();
for (Building& building : m_buildings)
{
const bool isHq = (building.type == BuildingType::Hq);
// 1. Advance every input belt and deliver arrivals (progress >= 0.5) into
// the input buffer — or the global stock for the HQ. Runs for all
// buildings so in-transit items keep moving even when feeding is gated
// off, and arrivals become consumable before tickProduction (step 4).
for (std::size_t i = 0; i < building.incomingItems.size(); ++i)
// HQ: pull building_block items and add to global stock.
if (building.type == BuildingType::Hq)
{
std::vector<BeltItemSlot>& lane = building.incomingItems[i];
advanceBeltSlots(lane, progressPerTick);
while (!lane.empty() && lane.front().progress >= 0.5)
for (const Port& port : building.inputPorts)
{
const Item arrived = lane.front().item;
lane.erase(lane.begin());
if (isHq)
const std::optional<ItemType> peeked = m_belts.peekItem(port);
if (peeked && peeked->id == "building_block")
{
m_addBuildingBlocks(1);
}
else
{
building.inputBuffer.counts[arrived.type]++;
const std::optional<Item> taken = m_belts.tryTakeItem(port);
if (taken)
{
m_addBuildingBlocks(1);
}
}
}
continue;
}
if (building.recipeId.empty())
{
continue;
}
if (building.type != BuildingType::Shipyard)
{
const RecipeDef* recipe = findRecipe(building.recipeId, building.type);
if (!recipe || recipe->inputs.empty())
{
continue;
}
}
// 2. Feed accepted items from adjacent belts onto the input belts at
// progress 0.0. The acceptance rules — the HQ building-block case, the
// required-input check, and the reservation — live in canAcceptInput so
// direct coupling (REQ-MAT-DIRECT-COUPLE) shares them exactly.
for (std::size_t i = 0; i < building.inputPorts.size(); ++i)
for (const Port& port : building.inputPorts)
{
const std::optional<ItemType> peeked = m_belts.peekItem(building.inputPorts[i]);
if (!peeked) { continue; }
if (!canAcceptInput(building, i, *peeked)) { continue; }
const std::optional<Item> taken = m_belts.tryTakeItem(building.inputPorts[i]);
const std::optional<ItemType> peeked = m_belts.peekItem(port);
if (!peeked)
{
continue;
}
const ItemType& type = *peeked;
// Accept only if this type is a required input and buffer has space.
const std::map<ItemType, int>::const_iterator capIt =
building.inputBuffer.caps.find(type);
if (capIt == building.inputBuffer.caps.end() || capIt->second == 0)
{
continue;
}
const int current = [&]() -> int
{
const std::map<ItemType, int>::const_iterator it =
building.inputBuffer.counts.find(type);
return (it != building.inputBuffer.counts.end()) ? it->second : 0;
}();
if (current >= capIt->second)
{
continue;
}
const std::optional<Item> taken = m_belts.tryTakeItem(port);
if (taken)
{
depositToInputBelt(building, i, *taken);
building.inputBuffer.counts[taken->type]++;
}
}
}
}
bool BuildingSystem::canAcceptInput(const Building& consumer,
std::size_t inputPortIndex,
const ItemType& type) const
{
if (inputPortIndex >= consumer.incomingItems.size()) { return false; }
if (!inputLaneEntryFree(consumer.incomingItems[inputPortIndex])) { return false; }
// The HQ has no input buffer; it accepts building blocks into the global stock
// (REQ-HQ-BELT-INPUT) with no reservation.
if (consumer.type == BuildingType::Hq)
{
return type.id == "building_block";
}
// Everyone else: the item must be a required input whose reservation-aware
// buffer has room — buffered + in-transit below the cap (REQ-MAT-INPUT-INTAKE).
const std::map<ItemType, int>::const_iterator capIt =
consumer.inputBuffer.caps.find(type);
if (capIt == consumer.inputBuffer.caps.end() || capIt->second == 0)
{
return false;
}
return consumer.pendingInputCount(type) < capIt->second;
}
void BuildingSystem::depositToInputBelt(Building& consumer,
std::size_t inputPortIndex,
const Item& item)
{
consumer.incomingItems[inputPortIndex].push_back(BeltItemSlot{item, 0.0});
}
bool BuildingSystem::tryDirectCoupleDeposit(BuildingId producerId,
const Port& outputPort,
const Item& item)
{
const std::map<std::pair<int, int>, BuildingId>::const_iterator occIt =
m_tileOccupancy.find({outputPort.tile.x(), outputPort.tile.y()});
if (occIt == m_tileOccupancy.end() || occIt->second == producerId)
{
return false;
}
Building* consumer = findBuildingMutable(occIt->second);
if (!consumer)
{
return false; // an unbuilt construction site, or not an operational building
}
// The coupling is the consumer input port meeting this output port: same flow
// direction, feeding the producer's output-port tile (REQ-MAT-DIRECT-COUPLE).
for (std::size_t j = 0; j < consumer->inputPorts.size(); ++j)
{
const Port& in = consumer->inputPorts[j];
if (in.direction != outputPort.direction) { continue; }
if (inputBodyTile(in.tile, in.direction) != outputPort.tile) { continue; }
if (!canAcceptInput(*consumer, j, item.type)) { return false; }
depositToInputBelt(*consumer, j, item);
return true;
}
return false;
}
void BuildingSystem::tickProduction(Tick currentTick)
{
TRACE();
@@ -922,15 +726,18 @@ void BuildingSystem::tickProduction(Tick currentTick)
continue;
}
const bool autoRecipe = isAutoRecipeBuildingType(building.type);
if (!autoRecipe && building.recipeId.empty())
if (building.recipeId.empty())
{
continue;
}
// If a production cycle is active, check for completion. Completion only
// needs the already-decided outputs, so it does not depend on which
// recipe is selected or auto-chosen.
const RecipeDef* recipe = findRecipe(building.recipeId, building.type);
if (!recipe)
{
continue;
}
// If a production cycle is active, check for completion.
if (building.production)
{
if (currentTick >= building.production->completesAt)
@@ -946,94 +753,66 @@ void BuildingSystem::tickProduction(Tick currentTick)
continue;
}
// Idle: gather the candidate recipes to try. Auto-recipe buildings
// (Smelter, Reprocessing Plant) have no selected recipe and try every
// 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)
// Idle: check if a new cycle can start.
// 1. All required inputs present?
bool inputsOk = true;
for (const RecipeIngredient& ing : recipe->inputs)
{
for (const RecipeDef& r : m_config.recipes.recipes)
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)
{
if (r.building == building.type && !r.inputs.empty())
{
candidates.push_back(&r);
}
inputsOk = false;
break;
}
}
if (!inputsOk)
{
continue;
}
// 2. Determine chosen outputs (roll for reprocessing).
std::vector<Item> chosen;
if (building.type == BuildingType::ReprocessingPlant)
{
chosen = rollReprocessingOutput(*recipe);
if (chosen.empty()) { continue; }
}
else
{
const RecipeDef* recipe = findRecipe(building.recipeId, building.type);
if (recipe)
for (const RecipeOutput& out : recipe->outputs)
{
candidates.push_back(recipe);
Item item;
item.type.id = out.item;
for (int i = 0; i < out.amount; ++i)
{
chosen.push_back(item);
}
}
}
for (const RecipeDef* recipe : candidates)
// 3. Output buffer has space for chosen outputs?
const int newSize = static_cast<int>(building.outputBuffer.items.size())
+ static_cast<int>(chosen.size());
if (newSize > building.outputBuffer.capacity)
{
// 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)
{
continue;
}
// 2. Determine chosen outputs (roll for reprocessing).
std::vector<Item> chosen;
if (building.type == BuildingType::ReprocessingPlant)
{
chosen = rollReprocessingOutput(*recipe);
if (chosen.empty()) { continue; }
}
else
{
for (const RecipeOutput& out : recipe->outputs)
{
Item item;
item.type.id = out.item;
for (int i = 0; i < out.amount; ++i)
{
chosen.push_back(item);
}
}
}
// 3. Output buffer has space for chosen outputs? Emerging items still
// count against the buffer (REQ-MAT-OUTPUT-EMERGE).
const int newSize = building.getOutputItemCount()
+ static_cast<int>(chosen.size());
if (newSize > building.outputBuffer.capacity)
{
continue;
}
// 4. Consume inputs and start cycle.
for (const RecipeIngredient& ing : recipe->inputs)
{
building.inputBuffer.counts[ItemType{ing.item}] -= ing.amount;
}
Production prod;
prod.recipeId = recipe->id;
prod.completesAt = currentTick + secondsToTicks(recipe->durationSeconds);
prod.chosenOutputs = std::move(chosen);
building.production = std::move(prod);
break; // At most one cycle starts per tick.
continue;
}
// 4. Consume inputs and start cycle.
for (const RecipeIngredient& ing : recipe->inputs)
{
building.inputBuffer.counts[ItemType{ing.item}] -= ing.amount;
}
Production prod;
prod.recipeId = building.recipeId;
prod.completesAt = currentTick + secondsToTicks(recipe->durationSeconds);
prod.chosenOutputs = std::move(chosen);
building.production = std::move(prod);
}
}
@@ -1065,16 +844,7 @@ void BuildingSystem::tickShipyardProduction(Tick currentTick)
{
const Port& p = building.outputPorts[0];
const QVector2D spawnPos(p.tile.x() + 0.5f, p.tile.y() + 0.5f);
// A shipyard builds exactly what the player configured and
// paid for. When no layout is set it produces a bare hull, so
// pass an explicit empty layout rather than nullopt: the latter
// would make ShipSystem fall back to the schematic's
// defaultModules (a wave-only loadout) and yield free weapons.
const std::optional<ShipLayoutConfig> layout =
building.shipLayout.has_value()
? building.shipLayout
: std::make_optional<ShipLayoutConfig>();
m_spawnShip(building.recipeId, spawnPos, layout);
m_spawnShip(building.recipeId, spawnPos, building.shipLayout);
}
building.production = std::nullopt;
}
@@ -1148,97 +918,31 @@ void BuildingSystem::tickShipyardProduction(Tick currentTick)
}
}
void BuildingSystem::tickOutputBelts()
void BuildingSystem::tickBeltPush()
{
TRACE();
// Use BeltSystem's own per-tick step so emerging items travel at exactly the
// same speed as real belts (REQ-GW-BELT-SPEED, REQ-MAT-OUTPUT-EMERGE).
const double progressPerTick = m_belts.getProgressPerTick_tpt();
for (Building& building : m_buildings)
{
for (std::size_t p = 0; p < building.outputPorts.size(); ++p)
if (building.outputBuffer.items.empty())
{
const Port& port = building.outputPorts[p];
std::vector<BeltItemSlot>& lane = building.emergingItems[p];
continue;
}
// 1. Advance emerging items using the shared belt packing (progress
// caps to 0.5 / 0.75 / 1.0 for up to three items).
advanceBeltSlots(lane, progressPerTick);
// 2. Hand the front item off once it reaches the output edge (progress
// 1.0): onto the adjacent real belt, or — if a building's input edge
// meets this port — straight into that building (REQ-MAT-DIRECT-COUPLE).
// On refusal (no belt/coupling, output-edge per REQ-MAT-ACCEPT-DIR, or
// a full target) it stays stuck at 1.0.
if (!lane.empty() && lane.front().progress >= 1.0)
for (const Port& outputPort : building.outputPorts)
{
if (building.outputBuffer.items.empty())
{
const Item item = lane.front().item;
if (m_belts.tryPutItem(port.tile, item, port.direction)
|| tryDirectCoupleDeposit(building.id, port, item))
{
lane.erase(lane.begin());
}
break;
}
// 3. Feed the next buffered item onto the lane at progress 0.5 when the
// entry slot is free — the lane holds at most three items and a new
// one needs a quarter-tile clearance ahead of 0.5.
if (!building.outputBuffer.items.empty()
&& lane.size() < 3
&& (lane.empty() || lane.back().progress >= 0.75))
const Item item = building.outputBuffer.items.front();
if (m_belts.tryPutItem(outputPort.tile, item, outputPort.direction))
{
lane.push_back(BeltItemSlot{building.outputBuffer.items.front(), 0.5});
building.outputBuffer.items.erase(building.outputBuffer.items.begin());
}
}
}
}
void BuildingSystem::forEachEmergingItem(
const std::function<void(const ItemType&, QPointF)>& visit) const
{
for (const Building& building : m_buildings)
{
for (std::size_t p = 0; p < building.outputPorts.size(); ++p)
{
const Port& port = building.outputPorts[p];
const QPoint bodyTile = outputBodyTile(port.tile, port.direction);
const std::vector<BeltItemSlot>& lane = building.emergingItems[p];
// Render least-progressed first (bottom) → most-progressed last (top),
// matching belt item ordering (REQ-GW-TILE-SIZE).
for (int i = static_cast<int>(lane.size()) - 1; i >= 0; --i)
{
visit(lane[i].item.type,
beltSlotWorldPos(bodyTile, port.direction, lane[i].progress));
}
}
}
}
void BuildingSystem::forEachIncomingItem(
const std::function<void(const ItemType&, QPointF)>& visit) const
{
for (const Building& building : m_buildings)
{
for (std::size_t p = 0; p < building.inputPorts.size(); ++p)
{
const Port& port = building.inputPorts[p];
const QPoint bodyTile = inputBodyTile(port.tile, port.direction);
const std::vector<BeltItemSlot>& lane = building.incomingItems[p];
// Render least-progressed first (bottom) → most-progressed last (top),
// matching belt item ordering (REQ-GW-TILE-SIZE).
for (int i = static_cast<int>(lane.size()) - 1; i >= 0; --i)
{
visit(lane[i].item.type,
beltSlotWorldPos(bodyTile, port.direction, lane[i].progress));
}
}
}
}
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
@@ -1255,18 +959,6 @@ const Building* BuildingSystem::findBuilding(BuildingId id) const
return nullptr;
}
Building* BuildingSystem::findBuildingMutable(BuildingId id)
{
for (Building& building : m_buildings)
{
if (building.id == id)
{
return &building;
}
}
return nullptr;
}
const ConstructionSite* BuildingSystem::findSite(BuildingId id) const
{
for (const ConstructionSite& site : m_constructionQueue)
@@ -1279,12 +971,12 @@ const ConstructionSite* BuildingSystem::findSite(BuildingId id) const
return nullptr;
}
std::vector<Building> BuildingSystem::getAllBuildings() const
std::vector<Building> BuildingSystem::allBuildings() const
{
return m_buildings;
}
std::vector<ConstructionSite> BuildingSystem::getAllSites() const
std::vector<ConstructionSite> BuildingSystem::allSites() const
{
return std::vector<ConstructionSite>(m_constructionQueue.begin(),
m_constructionQueue.end());
@@ -1308,7 +1000,7 @@ bool isProductionBuildingType(BuildingType type)
}
} // namespace
int BuildingSystem::getProductionBuildingCount() const
int BuildingSystem::productionBuildingCount() const
{
int count = 0;
for (const Building& b : m_buildings)
@@ -1318,7 +1010,7 @@ int BuildingSystem::getProductionBuildingCount() const
return count;
}
int BuildingSystem::getActiveProductionBuildingCount() const
int BuildingSystem::activeProductionBuildingCount() const
{
int count = 0;
for (const Building& b : m_buildings)
@@ -1328,7 +1020,7 @@ int BuildingSystem::getActiveProductionBuildingCount() const
return count;
}
std::vector<BuildingSystem::BeltTileInfo> BuildingSystem::getAllBeltTiles() const
std::vector<BuildingSystem::BeltTileInfo> BuildingSystem::allBeltTiles() const
{
std::vector<BeltTileInfo> result;
for (const Building& b : m_buildings)
@@ -1440,14 +1132,7 @@ void BuildingSystem::rotateInPlace(BuildingId id, Rotation newRotation)
absPort.direction = port.direction;
b.outputPorts.push_back(absPort);
}
// The output ports moved; discard any in-flight emerging items and re-size
// the lanes to the new port set (REQ-MAT-OUTPUT-EMERGE).
b.emergingItems.clear();
b.emergingItems.resize(b.outputPorts.size());
b.inputPorts = computeInputPorts(b);
// Likewise discard in-transit input items and re-size the input belts to
// the new port set (REQ-MAT-INPUT-INTAKE).
b.incomingItems.assign(b.inputPorts.size(), {});
// Re-register with BeltSystem (items on tile are discarded).
if (b.type == BuildingType::Belt)
@@ -1516,9 +1201,7 @@ bool BuildingSystem::deliverScrapToSalvageBay(BuildingId bayId)
{
return false;
}
// Emerging scrap still counts against the bay's holding capacity
// (REQ-MAT-OUTPUT-EMERGE).
if (bay->getOutputItemCount() >= bay->outputBuffer.capacity)
if (static_cast<int>(bay->outputBuffer.items.size()) >= bay->outputBuffer.capacity)
{
return false;
}
@@ -1553,14 +1236,7 @@ BuildingId BuildingSystem::placeImmediate(BuildingType type,
absPort.direction = port.direction;
building.outputPorts.push_back(absPort);
}
building.emergingItems.resize(building.outputPorts.size());
building.inputPorts = computeInputPorts(building);
building.incomingItems.assign(building.inputPorts.size(), {});
if (type == BuildingType::SalvageBay)
{
initSalvageBayBuffer(building);
}
m_buildings.push_back(std::move(building));
return id;
@@ -1661,26 +1337,6 @@ void BuildingSystem::appendChecksum(Hasher& hasher) const
appendInputBuffer(hasher, b.inputBuffer);
appendItems(hasher, b.outputBuffer.items);
hasher.append(b.outputBuffer.capacity);
hasher.append(b.emergingItems.size());
for (const std::vector<BeltItemSlot>& lane : b.emergingItems)
{
hasher.append(lane.size());
for (const BeltItemSlot& slot : lane)
{
hasher.append(slot.item.type.id);
hasher.append(slot.progress);
}
}
hasher.append(b.incomingItems.size());
for (const std::vector<BeltItemSlot>& lane : b.incomingItems)
{
hasher.append(lane.size());
for (const BeltItemSlot& slot : lane)
{
hasher.append(slot.item.type.id);
hasher.append(slot.progress);
}
}
hasher.append(b.production.has_value());
if (b.production.has_value())
{

View File

@@ -10,7 +10,6 @@
#include <vector>
#include <QPoint>
#include <QPointF>
#include <QVector2D>
#include "BeltSystem.h"
@@ -43,13 +42,13 @@ public:
std::mt19937& rng);
// -- Placement / demolish ------------------------------------------------
// 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
// 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
// (Simulation::tryPlaceBuilding) enforces the full rule via isPlacementValid.
std::optional<BuildingId> place(BuildingType type, QPoint anchor, Rotation rotation,
BuildingId place(BuildingType type, QPoint anchor, Rotation rotation,
Tick currentTick);
// Returns true if the placement satisfies REQ-BLD-PLACE-VALID terrain and
@@ -95,10 +94,7 @@ public:
void tickBeltPull();
void tickProduction(Tick currentTick);
void tickShipyardProduction(Tick currentTick);
// Advances each building's virtual output belts, hands finished items off onto
// the adjacent real belt, and feeds new buffered items into them
// (REQ-MAT-OUTPUT-EMERGE).
void tickOutputBelts();
void tickBeltPush();
// -- Queries -------------------------------------------------------------
struct BeltTileInfo
@@ -112,32 +108,19 @@ public:
const Building* findBuilding(BuildingId id) const;
const ConstructionSite* findSite(BuildingId id) const;
std::vector<Building> getAllBuildings() const;
std::vector<ConstructionSite> getAllSites() const;
std::vector<Building> allBuildings() const;
std::vector<ConstructionSite> allSites() const;
// REQ-UI-DEBUG-OVERLAY "Max Factory Production": count of completed
// (operational) Miner/Smelter/Assembler/ReprocessingPlant/Shipyard buildings.
int getProductionBuildingCount() const;
int productionBuildingCount() const;
// REQ-UI-DEBUG-OVERLAY "Current Factory Production": subset of the above
// that currently has an active production cycle.
int getActiveProductionBuildingCount() const;
std::vector<BeltTileInfo> getAllBeltTiles() const;
int activeProductionBuildingCount() const;
std::vector<BeltTileInfo> allBeltTiles() const;
bool isTileOccupied(QPoint tile) const;
// Visits every item currently emerging from a building output port on its
// virtual output belt (REQ-MAT-OUTPUT-EMERGE), passing the item type and its
// world-space centre (in tile units). Least-progressed first (drawn bottom) so
// callers can paint in visit order (REQ-GW-TILE-SIZE ordering).
void forEachEmergingItem(
const std::function<void(const ItemType&, QPointF)>& visit) const;
// Visits every item currently travelling inward on a building input port's
// virtual input belt (REQ-MAT-INPUT-INTAKE), passing the item type and its
// world-space centre (in tile units). Least-progressed first (drawn bottom).
void forEachIncomingItem(
const std::function<void(const ItemType&, QPointF)>& visit) const;
// Returns the entity id of the building or construction site whose footprint
// exactly coincides with the ghost (type, anchor, rot) and is of the same
// building type. Returns nullopt otherwise.
@@ -181,36 +164,12 @@ public:
void appendChecksum(Hasher& hasher) const;
private:
Building* findBuildingMutable(BuildingId id);
// True if the consumer would accept `type` at the given input port right now:
// it is a required input (or a building block for the HQ), the reservation-aware
// buffer has room, and the input belt entry is free (REQ-MAT-INPUT-INTAKE).
bool canAcceptInput(const Building& consumer,
std::size_t inputPortIndex,
const ItemType& type) const;
// Places an accepted item onto the consumer's input belt at progress 0.0,
// reserving a per-material buffer slot (REQ-MAT-INPUT-INTAKE).
void depositToInputBelt(Building& consumer,
std::size_t inputPortIndex,
const Item& item);
// Attempts to hand an emerging output item straight into a directly adjacent
// building whose input edge meets the producer's output port (REQ-MAT-DIRECT-COUPLE).
// Returns true if the item was accepted onto the consumer's input belt.
bool tryDirectCoupleDeposit(BuildingId producerId,
const Port& outputPort,
const Item& item);
const BuildingDef* findBuildingDef(BuildingType type) const;
const RecipeDef* findRecipe(const std::string& id, BuildingType type) const;
const ShipDef* findShipDef(const std::string& id) const;
const ModuleDef* findModuleDef(const std::string& id) const;
void initBuffers(Building& b, const RecipeDef& recipe) const;
// Buffers for an auto-recipe building (Smelter, Reprocessing Plant): input
// caps span the union of every recipe of the building's type; no player
// recipe is selected (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING).
void initAutoBuffers(Building& b) const;
void initShipyardBuffers(Building& b) const;
void initSalvageBayBuffer(Building& b) const;
std::vector<Port> computeInputPorts(const Building& b) const;
std::vector<Item> rollReprocessingOutput(const RecipeDef& recipe);
bool bodyCellsWithinWorldBounds(

View File

@@ -8,10 +8,8 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.h
${CMAKE_CURRENT_SOURCE_DIR}/ReplayPlayer.h
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.h
${CMAKE_CURRENT_SOURCE_DIR}/BeltSlot.h
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/Building.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h
@@ -32,9 +30,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ReplayPlayer.cpp
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BeltSlot.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp

View File

@@ -75,28 +75,28 @@ struct PlaceBuildingCommand : Command
struct DemolishCommand : Command
{
DemolishCommand() : Command(CommandKind::Demolish) {}
std::optional<BuildingId> id;
BuildingId id = kInvalidBuildingId;
};
struct RotateInPlaceCommand : Command
{
RotateInPlaceCommand() : Command(CommandKind::RotateInPlace) {}
std::optional<BuildingId> id;
Rotation newRotation = Rotation::East;
BuildingId id = kInvalidBuildingId;
Rotation newRotation = Rotation::East;
};
struct SetRecipeCommand : Command
{
SetRecipeCommand() : Command(CommandKind::SetRecipe) {}
std::optional<BuildingId> id;
std::string recipeId;
BuildingId id = kInvalidBuildingId;
std::string recipeId;
};
struct SetShipLayoutCommand : Command
{
SetShipLayoutCommand() : Command(CommandKind::SetShipLayout) {}
std::optional<BuildingId> id;
ShipLayoutConfig layout;
BuildingId id = kInvalidBuildingId;
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) {}
std::optional<BuildingId> id;
std::vector<ItemType> filterA;
BuildingId id = kInvalidBuildingId;
std::vector<ItemType> filterA;
std::vector<ItemType> filterB;
};

View File

@@ -46,18 +46,18 @@ void CommandManager::drain()
{
// Restart is a file boundary: a fresh file with the new seed.
m_recorder->startNewRun(m_simulation.getSeed(),
m_simulation.getRngFingerprint());
m_simulation.rngFingerprint());
}
}
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.getCurrentTick();
const Tick tick = m_simulation.currentTick();
m_simulation.apply(*command);
if (m_recorder)
{
m_recorder->recordCommand(tick, *command, m_simulation.getRngFingerprint());
m_recorder->recordCommand(tick, *command, m_simulation.rngFingerprint());
}
}
}
@@ -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.getRngFingerprint());
m_recorder->startNewRun(m_simulation.getSeed(), m_simulation.rngFingerprint());
}
}
@@ -85,8 +85,8 @@ void CommandManager::setReplayMode(bool replayMode)
void CommandManager::recordTickCheckpoint()
{
if (m_recorder && (m_simulation.getCurrentTick() % kChecksumIntervalTicks == 0))
if (m_recorder && (m_simulation.currentTick() % kChecksumIntervalTicks == 0))
{
m_recorder->recordChecksum(m_simulation.getCurrentTick(), m_simulation.getRngFingerprint());
m_recorder->recordChecksum(m_simulation.currentTick(), m_simulation.rngFingerprint());
}
}

View File

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

View File

@@ -1,12 +1,9 @@
#include "EntityHitTest.h"
#include <algorithm>
#include <cmath>
#include "EntityAdmin.h"
#include "PositionComponent.h"
#include "ScrapDataComponent.h"
#include "ShipIdentityComponent.h"
#include "StationBodyComponent.h"
#include "HealthComponent.h"
@@ -57,91 +54,3 @@ entt::entity entityAtWorldPos(EntityAdmin& admin, QVector2D worldPos)
return bestShip;
}
entt::entity scrapAtWorldPos(EntityAdmin& admin, QVector2D worldPos)
{
// Slightly larger than the scrap's rendered radius (0.2 tiles) so small piles
// remain easy to click; tunable.
constexpr float kScrapHitRadiusSquared = 0.35f * 0.35f;
entt::entity bestScrap = entt::null;
float bestDistSquared = kScrapHitRadiusSquared;
admin.forEach<ScrapDataComponent, PositionComponent>(
[&](entt::entity entity, const ScrapDataComponent& /*sd*/, const PositionComponent& pos)
{
const float dx = pos.value.x() - worldPos.x();
const float dy = pos.value.y() - worldPos.y();
const float distSquared = dx * dx + dy * dy;
if (distSquared < bestDistSquared)
{
bestDistSquared = distSquared;
bestScrap = entity;
}
});
return bestScrap;
}
std::vector<entt::entity> scrapInBox(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;
admin.forEach<ScrapDataComponent, PositionComponent>(
[&](entt::entity entity, const ScrapDataComponent& /*sd*/, const PositionComponent& pos)
{
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;
}
std::vector<entt::entity> actorsInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB)
{
const int minX = std::min(tileA.x(), tileB.x());
const int maxX = std::max(tileA.x(), tileB.x());
const int minY = std::min(tileA.y(), tileB.y());
const int maxY = std::max(tileA.y(), tileB.y());
std::vector<entt::entity> result;
// Stations: included when any occupied body cell lies in the box.
admin.forEach<StationBodyComponent, HealthComponent>(
[&](entt::entity entity, const StationBodyComponent& sb, const HealthComponent& h)
{
if (h.hp <= 0.0f) { return; }
for (const QPoint& cell : sb.bodyCells)
{
if (cell.x() >= minX && cell.x() <= maxX
&& cell.y() >= minY && cell.y() <= maxY)
{
result.push_back(entity);
return;
}
}
});
// Ships: included when the floored position tile lies in the box. Requiring
// ShipIdentityComponent excludes the HQ proxy and any station bodies.
admin.forEach<ShipIdentityComponent, PositionComponent, HealthComponent>(
[&](entt::entity entity, const ShipIdentityComponent& /*id*/,
const PositionComponent& pos, const HealthComponent& h)
{
if (h.hp <= 0.0f) { return; }
const int tileX = static_cast<int>(std::floor(pos.value.x()));
const int tileY = static_cast<int>(std::floor(pos.value.y()));
if (tileX >= minX && tileX <= maxX && tileY >= minY && tileY <= maxY)
{
result.push_back(entity);
}
});
return result;
}

View File

@@ -1,8 +1,5 @@
#pragma once
#include <vector>
#include <QPoint>
#include <QVector2D>
#include "entt/entity/entity.hpp"
@@ -10,19 +7,3 @@
class EntityAdmin;
entt::entity entityAtWorldPos(EntityAdmin& admin, QVector2D worldPos);
// Returns the nearest scrap pile whose center is within the scrap pick radius of
// worldPos, or entt::null if none (REQ-UI-SCRAP-CLICK-SELECT). Scrap is picked only
// after actors: entityAtWorldPos never returns scrap (scrap has no HealthComponent).
entt::entity scrapAtWorldPos(EntityAdmin& admin, QVector2D worldPos);
// Returns every scrap pile whose position falls within the inclusive tile rectangle
// spanned by tileA and tileB, in any corner order (REQ-UI-SCRAP-MULTI-SELECT).
std::vector<entt::entity> scrapInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB);
// Returns every living actor (ship or defence station, player or enemy) that falls
// within the inclusive tile rectangle spanned by tileA and tileB, in any corner order
// (REQ-UI-MULTI-SELECT, REQ-UI-ENTITY-CLICK-SELECT). A ship is included when its floored
// position tile lies in the box; a station is included when any of its body cells does.
// Dead actors (hp <= 0) and the HQ proxy are excluded.
std::vector<entt::entity> actorsInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB);

View File

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

View File

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

View File

@@ -58,7 +58,7 @@ std::string computeReplayConfigHash(const std::string& configDir)
hasher.appendBytes(bytes.constData(), static_cast<std::size_t>(bytes.size()));
}
}
return toHex(hasher.getValue());
return toHex(hasher.value());
}
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::getCurrentFilePath() const
const std::string& ReplayRecorder::currentFilePath() const
{
return m_filePath;
}

View File

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

View File

@@ -40,6 +40,7 @@ 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)
@@ -112,7 +113,7 @@ Simulation::~Simulation()
unregisterForEvents();
}
const GameConfig& Simulation::getConfig() const
const GameConfig& Simulation::config() const
{
return m_config;
}
@@ -136,7 +137,7 @@ void Simulation::reset(unsigned int seed)
m_gameOver = false;
m_isWon = false;
m_artifactCount = 0;
m_hqBuildingId = std::nullopt;
m_hqBuildingId = kInvalidBuildingId;
m_hqProxyEntity = entt::null;
m_playerStation1Entity = entt::null;
m_playerStation2Entity = entt::null;
@@ -216,12 +217,11 @@ void Simulation::apply(const Command& command)
case CommandKind::PlaceBuilding:
{
const PlaceBuildingCommand& c = static_cast<const PlaceBuildingCommand&>(command);
const std::optional<BuildingId> placed = tryPlaceBuilding(c.type, c.anchor, c.rotation);
if (!placed.has_value())
const BuildingId id = tryPlaceBuilding(c.type, c.anchor, c.rotation);
if (id == kInvalidBuildingId)
{
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:
@@ -315,7 +315,7 @@ void Simulation::tick()
m_buildingSystem->tickBeltPull(); // step 3
m_buildingSystem->tickProduction(m_currentTick); // step 4
m_buildingSystem->tickShipyardProduction(m_currentTick); // step 4b
m_buildingSystem->tickOutputBelts(); // step 5
m_buildingSystem->tickBeltPush(); // step 5
m_beltSystem.tick(); // step 6
// Step 7: ship behavior systems (movement arbitration via intent priority)
@@ -579,7 +579,7 @@ void Simulation::tickDeathsAndLoot()
}
else
{
const double genD = static_cast<double>(m_waveSystem->getGeneration());
const double genD = static_cast<double>(m_waveSystem->generation());
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->getGeneration();
const int destroyedLevel = m_waveSystem->generation();
m_waveSystem->onEnemyStationsDestroyed();
placeEnemyStationSet(m_waveSystem->getGeneration());
placeEnemyStationSet(m_waveSystem->generation());
generateSchematicChoices(destroyedLevel);
}
}
@@ -707,7 +707,14 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
else
{
option.type = SchematicType::Recipe;
option.displayName = toDisplayName(entry.id);
for (const RecipeDef& def : m_config.recipes.recipes)
{
if (def.id == entry.id && !def.outputs.empty())
{
option.displayName = toDisplayName(def.outputs[0].item);
break;
}
}
}
// REQ-DEF-SCHEMATIC-DROP: preview recipes newly implicitly unlocked by this option.
@@ -730,7 +737,7 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
const UnlockedSets hypothetical = computeUnlockedSets(
hypotheticalShipIds, hypotheticalModuleIds, hypotheticalRecipeSchematicIds);
option.newlyUnlockedRecipeIds = computeNewlyUnlockedRecipeIds(hypothetical);
option.newlyUnlockedItemNames = computeNewlyUnlockedItemNames(hypothetical);
m_pendingSchematicChoices.push_back(option);
}
@@ -918,20 +925,23 @@ Simulation::UnlockedSets Simulation::computeUnlockedSets(
return result;
}
std::vector<std::string> Simulation::computeNewlyUnlockedRecipeIds(const UnlockedSets& hypothetical) const
std::vector<std::string> Simulation::computeNewlyUnlockedItemNames(const UnlockedSets& hypothetical) const
{
std::vector<std::string> recipeIds;
std::set<std::string> itemNames;
for (const std::string& recipeId : hypothetical.recipeIds)
{
if (m_unlockedRecipeIds.count(recipeId) > 0) { continue; }
recipeIds.push_back(recipeId);
for (const RecipeDef& def : m_config.recipes.recipes)
{
if (def.id != recipeId) { continue; }
for (const RecipeOutput& out : def.outputs)
{
itemNames.insert(toDisplayName(out.item));
}
break;
}
}
std::sort(recipeIds.begin(), recipeIds.end(),
[](const std::string& lhs, const std::string& rhs)
{
return toDisplayName(lhs) < toDisplayName(rhs);
});
return recipeIds;
return std::vector<std::string>(itemNames.begin(), itemNames.end());
}
bool Simulation::isRecipeUnlocked(const std::string& recipeId) const
@@ -968,7 +978,7 @@ void Simulation::appendStringSet(Hasher& hasher, const std::set<std::string>& id
}
}
unsigned long long Simulation::getRngFingerprint() const
unsigned long long Simulation::rngFingerprint() const
{
return fingerprintRng(m_rng);
}
@@ -991,11 +1001,11 @@ unsigned long long Simulation::computeStateChecksum() const
hasher.append(m_expansionsPurchased);
// WaveSystem scalar state, reached through existing accessors.
hasher.append(getThreatLevel());
hasher.append(getThreatAccumulationRate());
hasher.append(getBossWaveCounter());
hasher.append(getBossCountdownTicks());
hasher.append(getNormalGapRemainingTicks());
hasher.append(threatLevel());
hasher.append(threatAccumulationRate());
hasher.append(bossWaveCounter());
hasher.append(bossCountdownTicks());
hasher.append(normalGapRemainingTicks());
// Schematic / unlock state (std::map and std::set iterate in sorted order).
appendSchematicMap(hasher, m_schematicLevels);
@@ -1052,7 +1062,7 @@ unsigned long long Simulation::computeStateChecksum() const
hasher.append(c.schematicId);
});
return hasher.getValue();
return hasher.value();
}
// ---------------------------------------------------------------------------
@@ -1081,7 +1091,7 @@ bool Simulation::hasSchematicChoicesPending() const
// Accessors
// ---------------------------------------------------------------------------
Tick Simulation::getCurrentTick() const
Tick Simulation::currentTick() const
{
return m_currentTick;
}
@@ -1091,18 +1101,18 @@ unsigned int Simulation::getSeed() const
return m_seed;
}
int Simulation::getBuildingBlocksStock() const
int Simulation::buildingBlocksStock() const
{
return m_buildingBlocksStock;
}
int Simulation::getCurrentAsteroidWidth_tiles() const
int Simulation::currentAsteroidWidth_tiles() const
{
return m_config.world.regions.asteroidWidth_tiles
+ m_expansionsPurchased * m_config.world.expansion.columnsPerExpansion_tiles;
}
int Simulation::getCurrentExpansionCost() const
int Simulation::currentExpansionCost() const
{
const double cost = m_config.world.expansion.costBuildingBlocksFormula.evaluate(
static_cast<double>(m_expansionsPurchased));
@@ -1111,14 +1121,14 @@ int Simulation::getCurrentExpansionCost() const
void Simulation::tryExpandAsteroid()
{
const int cost = getCurrentExpansionCost();
const int cost = currentExpansionCost();
if (m_buildingBlocksStock < cost)
{
return;
}
m_buildingBlocksStock -= cost;
++m_expansionsPurchased;
m_buildingSystem->setAsteroidWidth_tiles(getCurrentAsteroidWidth_tiles());
m_buildingSystem->setAsteroidWidth_tiles(currentAsteroidWidth_tiles());
}
bool Simulation::isGameOver() const
@@ -1131,44 +1141,44 @@ bool Simulation::isWon() const
return m_isWon;
}
int Simulation::getArtifactCount() const
int Simulation::artifactCount() const
{
return m_artifactCount;
}
double Simulation::getThreatLevel() const
double Simulation::threatLevel() const
{
return m_waveSystem->getThreatLevel();
return m_waveSystem->threatLevel();
}
double Simulation::getThreatAccumulationRate() const
double Simulation::threatAccumulationRate() const
{
return m_waveSystem->getThreatAccumulationRate();
return m_waveSystem->threatAccumulationRate();
}
double Simulation::getMaxFactoryProductionThreatRate() const
double Simulation::maxFactoryProductionThreatRate() const
{
return static_cast<double>(m_buildingSystem->getProductionBuildingCount());
return static_cast<double>(m_buildingSystem->productionBuildingCount());
}
double Simulation::getCurrentFactoryProductionThreatRate() const
double Simulation::currentFactoryProductionThreatRate() const
{
return static_cast<double>(m_buildingSystem->getActiveProductionBuildingCount());
return static_cast<double>(m_buildingSystem->activeProductionBuildingCount());
}
int Simulation::getBossWaveCounter() const
int Simulation::bossWaveCounter() const
{
return m_waveSystem->getBossWaveCounter();
return m_waveSystem->bossWaveCounter();
}
Tick Simulation::getBossCountdownTicks() const
Tick Simulation::bossCountdownTicks() const
{
return m_waveSystem->getBossCountdownTicks();
return m_waveSystem->bossCountdownTicks();
}
Tick Simulation::getNormalGapRemainingTicks() const
Tick Simulation::normalGapRemainingTicks() const
{
return m_waveSystem->getNormalGapRemainingTicks();
return m_waveSystem->normalGapRemainingTicks();
}
bool Simulation::isSchematicUnlocked(const std::string& shipId) const
@@ -1193,11 +1203,11 @@ bool Simulation::isModuleSchematicUnlocked(const std::string& moduleId) const
return it->second.unlocked;
}
std::optional<BuildingId> Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)
BuildingId Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)
{
if (!m_buildingSystem->isPlacementValid(type, anchor, rotation))
{
return std::nullopt;
return kInvalidBuildingId;
}
int cost = 0;
@@ -1211,7 +1221,7 @@ std::optional<BuildingId> Simulation::tryPlaceBuilding(BuildingType type, QPoint
}
if (m_buildingBlocksStock < cost)
{
return std::nullopt;
return kInvalidBuildingId;
}
m_buildingBlocksStock -= cost;
return m_buildingSystem->place(type, anchor, rotation, m_currentTick);
@@ -1222,52 +1232,52 @@ void Simulation::demolish(BuildingId id)
m_buildingBlocksStock += m_buildingSystem->demolish(id);
}
BuildingSystem& Simulation::getBuildingsMutable()
BuildingSystem& Simulation::buildingsMutable()
{
return *m_buildingSystem;
}
const BuildingSystem& Simulation::getBuildings() const
const BuildingSystem& Simulation::buildings() const
{
return *m_buildingSystem;
}
BeltSystem& Simulation::getBeltsMutable()
BeltSystem& Simulation::beltsMutable()
{
return m_beltSystem;
}
const BeltSystem& Simulation::getBelts() const
const BeltSystem& Simulation::belts() const
{
return m_beltSystem;
}
ShipSystem& Simulation::getShips()
ShipSystem& Simulation::ships()
{
return *m_shipSystem;
}
const ShipSystem& Simulation::getShips() const
const ShipSystem& Simulation::ships() const
{
return *m_shipSystem;
}
ScrapSystem& Simulation::getScraps()
ScrapSystem& Simulation::scraps()
{
return *m_scrapSystem;
}
const ScrapSystem& Simulation::getScraps() const
const ScrapSystem& Simulation::scraps() const
{
return *m_scrapSystem;
}
EntityAdmin& Simulation::getAdmin()
EntityAdmin& Simulation::admin()
{
return m_admin;
}
const EntityAdmin& Simulation::getAdmin() const
const EntityAdmin& Simulation::admin() const
{
return m_admin;
}

View File

@@ -2,7 +2,6 @@
#include <map>
#include <memory>
#include <optional>
#include <random>
#include <set>
#include <string>
@@ -42,7 +41,7 @@ public:
explicit Simulation(GameConfig config, unsigned int seed = 0);
~Simulation();
const GameConfig& getConfig() const;
const GameConfig& config() const;
// Reinitializes all simulation state as if constructed fresh.
void reset(unsigned int seed = 0);
@@ -69,26 +68,26 @@ public:
// Returns true if there are pending schematic choices waiting for player input.
bool hasSchematicChoicesPending() const;
Tick getCurrentTick() const;
Tick currentTick() const;
// The seed this run was (re)initialized with; written to the replay header.
unsigned int getSeed() const;
int getBuildingBlocksStock() const;
int buildingBlocksStock() const;
// Current asteroid width in tiles = base width + purchased expansions
// (REQ-EXP-UNLOCK, REQ-GW-ASTEROID-EXPAND).
int getCurrentAsteroidWidth_tiles() const;
int currentAsteroidWidth_tiles() const;
// Building block cost of the next expansion, floored to an integer
// (REQ-EXP-COST); x = number of expansions already purchased.
int getCurrentExpansionCost() const;
int currentExpansionCost() const;
bool isGameOver() const;
bool isWon() 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;
int artifactCount() const;
double threatLevel() const;
double threatAccumulationRate() const;
double maxFactoryProductionThreatRate() const;
double currentFactoryProductionThreatRate() const;
int bossWaveCounter() const;
Tick bossCountdownTicks() const;
Tick normalGapRemainingTicks() const;
// Ship schematic state query.
bool isSchematicUnlocked(const std::string& shipId) const;
@@ -103,25 +102,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 getRngFingerprint() const;
unsigned long long rngFingerprint() 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 getRngFingerprint().
// a superset of rngFingerprint().
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& getBuildings() const;
const BeltSystem& getBelts() const;
ShipSystem& getShips();
const ShipSystem& getShips() const;
ScrapSystem& getScraps();
const ScrapSystem& getScraps() const;
EntityAdmin& getAdmin();
const EntityAdmin& getAdmin() const;
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;
private:
// Grants tests access to the private player-action mutators below without
@@ -132,8 +131,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 nullopt if blocks are insufficient.
std::optional<BuildingId> tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation);
// Returns the new entity id, or kInvalidBuildingId if blocks are insufficient.
BuildingId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation);
// Demolishes the building with the given id and refunds building blocks.
void demolish(BuildingId id);
@@ -149,8 +148,8 @@ private:
void tryExpandAsteroid();
// Mutable subsystem accessors; same chokepoint rule as the mutators above.
BuildingSystem& getBuildingsMutable();
BeltSystem& getBeltsMutable();
BuildingSystem& buildingsMutable();
BeltSystem& beltsMutable();
void handleEvent(std::shared_ptr<const TracePrintRequestedEvent> event) override;
@@ -183,7 +182,7 @@ private:
int m_artifactCount = 0;
// Pre-placed structure IDs.
std::optional<BuildingId> m_hqBuildingId; // Building id (for belt integration)
BuildingId m_hqBuildingId; // Building id (for belt integration)
entt::entity m_hqProxyEntity; // ECS entity (HP, targeting)
entt::entity m_playerStation1Entity;
entt::entity m_playerStation2Entity;
@@ -234,9 +233,9 @@ private:
// True if every prerequisite in unlockRequires is explicitly unlocked (REQ-LOCK-PREREQ).
bool prerequisitesSatisfied(const std::vector<std::string>& unlockRequires) const;
// Ids (sorted alphabetically by display name) of the recipes in
// Display names (deduplicated, alphabetical) of output items of recipes in
// hypothetical.recipeIds that are not yet in m_unlockedRecipeIds.
std::vector<std::string> computeNewlyUnlockedRecipeIds(const UnlockedSets& hypothetical) const;
std::vector<std::string> computeNewlyUnlockedItemNames(const UnlockedSets& hypothetical) const;
EntityAdmin m_admin;
BeltSystem m_beltSystem;

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ static GameConfig loadConfig()
static void killEnemyStations(Simulation& sim)
{
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
sim.admin().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.getArtifactCount() == 0);
CHECK(sim.artifactCount() == 0);
CHECK_FALSE(sim.isWon());
}
@@ -145,7 +145,7 @@ TEST_CASE("ArtifactWinCondition: selecting artifact increments artifact count",
SimulationTestAccess::applySchematicChoice(sim,index);
CHECK(sim.getArtifactCount() == 1);
CHECK(sim.artifactCount() == 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.getArtifactCount() == 0);
CHECK(sim.artifactCount() == 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.getArtifactCount() == 1);
CHECK(sim.artifactCount() == 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.getArtifactCount() == 2);
CHECK(sim.artifactCount() == 2);
CHECK(sim.isWon());
}
@@ -249,6 +249,6 @@ TEST_CASE("ArtifactWinCondition: reset clears artifact count and win state",
sim.reset();
CHECK(sim.getArtifactCount() == 0);
CHECK(sim.artifactCount() == 0);
CHECK_FALSE(sim.isWon());
}

View File

@@ -954,7 +954,7 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b
Fixture f;
const BuildingId bayId = f.buildings.place(BuildingType::SalvageBay,
QPoint(-4, 0), Rotation::East, 0).value();
QPoint(-4, 0), Rotation::East, 0);
Tick t = 0;
for (int i = 0; i < 500; ++i)
{
@@ -984,45 +984,6 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b
REQUIRE(i.target.x() < pos(f.admin, ship).value.x());
}
TEST_CASE("SalvagerSystem: full-cargo ship at its SalvageBay hands over cargo", "[behavior]")
{
Fixture f;
const BuildingId bayId = f.buildings.place(BuildingType::SalvageBay,
QPoint(-4, 0), Rotation::East, 0).value();
Tick t = 0;
for (int i = 0; i < 500; ++i)
{
f.buildings.tickConstruction(t++);
if (f.buildings.findBuilding(bayId) != nullptr) { break; }
}
const Building* bay = f.buildings.findBuilding(bayId);
REQUIRE(bay != nullptr);
// Config-driven output-buffer capacity is applied on placement (REQ-BLD-SALVAGE-BAY).
REQUIRE(bay->outputBuffer.capacity == 20);
const QVector2D bayCenter(bay->anchor.x() + bay->footprint.width() / 2.0f,
bay->anchor.y() + bay->footprint.height() / 2.0f);
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
const entt::entity ship = f.ships.spawn("salvage_ship", bayCenter, false, salvageLayout);
f.admin.get<PositionComponent>(ship).value = bayCenter;
CargoComponent& cargo = f.admin.get<CargoComponent>(ship);
cargo.current = cargo.maxCapacity; // full cargo
const int before = cargo.current;
REQUIRE(before > 0);
f.admin.get<DeliverScrapBehavior>(ship).deliveryBay = bayId;
f.salvageTick();
// One unit handed over from cargo into the bay's output buffer.
REQUIRE(f.admin.get<CargoComponent>(ship).current == before - 1);
const Building* bayAfter = f.buildings.findBuilding(bayId);
REQUIRE(bayAfter != nullptr);
REQUIRE(bayAfter->outputBuffer.items.size() == 1);
REQUIRE(bayAfter->outputBuffer.items.front().type.id == "scrap");
}
// ---------------------------------------------------------------------------
// Collection range (per-module)
// ---------------------------------------------------------------------------

View File

@@ -44,14 +44,14 @@ TEST_CASE("BeltSystem: tryPutItem succeeds on registered belt", "[belt]")
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
REQUIRE(bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East));
REQUIRE(bs.tryPutItem(tile, makeItem("iron_ore")));
}
TEST_CASE("BeltSystem: tryPutItem fails on unregistered tile", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
REQUIRE_FALSE(bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East));
REQUIRE_FALSE(bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore")));
}
TEST_CASE("BeltSystem: tryPutItem fails after removeTile", "[belt]")
@@ -61,7 +61,7 @@ TEST_CASE("BeltSystem: tryPutItem fails after removeTile", "[belt]")
bs.placeBelt(tile, Rotation::East);
bs.removeTile(tile);
REQUIRE_FALSE(bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East));
REQUIRE_FALSE(bs.tryPutItem(tile, makeItem("iron_ore")));
}
// ---------------------------------------------------------------------------
@@ -74,10 +74,10 @@ TEST_CASE("BeltSystem: four items fit in one tile", "[belt]")
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
REQUIRE(bs.tryPutItem(tile, makeItem("a"), Rotation::East));
REQUIRE(bs.tryPutItem(tile, makeItem("b"), Rotation::East));
REQUIRE(bs.tryPutItem(tile, makeItem("c"), Rotation::East));
REQUIRE(bs.tryPutItem(tile, makeItem("d"), Rotation::East));
REQUIRE(bs.tryPutItem(tile, makeItem("a")));
REQUIRE(bs.tryPutItem(tile, makeItem("b")));
REQUIRE(bs.tryPutItem(tile, makeItem("c")));
REQUIRE(bs.tryPutItem(tile, makeItem("d")));
}
TEST_CASE("BeltSystem: fifth tryPutItem on full tile returns false", "[belt]")
@@ -86,12 +86,12 @@ TEST_CASE("BeltSystem: fifth tryPutItem on full tile returns false", "[belt]")
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(tile, makeItem("a"), Rotation::East);
bs.tryPutItem(tile, makeItem("b"), Rotation::East);
bs.tryPutItem(tile, makeItem("c"), Rotation::East);
bs.tryPutItem(tile, makeItem("d"), Rotation::East);
bs.tryPutItem(tile, makeItem("a"));
bs.tryPutItem(tile, makeItem("b"));
bs.tryPutItem(tile, makeItem("c"));
bs.tryPutItem(tile, makeItem("d"));
REQUIRE_FALSE(bs.tryPutItem(tile, makeItem("e"), Rotation::East));
REQUIRE_FALSE(bs.tryPutItem(tile, makeItem("e")));
}
// ---------------------------------------------------------------------------
@@ -103,7 +103,7 @@ TEST_CASE("BeltSystem: tryTakeItem returns placed item after reaching output edg
BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(tile, makeItem("iron_ore"));
bs.tick(); // advance to output edge
const std::optional<Item> taken = bs.tryTakeItem(eastPort(tile));
@@ -117,7 +117,7 @@ TEST_CASE("BeltSystem: tryTakeItem requires item to reach output edge before yie
BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(tile, makeItem("iron_ore"));
// Item placed but not yet at output edge — must not be available.
REQUIRE_FALSE(bs.tryTakeItem(eastPort(tile)).has_value());
@@ -133,8 +133,8 @@ TEST_CASE("BeltSystem: tryTakeItem with two items returns both after each reache
BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(tile, makeItem("first"), Rotation::East);
bs.tryPutItem(tile, makeItem("second"), Rotation::East);
bs.tryPutItem(tile, makeItem("first"));
bs.tryPutItem(tile, makeItem("second"));
// Front item reaches output edge after one tick.
bs.tick();
@@ -169,7 +169,7 @@ TEST_CASE("BeltSystem: item transfers from tile A to tile B and becomes availabl
bs.placeBelt(tileA, Rotation::East);
bs.placeBelt(tileB, Rotation::East);
bs.tryPutItem(tileA, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(tileA, makeItem("iron_ore"));
bs.tick(); // item reaches output edge of A, moves to B at progress 0
bs.tick(); // item reaches output edge of B
@@ -185,7 +185,7 @@ TEST_CASE("BeltSystem: item stays at progress 1.0 when next tile is absent", "[b
const QPoint tileA(0, 0);
bs.placeBelt(tileA, Rotation::East);
bs.tryPutItem(tileA, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(tileA, makeItem("iron_ore"));
bs.tick();
// Item should still be on tileA (no registered tile to the east).
@@ -202,7 +202,7 @@ TEST_CASE("BeltSystem: item traverses 3-tile chain in 3 ticks (one per tile)", "
bs.placeBelt(tileB, Rotation::East);
bs.placeBelt(tileC, Rotation::East);
bs.tryPutItem(tileA, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(tileA, makeItem("iron_ore"));
bs.tick(); // A output edge → moves to B at progress 0
bs.tick(); // B output edge → moves to C at progress 0
bs.tick(); // C output edge → available for pickup
@@ -221,13 +221,13 @@ TEST_CASE("BeltSystem: item stays blocked when next tile is full", "[belt]")
bs.placeBelt(tileB, Rotation::East);
// Fill tileB to capacity.
bs.tryPutItem(tileB, makeItem("b1"), Rotation::East);
bs.tryPutItem(tileB, makeItem("b2"), Rotation::East);
bs.tryPutItem(tileB, makeItem("b3"), Rotation::East);
bs.tryPutItem(tileB, makeItem("b4"), Rotation::East);
bs.tryPutItem(tileB, makeItem("b1"));
bs.tryPutItem(tileB, makeItem("b2"));
bs.tryPutItem(tileB, makeItem("b3"));
bs.tryPutItem(tileB, makeItem("b4"));
// Place item in tileA — should be blocked.
bs.tryPutItem(tileA, makeItem("a1"), Rotation::East);
bs.tryPutItem(tileA, makeItem("a1"));
bs.tick();
// Item in tileA must still be there.
@@ -246,13 +246,13 @@ TEST_CASE("BeltSystem: belt second slot is capped at progress 0.75", "[belt]")
bs.placeBelt(tile, Rotation::East);
// Advance front item to the output edge; it stays there (no next tile).
bs.tryPutItem(tile, makeItem("front_item"), Rotation::East);
bs.tryPutItem(tile, makeItem("front_item"));
bs.tick(); // slot[0]: 0.4
bs.tick(); // slot[0]: 0.8
bs.tick(); // slot[0]: 1.0 (capped, stuck)
// Place second item; slot[0] is at 1.0.
bs.tryPutItem(tile, makeItem("back_item"), Rotation::East);
bs.tryPutItem(tile, makeItem("back_item"));
bs.tick(); // slot[1]: 0.4
bs.tick(); // slot[1] would reach 0.8 — capped at 0.75
@@ -272,8 +272,8 @@ TEST_CASE("BeltSystem: clearTiles removes all items from specified tiles", "[bel
BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(tile, makeItem("copper_ore"), Rotation::East);
bs.tryPutItem(tile, makeItem("iron_ore"));
bs.tryPutItem(tile, makeItem("copper_ore"));
bs.clearTiles({tile});
@@ -289,7 +289,7 @@ TEST_CASE("BeltSystem: forEachVisualItem visits items inside viewport", "[belt]"
BeltSystem bs(kFastBeltSpeed);
const QPoint tile(5, 5);
bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(tile, makeItem("iron_ore"));
int count = 0;
bs.forEachVisualItem(QRect(0, 0, 20, 20), [&count](VisualItem) { ++count; });
@@ -302,7 +302,7 @@ TEST_CASE("BeltSystem: forEachVisualItem skips items outside viewport", "[belt]"
BeltSystem bs(kFastBeltSpeed);
const QPoint tile(50, 50);
bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(tile, makeItem("iron_ore"));
int count = 0;
bs.forEachVisualItem(QRect(0, 0, 20, 20), [&count](VisualItem) { ++count; });
@@ -315,7 +315,7 @@ TEST_CASE("BeltSystem: forEachVisualItem reports correct ItemType", "[belt]")
BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::East);
bs.tryPutItem(tile, makeItem("copper_ingot"), Rotation::East);
bs.tryPutItem(tile, makeItem("copper_ingot"));
std::vector<ItemType> seen;
bs.forEachVisualItem(QRect(-1, -1, 10, 10), [&seen](VisualItem vi)
@@ -348,10 +348,10 @@ TEST_CASE("BeltSystem: splitter alternates between outputA and outputB", "[belt]
bs.placeBelt(tileA, Rotation::North);
bs.placeBelt(tileB, Rotation::South);
bs.tryPutItem(tileIn, makeItem("item1"), Rotation::East);
bs.tryPutItem(tileIn, makeItem("item1"));
bs.tick(); // item1: tileIn -> splitter back (progress 0)
bs.tryPutItem(tileIn, makeItem("item2"), Rotation::East);
bs.tryPutItem(tileIn, makeItem("item2"));
bs.tick(); // item1 back -> 0.5 -> frontA; item2 advances but back is occupied
bs.tick(); // item1 frontA -> 1.0 -> tileA; item2 enters splitter back
bs.tick(); // item2 back -> 0.5 -> frontB; item1 at tileA output edge
@@ -388,7 +388,7 @@ TEST_CASE("BeltSystem: splitter routes to preferred output when item matches bot
bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {});
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); // tileIn -> splitter back
bs.tick(); // back -> frontA (both match, alternation, preferred A)
bs.tick(); // frontA -> tileA
@@ -411,7 +411,7 @@ TEST_CASE("BeltSystem: splitter routes item to output A when only filter A match
bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {ItemType{"copper_ore"}});
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); // tileIn -> splitter back
bs.tick(); // back -> frontA (exclusive match to A)
bs.tick(); // frontA reaches 1.0; no downstream belt, waits for building pickup
@@ -433,7 +433,7 @@ TEST_CASE("BeltSystem: splitter routes item to output B when only filter B match
bs.setSplitterFilters(tileSpl, {ItemType{"copper_ore"}}, {ItemType{"iron_ore"}});
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick();
bs.tick();
bs.tick();
@@ -456,17 +456,17 @@ TEST_CASE("BeltSystem: splitter alternates A then B when item matches both expli
bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {ItemType{"iron_ore"}});
// Item 1 → preferred A (nextOutputIsA=true initially).
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); bs.tick(); bs.tick();
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value());
// Item 2 → preferred B (nextOutputIsA toggled to false).
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); bs.tick(); bs.tick();
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::South}).has_value());
// Item 3 → preferred A again (nextOutputIsA toggled back to true).
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); bs.tick(); bs.tick();
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
@@ -486,7 +486,7 @@ TEST_CASE("BeltSystem: splitter routes unmatched item to the unfiltered output",
bs.setSplitterFilters(tileSpl, {ItemType{"copper_ore"}}, {});
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); bs.tick(); bs.tick();
REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value());
@@ -506,7 +506,7 @@ TEST_CASE("BeltSystem: splitter stalls when item matches neither filter", "[belt
bs.setSplitterFilters(tileSpl, {ItemType{"copper_ore"}}, {ItemType{"iron_ingot"}});
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); // tileIn -> splitter back
bs.tick(); // back reaches 0.5; routing fires but stalls (no filter match)
bs.tick(); // back stays at 0.5; stall persists
@@ -530,17 +530,17 @@ TEST_CASE("BeltSystem: splitter falls back to other output when preferred is blo
bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {ItemType{"iron_ore"}});
// Item 1 → preferred A (nextOutputIsA=true → false after routing).
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); bs.tick(); bs.tick(); // frontA = item1 at 1.0
// Item 2 → preferred B (nextOutputIsA=false → true after routing). Take item2 to free frontB.
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); bs.tick(); bs.tick(); // frontB = item2 at 1.0
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::South}).has_value());
// frontA still holds item1; nextOutputIsA=true (prefer A).
// Item 3: both match, preferred A is occupied → fallback to B without toggling nextOutputIsA.
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); bs.tick(); bs.tick(); // frontB = item3 at 1.0
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value()); // item1 still in A
@@ -549,7 +549,7 @@ TEST_CASE("BeltSystem: splitter falls back to other output when preferred is blo
// nextOutputIsA was not toggled by the fallback: next item should still prefer A.
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value()); // free frontA
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::South}).has_value()); // free frontB
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); bs.tick(); bs.tick();
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::North}).has_value()); // item4 → A (preferA still true)
REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
@@ -591,14 +591,14 @@ TEST_CASE("BeltSystem: splitter fallback enters the open output at progress 0.75
// Permanently block output A: route one item to frontA where it sticks at 1.0
// (North has no downstream tile, so it can never move out).
bs.tryPutItem(tileSpl, makeItem("blockA"), Rotation::East);
bs.tryPutItem(tileSpl, makeItem("blockA"));
bs.tick(); // back: 0.25
bs.tick(); // back: 0.5 -> frontA at 0.75 (preferred A), nextOutputIsA = false
bs.tick(); bs.tick(); // frontA: 0.75 -> 1.0 (stuck, no North downstream)
// Cycle one item through B as the *preferred* output (also enters at 0.75) to
// flip nextOutputIsA back to true and free frontB for the fallback case below.
bs.tryPutItem(tileSpl, makeItem("toB_pref"), Rotation::East);
bs.tryPutItem(tileSpl, makeItem("toB_pref"));
bs.tick(); // back: 0.25
bs.tick(); // back: 0.5 -> frontB at 0.75 (preferred B), nextOutputIsA = true
REQUIRE(southProgressOf("toB_pref") == Approx(0.75));
@@ -608,7 +608,7 @@ TEST_CASE("BeltSystem: splitter fallback enters the open output at progress 0.75
// Next item prefers A again (nextOutputIsA == true), but A is still blocked,
// so it falls back to B — and must enter near the edge at progress 0.75.
bs.tryPutItem(tileSpl, makeItem("toB_fallback"), Rotation::East);
bs.tryPutItem(tileSpl, makeItem("toB_fallback"));
bs.tick(); // back: 0.25
bs.tick(); // back: 0.5 -> fallback routes to frontB at 0.75
REQUIRE(southProgressOf("toB_fallback") == Approx(0.75));
@@ -648,13 +648,13 @@ TEST_CASE("BeltSystem: splitter with an exclusive filter enters its only output
};
// iron_ore matches filterA only -> sole eligible output A.
bs.tryPutItem(tileSpl, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(tileSpl, makeItem("iron_ore"));
bs.tick(); // back: 0.25
bs.tick(); // back: 0.5 -> routes to frontA at 0.75
REQUIRE(progressOf("iron_ore", Rotation::North) == Approx(0.75));
// copper_ore matches filterB only -> sole eligible output B.
bs.tryPutItem(tileSpl, makeItem("copper_ore"), Rotation::East);
bs.tryPutItem(tileSpl, makeItem("copper_ore"));
bs.tick(); // back: 0.25
bs.tick(); // back: 0.5 -> routes to frontB at 0.75
REQUIRE(progressOf("copper_ore", Rotation::South) == Approx(0.75));
@@ -692,13 +692,13 @@ TEST_CASE("BeltSystem: splitter alternation enters the preferred output at progr
};
// First item: preferred A (nextOutputIsA starts true) -> frontA at 0.75.
bs.tryPutItem(tileSpl, makeItem("first"), Rotation::East);
bs.tryPutItem(tileSpl, makeItem("first"));
bs.tick(); // back: 0.25
bs.tick(); // back: 0.5 -> routes to preferred frontA at 0.75, nextOutputIsA = false
REQUIRE(progressOf("first", Rotation::North) == Approx(0.75));
// Second item: preference flipped, B is free -> frontB at 0.75.
bs.tryPutItem(tileSpl, makeItem("second"), Rotation::East);
bs.tryPutItem(tileSpl, makeItem("second"));
bs.tick(); // back: 0.25 (first sticks at North 1.0, no downstream)
bs.tick(); // back: 0.5 -> routes to preferred frontB at 0.75
REQUIRE(progressOf("second", Rotation::South) == Approx(0.75));
@@ -718,7 +718,7 @@ TEST_CASE("BeltSystem: splitter back slot is capped at 0.5 and waits before rout
bs.placeBelt(tileIn, Rotation::East);
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); // item enters splitter back at progress 0; routing not yet triggered
// Back has not yet reached 0.5 — front slots empty, nothing available.
@@ -743,7 +743,7 @@ TEST_CASE("BeltSystem: splitter delivers item directly to building input via try
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
// No output belts — both outputs lead directly to building inputs.
bs.tryPutItem(tileIn, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(tileIn, makeItem("iron_ore"));
bs.tick(); // tileIn -> splitter back
bs.tick(); // back -> frontA at progress 0
bs.tick(); // frontA reaches 1.0; no downstream belt, item waits for building pickup
@@ -766,7 +766,7 @@ TEST_CASE("BeltSystem: splitter accepts new items after building pulls from fron
bs.placeBelt(tileIn, Rotation::East);
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
bs.tryPutItem(tileIn, makeItem("item1"), Rotation::East);
bs.tryPutItem(tileIn, makeItem("item1"));
bs.tick();
bs.tick();
bs.tick(); // item1 now in frontA at 1.0
@@ -775,7 +775,7 @@ TEST_CASE("BeltSystem: splitter accepts new items after building pulls from fron
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value());
// Feed item2; preferred is now South.
bs.tryPutItem(tileIn, makeItem("item2"), Rotation::East);
bs.tryPutItem(tileIn, makeItem("item2"));
bs.tick();
bs.tick();
bs.tick(); // item2 now in frontB at 1.0
@@ -812,21 +812,21 @@ TEST_CASE("BeltSystem: splitter alternates between two unregistered outputs (bui
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
// item1 → frontA (preferred, nextOutputIsA=true)
bs.tryPutItem(tileIn, makeItem("item1"), Rotation::East);
bs.tryPutItem(tileIn, makeItem("item1"));
bs.tick();
bs.tick();
bs.tick();
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::North}).has_value());
// item2 → frontB (preferred, nextOutputIsA now false)
bs.tryPutItem(tileIn, makeItem("item2"), Rotation::East);
bs.tryPutItem(tileIn, makeItem("item2"));
bs.tick();
bs.tick();
bs.tick();
REQUIRE(bs.tryTakeItem(Port{tileSpl, Rotation::South}).has_value());
// item3 → frontA again (nextOutputIsA toggled back to true)
bs.tryPutItem(tileIn, makeItem("item3"), Rotation::East);
bs.tryPutItem(tileIn, makeItem("item3"));
bs.tick();
bs.tick();
bs.tick();
@@ -847,7 +847,7 @@ TEST_CASE("BeltSystem: tunnel pairing — basic pair within max distance", "[bel
bs.placeTunnelEntry(entry, Rotation::East, 10);
bs.placeTunnelExit(exit, Rotation::East);
bs.tryPutItem(entry, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(entry, makeItem("iron_ore"));
// With kFastBeltSpeed, items cross one tile per tick.
// entry tile: 1 tick to reach front progress 1.0
@@ -873,7 +873,7 @@ TEST_CASE("BeltSystem: tunnel pairing — wrong direction prevents pair", "[belt
bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10);
bs.placeTunnelExit(QPoint(3, 0), Rotation::North);
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"));
for (int i = 0; i < 20; ++i)
{
@@ -891,7 +891,7 @@ TEST_CASE("BeltSystem: tunnel pairing — beyond max distance prevents pair", "[
bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 2);
bs.placeTunnelExit(QPoint(3, 0), Rotation::East);
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"));
for (int i = 0; i < 20; ++i)
{
@@ -912,7 +912,7 @@ TEST_CASE("BeltSystem: tunnel pairing — same-dir entry between blocks pairing"
bs.placeTunnelExit(QPoint(4, 0), Rotation::East);
// Put item on Entry2 — should reach exit.
bs.tryPutItem(QPoint(2, 0), makeItem("copper_ore"), Rotation::East);
bs.tryPutItem(QPoint(2, 0), makeItem("copper_ore"));
for (int i = 0; i < 20; ++i)
{
bs.tick();
@@ -921,7 +921,7 @@ TEST_CASE("BeltSystem: tunnel pairing — same-dir entry between blocks pairing"
bs.tryTakeItem(Port{QPoint(4, 0), Rotation::East});
// Put item on Entry1 — should NOT reach exit (Entry1 is unpaired).
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"));
for (int i = 0; i < 20; ++i)
{
bs.tick();
@@ -939,7 +939,7 @@ TEST_CASE("BeltSystem: tunnel pairing — cross-dir entry between is ignored", "
bs.placeTunnelEntry(QPoint(2, 0), Rotation::North, 10);
bs.placeTunnelExit(QPoint(4, 0), Rotation::East);
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"));
for (int i = 0; i < 20; ++i)
{
bs.tick();
@@ -958,7 +958,7 @@ TEST_CASE("BeltSystem: unpaired entry blocks items at front", "[belt]")
bs.placeTunnelEntry(QPoint(0, 0), Rotation::East, 10);
// No exit placed — entry is unpaired.
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(QPoint(0, 0), makeItem("iron_ore"));
for (int i = 0; i < 10; ++i)
{
bs.tick();
@@ -984,7 +984,7 @@ TEST_CASE("BeltSystem: demolish entry discards transit items", "[belt]")
bs.placeTunnelEntry(entry, Rotation::East, 10);
bs.placeTunnelExit(exit, Rotation::East);
bs.tryPutItem(entry, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(entry, makeItem("iron_ore"));
// Advance just enough for item to enter transit but not reach exit.
bs.tick(); // item enters entry front
@@ -1010,7 +1010,7 @@ TEST_CASE("BeltSystem: clearTiles discards tunnel transit items", "[belt]")
bs.placeTunnelEntry(entry, Rotation::East, 10);
bs.placeTunnelExit(exit, Rotation::East);
bs.tryPutItem(entry, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(entry, makeItem("iron_ore"));
bs.tick();
bs.tick();
@@ -1037,7 +1037,7 @@ TEST_CASE("BeltSystem: belt to entry to transit to exit to belt full chain", "[b
bs.placeTunnelExit(exit, Rotation::East);
bs.placeBelt(beltOut, Rotation::East);
bs.tryPutItem(beltIn, makeItem("iron_ore"), Rotation::East);
bs.tryPutItem(beltIn, makeItem("iron_ore"));
for (int i = 0; i < 30; ++i)
{
@@ -1061,11 +1061,11 @@ TEST_CASE("BeltSystem: multiple items transit tunnel in order", "[belt]")
bs.placeTunnelEntry(entry, Rotation::East, 10);
bs.placeTunnelExit(exit, Rotation::East);
bs.tryPutItem(entry, makeItem("item1"), Rotation::East);
bs.tryPutItem(entry, makeItem("item1"));
bs.tick();
bs.tick(); // item1 enters transit
bs.tryPutItem(entry, makeItem("item2"), Rotation::East);
bs.tryPutItem(entry, makeItem("item2"));
for (int i = 0; i < 30; ++i)
{
@@ -1086,94 +1086,3 @@ TEST_CASE("BeltSystem: multiple items transit tunnel in order", "[belt]")
REQUIRE(taken2.has_value());
REQUIRE(taken2->type.id == "item2");
}
// ---------------------------------------------------------------------------
// Output-edge rejection (REQ-MAT-ACCEPT-DIR)
// ---------------------------------------------------------------------------
TEST_CASE("BeltSystem: belt refuses an item arriving through its output edge", "[belt]")
{
// Belt A flows East into belt B, but B flows West — so the hand-off would
// enter B through its own (West) output edge and must be refused. Without the
// guard the item would ping-pong between the two belts forever.
BeltSystem bs(kFastBeltSpeed);
const QPoint tileA(0, 0);
const QPoint tileB(1, 0);
bs.placeBelt(tileA, Rotation::East);
bs.placeBelt(tileB, Rotation::West);
REQUIRE(bs.tryPutItem(tileA, makeItem("iron_ore"), Rotation::East));
for (int i = 0; i < 5; ++i)
{
bs.tick();
}
// B never accepts the item through its output edge...
REQUIRE_FALSE(bs.tryTakeItem(Port{tileB, Rotation::West}).has_value());
// ...and it stays blocked at A's output edge.
REQUIRE(bs.tryTakeItem(eastPort(tileA)).has_value());
}
TEST_CASE("BeltSystem: tryPutItem refuses a deposit onto a belt facing the source", "[belt]")
{
// A belt whose output edge faces back toward the depositing building must
// refuse the item; feeding through a non-output edge still works.
BeltSystem bs(kFastBeltSpeed);
const QPoint tile(0, 0);
bs.placeBelt(tile, Rotation::West);
// Item travelling East enters through the West (output) edge -> refused.
REQUIRE_FALSE(bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::East));
// Item travelling West enters through the East (back) edge -> accepted.
REQUIRE(bs.tryPutItem(tile, makeItem("iron_ore"), Rotation::West));
}
TEST_CASE("BeltSystem: splitter refuses an item arriving through an output edge", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint tileSpl(1, 0);
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
// Entering through the North output edge (item travelling South) -> refused.
REQUIRE_FALSE(bs.tryPutItem(tileSpl, makeItem("iron_ore"), Rotation::South));
// Entering through the South output edge (item travelling North) -> refused.
REQUIRE_FALSE(bs.tryPutItem(tileSpl, makeItem("iron_ore"), Rotation::North));
// Entering through a non-output (West) edge (item travelling East) -> accepted.
REQUIRE(bs.tryPutItem(tileSpl, makeItem("iron_ore"), Rotation::East));
}
TEST_CASE("BeltSystem: tunnel entry refuses an item arriving through its mouth edge", "[belt]")
{
BeltSystem bs(kFastBeltSpeed);
const QPoint entry(0, 0);
bs.placeTunnelEntry(entry, Rotation::East, 10);
// Item travelling West enters through the East mouth (output) edge -> refused.
REQUIRE_FALSE(bs.tryPutItem(entry, makeItem("iron_ore"), Rotation::West));
// Item travelling East enters through the back (West) edge -> accepted.
REQUIRE(bs.tryPutItem(entry, makeItem("iron_ore"), Rotation::East));
}
TEST_CASE("BeltSystem: tunnel exit refuses an item pushed into its output edge", "[belt]")
{
// A splitter's East front sits next to a tunnel exit whose output faces West
// (back toward the splitter). The front cannot hand off into the exit's
// output edge, so the item stays on the splitter front.
BeltSystem bs(kFastBeltSpeed);
const QPoint tileSpl(1, 0);
const QPoint exitTile(2, 0);
bs.placeSplitter(tileSpl, Rotation::East, Rotation::South);
bs.placeTunnelExit(exitTile, Rotation::West);
// Feed through the West edge (item travelling East); first item routes to the
// East front (nextOutputIsA starts true).
REQUIRE(bs.tryPutItem(tileSpl, makeItem("iron_ore"), Rotation::East));
for (int i = 0; i < 5; ++i)
{
bs.tick();
}
// The item is stuck on the splitter's East front; the exit never received it.
REQUIRE(bs.peekItem(Port{tileSpl, Rotation::East}).has_value());
REQUIRE_FALSE(bs.peekItem(Port{exitTile, Rotation::West}).has_value());
}

View File

@@ -8,7 +8,6 @@
#include "Blueprint.h"
#include "Building.h"
#include "BuildingConfig.h"
#include "BuildingsConfig.h"
#include "BuildingSystem.h"
#include "BuildingType.h"
@@ -527,15 +526,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).value();
BuildingType::Belt, cursor + offsetA, Rotation::East);
const BuildingId idB = SimulationTestAccess::place(sim,
BuildingType::Belt, cursor + offsetB, Rotation::East).value();
BuildingType::Belt, cursor + offsetB, Rotation::East);
REQUIRE(idA != kInvalidBuildingId);
REQUIRE(idB != kInvalidBuildingId);
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
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
}
TEST_CASE("Blueprint placement: cost is deducted for each building in sequence", "[blueprint]")
@@ -544,20 +543,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.getConfig().buildings.buildings)
for (const BuildingDef& def : sim.config().buildings.buildings)
{
if (def.type == BuildingType::Belt) { beltCost = def.cost; break; }
}
REQUIRE(beltCost > 0);
const int startBlocks = sim.getBuildingBlocksStock();
const int startBlocks = sim.buildingBlocksStock();
REQUIRE(startBlocks >= 2 * beltCost); // test config has enough starting blocks
SimulationTestAccess::place(sim,BuildingType::Belt, QPoint(-6, 0), Rotation::East);
REQUIRE(sim.getBuildingBlocksStock() == startBlocks - beltCost);
REQUIRE(sim.buildingBlocksStock() == startBlocks - beltCost);
SimulationTestAccess::place(sim,BuildingType::Belt, QPoint(-4, 0), Rotation::East);
REQUIRE(sim.getBuildingBlocksStock() == startBlocks - 2 * beltCost);
REQUIRE(sim.buildingBlocksStock() == startBlocks - 2 * beltCost);
}
TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidBuildingId and deducts nothing",
@@ -567,7 +566,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.getConfig().buildings.buildings)
for (const BuildingDef& def : sim.config().buildings.buildings)
{
if (def.type == BuildingType::Miner) { minerCost = def.cost; break; }
}
@@ -576,35 +575,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.getBuildingBlocksStock() >= minerCost)
while (sim.buildingBlocksStock() >= minerCost)
{
SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(col, 0), Rotation::East);
col -= 2;
}
const int blocksBeforeAttempt = sim.getBuildingBlocksStock();
const std::optional<BuildingId> id = SimulationTestAccess::place(sim,
const int blocksBeforeAttempt = sim.buildingBlocksStock();
const BuildingId id = SimulationTestAccess::place(sim,
BuildingType::Miner, QPoint(col - 2, 0), Rotation::East);
// Placement must fail and leave the stock unchanged.
REQUIRE_FALSE(id.has_value());
REQUIRE(sim.getBuildingBlocksStock() == blocksBeforeAttempt);
REQUIRE(id == kInvalidBuildingId);
REQUIRE(sim.buildingBlocksStock() == blocksBeforeAttempt);
}
TEST_CASE("Simulation: tryPlaceBuilding rejects terrain-invalid placement and charges nothing",
"[blueprint]")
{
Simulation sim(loadConfig());
const int startBlocks = sim.getBuildingBlocksStock();
const int startBlocks = sim.buildingBlocksStock();
// 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 std::optional<BuildingId> id =
const BuildingId id =
SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(0, 0), Rotation::East);
REQUIRE_FALSE(id.has_value());
REQUIRE(sim.getBuildingBlocksStock() == startBlocks);
REQUIRE(sim.getBuildings().getAllSites().empty());
REQUIRE(id == kInvalidBuildingId);
REQUIRE(sim.buildingBlocksStock() == startBlocks);
REQUIRE(sim.buildings().allSites().empty());
}
TEST_CASE("Simulation: tryPlaceBuilding accepts a valid asteroid spot, occupies tiles, charges cost",
@@ -613,25 +612,25 @@ TEST_CASE("Simulation: tryPlaceBuilding accepts a valid asteroid spot, occupies
Simulation sim(loadConfig());
int minerCost = 0;
for (const BuildingDef& def : sim.getConfig().buildings.buildings)
for (const BuildingDef& def : sim.config().buildings.buildings)
{
if (def.type == BuildingType::Miner) { minerCost = def.cost; break; }
}
REQUIRE(minerCost > 0);
const int startBlocks = sim.getBuildingBlocksStock();
const int startBlocks = sim.buildingBlocksStock();
// 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).value();
SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-3, 0), Rotation::East);
REQUIRE(id != kInvalidBuildingId);
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)));
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)));
// The output-port tile (1,1)+anchor = (-2,1) is not a body cell.
REQUIRE_FALSE(sim.getBuildings().isTileOccupied(QPoint(-2, 1)));
REQUIRE_FALSE(sim.buildings().isTileOccupied(QPoint(-2, 1)));
}
// ---------------------------------------------------------------------------
@@ -666,12 +665,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).value();
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East);
REQUIRE(id != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore");
const ConstructionSite* site = sim.getBuildings().findSite(id);
const ConstructionSite* site = sim.buildings().findSite(id);
REQUIRE(site != nullptr);
REQUIRE(site->recipeId == "mine_iron_ore");
}
@@ -681,7 +680,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).value();
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East);
REQUIRE(id != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_copper_ore");
@@ -692,97 +691,11 @@ TEST_CASE("Blueprint placement: recipe transfers to building after construction
sim.tick();
}
const Building* b = sim.getBuildings().findBuilding(id);
const Building* b = sim.buildings().findBuilding(id);
REQUIRE(b != nullptr);
REQUIRE(b->recipeId == "mine_copper_ore");
}
// ---------------------------------------------------------------------------
// Blueprint capture from construction sites (REQ-UI-BLUEPRINT-CREATE,
// REQ-UI-BLUEPRINT-STORAGE): a still-under-construction building is a valid
// blueprint source, captured identically to an operational building.
// ---------------------------------------------------------------------------
TEST_CASE("Blueprint creation: a construction site is captured", "[blueprint]")
{
Simulation sim(loadConfig());
// 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).value();
REQUIRE(id != kInvalidBuildingId);
REQUIRE(sim.getBuildings().findSite(id) != nullptr);
REQUIRE(sim.getBuildings().findBuilding(id) == nullptr);
const Blueprint bp = captureBlueprintFromSelection(sim, { id });
REQUIRE(bp.buildings.size() == 1);
REQUIRE(bp.buildings[0].type == BuildingType::Belt);
REQUIRE(bp.buildings[0].offset == QPoint(0, 0)); // single 1x1 building → zero offset
}
TEST_CASE("Blueprint creation: a construction site's recipe is captured", "[blueprint]")
{
Simulation sim(loadConfig());
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 Blueprint bp = captureBlueprintFromSelection(sim, { id });
REQUIRE(bp.buildings.size() == 1);
REQUIRE(bp.buildings[0].recipeId == "mine_iron_ore");
}
TEST_CASE("Blueprint creation: mixed operational building and construction site are both captured",
"[blueprint]")
{
Simulation sim(loadConfig());
// 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).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.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).value();
REQUIRE(idB != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(idB, "mine_copper_ore");
REQUIRE(sim.getBuildings().findSite(idB) != nullptr);
const Blueprint bp = captureBlueprintFromSelection(sim, { idA, idB });
REQUIRE(bp.buildings.size() == 2);
REQUIRE(bp.buildings[0].type == BuildingType::Miner);
REQUIRE(bp.buildings[1].type == BuildingType::Miner);
// Both the operational building's and the site's configs come through.
std::vector<std::string> recipes = { bp.buildings[0].recipeId, bp.buildings[1].recipeId };
std::sort(recipes.begin(), recipes.end());
REQUIRE(recipes == std::vector<std::string>{ "mine_copper_ore", "mine_iron_ore" });
// Distinct anchors → distinct offsets.
REQUIRE(bp.buildings[0].offset != bp.buildings[1].offset);
}
TEST_CASE("Blueprint creation: selectionHasPlaceableBuilding sees a construction site", "[blueprint]")
{
Simulation sim(loadConfig());
REQUIRE_FALSE(selectionHasPlaceableBuilding(sim, {}));
const BuildingId id =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId);
REQUIRE(sim.getBuildings().findSite(id) != nullptr);
REQUIRE(selectionHasPlaceableBuilding(sim, { id }));
}
TEST_CASE("Blueprint placement: interceptor schematic is unlocked at game start", "[blueprint]")
{
// "interceptor" has unlock_at_station_level = -1 in the test config.
@@ -847,7 +760,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).value();
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
REQUIRE(id != kInvalidBuildingId);
ShipLayoutConfig layout;
@@ -859,7 +772,7 @@ TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout
SimulationTestAccess::buildings(sim).setShipLayout(id, layout);
const ConstructionSite* site = sim.getBuildings().findSite(id);
const ConstructionSite* site = sim.buildings().findSite(id);
REQUIRE(site != nullptr);
REQUIRE(site->shipLayout.has_value());
REQUIRE(site->shipLayout->placedModules.size() == 1);
@@ -871,7 +784,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).value();
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
REQUIRE(id != kInvalidBuildingId);
ShipLayoutConfig layout;
@@ -885,7 +798,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.getConfig().buildings.buildings)
for (const BuildingDef& def : sim.config().buildings.buildings)
{
if (def.type == BuildingType::Shipyard) { constructionTime = def.constructionTimeSeconds; break; }
}
@@ -896,7 +809,7 @@ TEST_CASE("Blueprint placement: ship layout transfers to building after construc
sim.tick();
}
const Building* b = sim.getBuildings().findBuilding(id);
const Building* b = sim.buildings().findBuilding(id);
REQUIRE(b != nullptr);
REQUIRE(b->shipLayout.has_value());
REQUIRE(b->shipLayout->placedModules.size() == 1);

View File

@@ -1,133 +0,0 @@
#include "catch.hpp"
#include "Building.h"
#include "BuildingConfig.h"
#include "BuildingSystem.h"
#include "BuildingType.h"
#include "ConfigLoader.h"
#include "GameConfig.h"
#include "Rotation.h"
#include "ShipLayout.h"
#include "ShipsConfig.h"
#include "Simulation.h"
#include "SimulationTestAccess.h"
// readBuildingConfig underpins the copy-settings gesture (REQ-BLD-COPY-CONFIG):
// it extracts a building's recipe / schematic / layout / splitter filters so they
// can be stamped onto a same-type building. It reads operational buildings and
// construction sites alike.
namespace
{
GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
const BuildingDef* findDef(const GameConfig& cfg, BuildingType type)
{
for (const BuildingDef& def : cfg.buildings.buildings)
{
if (def.type == type) { return &def; }
}
return nullptr;
}
BuildingId placeOperational(Simulation& sim, const GameConfig& cfg,
BuildingType type, QPoint anchor)
{
const BuildingDef* def = findDef(cfg, type);
REQUIRE(def != nullptr);
return SimulationTestAccess::buildings(sim).placeImmediate(
type, def->surfaceMask, anchor, Rotation::East);
}
const ShipDef* findAvailableSchematic(const GameConfig& cfg)
{
for (const ShipDef& def : cfg.ships.ships)
{
if (def.unlockAtStationLevel == -1) { return &def; }
}
return nullptr;
}
} // namespace
TEST_CASE("readBuildingConfig returns a miner's selected recipe", "[copyconfig]")
{
const GameConfig cfg = loadConfig();
Simulation sim(loadConfig(), 7);
const BuildingId id = placeOperational(sim, cfg, BuildingType::Miner, QPoint(0, 0));
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore");
const std::optional<BuildingConfig> config = readBuildingConfig(sim, id);
REQUIRE(config.has_value());
CHECK(config->type == BuildingType::Miner);
REQUIRE(config->recipeId.has_value());
CHECK(*config->recipeId == "mine_iron_ore");
CHECK_FALSE(config->isSplitter);
CHECK_FALSE(config->shipLayout.has_value());
}
TEST_CASE("readBuildingConfig leaves recipe unset when nothing is selected",
"[copyconfig]")
{
const GameConfig cfg = loadConfig();
Simulation sim(loadConfig(), 7);
const BuildingId id = placeOperational(sim, cfg, BuildingType::Assembler, QPoint(0, 0));
const std::optional<BuildingConfig> config = readBuildingConfig(sim, id);
REQUIRE(config.has_value());
CHECK(config->type == BuildingType::Assembler);
CHECK_FALSE(config->recipeId.has_value()); // nothing to copy
CHECK_FALSE(config->isSplitter);
}
TEST_CASE("readBuildingConfig returns a shipyard's schematic and layout",
"[copyconfig]")
{
const GameConfig cfg = loadConfig();
Simulation sim(loadConfig(), 7);
const ShipDef* schematic = findAvailableSchematic(cfg);
REQUIRE(schematic != nullptr);
const BuildingId id = placeOperational(sim, cfg, BuildingType::Shipyard, QPoint(0, 0));
SimulationTestAccess::buildings(sim).setRecipe(id, schematic->id);
SimulationTestAccess::buildings(sim).setShipLayout(id, ShipLayoutConfig{});
const std::optional<BuildingConfig> config = readBuildingConfig(sim, id);
REQUIRE(config.has_value());
CHECK(config->type == BuildingType::Shipyard);
REQUIRE(config->recipeId.has_value());
CHECK(*config->recipeId == schematic->id);
CHECK(config->shipLayout.has_value());
}
TEST_CASE("readBuildingConfig reads a queued construction site", "[copyconfig]")
{
const GameConfig cfg = loadConfig();
Simulation sim(loadConfig(), 7);
// 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).value();
REQUIRE(id != kInvalidBuildingId);
REQUIRE(sim.getBuildings().findBuilding(id) == nullptr);
REQUIRE(sim.getBuildings().findSite(id) != nullptr);
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore");
const std::optional<BuildingConfig> config = readBuildingConfig(sim, id);
REQUIRE(config.has_value());
CHECK(config->type == BuildingType::Miner);
REQUIRE(config->recipeId.has_value());
CHECK(*config->recipeId == "mine_iron_ore");
}
TEST_CASE("readBuildingConfig returns nullopt for an unknown id", "[copyconfig]")
{
Simulation sim(loadConfig(), 7);
CHECK_FALSE(readBuildingConfig(sim, kInvalidBuildingId).has_value());
}

View File

@@ -58,28 +58,12 @@ static void runTicks(BuildingSystem& bs, BeltSystem& belts, int n, Tick& tick)
bs.tickConstruction(tick);
bs.tickBeltPull();
bs.tickProduction(tick);
bs.tickOutputBelts();
bs.tickBeltPush();
belts.tick();
++tick;
}
}
// All items currently on a building's output side: buffered plus still-emerging on
// the virtual output belts (REQ-MAT-OUTPUT-EMERGE). A produced item leaves the
// output buffer the moment it starts emerging, so tests count both.
static std::vector<Item> outputSideItems(const Building& b)
{
std::vector<Item> items = b.outputBuffer.items;
for (const std::vector<BeltItemSlot>& lane : b.emergingItems)
{
for (const BeltItemSlot& slot : lane)
{
items.push_back(slot.item);
}
}
return items;
}
// Owns a BuildingSystem and its dependencies for placement-bounds tests.
struct PlacementFixture
{
@@ -119,7 +103,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).value();
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
REQUIRE(id != kInvalidBuildingId);
// Miner mask ["AA","A>"] with East rotation → body at (0,0),(1,0),(0,1).
@@ -138,9 +122,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 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());
const BuildingId id = f.bs.place(BuildingType::Miner, QPoint(0, -1), Rotation::East, 0);
REQUIRE(id == kInvalidBuildingId);
REQUIRE(f.bs.allSites().empty());
REQUIRE_FALSE(f.bs.isTileOccupied(QPoint(0, 0)));
}
@@ -151,10 +135,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 std::optional<BuildingId> id = f.bs.place(BuildingType::Miner,
const BuildingId id = f.bs.place(BuildingType::Miner,
QPoint(0, heightTiles - 1), Rotation::East, 0);
REQUIRE_FALSE(id.has_value());
REQUIRE(f.bs.getAllSites().empty());
REQUIRE(id == kInvalidBuildingId);
REQUIRE(f.bs.allSites().empty());
}
TEST_CASE("BuildingSystem: place rejects a building left of the asteroid edge", "[building]")
@@ -162,10 +146,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 std::optional<BuildingId> id = f.bs.place(BuildingType::Miner,
const BuildingId id = f.bs.place(BuildingType::Miner,
QPoint(leftEdgeX - 1, 0), Rotation::East, 0);
REQUIRE_FALSE(id.has_value());
REQUIRE(f.bs.getAllSites().empty());
REQUIRE(id == kInvalidBuildingId);
REQUIRE(f.bs.allSites().empty());
}
TEST_CASE("BuildingSystem: place accepts a building flush against the world's left edge",
@@ -176,7 +160,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).value();
QPoint(leftEdgeX, 0), Rotation::East, 0);
REQUIRE(id != kInvalidBuildingId);
REQUIRE(f.bs.isTileOccupied(QPoint(leftEdgeX, 0)));
}
@@ -187,7 +171,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).value();
QPoint(1000, 0), Rotation::East, 0);
REQUIRE(id != kInvalidBuildingId);
}
@@ -230,16 +214,16 @@ TEST_CASE("BuildingSystem: placing a belt registers it with BeltSystem after con
bs.place(BuildingType::Belt, QPoint(5, 5), Rotation::East, 0);
// Belt is queued — not yet in BeltSystem.
REQUIRE_FALSE(belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore"), Rotation::East));
REQUIRE_FALSE(belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore")));
// Complete construction (1 s).
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
REQUIRE(belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore"), Rotation::East));
REQUIRE(bs.getAllBuildings().size() == 1);
REQUIRE(bs.getAllBuildings()[0].type == BuildingType::Belt);
REQUIRE(bs.getAllBuildings()[0].anchor == QPoint(5, 5));
REQUIRE(belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore")));
REQUIRE(bs.allBuildings().size() == 1);
REQUIRE(bs.allBuildings()[0].type == BuildingType::Belt);
REQUIRE(bs.allBuildings()[0].anchor == QPoint(5, 5));
}
TEST_CASE("BuildingSystem: placed building enters construction queue", "[building]")
@@ -256,10 +240,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).value();
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
REQUIRE(bs.getAllSites().size() == 1);
REQUIRE(bs.getAllBuildings().empty());
REQUIRE(bs.allSites().size() == 1);
REQUIRE(bs.allBuildings().empty());
REQUIRE(bs.findSite(id) != nullptr);
}
@@ -277,7 +261,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).value();
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
// 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 +273,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.getAllSites().empty());
REQUIRE(bs.allSites().empty());
}
// ---------------------------------------------------------------------------
@@ -312,7 +296,7 @@ TEST_CASE("BuildingSystem: first queued building starts construction immediately
rng);
bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
REQUIRE(bs.getAllSites().front().completesAt > 0);
REQUIRE(bs.allSites().front().completesAt > 0);
}
TEST_CASE("BuildingSystem: second queued building waits (completesAt == 0)", "[building]")
@@ -332,9 +316,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.getAllSites().size() == 2);
REQUIRE(bs.getAllSites()[0].completesAt > 0);
REQUIRE(bs.getAllSites()[1].completesAt == 0);
REQUIRE(bs.allSites().size() == 2);
REQUIRE(bs.allSites()[0].completesAt > 0);
REQUIRE(bs.allSites()[1].completesAt == 0);
}
TEST_CASE("BuildingSystem: construction completes after configured duration", "[building]")
@@ -351,14 +335,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).value();
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
// 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.getAllSites().empty());
REQUIRE(bs.allSites().empty());
REQUIRE(bs.findBuilding(id) != nullptr);
}
@@ -377,15 +361,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).value();
const BuildingId id2 = bs.place(BuildingType::Miner, QPoint(5, 5), Rotation::East, 0);
// 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.getAllSites().size() == 1);
REQUIRE(bs.getAllSites().front().id == id2);
REQUIRE(bs.getAllSites().front().completesAt > 0);
REQUIRE(bs.allSites().size() == 1);
REQUIRE(bs.allSites().front().id == id2);
REQUIRE(bs.allSites().front().completesAt > 0);
}
// ---------------------------------------------------------------------------
@@ -406,7 +390,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).value();
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(id, "mine_iron_ore");
Tick tick = 0;
@@ -418,11 +402,8 @@ TEST_CASE("BuildingSystem: miner produces iron_ore after recipe duration", "[bui
const Building* b = bs.findBuilding(id);
REQUIRE(b != nullptr);
// No belt at the output port, so the produced item emerges and stays on the
// building's virtual output belt (REQ-MAT-OUTPUT-EMERGE).
const std::vector<Item> out = outputSideItems(*b);
REQUIRE(out.size() == 1);
REQUIRE(out.front().type.id == "iron_ore");
REQUIRE_FALSE(b->outputBuffer.items.empty());
REQUIRE(b->outputBuffer.items.front().type.id == "iron_ore");
}
TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
@@ -439,7 +420,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).value();
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(id, "mine_iron_ore");
Tick tick = 0;
@@ -455,9 +436,7 @@ TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
const Building* b = bs.findBuilding(id);
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->getOutputItemCount() == 2);
REQUIRE(static_cast<int>(b->outputBuffer.items.size()) == 2);
REQUIRE_FALSE(b->production.has_value());
}
@@ -479,29 +458,28 @@ 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).value();
const BuildingId smelterId = bs.place(BuildingType::Smelter, QPoint(10, 0), Rotation::East, 0).value();
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);
(void)smelterId;
Tick tick = 0;
// Both still under construction.
REQUIRE(bs.getProductionBuildingCount() == 0);
REQUIRE(bs.productionBuildingCount() == 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.getProductionBuildingCount() == 1);
REQUIRE(bs.productionBuildingCount() == 1);
runTicks(bs, belts, static_cast<int>(secondsToTicks(15.0)), tick);
REQUIRE(bs.getProductionBuildingCount() == 2);
REQUIRE(bs.productionBuildingCount() == 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.getActiveProductionBuildingCount() == 0);
// Neither has a recipe selected, so neither has an active cycle.
REQUIRE(bs.activeProductionBuildingCount() == 0);
bs.setRecipe(minerId, "mine_iron_ore");
runTicks(bs, belts, 1, tick);
REQUIRE(bs.getActiveProductionBuildingCount() == 1);
REQUIRE(bs.activeProductionBuildingCount() == 1);
}
TEST_CASE("BuildingSystem: activeProductionBuildingCount tracks production cycle state",
@@ -519,16 +497,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).value();
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(id, "mine_iron_ore");
Tick tick = 0;
// Not yet operational while under construction.
REQUIRE(bs.getActiveProductionBuildingCount() == 0);
REQUIRE(bs.activeProductionBuildingCount() == 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.getActiveProductionBuildingCount() == 1);
REQUIRE(bs.activeProductionBuildingCount() == 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 +514,9 @@ TEST_CASE("BuildingSystem: activeProductionBuildingCount tracks production cycle
const Building* b = bs.findBuilding(id);
REQUIRE(b != nullptr);
REQUIRE(b->getOutputItemCount() == 2);
REQUIRE(static_cast<int>(b->outputBuffer.items.size()) == 2);
REQUIRE_FALSE(b->production.has_value());
REQUIRE(bs.getActiveProductionBuildingCount() == 0);
REQUIRE(bs.activeProductionBuildingCount() == 0);
}
// ---------------------------------------------------------------------------
@@ -563,9 +541,8 @@ 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).value();
// Smelters have no recipe selection (REQ-BLD-SMELTER); they auto-accept any
// ore/scrap that is an input to a smelter recipe.
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(sid, "iron_ingot");
// Complete construction (15s → tick 450+1 = 451 ticks).
Tick tick = 0;
@@ -580,192 +557,10 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing
const Building* b = bs.findBuilding(sid);
REQUIRE(b != nullptr);
// The item was accepted; it may still be travelling inward on the input belt,
// so count buffered + in-transit (REQ-MAT-INPUT-INTAKE).
REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) >= 1);
}
// An accepted input item travels inward on its input belt before it becomes usable
// stock: it is reserved (counts against the cap) on entry and only enters the
// buffer on reaching the tile centre (REQ-MAT-INPUT-INTAKE).
TEST_CASE("BuildingSystem: accepted input travels inward before entering the buffer",
"[building]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(static_cast<double>(kTickRateHz)); // fast belt: 1 tile/tick
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);
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);
belts.placeBelt(QPoint(2, 0), Rotation::West);
belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
belts.tick();
bs.tickBeltPull(); // accepts the item onto the input belt at progress 0.0
const Building* b = bs.findBuilding(sid);
REQUIRE(b != nullptr);
// Reserved but not yet consumable: nothing in the buffer, but it counts against
// the cap via pendingInputCount.
const std::map<ItemType, int>::const_iterator it0 =
const std::map<ItemType, int>::const_iterator it =
b->inputBuffer.counts.find(ItemType{"iron_ore"});
REQUIRE((it0 == b->inputBuffer.counts.end() || it0->second == 0));
REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) == 1);
// One more pull tick advances the input belt to the centre; the item arrives.
bs.tickBeltPull();
REQUIRE(b->inputBuffer.counts.at(ItemType{"iron_ore"}) == 1);
REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) == 1);
}
// The acceptance test counts in-transit items, so buffered + reserved never exceeds
// the per-material cap; excess items stay on the belt (REQ-MAT-INPUT-INTAKE).
TEST_CASE("BuildingSystem: input reservation caps buffered plus in-transit at the cap",
"[building]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(static_cast<double>(kTickRateHz)); // fast belt
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);
const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(25.0)) + 1, tick);
// Feed scrap via an input belt without ever running production (only pull), so
// the buffer fills and stays full. Try to over-fill it well past the cap.
belts.placeBelt(QPoint(-1, 0), Rotation::East);
for (int i = 0; i < 20; ++i)
{
belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East);
belts.tick();
bs.tickBeltPull();
}
const Building* b = bs.findBuilding(id);
REQUIRE(b != nullptr);
const int cap = b->inputBuffer.caps.at(ItemType{"scrap"});
REQUIRE(cap > 0);
// buffered + in-transit is capped; the plant never over-pulls.
REQUIRE(b->pendingInputCount(ItemType{"scrap"}) == cap);
// Excess scrap is left stuck on the feeding belt rather than silently dropped.
REQUIRE(belts.peekItem(eastPort(QPoint(-1, 0))).has_value());
}
// A smelter auto-selects the matching recipe for whatever it is fed, with no
// player recipe selection (REQ-BLD-SMELTER).
TEST_CASE("BuildingSystem: smelter auto-smelts ore without a recipe selection",
"[building]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(static_cast<double>(kTickRateHz));
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);
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);
// Feed 2 iron_ore (the test-config iron_ingot recipe needs 2) via a
// west-flowing belt at input port (2,0).
belts.placeBelt(QPoint(2, 0), Rotation::West);
for (int i = 0; i < 2; ++i)
{
belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
belts.tick();
bs.tickBeltPull();
}
// iron_ingot recipe cycle is 2s; run to completion.
runTicks(bs, belts, static_cast<int>(secondsToTicks(2.0)) + 2, tick);
const Building* b = bs.findBuilding(sid);
REQUIRE(b != nullptr);
bool hasIronIngot = false;
for (const Item& item : outputSideItems(*b))
{
if (item.type.id == "iron_ingot") { hasIronIngot = true; }
}
REQUIRE(hasIronIngot);
}
// With mixed inputs, the smelter runs whichever recipe is currently satisfiable
// and leaves an incomplete batch of another input waiting (see the union-of-
// inputs caps in initAutoBuffers).
TEST_CASE("BuildingSystem: smelter runs a satisfiable recipe while an incomplete batch waits",
"[building]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(static_cast<double>(kTickRateHz));
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);
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);
// Feed 1 iron_ore (iron_ingot needs 2 — incomplete) then 2 copper_ore
// (copper_ingot needs 2 — satisfiable) via the west-flowing input belt.
belts.placeBelt(QPoint(2, 0), Rotation::West);
const char* fed[] = { "iron_ore", "copper_ore", "copper_ore" };
for (const char* id : fed)
{
belts.tryPutItem(QPoint(2, 0), makeItem(id));
belts.tick();
bs.tickBeltPull();
}
// copper_ingot cycle is 2.5s; run to completion.
runTicks(bs, belts, static_cast<int>(secondsToTicks(2.5)) + 2, tick);
const Building* b = bs.findBuilding(sid);
REQUIRE(b != nullptr);
// Copper was smelted; the lone iron_ore still waits for a second unit.
bool hasCopperIngot = false;
for (const Item& item : outputSideItems(*b))
{
if (item.type.id == "copper_ingot") { hasCopperIngot = true; }
}
REQUIRE(hasCopperIngot);
const std::map<ItemType, int>::const_iterator ironIt =
b->inputBuffer.counts.find(ItemType{"iron_ore"});
REQUIRE(ironIt != b->inputBuffer.counts.end());
REQUIRE(ironIt->second == 1);
REQUIRE(it != b->inputBuffer.counts.end());
REQUIRE(it->second >= 1);
}
// ---------------------------------------------------------------------------
@@ -786,7 +581,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).value();
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(id, "mine_iron_ore");
// Belt at the miner's output port tile (1,1) flowing East.
@@ -807,84 +602,6 @@ TEST_CASE("BuildingSystem: miner output buffer drains onto adjacent belt", "[bui
REQUIRE(item->type.id == "iron_ore");
}
// Two directly adjacent buildings whose ports meet transfer items with no belt in
// between: a miner's iron_ore output feeds straight into a smelter, which smelts it
// (REQ-MAT-DIRECT-COUPLE).
TEST_CASE("BuildingSystem: output port couples directly into an adjacent input port",
"[building]")
{
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);
// 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).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).value();
Tick tick = 0;
// Smelter build (15s) + margin for coupling and a smelt cycle.
runTicks(bs, belts, static_cast<int>(secondsToTicks(30.0)), tick);
const Building* smelter = bs.findBuilding(smelterId);
REQUIRE(smelter != nullptr);
// iron_ore reached the smelter over the direct coupling and was smelted.
bool hasIronIngot = false;
for (const Item& produced : outputSideItems(*smelter))
{
if (produced.type.id == "iron_ingot") { hasIronIngot = true; }
}
REQUIRE(hasIronIngot);
}
// A producer coupled to a building that cannot accept its item delivers nothing; the
// item stays stuck at the producer's output port (REQ-MAT-DIRECT-COUPLE acceptance).
TEST_CASE("BuildingSystem: direct coupling to a non-consumer leaves the item stuck",
"[building]")
{
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);
// Producing miner at (0,0), output port (1,1) East.
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).value();
Tick tick = 0;
// Both miners build sequentially (10s each), then the producer runs and jams.
runTicks(bs, belts, static_cast<int>(secondsToTicks(25.0)), tick);
const Building* miner = bs.findBuilding(minerId);
const Building* sink = bs.findBuilding(sinkId);
REQUIRE(miner != nullptr);
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->getOutputItemCount() == miner->outputBuffer.capacity);
}
// ---------------------------------------------------------------------------
// setRecipe clears buffers
// ---------------------------------------------------------------------------
@@ -904,7 +621,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).value();
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(id, "mine_iron_ore");
Tick tick = 0;
@@ -916,15 +633,13 @@ TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production"
{
const Building* b = bs.findBuilding(id);
REQUIRE(b != nullptr);
REQUIRE(b->getOutputItemCount() > 0);
REQUIRE_FALSE(b->outputBuffer.items.empty());
}
bs.setRecipe(id, "mine_copper_ore");
const Building* b = bs.findBuilding(id);
// Clearing the output buffer on a recipe change also discards emerging items
// (REQ-MAT-OUTPUT-EMERGE).
REQUIRE(b->getOutputItemCount() == 0);
REQUIRE(b->outputBuffer.items.empty());
REQUIRE_FALSE(b->production.has_value());
}
@@ -948,9 +663,8 @@ TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max
rng);
const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
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.
QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(id, "reprocessing_cycle");
// Complete construction (25s).
Tick tick = 0;
@@ -980,9 +694,8 @@ 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).value();
// Reprocessing plants have no recipe selection (REQ-BLD-REPROCESSING); the
// single reprocessing recipe is applied automatically on completion.
QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(id, "reprocessing_cycle");
// Complete construction (25s).
Tick tick = 0;
@@ -994,17 +707,19 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta
belts.placeBelt(QPoint(-1, 0), Rotation::East);
for (int i = 0; i < 5; ++i)
{
belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East);
belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"));
belts.tick();
bs.tickBeltPull();
}
// Verify all five scrap were accepted; some may still be travelling inward on
// the input belt (REQ-MAT-INPUT-INTAKE), so count buffered + in-transit.
// Verify scrap is in input buffer.
{
const Building* b = bs.findBuilding(id);
REQUIRE(b != nullptr);
REQUIRE(b->pendingInputCount(ItemType{"scrap"}) == 5);
const std::map<ItemType, int>::const_iterator it =
b->inputBuffer.counts.find(ItemType{"scrap"});
REQUIRE(it != b->inputBuffer.counts.end());
REQUIRE(it->second == 5);
}
// Run production cycle (3s = 90 ticks + 1 for the completion tick).
@@ -1056,7 +771,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).value();
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
const std::optional<BuildingId> result =
bs.findRotateInPlaceTarget(BuildingType::Belt, QPoint(0, 0), Rotation::North);
@@ -1079,11 +794,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).value();
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
REQUIRE(bs.getAllSites().empty());
REQUIRE(bs.allSites().empty());
const std::optional<BuildingId> result =
bs.findRotateInPlaceTarget(BuildingType::Belt, QPoint(0, 0), Rotation::South);
@@ -1154,7 +869,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).value();
const BuildingId id = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
const std::optional<BuildingId> result =
bs.findRotateInPlaceTarget(BuildingType::Smelter, QPoint(0, 0), Rotation::North);
@@ -1181,7 +896,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).value();
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
REQUIRE(bs.findSite(id)->rotation == Rotation::East);
bs.rotateInPlace(id, Rotation::North);
@@ -1204,7 +919,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).value();
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
const Tick completesAt = bs.findSite(id)->completesAt;
REQUIRE(completesAt > 0);
@@ -1228,7 +943,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).value();
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
@@ -1259,7 +974,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).value();
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
@@ -1276,7 +991,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).value();
const BuildingId id = f.bs.place(BuildingType::Splitter, tile, Rotation::East, 0);
REQUIRE(id != kInvalidBuildingId);
REQUIRE(f.bs.findSite(id) != nullptr);
@@ -1295,12 +1010,12 @@ TEST_CASE("BuildingSystem: splitter filters configured on a construction site ca
// Run until construction completes.
Tick tick = 0;
while (f.bs.getAllBuildings().empty() && tick < 100000)
while (f.bs.allBuildings().empty() && tick < 100000)
{
runTicks(f.bs, f.belts, 1, tick);
}
REQUIRE(f.bs.getAllBuildings().size() == 1);
REQUIRE(f.bs.getAllBuildings()[0].type == BuildingType::Splitter);
REQUIRE(f.bs.allBuildings().size() == 1);
REQUIRE(f.bs.allBuildings()[0].type == BuildingType::Splitter);
// The built splitter is registered with BeltSystem carrying the filters.
const std::optional<BeltSystem::SplitterInfo> builtInfo = f.belts.getSplitterInfo(tile);

View File

@@ -9,7 +9,6 @@ add_files(
BeltSystemTest.cpp
SurfaceMaskTest.cpp
BuildingTest.cpp
BuildingConfigTest.cpp
ShipTest.cpp
ScrapTest.cpp
BehaviorSystemTest.cpp

View File

@@ -183,7 +183,7 @@ TEST_CASE("CombatSystem: player station fires at enemy ship in range", "[combat]
// Find the player station entity via ECS.
entt::entity stationEntity = entt::null;
QVector2D stationCenter;
sim.getAdmin().forEach<StationBodyComponent, FactionComponent>(
sim.admin().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.getAdmin().isValid(stationEntity));
REQUIRE(sim.admin().isValid(stationEntity));
const ShipDef* combatDef = findCombatShip(sim.getConfig());
const ShipDef* combatDef = findCombatShip(sim.config());
REQUIRE(combatDef != nullptr);
const entt::entity enemyShip = sim.getShips().spawn(
const entt::entity enemyShip = sim.ships().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.getAdmin().forEach<StationBodyComponent, FactionComponent>(
sim.admin().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.getAdmin().isValid(stationEntity));
REQUIRE(sim.admin().isValid(stationEntity));
const ShipDef* combatDef = findCombatShip(sim.getConfig());
const ShipDef* combatDef = findCombatShip(sim.config());
REQUIRE(combatDef != nullptr);
sim.getShips().spawn(
sim.ships().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.getAdmin().forEach<StationBodyComponent, FactionComponent>(
sim.admin().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.getAdmin().isValid(stationEntity));
REQUIRE(sim.admin().isValid(stationEntity));
const ShipDef* combatDef = findCombatShip(sim.getConfig());
const ShipDef* combatDef = findCombatShip(sim.config());
REQUIRE(combatDef != nullptr);
const entt::entity playerShip = sim.getShips().spawn(
const entt::entity playerShip = sim.ships().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.getConfig());
const ShipDef* combatDef = findCombatShip(sim.config());
REQUIRE(combatDef != nullptr);
const entt::entity ship = sim.getShips().spawn(combatDef->id,
const entt::entity ship = sim.ships().spawn(combatDef->id,
QVector2D(10.0f, 10.0f));
sim.getAdmin().get<HealthComponent>(ship).hp = -1.0f;
sim.admin().get<HealthComponent>(ship).hp = -1.0f;
sim.tick();
REQUIRE_FALSE(sim.getAdmin().isValid(ship));
REQUIRE_FALSE(sim.admin().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.getShips().spawn("interceptor",
const entt::entity ship = sim.ships().spawn("interceptor",
QVector2D(10.0f, 10.0f));
sim.getAdmin().get<HealthComponent>(ship).hp = -1.0f;
sim.admin().get<HealthComponent>(ship).hp = -1.0f;
sim.tick();
const std::vector<ScrapInfo> scraps = sim.getScraps().getAllScrapInfo();
const std::vector<ScrapInfo> scraps = sim.scraps().allScrapInfo();
REQUIRE(scraps.size() == 1);
CHECK(sim.getAdmin().get<ScrapDataComponent>(scraps[0].entity).amount == 59);
CHECK(sim.admin().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.getAdmin().forEach<HqProxyComponent, HealthComponent>(
sim.admin().forEach<HqProxyComponent, HealthComponent>(
[](entt::entity /*e*/, const HqProxyComponent& /*hq*/, HealthComponent& h)
{
h.hp = -1.0f;

View File

@@ -52,7 +52,7 @@ TEST_CASE("apply(PlaceBuildingCommand) with recipe matches place-then-setRecipe"
viaCommand.apply(command);
const BuildingId id =
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
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).value();
SimulationTestAccess::place(viaCommand, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
const BuildingId idB =
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-3, 0), Rotation::East).value();
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
REQUIRE(idA == idB);
DemolishCommand command;

View File

@@ -83,16 +83,6 @@ TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[c
REQUIRE(cfg.world.rallyOrbitRadius_tiles == Approx(5.0));
REQUIRE(cfg.world.scrapPerThreat == Approx(1.0));
// Optional header building blocks tooltip (REQ-UI-BLOCKS-TOOLTIP).
REQUIRE(cfg.world.buildingBlocksTooltip.has_value());
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));
@@ -113,21 +103,6 @@ TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[c
REQUIRE(minerIt != cfg.buildings.buildings.end());
REQUIRE(minerIt->cost == 15);
REQUIRE(minerIt->surfaceMask.size() == 2);
// Miner has no output-buffer-capacity override; the Salvage Bay does.
REQUIRE_FALSE(minerIt->outputBufferCapacity.has_value());
const auto salvageBayIt = std::find_if(
cfg.buildings.buildings.begin(), cfg.buildings.buildings.end(),
[](const BuildingDef& b) { return b.type == BuildingType::SalvageBay; });
REQUIRE(salvageBayIt != cfg.buildings.buildings.end());
REQUIRE(salvageBayIt->outputBufferCapacity.has_value());
REQUIRE(*salvageBayIt->outputBufferCapacity == 20);
// Optional per-building tooltip (REQ-UI-BUILD-TOOLTIP): the Salvage Bay
// defines one; the Miner leaves it unset.
REQUIRE(salvageBayIt->tooltip.has_value());
REQUIRE(*salvageBayIt->tooltip == "Drop-off point for salvage ships.");
REQUIRE_FALSE(minerIt->tooltip.has_value());
// recipes.toml — reprocessing cycle has three weighted outputs.
const auto reproIt = std::find_if(
@@ -160,21 +135,6 @@ TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[c
REQUIRE(salvageShipIt != cfg.ships.ships.end());
REQUIRE(salvageShipIt->defaultModules.empty());
// modules.toml — optional per-module tooltip (REQ-MOD-UI-MODULE-TOOLTIP):
// armor_plate defines one; salvager leaves it unset.
const auto armorPlateIt = std::find_if(
cfg.modules.modules.begin(), cfg.modules.modules.end(),
[](const ModuleDef& m) { return m.id == "armor_plate"; });
REQUIRE(armorPlateIt != cfg.modules.modules.end());
REQUIRE(armorPlateIt->tooltip.has_value());
REQUIRE(*armorPlateIt->tooltip == "Adds a large flat bonus to hit points.");
const auto salvagerModuleIt = std::find_if(
cfg.modules.modules.begin(), cfg.modules.modules.end(),
[](const ModuleDef& m) { return m.id == "salvager"; });
REQUIRE(salvagerModuleIt != cfg.modules.modules.end());
REQUIRE_FALSE(salvagerModuleIt->tooltip.has_value());
// stations.toml
REQUIRE(cfg.stations.playerStation.level == 5);
REQUIRE(cfg.stations.playerStation.hpFormula.evaluate(5.0) == Approx(500.0)); // 300 + 40*5

View File

@@ -67,7 +67,7 @@ TEST_CASE("Hasher: identical inputs produce identical values", "[determinism]")
b.append(3.5f);
b.append(std::string("ore"));
REQUIRE(a.getValue() == b.getValue());
REQUIRE(a.value() == b.value());
}
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.getValue() != b.getValue());
REQUIRE(a.value() != b.value());
}
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.getValue() != b.getValue());
REQUIRE(a.value() != b.value());
}
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.getValue() == b.getValue());
REQUIRE(a.value() == b.value());
}
// ---------------------------------------------------------------------------
@@ -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.getRngFingerprint() == b.getRngFingerprint());
REQUIRE(a.rngFingerprint() == b.rngFingerprint());
}
// ---------------------------------------------------------------------------

View File

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

View File

@@ -22,7 +22,7 @@ static GameConfig loadConfig()
// tickDeathsAndLoot fires, triggering the push and schematic choices.
static void killEnemyStations(Simulation& sim)
{
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
sim.admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity, StationBodyComponent&, FactionComponent& faction, HealthComponent& health)
{
if (faction.isEnemy)
@@ -283,7 +283,7 @@ TEST_CASE("RecipeSchematic: reset keeps -1 recipes unlocked and their seed items
// Unlock dialog: newly-unlocked recipe preview (REQ-DEF-SCHEMATIC-DROP)
// ---------------------------------------------------------------------------
TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds is sorted, deduplicated, and empty for level-ups",
TEST_CASE("RecipeSchematic: newlyUnlockedItemNames is sorted, deduplicated, and empty for level-ups",
"[recipe_schematic]")
{
Simulation sim(loadConfig());
@@ -295,11 +295,10 @@ TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds is sorted, deduplicated, and
for (const SchematicChoiceOption& opt : sim.getPendingSchematicChoices())
{
// Strictly ascending display names imply sorted and deduplicated.
for (std::size_t j = 1; j < opt.newlyUnlockedRecipeIds.size(); ++j)
// Strictly ascending implies sorted and deduplicated.
for (std::size_t j = 1; j < opt.newlyUnlockedItemNames.size(); ++j)
{
CHECK(toDisplayName(opt.newlyUnlockedRecipeIds[j - 1])
< toDisplayName(opt.newlyUnlockedRecipeIds[j]));
CHECK(opt.newlyUnlockedItemNames[j - 1] < opt.newlyUnlockedItemNames[j]);
}
}
@@ -307,7 +306,7 @@ TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds is sorted, deduplicated, and
}
}
TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds matches recipes that actually become unlocked",
TEST_CASE("RecipeSchematic: newlyUnlockedItemNames matches recipes that actually become unlocked",
"[recipe_schematic]")
{
Simulation sim(loadConfig());
@@ -337,22 +336,21 @@ TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds matches recipes that actually
SimulationTestAccess::applySchematicChoice(sim, 0);
std::vector<std::string> expected;
std::set<std::string> expectedNames;
for (const RecipeDef& def : cfg.recipes.recipes)
{
if ((def.building == BuildingType::Miner || def.building == BuildingType::Assembler)
&& sim.isRecipeUnlocked(def.id) && unlockedBefore.count(def.id) == 0)
{
expected.push_back(def.id);
for (const RecipeOutput& out : def.outputs)
{
expectedNames.insert(toDisplayName(out.item));
}
}
}
std::sort(expected.begin(), expected.end(),
[](const std::string& lhs, const std::string& rhs)
{
return toDisplayName(lhs) < toDisplayName(rhs);
});
const std::vector<std::string> expected(expectedNames.begin(), expectedNames.end());
REQUIRE(choice.newlyUnlockedRecipeIds == expected);
REQUIRE(choice.newlyUnlockedItemNames == expected);
}
}

View File

@@ -166,7 +166,7 @@ TEST_CASE("a recorded run replays to byte-identical state with no desync", "[rep
manager.drain();
for (int i = 0; i < 60; ++i) { rec.tick(); manager.recordTickCheckpoint(); }
replayPath = recorderPtr->getCurrentFilePath();
replayPath = recorderPtr->currentFilePath();
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.getCurrentTick());
player.advanceTo(play.currentTick());
}
REQUIRE_FALSE(player.getDesyncTick().has_value());
REQUIRE(play.getCurrentTick() == 150);
REQUIRE(play.currentTick() == 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->getCurrentFilePath();
replayPath = recorderPtr->currentFilePath();
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.getCurrentTick());
player.advanceTo(play.currentTick());
}
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->getCurrentFilePath();
replayPath = recorderPtr->currentFilePath();
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.getCurrentTick());
player.advanceTo(play.currentTick());
}
REQUIRE_FALSE(player.getDesyncTick().has_value());
REQUIRE(play.getCurrentTick() == 2400);
REQUIRE(play.currentTick() == 2400);
REQUIRE(play.computeStateChecksum() == recordedFinalChecksum);
QFile::remove(QString::fromStdString(replayPath));

View File

@@ -120,7 +120,7 @@ TEST_CASE("ReplayRecorder writes a well-formed file", "[replay]")
recorder.recordChecksum(30, 0x0ffffffffffffff0ull);
const std::string path = recorder.getCurrentFilePath();
const std::string path = recorder.currentFilePath();
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->getCurrentFilePath();
const std::string path = recorderPtr->currentFilePath();
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.getCurrentFilePath();
const std::string first = recorder.currentFilePath();
recorder.startNewRun(2u, 0ull);
const std::string second = recorder.getCurrentFilePath();
const std::string second = recorder.currentFilePath();
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->getCurrentFilePath();
const std::string firstPath = recorderPtr->currentFilePath();
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->getCurrentFilePath();
const std::string secondPath = recorderPtr->currentFilePath();
REQUIRE(firstPath != secondPath);
REQUIRE(secondPath.find("_999.replay") != std::string::npos);

View File

@@ -1,24 +1,12 @@
#include "catch.hpp"
#include <QSize>
#include <QVector2D>
#include <algorithm>
#include "DespawnAtComponent.h"
#include "EntityAdmin.h"
#include "EntityHitTest.h"
#include "ScrapDataComponent.h"
#include "ScrapSystem.h"
namespace
{
bool contains(const std::vector<entt::entity>& v, entt::entity e)
{
return std::find(v.begin(), v.end(), e) != v.end();
}
} // namespace
// ---------------------------------------------------------------------------
// Spawn
// ---------------------------------------------------------------------------
@@ -149,123 +137,6 @@ 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.getAllScrapInfo();
const std::vector<ScrapInfo> info = ss.allScrapInfo();
REQUIRE(info.size() == 2);
}
TEST_CASE("ScrapSystem: allScrapInfo reports each pile's remaining amount", "[scrap]")
{
EntityAdmin admin;
ScrapSystem ss(admin);
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.getAllScrapInfo();
REQUIRE(info.size() == 2);
for (const ScrapInfo& i : info)
{
if (i.entity == a) { REQUIRE(i.amount == 3); }
else if (i.entity == b) { REQUIRE(i.amount == 6); }
else { FAIL("unexpected scrap entity"); }
}
}
// ---------------------------------------------------------------------------
// Selection hit-testing (REQ-UI-SCRAP-CLICK-SELECT, REQ-UI-SCRAP-MULTI-SELECT)
// ---------------------------------------------------------------------------
TEST_CASE("scrapAtWorldPos returns the pile near a point and null when far", "[scrap]")
{
EntityAdmin admin;
ScrapSystem ss(admin);
const entt::entity e = ss.spawn(QVector2D(3.0f, 4.0f), 5, 100);
// Extra parens keep Catch from decomposing the comparison, which is ambiguous
// between Catch's expression templates and entt's entity operator==.
REQUIRE((scrapAtWorldPos(admin, QVector2D(3.1f, 4.0f)) == e));
REQUIRE((scrapAtWorldPos(admin, QVector2D(10.0f, 10.0f)) == entt::null));
}
TEST_CASE("scrapAtWorldPos returns the nearest of several piles", "[scrap]")
{
EntityAdmin admin;
ScrapSystem ss(admin);
const entt::entity near = ss.spawn(QVector2D(2.0f, 2.0f), 1, 100);
ss.spawn(QVector2D(2.4f, 2.0f), 1, 100);
REQUIRE((scrapAtWorldPos(admin, QVector2D(2.05f, 2.0f)) == near));
}
TEST_CASE("entityAtWorldPos never returns a scrap pile", "[scrap]")
{
EntityAdmin admin;
ScrapSystem ss(admin);
ss.spawn(QVector2D(3.0f, 4.0f), 5, 100);
// Scrap has no HealthComponent, so the actor hit-test ignores it entirely.
REQUIRE((entityAtWorldPos(admin, QVector2D(3.0f, 4.0f)) == entt::null));
}
TEST_CASE("scrapInBox returns exactly the piles inside the tile rectangle", "[scrap]")
{
EntityAdmin admin;
ScrapSystem ss(admin);
const entt::entity inA = ss.spawn(QVector2D(1.2f, 2.7f), 1, 100); // tile (1,2)
const entt::entity inB = ss.spawn(QVector2D(4.9f, 5.1f), 1, 100); // tile (4,5)
const entt::entity outX = ss.spawn(QVector2D(10.0f, 10.0f), 1, 100);
// Box given in reversed corner order to confirm normalization.
const std::vector<entt::entity> hit = scrapInBox(admin, QPoint(5, 5), QPoint(0, 0));
REQUIRE(hit.size() == 2);
REQUIRE(contains(hit, inA));
REQUIRE(contains(hit, inB));
REQUIRE_FALSE(contains(hit, outX));
}
TEST_CASE("actorsInBox returns living ships and stations, excluding scrap and dead actors",
"[actor]")
{
EntityAdmin admin;
// Two living ships inside the box: one player, one enemy.
const entt::entity playerShip = admin.spawnShip(
QVector2D(1.5f, 2.5f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
"fighter", false); // tile (1,2)
const entt::entity enemyShip = admin.spawnShip(
QVector2D(4.2f, 5.8f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
"raider", true); // tile (4,5)
// A dead ship inside the box is excluded.
const entt::entity deadShip = admin.spawnShip(
QVector2D(3.0f, 3.0f), 0.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
"fighter", false);
// A ship outside the box is excluded.
const entt::entity outsideShip = admin.spawnShip(
QVector2D(20.0f, 20.0f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
"fighter", false);
// A station is included when any body cell lies inside the box.
const std::vector<QPoint> stationCells{ QPoint(2, 2), QPoint(3, 2) };
const entt::entity station = admin.spawnStation(
QPoint(2, 2), QSize(2, 1), stationCells, 200.0f, 200.0f, true);
// Scrap and the HQ proxy are never actors.
admin.spawnScrap(QVector2D(1.0f, 1.0f), 5, Tick(1000));
admin.spawnHqProxy(QVector2D(0.5f, 0.5f), 500.0f, 500.0f);
const std::vector<entt::entity> hit = actorsInBox(admin, QPoint(5, 5), QPoint(0, 0));
REQUIRE(hit.size() == 3);
REQUIRE(contains(hit, playerShip));
REQUIRE(contains(hit, enemyShip));
REQUIRE(contains(hit, station));
REQUIRE_FALSE(contains(hit, deadShip));
REQUIRE_FALSE(contains(hit, outsideShip));
}

View File

@@ -6,7 +6,6 @@
#include "ConfigLoader.h"
#include "DynamicBodyComponent.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "GameConfig.h"
#include "HealthComponent.h"
#include "ItemType.h"
@@ -14,7 +13,6 @@
#include "ModulesConfig.h"
#include "Rotation.h"
#include "SensorRangeComponent.h"
#include "ShipIdentityComponent.h"
#include "ShipLayout.h"
#include "ShipStatsCalculator.h"
#include "ShipSystem.h"
@@ -87,7 +85,7 @@ static void fillMaterials(Simulation& sim, BuildingId yardId,
}
for (const PlacedModule& pm : layout.placedModules)
{
for (const ModuleDef& modDef : sim.getConfig().modules.modules)
for (const ModuleDef& modDef : sim.config().modules.modules)
{
if (modDef.id == pm.moduleId)
{
@@ -109,22 +107,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.getConfig(), "interceptor");
const ShipDef* def = findSchematic(sim.config(), "interceptor");
REQUIRE(def != nullptr);
const float expectedHp = static_cast<float>(def->health.hp);
const entt::entity e = sim.getShips().spawn("interceptor",
const entt::entity e = sim.ships().spawn("interceptor",
QVector2D(5.0f, 5.0f), false, std::nullopt);
REQUIRE(sim.getAdmin().isValid(e));
CHECK(sim.getAdmin().get<HealthComponent>(e).maxHp == Approx(expectedHp));
REQUIRE(sim.admin().isValid(e));
CHECK(sim.admin().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.getConfig(), "interceptor");
const ShipDef* def = findSchematic(sim.config(), "interceptor");
REQUIRE(def != nullptr);
const float baseHp = static_cast<float>(def->health.hp);
@@ -136,23 +134,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.getShips().spawn("interceptor",
const entt::entity e = sim.ships().spawn("interceptor",
QVector2D(5.0f, 5.0f), false, layout);
REQUIRE(sim.getAdmin().isValid(e));
REQUIRE(sim.admin().isValid(e));
// armor_plate has multiplied_hp_formula = "1.5"
// final = base * (1 + (1.5 - 1)) + 0 = base * 1.5
CHECK(sim.getAdmin().get<HealthComponent>(e).maxHp == Approx(baseHp * 1.5f));
CHECK(sim.getAdmin().get<HealthComponent>(e).hp == sim.getAdmin().get<HealthComponent>(e).maxHp);
CHECK(sim.admin().get<HealthComponent>(e).maxHp == Approx(baseHp * 1.5f));
CHECK(sim.admin().get<HealthComponent>(e).hp == sim.admin().get<HealthComponent>(e).maxHp);
}
TEST_CASE("Ship spawn: additive sensor module applies correctly", "[modules]")
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
const ShipDef* def = findSchematic(sim.config(), "interceptor");
REQUIRE(def != nullptr);
const float tileSize = static_cast<float>(sim.getConfig().world.tileSize_m);
const float tileSize = static_cast<float>(sim.config().world.tileSize_m);
const float baseRange_tiles = static_cast<float>(def->sensor.sensorRange_m) / tileSize;
ShipLayoutConfig layout;
@@ -162,19 +160,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.getShips().spawn("interceptor",
const entt::entity e = sim.ships().spawn("interceptor",
QVector2D(5.0f, 5.0f), false, layout);
REQUIRE(sim.getAdmin().isValid(e));
REQUIRE(sim.admin().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.getAdmin().get<SensorRangeComponent>(e).value_tiles == Approx(baseRange_tiles + 10.0f));
CHECK(sim.admin().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.getConfig(), "interceptor");
const ShipDef* def = findSchematic(sim.config(), "interceptor");
REQUIRE(def != nullptr);
const float baseHp = static_cast<float>(def->health.hp);
@@ -189,14 +187,14 @@ TEST_CASE("Ship spawn: multiple modules stack correctly", "[modules]")
layout.placedModules.push_back(pm);
}
const entt::entity e = sim.getShips().spawn("interceptor",
const entt::entity e = sim.ships().spawn("interceptor",
QVector2D(5.0f, 5.0f), false, layout);
REQUIRE(sim.getAdmin().isValid(e));
REQUIRE(sim.admin().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.getAdmin().get<HealthComponent>(e).maxHp == Approx(baseHp * 2.0f));
CHECK(sim.admin().get<HealthComponent>(e).maxHp == Approx(baseHp * 2.0f));
}
// ---------------------------------------------------------------------------
@@ -207,7 +205,7 @@ TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
"[modules][shipyard]")
{
Simulation sim(loadConfig(), 42);
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
const BuildingDef* yardDef = findShipyardDef(sim.config());
REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef);
@@ -222,7 +220,7 @@ TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
const Building* b = sim.getBuildings().findBuilding(yardId);
const Building* b = sim.buildings().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 +232,9 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
"[modules][shipyard]")
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
const ShipDef* def = findSchematic(sim.config(), "interceptor");
REQUIRE(def != nullptr);
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
const BuildingDef* yardDef = findShipyardDef(sim.config());
REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef);
@@ -247,7 +245,7 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
fillMaterials(sim, yardId, *def, emptyLayout);
sim.tick();
const Building* b1 = sim.getBuildings().findBuilding(yardId);
const Building* b1 = sim.buildings().findBuilding(yardId);
REQUIRE(b1 != nullptr);
REQUIRE(b1->production.has_value());
@@ -261,59 +259,15 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
const Building* b2 = sim.getBuildings().findBuilding(yardId);
const Building* b2 = sim.buildings().findBuilding(yardId);
REQUIRE(b2 != nullptr);
CHECK_FALSE(b2->production.has_value());
}
TEST_CASE("Shipyard: builds a bare hull when no layout is configured",
"[modules][shipyard]")
{
Simulation sim(loadConfig(), 42);
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
// that weapon out for free: it builds an unarmed bare hull, matching the
// base-hull materials it was charged.
REQUIRE_FALSE(def->defaultModules.empty());
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef);
SimulationTestAccess::buildings(sim).setRecipe(yardId, "interceptor");
// Deliberately no setShipLayout: recipe set, layout left unconfigured.
// Charge only the base-hull materials (an empty layout adds none).
fillMaterials(sim, yardId, *def, ShipLayoutConfig{});
// Tick through one full production cycle so the ship spawns.
const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);
for (Tick i = 0; i <= cycleTicks; ++i)
{
sim.tick();
}
// Locate the freshly built player ship.
entt::entity built = entt::null;
sim.getAdmin().forEach<ShipIdentityComponent, FactionComponent>(
[&](entt::entity e, const ShipIdentityComponent& si, const FactionComponent& fac)
{
if (!fac.isEnemy && si.schematicId == "interceptor") { built = e; }
});
REQUIRE(sim.getAdmin().isValid(built));
// Bare hull: the schematic's default weapon must NOT have been installed.
const bool hasWeapon =
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.getConfig());
const BuildingDef* yardDef = findShipyardDef(sim.config());
REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef);
@@ -327,49 +281,17 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
layout.placedModules.push_back(pm);
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
const Building* b1 = sim.getBuildings().findBuilding(yardId);
const Building* b1 = sim.buildings().findBuilding(yardId);
REQUIRE(b1 != nullptr);
REQUIRE(b1->shipLayout.has_value());
SimulationTestAccess::buildings(sim).setRecipe(yardId,"destroyer");
const Building* b2 = sim.getBuildings().findBuilding(yardId);
const Building* b2 = sim.buildings().findBuilding(yardId);
REQUIRE(b2 != nullptr);
CHECK_FALSE(b2->shipLayout.has_value());
}
TEST_CASE("Shipyard: setRecipe with unchanged recipe keeps ship layout",
"[modules][shipyard]")
{
Simulation sim(loadConfig(), 42);
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef);
SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor");
ShipLayoutConfig layout;
PlacedModule pm;
pm.moduleId = "armor_plate";
pm.position = QPoint(0, 0);
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
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.getBuildings().findBuilding(yardId);
REQUIRE(b2 != nullptr);
REQUIRE(b2->shipLayout.has_value());
REQUIRE(b2->shipLayout->placedModules.size() == 1);
CHECK(b2->shipLayout->placedModules[0].moduleId == "armor_plate");
}
// ---------------------------------------------------------------------------
// Weapon modifier simulation tests
// ---------------------------------------------------------------------------
@@ -377,7 +299,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.getConfig(), "interceptor");
const ShipDef* def = findSchematic(sim.config(), "interceptor");
REQUIRE(def != nullptr);
ShipLayoutConfig layout;
@@ -390,22 +312,22 @@ TEST_CASE("Ship spawn: weapon_primer multiplies attack rate in simulation", "[mo
layout.placedModules.push_back(pm);
}
const entt::entity ship = sim.getShips().spawn("interceptor",
const entt::entity ship = sim.ships().spawn("interceptor",
QVector2D(5.0f, 5.0f), false, layout);
const entt::entity weapon = findFirstWeaponChild(sim.getAdmin(), ship);
REQUIRE(sim.getAdmin().isValid(weapon));
const entt::entity weapon = findFirstWeaponChild(sim.admin(), ship);
REQUIRE(sim.admin().isValid(weapon));
// base rate = 2.0 hz; weapon_primer multiplier = 1.2 → 2.4 hz
CHECK(sim.getAdmin().get<WeaponComponent>(weapon).fireRateHz == Approx(2.4f));
CHECK(sim.admin().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.getConfig(), "interceptor");
const ShipDef* def = findSchematic(sim.config(), "interceptor");
REQUIRE(def != nullptr);
const float tileSize = static_cast<float>(sim.getConfig().world.tileSize_m);
const float tileSize = static_cast<float>(sim.config().world.tileSize_m);
ShipLayoutConfig layout;
for (const std::string& id : {"laser_cannon", "weapon_stabilizer"})
@@ -417,22 +339,22 @@ TEST_CASE("Ship spawn: weapon_stabilizer multiplies attack range in simulation",
layout.placedModules.push_back(pm);
}
const entt::entity ship = sim.getShips().spawn("interceptor",
const entt::entity ship = sim.ships().spawn("interceptor",
QVector2D(5.0f, 5.0f), false, layout);
const entt::entity weapon = findFirstWeaponChild(sim.getAdmin(), ship);
REQUIRE(sim.getAdmin().isValid(weapon));
const entt::entity weapon = findFirstWeaponChild(sim.admin(), ship);
REQUIRE(sim.admin().isValid(weapon));
// base range = 50 m / tileSize = 5 tiles; weapon_stabilizer multiplier = 1.5 → 7.5 tiles
CHECK(sim.getAdmin().get<WeaponComponent>(weapon).range_tiles == Approx(50.0f / tileSize * 1.5f));
CHECK(sim.admin().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.getConfig(), "interceptor");
const ShipDef* def = findSchematic(sim.config(), "interceptor");
REQUIRE(def != nullptr);
const float tileSize = static_cast<float>(sim.getConfig().world.tileSize_m);
const float tileSize = static_cast<float>(sim.config().world.tileSize_m);
const float tickRate = static_cast<float>(kTickRateHz);
const float base_mpss = static_cast<float>(def->movement.mainAcceleration_mpss);
@@ -443,21 +365,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.getShips().spawn("interceptor",
const entt::entity e = sim.ships().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.getAdmin().get<DynamicBodyComponent>(e).mainAcceleration_tptt == Approx(expected));
CHECK(sim.admin().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.getConfig(), "interceptor");
const ShipDef* def = findSchematic(sim.config(), "interceptor");
REQUIRE(def != nullptr);
const float tileSize = static_cast<float>(sim.getConfig().world.tileSize_m);
const float tileSize = static_cast<float>(sim.config().world.tileSize_m);
const float tickRate = static_cast<float>(kTickRateHz);
const float base_mpss = static_cast<float>(def->movement.maneuveringAcceleration_mpss);
@@ -468,12 +390,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.getShips().spawn("interceptor",
const entt::entity e = sim.ships().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.getAdmin().get<DynamicBodyComponent>(e).maneuveringAcceleration_tptt == Approx(expected));
CHECK(sim.admin().get<DynamicBodyComponent>(e).maneuveringAcceleration_tptt == Approx(expected));
}
// ---------------------------------------------------------------------------

View File

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

View File

@@ -56,7 +56,7 @@ static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
static int countShips(Simulation& sim)
{
int n = 0;
sim.getAdmin().forEach<ShipIdentityComponent>(
sim.admin().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.getConfig());
const ShipDef* def = findAvailableSchematic(sim.config());
REQUIRE(def != nullptr);
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
const BuildingDef* yardDef = findShipyardDef(sim.config());
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.getAdmin().forEach<ShipIdentityComponent, FactionComponent>(
sim.admin().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.getConfig());
const BuildingDef* yardDef = findShipyardDef(sim.config());
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.getConfig());
const ShipDef* def = findAvailableSchematic(sim.config());
REQUIRE(def != nullptr);
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
const BuildingDef* yardDef = findShipyardDef(sim.config());
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.getConfig());
const ShipDef* def = findAvailableSchematic(sim.config());
REQUIRE(def != nullptr);
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
const BuildingDef* yardDef = findShipyardDef(sim.config());
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.getBuildings().getAllBuildings())
for (const Building& b : sim.buildings().allBuildings())
{
if (b.id == yardId)
{

View File

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

View File

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

View File

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

View File

@@ -49,7 +49,7 @@ TEST_CASE("WaveSystem: threat accumulates at boss wave counter rate", "[wave]")
ws.tickThreatAccumulation();
}
REQUIRE(ws.getThreatLevel() == Approx(1.0));
REQUIRE(ws.threatLevel() == 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.getThreatAccumulationRate() == Approx(1.0));
REQUIRE(ws.threatAccumulationRate() == 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.getThreatAccumulationRate() == Approx(0.0));
REQUIRE(ws.threatAccumulationRate() == Approx(0.0));
const double before = ws.getThreatLevel();
const double before = ws.threatLevel();
for (int i = 0; i < static_cast<int>(secondsToTicks(1.0)); ++i)
{
ws.tickThreatAccumulation();
}
REQUIRE(ws.getThreatLevel() == Approx(before));
REQUIRE(ws.threatLevel() == 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.getGeneration() == 0);
REQUIRE(ws.generation() == 0);
ws.onEnemyStationsDestroyed();
REQUIRE(ws.getGeneration() == 1);
REQUIRE(ws.generation() == 1);
ws.onEnemyStationsDestroyed();
REQUIRE(ws.getGeneration() == 2);
REQUIRE(ws.generation() == 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.getBuildings().getAllBuildings())
for (const Building& b : sim.buildings().allBuildings())
{
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.getAdmin().forEach<StationBodyComponent, FactionComponent>(
sim.admin().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.getConfig().stations.hq.hpFormula.evaluate(0.0));
static_cast<float>(sim.config().stations.hq.hpFormula.evaluate(0.0));
bool found = false;
float actualHp = 0.0f;
sim.getAdmin().forEach<HqProxyComponent, HealthComponent>(
sim.admin().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.getBuildings().getAllBuildings())
for (const Building& b : sim.buildings().allBuildings())
{
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.getAdmin().forEach<WeaponComponent, ModuleOwnerComponent>(
sim.admin().forEach<WeaponComponent, ModuleOwnerComponent>(
[&](entt::entity /*e*/, const WeaponComponent& w, const ModuleOwnerComponent& mo)
{
if (!sim.getAdmin().hasAll<StationBodyComponent>(mo.owner)) { return; }
const FactionComponent& f = sim.getAdmin().get<FactionComponent>(mo.owner);
if (!sim.admin().hasAll<StationBodyComponent>(mo.owner)) { return; }
const FactionComponent& f = sim.admin().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.getAdmin().forEach<WeaponComponent, ModuleOwnerComponent>(
sim.admin().forEach<WeaponComponent, ModuleOwnerComponent>(
[&](entt::entity /*e*/, const WeaponComponent& w, const ModuleOwnerComponent& mo)
{
if (!sim.getAdmin().hasAll<StationBodyComponent>(mo.owner)) { return; }
const FactionComponent& f = sim.getAdmin().get<FactionComponent>(mo.owner);
if (!sim.admin().hasAll<StationBodyComponent>(mo.owner)) { return; }
const FactionComponent& f = sim.admin().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.getAdmin().forEach<ShipIdentityComponent, FactionComponent>(
sim.admin().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.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
sim.admin().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.getAdmin().forEach<StationBodyComponent, FactionComponent>(
sim.admin().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.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
sim.admin().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.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
sim.admin().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.getConfig().ships.ships)
for (const ShipDef& def : sim.config().ships.ships)
{
if (def.id == opt.schematicId) { validId = true; break; }
}
if (!validId)
{
for (const ModuleDef& def : sim.getConfig().modules.modules)
for (const ModuleDef& def : sim.config().modules.modules)
{
if (def.id == opt.schematicId) { validId = true; break; }
}
}
if (!validId)
{
for (const RecipeDef& def : sim.getConfig().recipes.recipes)
for (const RecipeDef& def : sim.config().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.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
sim.admin().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.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
sim.admin().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.getAdmin().forEach<StationBodyComponent, FactionComponent>(
sim.admin().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.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
sim.admin().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.getAdmin().forEach<StationBodyComponent, FactionComponent>(
sim.admin().forEach<StationBodyComponent, FactionComponent>(
[&](entt::entity /*e*/, const StationBodyComponent& sb, const FactionComponent& f)
{
if (f.isEnemy && sb.anchor.x() > newX)

View File

@@ -19,7 +19,6 @@
#include "ExitBlueprintModeRequestedEvent.h"
#include "Building.h"
#include "BuildingConfig.h"
#include "BuildingSystem.h"
#include "Simulation.h"
@@ -28,6 +27,7 @@ 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);
@@ -53,15 +53,23 @@ BlueprintPanel::BlueprintPanel(Simulation* sim, const GameConfig* config, QWidge
connect(m_createBtn, &QPushButton::clicked, this, &BlueprintPanel::onCreateClicked);
loadFromDisk();
rebuildButtons();
QHBoxLayout* ioLayout = new QHBoxLayout();
m_saveBtn = new QPushButton(tr("Save"), this);
m_loadBtn = new QPushButton(tr("Load"), this);
m_saveBtn->setFixedHeight(36);
m_loadBtn->setFixedHeight(36);
ioLayout->addWidget(m_saveBtn);
ioLayout->addWidget(m_loadBtn);
layout->addLayout(ioLayout);
connect(m_saveBtn, &QPushButton::clicked, this, &BlueprintPanel::onSaveClicked);
connect(m_loadBtn, &QPushButton::clicked, this, &BlueprintPanel::onLoadClicked);
registerForEvents();
}
BlueprintPanel::~BlueprintPanel()
{
saveToDisk();
unregisterForEvents();
}
@@ -79,11 +87,11 @@ void BlueprintPanel::handleEvent(std::shared_ptr<const BuildingBlocksChangedEven
void BlueprintPanel::clearActiveBlueprintButton()
{
if (m_activeIndex.has_value() && *m_activeIndex < static_cast<int>(m_blueprintButtons.size()))
if (m_activeIndex >= 0 && 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 = std::nullopt;
m_activeIndex = -1;
refreshButtonStates();
}
@@ -108,13 +116,13 @@ void BlueprintPanel::onDeleteBlueprintClicked(int index)
{
if (m_activeIndex == index)
{
m_activeIndex = std::nullopt;
m_activeIndex = -1;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExitBlueprintModeRequestedEvent>());
}
else if (m_activeIndex.has_value() && *m_activeIndex > index)
else if (m_activeIndex > index)
{
--*m_activeIndex;
m_activeIndex--;
}
m_blueprints.erase(m_blueprints.begin() + index);
rebuildButtons();
@@ -132,9 +140,9 @@ void BlueprintPanel::onBlueprintButtonClicked(int index)
return;
}
if (m_activeIndex.has_value() && *m_activeIndex < static_cast<int>(m_blueprintButtons.size()))
if (m_activeIndex >= 0 && 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;
@@ -145,10 +153,67 @@ void BlueprintPanel::onBlueprintButtonClicked(int index)
Blueprint BlueprintPanel::createBlueprintFromSelection() const
{
// Capture is shared, testable logic in lib/sim: it resolves each selected id as an
// operational building or a construction site alike (REQ-UI-BLUEPRINT-CREATE,
// REQ-UI-BLUEPRINT-STORAGE).
return captureBlueprintFromSelection(*m_sim, m_selectedBuildingIds);
struct Entry
{
const Building* building;
};
std::vector<Entry> entries;
entries.reserve(m_selectedBuildingIds.size());
for (const BuildingId id : m_selectedBuildingIds)
{
const Building* b = m_sim->buildings().findBuilding(id);
if (!b) { continue; }
const bool placeable = [&]() {
for (const BuildingDef& def : m_config->buildings.buildings)
{
if (def.type == b->type) { return def.playerPlaceable; }
}
return false;
}();
if (placeable) { entries.push_back({ b }); }
}
if (entries.empty()) { return Blueprint{}; }
int minX = INT_MAX, maxX = INT_MIN;
int minY = INT_MAX, maxY = INT_MIN;
for (const Entry& e : entries)
{
for (const QPoint& cell : e.building->bodyCells)
{
minX = std::min(minX, cell.x());
maxX = std::max(maxX, cell.x());
minY = std::min(minY, cell.y());
maxY = std::max(maxY, cell.y());
}
}
const QPoint center((minX + maxX) / 2, (minY + maxY) / 2);
Blueprint bp;
bp.buildings.reserve(entries.size());
for (const Entry& e : entries)
{
BlueprintBuilding bb;
bb.type = e.building->type;
bb.rotation = e.building->rotation;
bb.offset = e.building->anchor - center;
bb.recipeId = e.building->recipeId;
bb.shipLayout = e.building->shipLayout;
if (e.building->type == BuildingType::Splitter)
{
const std::optional<BeltSystem::SplitterInfo> info =
m_sim->belts().getSplitterInfo(e.building->anchor);
if (info.has_value())
{
bb.splitterFilterA = info->filterA;
bb.splitterFilterB = info->filterB;
}
}
bp.buildings.push_back(bb);
}
return bp;
}
int BlueprintPanel::computeBlueprintCost(const Blueprint& bp) const
@@ -156,8 +221,14 @@ int BlueprintPanel::computeBlueprintCost(const Blueprint& bp) const
int total = 0;
for (const BlueprintBuilding& bb : bp.buildings)
{
const BuildingDef* def = m_config->buildings.findBuildingDef(bb.type);
if (def) { total += def->cost; }
for (const BuildingDef& def : m_config->buildings.buildings)
{
if (def.type == bb.type)
{
total += def.cost;
break;
}
}
}
return total;
}
@@ -176,7 +247,7 @@ void BlueprintPanel::rebuildButtons()
{
const Blueprint& bp = m_blueprints[static_cast<std::size_t>(i)];
const int cost = computeBlueprintCost(bp);
const QString label = bp.name + "\n" + tr("%1 Building Blocks").arg(cost);
const QString label = bp.name + "\n" + tr("%1 Blocks").arg(cost);
QWidget* row = new QWidget(m_buttonsContainer);
QHBoxLayout* rowLayout = new QHBoxLayout(row);
@@ -210,42 +281,83 @@ void BlueprintPanel::rebuildButtons()
refreshButtonStates();
}
void BlueprintPanel::saveToDisk() const
void BlueprintPanel::onSaveClicked()
{
// Persist on shutdown; write errors are silently ignored (REQ-UI-BLUEPRINT-SAVE).
const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml";
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { return; }
try
{
const std::string content = BlueprintSerializer::serialize(m_blueprints);
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text))
{
QMessageBox::critical(this, tr("Save Failed"),
tr("Could not open file for writing:\n%1").arg(path));
return;
}
file.write(QByteArray::fromStdString(content));
}
catch (...) {}
catch (const std::exception& e)
{
QMessageBox::critical(this, tr("Save Failed"),
tr("Failed to save blueprints:\n%1").arg(e.what()));
}
}
void BlueprintPanel::loadFromDisk()
void BlueprintPanel::onLoadClicked()
{
// Load at startup (REQ-UI-BLUEPRINT-LOAD). Missing file: start empty, no error.
QMessageBox box(this);
box.setWindowTitle(tr("Load Blueprints"));
box.setText(tr("Load blueprints? This will replace all current blueprints."));
QPushButton* confirmBtn = box.addButton(tr("Confirm"), QMessageBox::AcceptRole);
box.addButton(tr("Cancel"), QMessageBox::RejectRole);
box.exec();
if (box.clickedButton() != confirmBtn) { return; }
const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml";
QFile file(path);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { return; }
try
{
m_blueprints = BlueprintSerializer::deserialize(file.readAll().toStdString());
QFile file(path);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
{
QMessageBox::critical(this, tr("Load Failed"),
tr("Could not open file:\n%1").arg(path));
return;
}
const std::string content = file.readAll().toStdString();
std::vector<Blueprint> loaded = BlueprintSerializer::deserialize(content);
if (m_activeIndex >= 0)
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExitBlueprintModeRequestedEvent>());
m_activeIndex = -1;
}
m_blueprints = std::move(loaded);
rebuildButtons();
}
catch (const std::exception& e)
{
QMessageBox::critical(this, tr("Load Failed"),
tr("Failed to load blueprints:\n%1").arg(e.what()));
m_blueprints.clear();
}
}
void BlueprintPanel::refreshButtonStates()
{
// A construction site counts the same as an operational building (REQ-UI-BLUEPRINT-CREATE).
m_createBtn->setEnabled(selectionHasPlaceableBuilding(*m_sim, m_selectedBuildingIds));
const bool anyPlaceable = [&]() {
for (const BuildingId id : m_selectedBuildingIds)
{
const Building* b = m_sim->buildings().findBuilding(id);
if (!b) { continue; }
for (const BuildingDef& def : m_config->buildings.buildings)
{
if (def.type == b->type) { return def.playerPlaceable; }
}
}
return false;
}();
m_createBtn->setEnabled(anyPlaceable);
for (int i = 0; i < static_cast<int>(m_blueprintButtons.size()); ++i)
{
@@ -265,17 +377,3 @@ void BlueprintPanel::handleEvent(std::shared_ptr<const BlueprintModeExitedEvent>
{
clearActiveBlueprintButton();
}
void BlueprintPanel::handleEvent(std::shared_ptr<const TemporaryBlueprintRequestedEvent> /*event*/)
{
// Temporary blueprint (REQ-UI-BLUEPRINT-TEMP): build from the current selection and
// enter placement mode without adding it to the list or persisting it. If nothing
// player-placeable is selected, do nothing.
Blueprint bp = createBlueprintFromSelection();
if (bp.buildings.empty()) { return; }
// No saved blueprint is active while a temporary one is being placed.
clearActiveBlueprintButton();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintPlacementRequestedEvent>(std::move(bp)));
}

View File

@@ -1,6 +1,5 @@
#pragma once
#include <optional>
#include <vector>
#include <QWidget>
@@ -12,7 +11,6 @@
#include "EventHandler.h"
#include "GameConfig.h"
#include "SelectionChangedEvent.h"
#include "TemporaryBlueprintRequestedEvent.h"
#include "Tick.h"
class Simulation;
@@ -23,8 +21,7 @@ class QVBoxLayout;
class BlueprintPanel : public QWidget,
public CombinedEventHandler<BuildingBlocksChangedEvent,
SelectionChangedEvent,
BlueprintModeExitedEvent,
TemporaryBlueprintRequestedEvent>
BlueprintModeExitedEvent>
{
Q_OBJECT
@@ -36,12 +33,13 @@ private:
void handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) override;
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const BlueprintModeExitedEvent> event) override;
void handleEvent(std::shared_ptr<const TemporaryBlueprintRequestedEvent> event) override;
private slots:
void onCreateClicked();
void onDeleteBlueprintClicked(int index);
void onBlueprintButtonClicked(int index);
void onSaveClicked();
void onLoadClicked();
private:
void onSelectionChanged(const std::vector<BuildingId>& ids);
@@ -50,17 +48,17 @@ private:
int computeBlueprintCost(const Blueprint& bp) const;
void rebuildButtons();
void refreshButtonStates();
void loadFromDisk();
void saveToDisk() const;
Simulation* m_sim;
const GameConfig* m_config;
std::vector<BuildingId> m_selectedBuildingIds;
int m_currentBlocks;
std::optional<int> m_activeIndex; // nullopt = no blueprint selected
int m_activeIndex;
std::vector<Blueprint> m_blueprints;
std::vector<QPushButton*> m_blueprintButtons;
QPushButton* m_createBtn;
QPushButton* m_saveBtn;
QPushButton* m_loadBtn;
QWidget* m_buttonsContainer;
QVBoxLayout* m_buttonsLayout;
};

View File

@@ -12,13 +12,12 @@
#include "DisplayName.h"
#include "EventManager.h"
#include "ExitBuilderModeRequestedEvent.h"
#include "Simulation.h"
BuildButtonGrid::BuildButtonGrid(Simulation* sim, const GameConfig* config, QWidget* parent)
BuildButtonGrid::BuildButtonGrid(const GameConfig* config, QWidget* parent)
: QWidget(parent)
, m_sim(sim)
, m_config(config)
, m_activeIndex(-1)
{
QGridLayout* layout = new QGridLayout(this);
layout->setSpacing(4);
@@ -39,14 +38,10 @@ BuildButtonGrid::BuildButtonGrid(Simulation* sim, const GameConfig* config, QWid
m_costs[def.type] = def.cost;
const QString label = QString::fromStdString(toDisplayName(def.id))
+ "\n" + tr("%1 Building Blocks").arg(def.cost);
+ "\n" + tr("%1 Blocks").arg(def.cost);
QPushButton* btn = new QPushButton(label, this);
btn->setCheckable(true);
btn->setFixedHeight(48);
if (def.tooltip)
{
btn->setToolTip(QString::fromStdString(*def.tooltip));
}
layout->addWidget(btn, row, col);
const int idx = static_cast<int>(m_buttons.size());
@@ -80,42 +75,24 @@ BuildButtonGrid::~BuildButtonGrid()
unregisterForEvents();
}
void BuildButtonGrid::updateAffordability()
void BuildButtonGrid::updateAffordability(int buildingBlocks)
{
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 == i);
m_buttons[i]->setEnabled(buildingBlocks >= cost || m_activeIndex == static_cast<int>(i));
}
}
void BuildButtonGrid::clearActiveButton()
{
if (m_activeIndex)
if (m_activeIndex >= 0 && m_activeIndex < static_cast<int>(m_buttons.size()))
{
m_buttons[*m_activeIndex]->setChecked(false);
m_buttons[static_cast<std::size_t>(m_activeIndex)]->setChecked(false);
}
m_activeIndex.reset();
m_activeIndex = -1;
}
void BuildButtonGrid::onBuildButton(int index)
@@ -124,9 +101,8 @@ void BuildButtonGrid::onBuildButton(int index)
{
return;
}
const std::size_t idx = static_cast<std::size_t>(index);
if (m_activeIndex == idx)
if (m_activeIndex == index)
{
clearActiveButton();
EventManager::getInstance()->sendEventImmediately(
@@ -134,15 +110,15 @@ void BuildButtonGrid::onBuildButton(int index)
return;
}
if (m_activeIndex)
if (m_activeIndex >= 0 && m_activeIndex < static_cast<int>(m_buttons.size()))
{
m_buttons[*m_activeIndex]->setChecked(false);
m_buttons[static_cast<std::size_t>(m_activeIndex)]->setChecked(false);
}
m_activeIndex = idx;
m_buttons[idx]->setChecked(true);
m_activeIndex = index;
m_buttons[static_cast<std::size_t>(index)]->setChecked(true);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BuildingTypeSelectedEvent>(m_types[idx]));
std::make_shared<BuildingTypeSelectedEvent>(m_types[static_cast<std::size_t>(index)]));
}
void BuildButtonGrid::handleEvent(std::shared_ptr<const BuilderModeExitedEvent> /*event*/)
@@ -150,11 +126,6 @@ void BuildButtonGrid::handleEvent(std::shared_ptr<const BuilderModeExitedEvent>
clearActiveButton();
}
void BuildButtonGrid::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> /*event*/)
{
updateAffordability();
}
void BuildButtonGrid::handleEvent(std::shared_ptr<const DemolishModeChangedEvent> event)
{
m_demolishButton->setChecked(event->active);

View File

@@ -1,56 +1,46 @@
#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,
BuildingBlocksChangedEvent>
BuildHotkeyPressedEvent>
{
Q_OBJECT
public:
BuildButtonGrid(Simulation* sim, const GameConfig* config, QWidget* parent = nullptr);
BuildButtonGrid(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;
std::optional<std::size_t> m_activeIndex;
int m_activeIndex;
QPushButton* m_demolishButton;
};

View File

@@ -3,7 +3,6 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/VisualsConfig.h
${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.h
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.h
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.h
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h
@@ -14,7 +13,6 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.h
PARENT_SCOPE
)
@@ -22,7 +20,6 @@ SET(SRCS
${SRCS}
${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.cpp
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.cpp
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.cpp
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp
@@ -33,6 +30,5 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.cpp
PARENT_SCOPE
)

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More