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
50 changed files with 453 additions and 1309 deletions

View File

@@ -4,20 +4,10 @@ message(STATUS "Using CMake ${CMAKE_VERSION}")
include(cmake/add_files.cmake) include(cmake/add_files.cmake)
include(cmake/create_source_groups.cmake) include(cmake/create_source_groups.cmake)
include(cmake/version.cmake)
# Project ---------------------------------------------------------------------- # Project ----------------------------------------------------------------------
# Product identity — anything that depends on the product/project name is defined project(DotaFactory)
# 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})
set(CMAKE_BUILD_TYPE_INIT "Release") set(CMAKE_BUILD_TYPE_INIT "Release")

View File

@@ -81,8 +81,7 @@ id = "salvage_bay"
cost = 25 cost = 25
player_placeable = true player_placeable = true
construction_time_seconds = 1 construction_time_seconds = 1
output_buffer_capacity = 20
surface_mask = [ surface_mask = [
"<AAS", "SAA",
" AAS", "SAA>",
] ]

View File

@@ -56,9 +56,12 @@ production_time_seconds = 3
fill_color = "#FF8040" fill_color = "#FF8040"
glyph = "Rm" 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] [module.weapon]
damage = 14 damage = 14
attack_range_m = 70 attack_range_m = 80
attack_rate_hz = 1.5 attack_rate_hz = 1.5
@@ -75,9 +78,12 @@ production_time_seconds = 4
fill_color = "#FF8040" fill_color = "#FF8040"
glyph = "Rl" 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] [module.weapon]
damage = 52 damage = 52
attack_range_m = 100 attack_range_m = 130
attack_rate_hz = 0.8 attack_rate_hz = 0.8
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------

View File

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

View File

@@ -17,13 +17,6 @@ player_buffer_width_tiles = 20
contest_zone_width_tiles = 60 contest_zone_width_tiles = 60
enemy_buffer_width_tiles = 20 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 = 8.0
pan_speed_fast_tiles_per_second = 24.0
pan_ramp_band_width_tiles = 16
[expansion] [expansion]
columns_per_expansion_tiles = 10 columns_per_expansion_tiles = 10
# x = expansions already purchased; ~1 per cycle mid-game, decelerating # 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: # using the verified fitted threat values from tools/threat_report.py:
# drone 10.5, frigate 47, destroyer 99, cruiser 233.5, # drone 10.5, frigate 47, destroyer 99, cruiser 233.5,
# battlecruiser 354.5, battleship 722.5, dreadnought 1491.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 # Module arrays mirror the ships' default_modules loadouts unless a
# doctrine variant is the point of the arena. # 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"}, {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]] [[arena]]
name = "Repair escort vs raw numbers (444 vs 444)" name = "Repair escort vs raw numbers (444 vs 444)"
height_tiles = 10 height_tiles = 10

View File

@@ -83,7 +83,6 @@ id = "salvage_bay"
cost = 25 cost = 25
player_placeable = true player_placeable = true
construction_time_seconds = 15 construction_time_seconds = 15
output_buffer_capacity = 20
surface_mask = [ surface_mask = [
"SAA", "SAA",
"SAA>", "SAA>",

View File

@@ -17,11 +17,6 @@ player_buffer_width_tiles = 10
contest_zone_width_tiles = 30 contest_zone_width_tiles = 30
enemy_buffer_width_tiles = 15 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] [expansion]
columns_per_expansion_tiles = 10 columns_per_expansion_tiles = 10
cost_building_blocks_formula = "400 * 2^x" 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

@@ -21,8 +21,12 @@ REQ-* ids in [../requirements.md](../requirements.md).
First full balancing round complete (2026-07-06): targets → tree → First full balancing round complete (2026-07-06): targets → tree →
numbers → threat-calculator parity → combat stats (arena-converged) → numbers → threat-calculator parity → combat stats (arena-converged) →
pacing. Next step: full-game playtests against the run-shape targets in pacing. Playtesting in progress: playtest 1 (two full ~40-min wins,
`targets.md`. 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 ## Open action items

View File

@@ -94,10 +94,16 @@ geometry-validated against the hull grids)
## Combat stats ## 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; **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, **Hull HP** (15/threat prior + empirical trims): drone 60, frigate 300,
destroyer 550, cruiser 1500, battlecruiser 2400, battleship 6300, 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. battleship 15|14|7|2|1, dreadnought/carrier 10|8|4|1|0.5.
Sensors: 150/200/220/250/260/280/300/350 m. Sensors: 150/200/220/250/260/280/300/350 m.
**Other modules:** armor_plates +1200 HP; repair_tool 9 HP × 1 Hz, **Other modules:** armor_plates +1200 HP; repair_tool 4 HP × 1 Hz,
range 80; salvager range 60, cargo 20, 0.5 collections/s; afterburner 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; ×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 sensor_booster +50 m; weapon_upgrade ×1.2 damage; weapon_primer ×1.2
rate; weapon_stabilizer ×1.3 range ×0.8 rate. 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. — ~1 expansion per cycle mid-game, 23 cycles apart late.
- **First full balancing round complete.** Next: full-game playtests - **First full balancing round complete.** Next: full-game playtests
against the run-shape targets. 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,7 +4,7 @@
Config files use the TOML format. The following config files drive game parameters: 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, and view pan speeds (slow and fast horizontal pan speed and pan ramp band width). - **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. - **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). - **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). - **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).
@@ -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-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-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: 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-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 ## Building Types
@@ -126,7 +118,7 @@ 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-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-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-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-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-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: - 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 only one output's filter is routed to that output.
@@ -319,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. 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 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. - 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: 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:
@@ -409,13 +401,7 @@ The screen is divided into two columns: a main column (75% width) containing the
### Game World ### 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: The player scrolls the view horizontally across the scrollable area by pressing A (scroll left) and D (scroll right).
- 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-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-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-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-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-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.
@@ -427,7 +413,6 @@ The screen is divided into two columns: a main column (75% width) containing the
- **A / D** — scroll the view left / right (REQ-UI-SCROLL). - **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.) - **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). - **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). - **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): - **Build mode selection** — pressing a build hotkey activates builder mode for the corresponding building type, equivalent to clicking its build button (REQ-BLD-BUILDER-MODE):
- **1** — Belt, **2** — Splitter, **3** — Tunnel Entry, **4** — Tunnel Exit. - **1** — Belt, **2** — Splitter, **3** — Tunnel Entry, **4** — Tunnel Exit.
@@ -478,12 +463,10 @@ The screen is divided into two columns: a main column (75% width) containing the
### Blueprint Panel ### 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. 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. 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. - 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.
@@ -494,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-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 ## Balancing Tool

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_APP_NAME "${TARGET_BASE_NAME}")
set(TARGET_LIB_NAME "${TARGET_BASE_NAME}_lib") 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}) 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(APP_FILES)
unset(RELATIVE_HDRS) unset(RELATIVE_HDRS)
unset(RELATIVE_SRCS) unset(RELATIVE_SRCS)

View File

@@ -1,6 +1,5 @@
#pragma once #pragma once
#include <optional>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -19,10 +18,6 @@ struct BuildingDef
// Stored as raw strings here; parsing into per-cell tiles + output ports // Stored as raw strings here; parsing into per-cell tiles + output ports
// happens when buildings are placed, not at load time. // happens when buildings are placed, not at load time.
std::vector<std::string> surfaceMask; 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;
}; };
struct BuildingsConfig struct BuildingsConfig

View File

@@ -305,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.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.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; return cfg;
} }
@@ -337,12 +333,6 @@ BuildingsConfig ConfigLoader::loadBuildings(const std::string& path)
def.constructionTimeSeconds = requireDouble(mt["construction_time_seconds"], file, elemPath + ".construction_time_seconds"); def.constructionTimeSeconds = requireDouble(mt["construction_time_seconds"], file, elemPath + ".construction_time_seconds");
def.surfaceMask = requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask"); 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"));
}
const std::optional<BuildingType> parsedType = parseBuildingType(def.id); const std::optional<BuildingType> parsedType = parseBuildingType(def.id);
if (!parsedType) if (!parsedType)
{ {

View File

@@ -53,14 +53,6 @@ struct WorldArtifacts
int artifactWinCount; 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 struct WorldConfig
{ {
int heightTiles; // REQ-GW-HEIGHT int heightTiles; // REQ-GW-HEIGHT
@@ -81,5 +73,4 @@ struct WorldConfig
WorldWaves waves; WorldWaves waves;
WorldTargeting targeting; WorldTargeting targeting;
WorldArtifacts artifacts; WorldArtifacts artifacts;
WorldScroll scroll;
}; };

View File

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

View File

@@ -22,7 +22,6 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/DemolishModeToggleRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/DemolishModeToggleRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPlacementRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPlacementRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/ExitBlueprintModeRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/ExitBlueprintModeRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/TemporaryBlueprintRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/SpeedChangeRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/SpeedChangeRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/LayoutDialogRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/LayoutDialogRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionRequestedEvent.h
@@ -32,7 +31,6 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/BeamFiredEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BeamFiredEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/CommandRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/CommandRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/PlayerCommandsAppliedEvent.h
PARENT_SCOPE PARENT_SCOPE
) )

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,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,51 +0,0 @@
#include "BuildingConfig.h"
#include "BeltSystem.h"
#include "Building.h"
#include "BuildingSystem.h"
#include "Simulation.h"
std::optional<BuildingConfig> readBuildingConfig(const Simulation& sim, BuildingId id)
{
const Building* building = sim.buildings().findBuilding(id);
const ConstructionSite* site = building ? nullptr : sim.buildings().findSite(id);
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.belts().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;
}

View File

@@ -1,40 +0,0 @@
#pragma once
#include <optional>
#include <string>
#include <vector>
#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);

View File

@@ -154,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 std::vector<Port> BuildingSystem::computeInputPorts(const Building& b) const
{ {
// Build lookup sets for quick membership checks. // Build lookup sets for quick membership checks.
@@ -419,12 +409,6 @@ void BuildingSystem::setRecipe(BuildingId id, const std::string& recipeId)
{ {
if (site.id == id) if (site.id == id)
{ {
// 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.recipeId = recipeId;
site.shipLayout = std::nullopt; site.shipLayout = std::nullopt;
return; return;
@@ -436,12 +420,6 @@ void BuildingSystem::setRecipe(BuildingId id, const std::string& recipeId)
{ {
if (building.id == id) if (building.id == id)
{ {
// 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.recipeId = recipeId;
building.shipLayout = std::nullopt; building.shipLayout = std::nullopt;
building.inputBuffer.counts.clear(); building.inputBuffer.counts.clear();
@@ -599,11 +577,7 @@ void BuildingSystem::tickConstruction(Tick currentTick)
} }
building.inputPorts = computeInputPorts(building); building.inputPorts = computeInputPorts(building);
if (building.type == BuildingType::SalvageBay) if (!building.recipeId.empty())
{
initSalvageBayBuffer(building);
}
else if (!building.recipeId.empty())
{ {
if (building.type == BuildingType::Shipyard) if (building.type == BuildingType::Shipyard)
{ {
@@ -870,16 +844,7 @@ void BuildingSystem::tickShipyardProduction(Tick currentTick)
{ {
const Port& p = building.outputPorts[0]; const Port& p = building.outputPorts[0];
const QVector2D spawnPos(p.tile.x() + 0.5f, p.tile.y() + 0.5f); const QVector2D spawnPos(p.tile.x() + 0.5f, p.tile.y() + 0.5f);
// A shipyard builds exactly what the player configured and m_spawnShip(building.recipeId, spawnPos, building.shipLayout);
// 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);
} }
building.production = std::nullopt; building.production = std::nullopt;
} }
@@ -1273,11 +1238,6 @@ BuildingId BuildingSystem::placeImmediate(BuildingType type,
} }
building.inputPorts = computeInputPorts(building); building.inputPorts = computeInputPorts(building);
if (type == BuildingType::SalvageBay)
{
initSalvageBayBuffer(building);
}
m_buildings.push_back(std::move(building)); m_buildings.push_back(std::move(building));
return id; return id;
} }

View File

@@ -170,7 +170,6 @@ private:
const ModuleDef* findModuleDef(const std::string& id) const; const ModuleDef* findModuleDef(const std::string& id) const;
void initBuffers(Building& b, const RecipeDef& recipe) const; void initBuffers(Building& b, const RecipeDef& recipe) const;
void initShipyardBuffers(Building& b) const; void initShipyardBuffers(Building& b) const;
void initSalvageBayBuffer(Building& b) const;
std::vector<Port> computeInputPorts(const Building& b) const; std::vector<Port> computeInputPorts(const Building& b) const;
std::vector<Item> rollReprocessingOutput(const RecipeDef& recipe); std::vector<Item> rollReprocessingOutput(const RecipeDef& recipe);
bool bodyCellsWithinWorldBounds( bool bodyCellsWithinWorldBounds(

View File

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

View File

@@ -707,7 +707,14 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
else else
{ {
option.type = SchematicType::Recipe; 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. // 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( const UnlockedSets hypothetical = computeUnlockedSets(
hypotheticalShipIds, hypotheticalModuleIds, hypotheticalRecipeSchematicIds); hypotheticalShipIds, hypotheticalModuleIds, hypotheticalRecipeSchematicIds);
option.newlyUnlockedRecipeIds = computeNewlyUnlockedRecipeIds(hypothetical); option.newlyUnlockedItemNames = computeNewlyUnlockedItemNames(hypothetical);
m_pendingSchematicChoices.push_back(option); m_pendingSchematicChoices.push_back(option);
} }
@@ -918,20 +925,23 @@ Simulation::UnlockedSets Simulation::computeUnlockedSets(
return result; 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) for (const std::string& recipeId : hypothetical.recipeIds)
{ {
if (m_unlockedRecipeIds.count(recipeId) > 0) { continue; } if (m_unlockedRecipeIds.count(recipeId) > 0) { continue; }
recipeIds.push_back(recipeId); for (const RecipeDef& def : m_config.recipes.recipes)
}
std::sort(recipeIds.begin(), recipeIds.end(),
[](const std::string& lhs, const std::string& rhs)
{ {
return toDisplayName(lhs) < toDisplayName(rhs); if (def.id != recipeId) { continue; }
}); for (const RecipeOutput& out : def.outputs)
return recipeIds; {
itemNames.insert(toDisplayName(out.item));
}
break;
}
}
return std::vector<std::string>(itemNames.begin(), itemNames.end());
} }
bool Simulation::isRecipeUnlocked(const std::string& recipeId) const bool Simulation::isRecipeUnlocked(const std::string& recipeId) const

View File

@@ -233,9 +233,9 @@ private:
// True if every prerequisite in unlockRequires is explicitly unlocked (REQ-LOCK-PREREQ). // True if every prerequisite in unlockRequires is explicitly unlocked (REQ-LOCK-PREREQ).
bool prerequisitesSatisfied(const std::vector<std::string>& unlockRequires) const; 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. // 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; EntityAdmin m_admin;
BeltSystem m_beltSystem; BeltSystem m_beltSystem;

View File

@@ -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()); 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);
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) // Collection range (per-module)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

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);
REQUIRE(id != kInvalidBuildingId);
REQUIRE(sim.buildings().findBuilding(id) == nullptr);
REQUIRE(sim.buildings().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

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

View File

@@ -103,15 +103,6 @@ TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[c
REQUIRE(minerIt != cfg.buildings.buildings.end()); REQUIRE(minerIt != cfg.buildings.buildings.end());
REQUIRE(minerIt->cost == 15); REQUIRE(minerIt->cost == 15);
REQUIRE(minerIt->surfaceMask.size() == 2); 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);
// recipes.toml — reprocessing cycle has three weighted outputs. // recipes.toml — reprocessing cycle has three weighted outputs.
const auto reproIt = std::find_if( const auto reproIt = std::find_if(

View File

@@ -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) // 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]") "[recipe_schematic]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadConfig());
@@ -295,11 +295,10 @@ TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds is sorted, deduplicated, and
for (const SchematicChoiceOption& opt : sim.getPendingSchematicChoices()) for (const SchematicChoiceOption& opt : sim.getPendingSchematicChoices())
{ {
// Strictly ascending display names imply sorted and deduplicated. // Strictly ascending implies sorted and deduplicated.
for (std::size_t j = 1; j < opt.newlyUnlockedRecipeIds.size(); ++j) for (std::size_t j = 1; j < opt.newlyUnlockedItemNames.size(); ++j)
{ {
CHECK(toDisplayName(opt.newlyUnlockedRecipeIds[j - 1]) CHECK(opt.newlyUnlockedItemNames[j - 1] < opt.newlyUnlockedItemNames[j]);
< toDisplayName(opt.newlyUnlockedRecipeIds[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]") "[recipe_schematic]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadConfig());
@@ -337,22 +336,21 @@ TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds matches recipes that actually
SimulationTestAccess::applySchematicChoice(sim, 0); SimulationTestAccess::applySchematicChoice(sim, 0);
std::vector<std::string> expected; std::set<std::string> expectedNames;
for (const RecipeDef& def : cfg.recipes.recipes) for (const RecipeDef& def : cfg.recipes.recipes)
{ {
if ((def.building == BuildingType::Miner || def.building == BuildingType::Assembler) if ((def.building == BuildingType::Miner || def.building == BuildingType::Assembler)
&& sim.isRecipeUnlocked(def.id) && unlockedBefore.count(def.id) == 0) && sim.isRecipeUnlocked(def.id) && unlockedBefore.count(def.id) == 0)
{ {
expected.push_back(def.id); for (const RecipeOutput& out : def.outputs)
}
}
std::sort(expected.begin(), expected.end(),
[](const std::string& lhs, const std::string& rhs)
{ {
return toDisplayName(lhs) < toDisplayName(rhs); expectedNames.insert(toDisplayName(out.item));
}); }
}
}
const std::vector<std::string> expected(expectedNames.begin(), expectedNames.end());
REQUIRE(choice.newlyUnlockedRecipeIds == expected); REQUIRE(choice.newlyUnlockedItemNames == expected);
} }
} }

View File

@@ -6,7 +6,6 @@
#include "ConfigLoader.h" #include "ConfigLoader.h"
#include "DynamicBodyComponent.h" #include "DynamicBodyComponent.h"
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include "FactionComponent.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "HealthComponent.h" #include "HealthComponent.h"
#include "ItemType.h" #include "ItemType.h"
@@ -14,7 +13,6 @@
#include "ModulesConfig.h" #include "ModulesConfig.h"
#include "Rotation.h" #include "Rotation.h"
#include "SensorRangeComponent.h" #include "SensorRangeComponent.h"
#include "ShipIdentityComponent.h"
#include "ShipLayout.h" #include "ShipLayout.h"
#include "ShipStatsCalculator.h" #include "ShipStatsCalculator.h"
#include "ShipSystem.h" #include "ShipSystem.h"
@@ -266,50 +264,6 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
CHECK_FALSE(b2->production.has_value()); 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.config(), "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.config());
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.admin().forEach<ShipIdentityComponent, FactionComponent>(
[&](entt::entity e, const ShipIdentityComponent& si, const FactionComponent& fac)
{
if (!fac.isEnemy && si.schematicId == "interceptor") { built = e; }
});
REQUIRE(sim.admin().isValid(built));
// Bare hull: the schematic's default weapon must NOT have been installed.
const bool hasWeapon =
findFirstWeaponChild(sim.admin(), built) != entt::null;
CHECK_FALSE(hasWeapon);
}
TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]") TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadConfig(), 42);
@@ -338,38 +292,6 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
CHECK_FALSE(b2->shipLayout.has_value()); 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.config());
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.buildings().findBuilding(yardId);
REQUIRE(b1 != nullptr);
REQUIRE(b1->shipLayout.has_value());
// Re-selecting the same recipe must be a no-op and preserve the layout.
SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor");
const Building* b2 = sim.buildings().findBuilding(yardId);
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 // Weapon modifier simulation tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -19,7 +19,6 @@
#include "ExitBlueprintModeRequestedEvent.h" #include "ExitBlueprintModeRequestedEvent.h"
#include "Building.h" #include "Building.h"
#include "BuildingConfig.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "Simulation.h" #include "Simulation.h"
@@ -54,15 +53,23 @@ BlueprintPanel::BlueprintPanel(Simulation* sim, const GameConfig* config, QWidge
connect(m_createBtn, &QPushButton::clicked, this, &BlueprintPanel::onCreateClicked); connect(m_createBtn, &QPushButton::clicked, this, &BlueprintPanel::onCreateClicked);
loadFromDisk(); QHBoxLayout* ioLayout = new QHBoxLayout();
rebuildButtons(); 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(); registerForEvents();
} }
BlueprintPanel::~BlueprintPanel() BlueprintPanel::~BlueprintPanel()
{ {
saveToDisk();
unregisterForEvents(); unregisterForEvents();
} }
@@ -192,16 +199,17 @@ Blueprint BlueprintPanel::createBlueprintFromSelection() const
bb.type = e.building->type; bb.type = e.building->type;
bb.rotation = e.building->rotation; bb.rotation = e.building->rotation;
bb.offset = e.building->anchor - center; bb.offset = e.building->anchor - center;
// Recipe / schematic / layout / splitter-filter capture is shared with the bb.recipeId = e.building->recipeId;
// copy-settings gesture (REQ-BLD-COPY-CONFIG) via readBuildingConfig. bb.shipLayout = e.building->shipLayout;
const std::optional<BuildingConfig> config = if (e.building->type == BuildingType::Splitter)
readBuildingConfig(*m_sim, e.building->id);
if (config.has_value())
{ {
bb.recipeId = config->recipeId.value_or(std::string()); const std::optional<BeltSystem::SplitterInfo> info =
bb.shipLayout = config->shipLayout; m_sim->belts().getSplitterInfo(e.building->anchor);
bb.splitterFilterA = config->splitterFilterA; if (info.has_value())
bb.splitterFilterB = config->splitterFilterB; {
bb.splitterFilterA = info->filterA;
bb.splitterFilterB = info->filterB;
}
} }
bp.buildings.push_back(bb); bp.buildings.push_back(bb);
} }
@@ -273,35 +281,65 @@ void BlueprintPanel::rebuildButtons()
refreshButtonStates(); 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"; const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml";
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { return; }
try try
{ {
const std::string content = BlueprintSerializer::serialize(m_blueprints); 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)); 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"; const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml";
QFile file(path);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { return; }
try 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) catch (const std::exception& e)
{ {
QMessageBox::critical(this, tr("Load Failed"), QMessageBox::critical(this, tr("Load Failed"),
tr("Failed to load blueprints:\n%1").arg(e.what())); tr("Failed to load blueprints:\n%1").arg(e.what()));
m_blueprints.clear();
} }
} }
@@ -339,17 +377,3 @@ void BlueprintPanel::handleEvent(std::shared_ptr<const BlueprintModeExitedEvent>
{ {
clearActiveBlueprintButton(); 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

@@ -11,7 +11,6 @@
#include "EventHandler.h" #include "EventHandler.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "SelectionChangedEvent.h" #include "SelectionChangedEvent.h"
#include "TemporaryBlueprintRequestedEvent.h"
#include "Tick.h" #include "Tick.h"
class Simulation; class Simulation;
@@ -22,8 +21,7 @@ class QVBoxLayout;
class BlueprintPanel : public QWidget, class BlueprintPanel : public QWidget,
public CombinedEventHandler<BuildingBlocksChangedEvent, public CombinedEventHandler<BuildingBlocksChangedEvent,
SelectionChangedEvent, SelectionChangedEvent,
BlueprintModeExitedEvent, BlueprintModeExitedEvent>
TemporaryBlueprintRequestedEvent>
{ {
Q_OBJECT Q_OBJECT
@@ -35,12 +33,13 @@ private:
void handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) override; void handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) override;
void handleEvent(std::shared_ptr<const SelectionChangedEvent> 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 BlueprintModeExitedEvent> event) override;
void handleEvent(std::shared_ptr<const TemporaryBlueprintRequestedEvent> event) override;
private slots: private slots:
void onCreateClicked(); void onCreateClicked();
void onDeleteBlueprintClicked(int index); void onDeleteBlueprintClicked(int index);
void onBlueprintButtonClicked(int index); void onBlueprintButtonClicked(int index);
void onSaveClicked();
void onLoadClicked();
private: private:
void onSelectionChanged(const std::vector<BuildingId>& ids); void onSelectionChanged(const std::vector<BuildingId>& ids);
@@ -49,8 +48,6 @@ private:
int computeBlueprintCost(const Blueprint& bp) const; int computeBlueprintCost(const Blueprint& bp) const;
void rebuildButtons(); void rebuildButtons();
void refreshButtonStates(); void refreshButtonStates();
void loadFromDisk();
void saveToDisk() const;
Simulation* m_sim; Simulation* m_sim;
const GameConfig* m_config; const GameConfig* m_config;
@@ -60,6 +57,8 @@ private:
std::vector<Blueprint> m_blueprints; std::vector<Blueprint> m_blueprints;
std::vector<QPushButton*> m_blueprintButtons; std::vector<QPushButton*> m_blueprintButtons;
QPushButton* m_createBtn; QPushButton* m_createBtn;
QPushButton* m_saveBtn;
QPushButton* m_loadBtn;
QWidget* m_buttonsContainer; QWidget* m_buttonsContainer;
QVBoxLayout* m_buttonsLayout; QVBoxLayout* m_buttonsLayout;
}; };

View File

@@ -13,7 +13,6 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.h
PARENT_SCOPE PARENT_SCOPE
) )
@@ -31,6 +30,5 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.cpp
PARENT_SCOPE PARENT_SCOPE
) )

View File

@@ -39,7 +39,6 @@
#include "FactionComponent.h" #include "FactionComponent.h"
#include "GameOverEvent.h" #include "GameOverEvent.h"
#include "HealthComponent.h" #include "HealthComponent.h"
#include "HqProxyComponent.h"
#include "PositionComponent.h" #include "PositionComponent.h"
#include "RepairBehavior.h" #include "RepairBehavior.h"
#include "SalvageScrapBehavior.h" #include "SalvageScrapBehavior.h"
@@ -56,7 +55,6 @@
#include "EscapeMenuRequestedEvent.h" #include "EscapeMenuRequestedEvent.h"
#include "TracePrintRequestedEvent.h" #include "TracePrintRequestedEvent.h"
#include "BuildHotkeyPressedEvent.h" #include "BuildHotkeyPressedEvent.h"
#include "TemporaryBlueprintRequestedEvent.h"
#include "BossWaveUpdatedEvent.h" #include "BossWaveUpdatedEvent.h"
#include "BuilderModeExitedEvent.h" #include "BuilderModeExitedEvent.h"
#include "BlueprintModeExitedEvent.h" #include "BlueprintModeExitedEvent.h"
@@ -64,7 +62,6 @@
#include "ExpansionCostChangedEvent.h" #include "ExpansionCostChangedEvent.h"
#include "GameSpeedChangedEvent.h" #include "GameSpeedChangedEvent.h"
#include "SchematicChoicesAvailableEvent.h" #include "SchematicChoicesAvailableEvent.h"
#include "PlayerCommandsAppliedEvent.h"
#include "TickAdvancedEvent.h" #include "TickAdvancedEvent.h"
namespace namespace
@@ -215,7 +212,6 @@ void GameWorldView::onFrame()
// Drain queued player commands once per frame, before the tick batch. This // Drain queued player commands once per frame, before the tick batch. This
// runs even at 0x so a paused player sees placed construction sites // runs even at 0x so a paused player sees placed construction sites
// immediately, while staying deterministic (see docs/replay_design.md). // immediately, while staying deterministic (see docs/replay_design.md).
const bool commandsApplied = m_commandManager.hasPending();
m_commandManager.drain(); m_commandManager.drain();
// A drained Reset reinitialized the simulation; reset the view to match. // A drained Reset reinitialized the simulation; reset the view to match.
@@ -225,17 +221,6 @@ void GameWorldView::onFrame()
resetForNewGame(); resetForNewGame();
} }
// Notify presentation widgets that queued commands were applied, so a
// paused player still sees the effect (e.g. a shipyard's layout preview
// after picking a schematic) even though no tick advances. UI-only: this
// does not touch the command queue or simulation, so replay recording
// and determinism are unaffected.
if (commandsApplied)
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<PlayerCommandsAppliedEvent>());
}
const int ticks = m_tickDriver.advance( const int ticks = m_tickDriver.advance(
static_cast<double>(elapsed), m_gameSpeedMultiplier); static_cast<double>(elapsed), m_gameSpeedMultiplier);
for (int i = 0; i < ticks; ++i) for (int i = 0; i < ticks; ++i)
@@ -271,25 +256,9 @@ void GameWorldView::onFrame()
m_activeBeams = std::move(live); m_activeBeams = std::move(live);
} }
// Expire copy/paste flashes. Lifetime is wall-clock (the frame delta), so the
// flash plays for a fixed real duration regardless of game speed, including
// while the game is paused (REQ-BLD-COPY-CONFIG-FEEDBACK).
if (!m_copyConfigFlashes.empty())
{
std::vector<CopyConfigFlash> live;
for (CopyConfigFlash flash : m_copyConfigFlashes)
{
flash.remainingMs -= elapsed;
if (flash.remainingMs > 0) { live.push_back(flash); }
}
m_copyConfigFlashes = std::move(live);
}
// Apply held scroll // Apply held scroll
{ {
// Pan speed depends on where the view is centered (REQ-UI-SCROLL-SPEED). const float delta = kScrollSpeedTilesPerSec
const float viewCenterX = m_scrollXTiles + viewportWidthTiles() / 2.0f;
const float delta = panSpeedTilesPerSecondAt(viewCenterX)
* static_cast<float>(elapsed) / 1000.0f; * static_cast<float>(elapsed) / 1000.0f;
const float scrollBefore = m_scrollXTiles; const float scrollBefore = m_scrollXTiles;
if (m_scrollLeft) { m_scrollXTiles -= delta; } if (m_scrollLeft) { m_scrollXTiles -= delta; }
@@ -398,7 +367,6 @@ void GameWorldView::paintGL()
drawTiles(painter); drawTiles(painter);
drawBuildings(painter); drawBuildings(painter);
drawCopyConfigFeedback(painter);
drawStations(painter); drawStations(painter);
drawBeltItems(painter); drawBeltItems(painter);
drawScrap(painter); drawScrap(painter);
@@ -506,39 +474,6 @@ float GameWorldView::enemyStationRightEdge() const
return rightX; return rightX;
} }
namespace
{
// Linearly blend from valueAt0 (for x <= x0) to valueAt1 (for x >= x1), clamped
// outside [x0, x1]. A zero- or negative-width band collapses to a hard step at x1.
float lerpClamped(float valueAt0, float valueAt1, float x0, float x1, float x)
{
if (x1 <= x0) { return x < x1 ? valueAt0 : valueAt1; }
const float t = std::max(0.0f, std::min(1.0f, (x - x0) / (x1 - x0)));
return valueAt0 + (valueAt1 - valueAt0) * t;
}
}
float GameWorldView::panSpeedTilesPerSecondAt(float viewCenterXTiles) const
{
// Slow near the asteroid/player buffer, fast across the contest zone, with a
// linear ramp straddling each contest-zone boundary (REQ-UI-SCROLL-SPEED). The
// contest zone spans from the player buffer's right edge to the enemy stations,
// the latter tracked live so the ramp follows the front line as it is pushed.
const float slow = static_cast<float>(m_config->world.scroll.panSpeedSlow_tps);
const float fast = static_cast<float>(m_config->world.scroll.panSpeedFast_tps);
const float half = static_cast<float>(m_config->world.scroll.panRampBandWidth_tiles) / 2.0f;
const float leftEdge = static_cast<float>(m_config->world.regions.playerBufferWidth_tiles);
const float rightEdge = enemyStationRightEdge();
// Rising ramp at the left boundary (slow -> fast) and falling ramp at the right
// boundary (fast -> slow); their minimum yields flat-slow outside, flat-fast in
// the middle, and — if the bands overlap in a narrow contest zone — a single peak
// below the fast speed where the two ramps cross.
const float leftRamp = lerpClamped(slow, fast, leftEdge - half, leftEdge + half, viewCenterXTiles);
const float rightRamp = lerpClamped(fast, slow, rightEdge - half, rightEdge + half, viewCenterXTiles);
return std::min(leftRamp, rightRamp);
}
void GameWorldView::clampScroll() void GameWorldView::clampScroll()
{ {
const float leftBound = asteroidLeftEdge(); const float leftBound = asteroidLeftEdge();
@@ -622,41 +557,6 @@ BuildingId GameWorldView::siteAtTile(QPoint tile) const
} }
std::vector<BuildingId> GameWorldView::buildingsInBox(QPoint cornerA, QPoint cornerB) const
{
const int x0 = std::min(cornerA.x(), cornerB.x());
const int y0 = std::min(cornerA.y(), cornerB.y());
const int x1 = std::max(cornerA.x(), cornerB.x());
const int y1 = std::max(cornerA.y(), cornerB.y());
std::vector<BuildingId> ids;
for (const Building& b : m_sim->buildings().allBuildings())
{
for (const QPoint& cell : b.bodyCells)
{
if (cell.x() >= x0 && cell.x() <= x1
&& cell.y() >= y0 && cell.y() <= y1)
{
ids.push_back(b.id);
break;
}
}
}
for (const ConstructionSite& s : m_sim->buildings().allSites())
{
for (const QPoint& cell : s.bodyCells)
{
if (cell.x() >= x0 && cell.x() <= x1
&& cell.y() >= y0 && cell.y() <= y1)
{
ids.push_back(s.id);
break;
}
}
}
return ids;
}
std::optional<QVector2D> GameWorldView::entityPosition(entt::entity entity) const std::optional<QVector2D> GameWorldView::entityPosition(entt::entity entity) const
{ {
if (!m_sim->admin().isValid(entity) || !m_sim->admin().hasAll<PositionComponent>(entity)) if (!m_sim->admin().isValid(entity) || !m_sim->admin().hasAll<PositionComponent>(entity))
@@ -924,19 +824,16 @@ void GameWorldView::drawBuildings(QPainter& painter)
port.direction, bv.outline); port.direction, bv.outline);
} }
// HP bar below the HQ footprint; the HQ's HP lives on its proxy entity. bool selected = false;
if (b.type == BuildingType::Hq) for (BuildingId selId : m_selectedBuildingIds)
{ {
m_sim->admin().forEach<HqProxyComponent, FactionComponent, HealthComponent>( if (selId == b.id) { selected = true; break; }
[&](entt::entity /*e*/, const HqProxyComponent& /*hq*/,
const FactionComponent& f, const HealthComponent& h)
{
if (h.maxHp > 0.0f)
{
drawHpBar(painter, bboxRect.left(), bboxRect.bottom() + 1.0,
bboxRect.width(), h.hp / h.maxHp, f.isEnemy);
} }
}); if (selected)
{
painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
painter.setBrush(Qt::NoBrush);
painter.drawRect(bboxRect.adjusted(-1, -1, 1, 1));
} }
} }
@@ -961,6 +858,20 @@ void GameWorldView::drawBuildings(QPainter& painter)
painter.setBrush(Qt::NoBrush); painter.setBrush(Qt::NoBrush);
painter.drawRect(bboxRect); painter.drawRect(bboxRect);
bool selected = false;
for (BuildingId selId : m_selectedBuildingIds)
{
if (selId == s.id) { selected = true; break; }
}
if (selected)
{
painter.setOpacity(1.0);
painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
painter.setBrush(Qt::NoBrush);
painter.drawRect(bboxRect.adjusted(-1, -1, 1, 1));
painter.setOpacity(0.5);
}
const BuildingDef* siteDef = findBuildingDef(s.type); const BuildingDef* siteDef = findBuildingDef(s.type);
if (siteDef) if (siteDef)
{ {
@@ -1005,86 +916,6 @@ void GameWorldView::drawBuildings(QPainter& painter)
} }
} }
painter.setOpacity(1.0); painter.setOpacity(1.0);
// Selection highlights are drawn last, after every building and construction
// site fill, so a selected building surrounded by neighbours keeps its outline:
// the highlight sits 1px outside the footprint (into adjacent tiles), and drawing
// it inline would let later-drawn neighbours overpaint it with their body fill.
drawSelectionHighlights(painter);
}
std::optional<QRectF> GameWorldView::footprintWidgetRect(BuildingId id) const
{
std::optional<QPoint> anchor;
std::optional<QSize> footprint;
if (const Building* b = m_sim->buildings().findBuilding(id))
{
anchor = b->anchor;
footprint = b->footprint;
}
else if (const ConstructionSite* s = m_sim->buildings().findSite(id))
{
anchor = s->anchor;
footprint = s->footprint;
}
if (!anchor.has_value() || !footprint.has_value()) { return std::nullopt; }
const QPointF tl = tileToWidget(*anchor);
return QRectF(tl.x(), tl.y(),
footprint->width() * static_cast<qreal>(tilePx()),
footprint->height() * static_cast<qreal>(tilePx()));
}
void GameWorldView::drawSelectionHighlights(QPainter& painter)
{
painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
painter.setBrush(Qt::NoBrush);
for (BuildingId selId : m_selectedBuildingIds)
{
const std::optional<QRectF> rect = footprintWidgetRect(selId);
if (!rect.has_value()) { continue; }
// Outline sits 1px outside the footprint (into adjacent tiles).
painter.drawRect(rect->adjusted(-1, -1, 1, 1));
}
}
void GameWorldView::drawCopyConfigFeedback(QPainter& painter)
{
const QColor color = m_visuals->overlays.copyConfig;
// Eligible-target tint: while a configuration is cached, every same-type
// building and site (the source included) is a valid paste target and is
// washed in the copy-settings color (REQ-BLD-COPY-CONFIG-FEEDBACK).
if (m_copiedConfig.has_value())
{
painter.setPen(Qt::NoPen);
painter.setBrush(color);
const BuildingType type = m_copiedConfig->type;
for (const Building& b : m_sim->buildings().allBuildings())
{
if (b.type != type) { continue; }
const std::optional<QRectF> rect = footprintWidgetRect(b.id);
if (rect.has_value()) { painter.drawRect(*rect); }
}
for (const ConstructionSite& s : m_sim->buildings().allSites())
{
if (s.type != type) { continue; }
const std::optional<QRectF> rect = footprintWidgetRect(s.id);
if (rect.has_value()) { painter.drawRect(*rect); }
}
}
// Copy / paste flashes: a brief outline in the same color, drawn like the
// selection outline (REQ-BLD-COPY-CONFIG-FEEDBACK).
painter.setPen(QPen(color, 2));
painter.setBrush(Qt::NoBrush);
for (const CopyConfigFlash& flash : m_copyConfigFlashes)
{
const std::optional<QRectF> rect = footprintWidgetRect(flash.id);
if (rect.has_value()) { painter.drawRect(rect->adjusted(-1, -1, 1, 1)); }
}
} }
void GameWorldView::drawBeltItems(QPainter& painter) void GameWorldView::drawBeltItems(QPainter& painter)
@@ -1162,8 +993,14 @@ void GameWorldView::drawStations(QPainter& painter)
// HP bar below footprint. // HP bar below footprint.
if (h.maxHp > 0.0f) if (h.maxHp > 0.0f)
{ {
drawHpBar(painter, bboxRect.left(), bboxRect.bottom() + 1.0, const float fraction = std::max(0.0f, h.hp / h.maxHp);
bboxRect.width(), h.hp / h.maxHp, f.isEnemy); 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),
QColor(60, 60, 60));
painter.fillRect(QRectF(bboxRect.left(), barY, barW * static_cast<qreal>(fraction), barH),
f.isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60));
} }
}); });
} }
@@ -1209,24 +1046,18 @@ void GameWorldView::drawShips(QPainter& painter)
if (h.maxHp > 0.0f) if (h.maxHp > 0.0f)
{ {
const float fraction = std::max(0.0f, h.hp / h.maxHp);
const qreal barW = static_cast<qreal>(fwd) * 2.0; const qreal barW = static_cast<qreal>(fwd) * 2.0;
const qreal barH = static_cast<qreal>(tilePx()) * 0.12;
const qreal barX = center.x() - static_cast<qreal>(fwd); const qreal barX = center.x() - static_cast<qreal>(fwd);
const qreal barY = center.y() + static_cast<qreal>(fwd) + 1.0; const qreal barY = center.y() + static_cast<qreal>(fwd) + 1.0;
drawHpBar(painter, barX, barY, barW, h.hp / h.maxHp, fac.isEnemy); painter.fillRect(QRectF(barX, barY, barW, barH), QColor(60, 60, 60));
painter.fillRect(QRectF(barX, barY, barW * static_cast<qreal>(fraction), barH),
fac.isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60));
} }
}); });
} }
void GameWorldView::drawHpBar(QPainter& painter, qreal left, qreal top, qreal width,
float fraction, bool isEnemy)
{
const qreal barH = static_cast<qreal>(tilePx()) * 0.12;
const float clamped = std::max(0.0f, fraction);
painter.fillRect(QRectF(left, top, width, barH), QColor(60, 60, 60));
painter.fillRect(QRectF(left, top, width * static_cast<qreal>(clamped), barH),
isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60));
}
void GameWorldView::drawDebugSensorRanges(QPainter& painter) void GameWorldView::drawDebugSensorRanges(QPainter& painter)
{ {
painter.setBrush(Qt::NoBrush); painter.setBrush(Qt::NoBrush);
@@ -1392,28 +1223,8 @@ void GameWorldView::drawOverlays(QPainter& painter)
} }
} }
// Demolish tint: while dragging a demolish box, tint every covered // Demolish hover tint
// building/site (REQ-BLD-DEMOLISH-BOX); otherwise tint the hovered one. if (m_demolishMode && m_demolishHoverBuildingId != kInvalidBuildingId)
if (m_demolishMode && m_boxSelecting)
{
for (BuildingId id : buildingsInBox(m_boxStartTile, m_boxCurrentTile))
{
const Building* b = m_sim->buildings().findBuilding(id);
if (b && b->type == BuildingType::Hq) { continue; }
const std::vector<QPoint>* cells = nullptr;
const ConstructionSite* s = nullptr;
if (b) { cells = &b->bodyCells; }
else if ((s = m_sim->buildings().findSite(id))) { cells = &s->bodyCells; }
if (cells)
{
for (const QPoint& cell : *cells)
{
painter.fillRect(tileRect(cell), m_visuals->overlays.demolishTint);
}
}
}
}
else if (m_demolishMode && m_demolishHoverBuildingId != kInvalidBuildingId)
{ {
const Building* b = m_sim->buildings().findBuilding(m_demolishHoverBuildingId); const Building* b = m_sim->buildings().findBuilding(m_demolishHoverBuildingId);
if (b) if (b)
@@ -1642,13 +1453,6 @@ void GameWorldView::keyPressEvent(QKeyEvent* event)
else if (m_blueprintMode.has_value()) { exitBlueprintMode(); } else if (m_blueprintMode.has_value()) { exitBlueprintMode(); }
else { toggleDemolishMode(); } else { toggleDemolishMode(); }
break; break;
case Qt::Key_T:
// Request a temporary blueprint from the current selection (REQ-UI-BLUEPRINT-TEMP).
// The BlueprintPanel owns the selection and blueprint-capture logic; it decides
// whether anything placeable is selected and drives placement mode from there.
EventManager::getInstance()->sendEventImmediately(
std::make_shared<TemporaryBlueprintRequestedEvent>());
break;
case Qt::Key_Escape: case Qt::Key_Escape:
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<EscapeMenuRequestedEvent>()); std::make_shared<EscapeMenuRequestedEvent>());
@@ -1676,8 +1480,6 @@ void GameWorldView::keyReleaseEvent(QKeyEvent* event)
} }
if (event->key() == Qt::Key_A) { m_scrollLeft = false; } if (event->key() == Qt::Key_A) { m_scrollLeft = false; }
if (event->key() == Qt::Key_D) { m_scrollRight = false; } if (event->key() == Qt::Key_D) { m_scrollRight = false; }
// Releasing Shift discards the copied building settings (REQ-BLD-COPY-CONFIG).
if (event->key() == Qt::Key_Shift) { m_copiedConfig.reset(); }
QOpenGLWidget::keyReleaseEvent(event); QOpenGLWidget::keyReleaseEvent(event);
} }
@@ -1690,15 +1492,6 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
if (m_builderType.has_value()) { exitBuilderMode(); } if (m_builderType.has_value()) { exitBuilderMode(); }
else if (m_blueprintMode.has_value()) { exitBlueprintMode(); } else if (m_blueprintMode.has_value()) { exitBlueprintMode(); }
else if (m_demolishMode) { toggleDemolishMode(); } else if (m_demolishMode) { toggleDemolishMode(); }
else if (event->modifiers() & Qt::ShiftModifier)
{
// Shift + right-click copies a building's settings, but only in the
// default selection mode (REQ-BLD-COPY-CONFIG).
const QPoint tile = widgetToTile(event->pos());
BuildingId id = buildingAtTile(tile);
if (id == kInvalidBuildingId) { id = siteAtTile(tile); }
if (id != kInvalidBuildingId) { copyConfigFrom(id); }
}
} }
return; return;
} }
@@ -1725,28 +1518,27 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
} }
else if (m_demolishMode) else if (m_demolishMode)
{ {
// Start a demolish box drag; a plain click resolves as a 1x1 box on BuildingId hovered = buildingAtTile(tile);
// release (REQ-BLD-DEMOLISH-CLICK, REQ-BLD-DEMOLISH-BOX). if (hovered == kInvalidBuildingId)
m_boxSelecting = true; {
m_boxStartTile = tile; hovered = siteAtTile(tile);
m_boxCurrentTile = tile; }
if (hovered != kInvalidBuildingId)
{
const Building* b = m_sim->buildings().findBuilding(hovered);
const bool isProtected = b && b->type == BuildingType::Hq;
if (!isProtected)
{
std::shared_ptr<DemolishCommand> command =
std::make_shared<DemolishCommand>();
command->id = hovered;
enqueueCommand(command);
m_demolishHoverBuildingId = kInvalidBuildingId;
}
}
} }
else else
{ {
// Shift + left-click applies the copied settings to a same-type building
// (REQ-BLD-COPY-CONFIG). Consumes the click so it does not change the
// selection. Only active in the default selection mode.
if ((event->modifiers() & Qt::ShiftModifier) && m_copiedConfig.has_value())
{
BuildingId id = buildingAtTile(tile);
if (id == kInvalidBuildingId) { id = siteAtTile(tile); }
if (id != kInvalidBuildingId)
{
pasteConfigTo(id);
return;
}
}
const QVector2D worldPos = widgetToWorld(event->pos()); const QVector2D worldPos = widgetToWorld(event->pos());
const entt::entity hitEntity = entityAtWorldPos(m_sim->admin(), worldPos); const entt::entity hitEntity = entityAtWorldPos(m_sim->admin(), worldPos);
@@ -1831,7 +1623,6 @@ void GameWorldView::mouseMoveEvent(QMouseEvent* event)
else if (m_demolishMode) else if (m_demolishMode)
{ {
m_demolishHoverBuildingId = buildingAtTile(tile); m_demolishHoverBuildingId = buildingAtTile(tile);
if (m_boxSelecting) { m_boxCurrentTile = tile; }
} }
else if (m_boxSelecting) else if (m_boxSelecting)
{ {
@@ -1853,33 +1644,44 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
{ {
m_boxSelecting = false; m_boxSelecting = false;
const std::vector<BuildingId> boxIds = const int x0 = std::min(m_boxStartTile.x(), m_boxCurrentTile.x());
buildingsInBox(m_boxStartTile, m_boxCurrentTile); const int y0 = std::min(m_boxStartTile.y(), m_boxCurrentTile.y());
const int x1 = std::max(m_boxStartTile.x(), m_boxCurrentTile.x());
const int y1 = std::max(m_boxStartTile.y(), m_boxCurrentTile.y());
if (m_demolishMode) std::vector<BuildingId> boxSel;
for (const Building& b : m_sim->buildings().allBuildings())
{ {
// Demolish every covered building/site; the HQ is protected for (const QPoint& cell : b.bodyCells)
// (REQ-BLD-DEMOLISH, REQ-BLD-DEMOLISH-BOX).
for (BuildingId id : boxIds)
{ {
const Building* b = m_sim->buildings().findBuilding(id); if (cell.x() >= x0 && cell.x() <= x1
if (b && b->type == BuildingType::Hq) { continue; } && cell.y() >= y0 && cell.y() <= y1)
std::shared_ptr<DemolishCommand> command = {
std::make_shared<DemolishCommand>(); boxSel.push_back(b.id);
command->id = id; break;
enqueueCommand(command); }
}
}
for (const ConstructionSite& s : m_sim->buildings().allSites())
{
for (const QPoint& cell : s.bodyCells)
{
if (cell.x() >= x0 && cell.x() <= x1
&& cell.y() >= y0 && cell.y() <= y1)
{
boxSel.push_back(s.id);
break;
}
} }
m_demolishHoverBuildingId = kInvalidBuildingId;
return;
} }
if (!(event->modifiers() & Qt::ControlModifier)) if (!(event->modifiers() & Qt::ControlModifier))
{ {
m_selectedBuildingIds = boxIds; m_selectedBuildingIds = boxSel;
} }
else else
{ {
for (BuildingId id : boxIds) for (BuildingId id : boxSel)
{ {
bool found = false; bool found = false;
for (BuildingId sel : m_selectedBuildingIds) for (BuildingId sel : m_selectedBuildingIds)
@@ -1952,85 +1754,6 @@ void GameWorldView::rotateGhost(bool clockwise)
} }
} }
void GameWorldView::copyConfigFrom(BuildingId id)
{
const std::optional<BuildingConfig> config = readBuildingConfig(*m_sim, id);
if (!config.has_value()) { return; }
// Only cache when there is something to copy: a selected recipe / schematic
// (Miner, Assembler, Shipyard) or any Splitter (empty filters = accept-all).
// Building types with no settings (Smelter, Reprocessing Plant, Salvage Bay,
// belts / tunnels, HQ) leave any existing cache untouched (REQ-BLD-COPY-CONFIG).
if (!config->recipeId.has_value() && !config->isSplitter) { return; }
m_copiedConfig = config;
m_copyConfigFlashes.push_back({ id, kCopyFlashDurationMs });
}
void GameWorldView::pasteConfigTo(BuildingId id)
{
if (!m_copiedConfig.has_value()) { return; }
const std::optional<BuildingConfig> target = readBuildingConfig(*m_sim, id);
if (!target.has_value() || target->type != m_copiedConfig->type) { return; }
// The paste applies below; flash the target to confirm (REQ-BLD-COPY-CONFIG-FEEDBACK).
m_copyConfigFlashes.push_back({ id, kCopyFlashDurationMs });
const BuildingConfig& source = *m_copiedConfig;
// The cached settings were valid on a same-type source building, so they are
// valid and available on the target: unlock state is global, so a selected
// recipe / schematic (REQ-LOCK-UI-RECIPE, REQ-LOCK-UI-SCHEMATIC) and splitter
// filter item types (REQ-LOCK-UI-SPLITTER) remain unlocked. Applying reuses the
// same configuration commands as the selected building panel, inheriting their
// buffer-clearing and mid-cycle-cancel semantics (REQ-MAT-INPUT-BUFFER,
// REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD).
if (source.isSplitter)
{
// Operational splitters are configured by tile; sites by BuildingId
// (mirrors SelectedBuildingPanel::onSplitterFilterChanged).
if (const Building* building = m_sim->buildings().findBuilding(id))
{
std::shared_ptr<SetSplitterFiltersCommand> command =
std::make_shared<SetSplitterFiltersCommand>();
command->tile = building->anchor;
command->filterA = source.splitterFilterA;
command->filterB = source.splitterFilterB;
enqueueCommand(command);
}
else
{
std::shared_ptr<SetSiteSplitterFiltersCommand> command =
std::make_shared<SetSiteSplitterFiltersCommand>();
command->id = id;
command->filterA = source.splitterFilterA;
command->filterB = source.splitterFilterB;
enqueueCommand(command);
}
return;
}
if (source.recipeId.has_value())
{
std::shared_ptr<SetRecipeCommand> command = std::make_shared<SetRecipeCommand>();
command->id = id;
command->recipeId = *source.recipeId;
enqueueCommand(command);
}
// For a shipyard the schematic (above) must be applied before its module
// layout, so both commands drain in order at the next tick boundary.
if (source.type == BuildingType::Shipyard && source.shipLayout.has_value())
{
std::shared_ptr<SetShipLayoutCommand> command =
std::make_shared<SetShipLayoutCommand>();
command->id = id;
command->layout = *source.shipLayout;
enqueueCommand(command);
}
}
void GameWorldView::enterBuilderMode(BuildingType type) void GameWorldView::enterBuilderMode(BuildingType type)
{ {
m_builderType = type; m_builderType = type;
@@ -2103,8 +1826,6 @@ void GameWorldView::resetForNewGame()
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<DemolishModeChangedEvent>(false)); std::make_shared<DemolishModeChangedEvent>(false));
m_selectedBuildingIds.clear(); m_selectedBuildingIds.clear();
m_copiedConfig = std::nullopt;
m_copyConfigFlashes.clear();
m_boxSelecting = false; m_boxSelecting = false;
m_scrollXTiles = 0.0f; m_scrollXTiles = 0.0f;
m_scrollLeft = false; m_scrollLeft = false;

View File

@@ -14,7 +14,6 @@
#include <QVector2D> #include <QVector2D>
#include "Blueprint.h" #include "Blueprint.h"
#include "BuildingConfig.h"
#include "BlueprintModeExitedEvent.h" #include "BlueprintModeExitedEvent.h"
#include "BlueprintPlacementRequestedEvent.h" #include "BlueprintPlacementRequestedEvent.h"
#include "BuilderModeExitedEvent.h" #include "BuilderModeExitedEvent.h"
@@ -116,14 +115,10 @@ private:
void drawTiles(QPainter& painter); void drawTiles(QPainter& painter);
void drawBuildings(QPainter& painter); void drawBuildings(QPainter& painter);
void drawSelectionHighlights(QPainter& painter);
void drawCopyConfigFeedback(QPainter& painter);
void drawStations(QPainter& painter); void drawStations(QPainter& painter);
void drawBeltItems(QPainter& painter); void drawBeltItems(QPainter& painter);
void drawScrap(QPainter& painter); void drawScrap(QPainter& painter);
void drawShips(QPainter& painter); void drawShips(QPainter& painter);
void drawHpBar(QPainter& painter, qreal left, qreal top, qreal width,
float fraction, bool isEnemy);
void drawDebugSensorRanges(QPainter& painter); void drawDebugSensorRanges(QPainter& painter);
void drawDebugTargetLines(QPainter& painter); void drawDebugTargetLines(QPainter& painter);
void drawDebugOverlay(QPainter& painter); void drawDebugOverlay(QPainter& painter);
@@ -139,24 +134,15 @@ private:
QPoint widgetToTile(QPoint widgetPt) const; QPoint widgetToTile(QPoint widgetPt) const;
QRectF tileRect(QPoint tile) const; QRectF tileRect(QPoint tile) const;
QRect viewportRect() const; QRect viewportRect() const;
// Widget-space rectangle covering a building or construction site's footprint,
// or nullopt if the id resolves to neither. Shared by the selection highlight
// and the copy-settings feedback (REQ-BLD-COPY-CONFIG-FEEDBACK).
std::optional<QRectF> footprintWidgetRect(BuildingId id) const;
float asteroidLeftEdge() const; float asteroidLeftEdge() const;
float enemyStationRightEdge() const; float enemyStationRightEdge() const;
// Horizontal pan speed at a given view-center X, in tiles/s (REQ-UI-SCROLL-SPEED).
float panSpeedTilesPerSecondAt(float viewCenterXTiles) const;
void clampScroll(); void clampScroll();
bool isValidPlacement(BuildingType type, QPoint anchor, Rotation rot) const; bool isValidPlacement(BuildingType type, QPoint anchor, Rotation rot) const;
const BuildingDef* findBuildingDef(BuildingType type) const; const BuildingDef* findBuildingDef(BuildingType type) const;
BuildingId buildingAtTile(QPoint tile) const; BuildingId buildingAtTile(QPoint tile) const;
BuildingId siteAtTile(QPoint tile) const; BuildingId siteAtTile(QPoint tile) const;
// Ids of all buildings and construction sites whose footprint intersects
// the tile box spanned by the two (unordered) corner tiles.
std::vector<BuildingId> buildingsInBox(QPoint cornerA, QPoint cornerB) const;
QVector2D widgetToWorld(QPoint widgetPt) const; QVector2D widgetToWorld(QPoint widgetPt) const;
void drawPortGlyph(QPainter& painter, QPoint bodyTile, void drawPortGlyph(QPainter& painter, QPoint bodyTile,
@@ -171,12 +157,6 @@ private:
void stepSpeed(int delta); void stepSpeed(int delta);
void placeAtTile(QPoint tile); void placeAtTile(QPoint tile);
// Copy-settings gesture (REQ-BLD-COPY-CONFIG): Shift+right-click copies a
// building's configuration into m_copiedConfig; Shift+left-click applies it to
// another building of the same type via the existing configuration commands.
void copyConfigFrom(BuildingId id);
void pasteConfigTo(BuildingId id);
void enterBuilderMode(BuildingType type); void enterBuilderMode(BuildingType type);
void exitBuilderMode(); void exitBuilderMode();
void enterBlueprintMode(Blueprint blueprint); void enterBlueprintMode(Blueprint blueprint);
@@ -193,6 +173,7 @@ private:
// Beam lifetime in game ticks so beams freeze with the simulation when // Beam lifetime in game ticks so beams freeze with the simulation when
// paused or slowed, instead of fading on wall-clock time (REQ-SHP-FIRING-BEAM). // paused or slowed, instead of fading on wall-clock time (REQ-SHP-FIRING-BEAM).
static constexpr Tick kBeamLifetimeTicks = secondsToTicks(0.3); static constexpr Tick kBeamLifetimeTicks = secondsToTicks(0.3);
static constexpr float kScrollSpeedTilesPerSec = 10.0f;
Simulation* m_sim; Simulation* m_sim;
const GameConfig* m_config; const GameConfig* m_config;
@@ -227,22 +208,6 @@ private:
std::optional<Blueprint> m_blueprintMode; std::optional<Blueprint> m_blueprintMode;
QPoint m_blueprintGhostTile; QPoint m_blueprintGhostTile;
// Temporary cache for the copy-settings gesture (REQ-BLD-COPY-CONFIG); held
// only while Shift is down and cleared on Shift release.
std::optional<BuildingConfig> m_copiedConfig;
// Brief outline flash shown on a building when settings are copied from it or
// pasted onto it (REQ-BLD-COPY-CONFIG-FEEDBACK). remainingMs counts down in
// wall-clock time so the flash plays at a fixed length regardless of game speed
// (and while paused).
struct CopyConfigFlash
{
BuildingId id;
qint64 remainingMs;
};
std::vector<CopyConfigFlash> m_copyConfigFlashes;
static constexpr qint64 kCopyFlashDurationMs = 300;
bool m_demolishMode; bool m_demolishMode;
BuildingId m_demolishHoverBuildingId; BuildingId m_demolishHoverBuildingId;
bool m_debugDraw; bool m_debugDraw;

View File

@@ -142,7 +142,7 @@ void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEven
const double prevSpeed = m_gameWorldView->gameSpeed(); const double prevSpeed = m_gameWorldView->gameSpeed();
m_gameWorldView->setGameSpeed(0.0); m_gameWorldView->setGameSpeed(0.0);
SchematicChoiceDialog dialog(event->choices, m_sim->config().recipes, this); SchematicChoiceDialog dialog(event->choices, this);
dialog.exec(); dialog.exec();
std::shared_ptr<ApplySchematicChoiceCommand> command = std::shared_ptr<ApplySchematicChoiceCommand> command =

View File

@@ -12,7 +12,6 @@
#include "DisplayName.h" #include "DisplayName.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "RecipesConfig.h" #include "RecipesConfig.h"
#include "RecipeTooltip.h"
#include "ShipsConfig.h" #include "ShipsConfig.h"
#include "Simulation.h" #include "Simulation.h"
@@ -26,6 +25,39 @@ QString itemLine(const std::string& itemId, int amount)
+ QStringLiteral(" ×") + QString::number(amount); + QStringLiteral(" ×") + QString::number(amount);
} }
QString recipeTooltip(const RecipeDef& recipe)
{
QStringList lines;
lines << QObject::tr("Recipe: %1")
.arg(QString::fromStdString(toDisplayName(recipe.id)));
if (recipe.inputs.empty())
{
lines << QObject::tr("Inputs: none");
}
else
{
lines << QObject::tr("Inputs:");
for (const RecipeIngredient& ingredient : recipe.inputs)
{
lines << itemLine(ingredient.item, ingredient.amount);
}
}
lines << QObject::tr("Completion time: %1 s").arg(recipe.durationSeconds);
if (!recipe.outputs.empty())
{
lines << QObject::tr("Produces:");
for (const RecipeOutput& output : recipe.outputs)
{
lines << itemLine(output.item, output.amount);
}
}
return lines.join('\n');
}
QString shipTooltip(const ShipDef& def) QString shipTooltip(const ShipDef& def)
{ {
const QString name = QString::fromStdString(toDisplayName(def.id)); const QString name = QString::fromStdString(toDisplayName(def.id));
@@ -84,7 +116,7 @@ std::vector<RecipeSelectionOption> buildRecipeSelectionOptions(
} }
options.push_back({recipe.id, options.push_back({recipe.id,
QString::fromStdString(toDisplayName(recipe.id)), QString::fromStdString(toDisplayName(recipe.id)),
buildRecipeTooltip(recipe)}); recipeTooltip(recipe)});
} }
} }

View File

@@ -1,52 +0,0 @@
#include "RecipeTooltip.h"
#include <QObject>
#include <QStringList>
#include "DisplayName.h"
#include "RecipesConfig.h"
namespace
{
QString itemLine(const std::string& itemId, int amount)
{
return QStringLiteral(" ")
+ QString::fromStdString(toDisplayName(itemId))
+ QStringLiteral(" ×") + QString::number(amount);
}
} // namespace
QString buildRecipeTooltip(const RecipeDef& recipe)
{
QStringList lines;
lines << QObject::tr("Recipe: %1")
.arg(QString::fromStdString(toDisplayName(recipe.id)));
if (recipe.inputs.empty())
{
lines << QObject::tr("Inputs: none");
}
else
{
lines << QObject::tr("Inputs:");
for (const RecipeIngredient& ingredient : recipe.inputs)
{
lines << itemLine(ingredient.item, ingredient.amount);
}
}
lines << QObject::tr("Completion time: %1 s").arg(recipe.durationSeconds);
if (!recipe.outputs.empty())
{
lines << QObject::tr("Produces:");
for (const RecipeOutput& output : recipe.outputs)
{
lines << itemLine(output.item, output.amount);
}
}
return lines.join('\n');
}

View File

@@ -1,11 +0,0 @@
#pragma once
#include <QString>
struct RecipeDef;
// Builds the recipe info tooltip text (REQ-UI-SELECT-TOOLTIP): the recipe name,
// each input item name and quantity, the completion time, and the produced
// output item name and quantity. Shared by the recipe-selection dialog and the
// schematic choice dialog's "Unlocks recipes:" list so both render identically.
QString buildRecipeTooltip(const RecipeDef& recipe);

View File

@@ -3,29 +3,11 @@
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QLabel> #include <QLabel>
#include <QPushButton> #include <QPushButton>
#include <QStringList>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "DisplayName.h"
#include "RecipeTooltip.h"
#include "RecipesConfig.h"
namespace
{
const RecipeDef* findRecipe(const RecipesConfig& recipes, const std::string& id)
{
for (const RecipeDef& recipe : recipes.recipes)
{
if (recipe.id == id) { return &recipe; }
}
return nullptr;
}
} // namespace
SchematicChoiceDialog::SchematicChoiceDialog( SchematicChoiceDialog::SchematicChoiceDialog(
const std::vector<SchematicChoiceOption>& options, const std::vector<SchematicChoiceOption>& options,
const RecipesConfig& recipes,
QWidget* parent) QWidget* parent)
: QDialog(parent) : QDialog(parent)
, m_chosenIndex(0) , m_chosenIndex(0)
@@ -88,33 +70,30 @@ SchematicChoiceDialog::SchematicChoiceDialog(
typeLabel->setAlignment(Qt::AlignCenter); typeLabel->setAlignment(Qt::AlignCenter);
cardLayout->addWidget(typeLabel); cardLayout->addWidget(typeLabel);
QLabel* unlocksHeaderLabel = new QLabel(tr("Unlocks recipes:"), card); QLabel* unlocksHeaderLabel = new QLabel(tr("Unlocks recipes for:"), card);
QFont unlocksHeaderFont = unlocksHeaderLabel->font(); QFont unlocksHeaderFont = unlocksHeaderLabel->font();
unlocksHeaderFont.setBold(true); unlocksHeaderFont.setBold(true);
unlocksHeaderLabel->setFont(unlocksHeaderFont); unlocksHeaderLabel->setFont(unlocksHeaderFont);
unlocksHeaderLabel->setAlignment(Qt::AlignCenter); unlocksHeaderLabel->setAlignment(Qt::AlignCenter);
cardLayout->addWidget(unlocksHeaderLabel); cardLayout->addWidget(unlocksHeaderLabel);
if (option.newlyUnlockedRecipeIds.empty()) QString unlocksText;
if (option.newlyUnlockedItemNames.empty())
{ {
QLabel* noneLabel = new QLabel(tr("None"), card); unlocksText = tr("None");
noneLabel->setAlignment(Qt::AlignCenter);
cardLayout->addWidget(noneLabel);
} }
else else
{ {
for (const std::string& recipeId : option.newlyUnlockedRecipeIds) QStringList itemLines;
for (const std::string& itemName : option.newlyUnlockedItemNames)
{ {
QLabel* recipeLabel = new QLabel( itemLines << QString::fromStdString(itemName);
QString::fromStdString(toDisplayName(recipeId)), card);
recipeLabel->setAlignment(Qt::AlignCenter);
if (const RecipeDef* def = findRecipe(recipes, recipeId))
{
recipeLabel->setToolTip(buildRecipeTooltip(*def));
}
cardLayout->addWidget(recipeLabel);
} }
unlocksText = itemLines.join("\n");
} }
QLabel* unlocksLabel = new QLabel(unlocksText, card);
unlocksLabel->setAlignment(Qt::AlignCenter);
cardLayout->addWidget(unlocksLabel);
} }
QPushButton* selectButton = new QPushButton(tr("Select"), card); QPushButton* selectButton = new QPushButton(tr("Select"), card);

View File

@@ -6,15 +6,12 @@
#include "SchematicChoiceOption.h" #include "SchematicChoiceOption.h"
struct RecipesConfig;
class SchematicChoiceDialog : public QDialog class SchematicChoiceDialog : public QDialog
{ {
Q_OBJECT Q_OBJECT
public: public:
SchematicChoiceDialog(const std::vector<SchematicChoiceOption>& options, SchematicChoiceDialog(const std::vector<SchematicChoiceOption>& options,
const RecipesConfig& recipes,
QWidget* parent = nullptr); QWidget* parent = nullptr);
int getChosenIndex() const; int getChosenIndex() const;

View File

@@ -33,7 +33,6 @@
#include "ItemType.h" #include "ItemType.h"
#include "LayoutDialogRequestedEvent.h" #include "LayoutDialogRequestedEvent.h"
#include "ModulesConfig.h" #include "ModulesConfig.h"
#include "PlayerCommandsAppliedEvent.h"
#include "RecipeSelectionDialog.h" #include "RecipeSelectionDialog.h"
#include "RecipeSelectionRequestedEvent.h" #include "RecipeSelectionRequestedEvent.h"
#include "Rotation.h" #include "Rotation.h"
@@ -295,12 +294,38 @@ void SelectedBuildingPanel::buildSingle(BuildingId id)
} }
m_recipeSelectButton->show(); m_recipeSelectButton->show();
updateShipyardLayoutWidgets(type, recipeId, shipLayout); if (type == BuildingType::Shipyard && !recipeId.empty())
{
const ShipDef* sDef = findShipDef(recipeId);
if (sDef && !sDef->layout.empty())
{
ShipLayoutConfig layout;
if (shipLayout.has_value())
{
layout = *shipLayout;
}
m_layoutPreview->setShipAndLayout(
sDef->layout, layout, &m_config->modules.modules);
m_layoutPreview->show();
m_configureLayoutBtn->show();
}
else
{
m_layoutPreview->hide();
m_configureLayoutBtn->hide();
}
}
else
{
m_layoutPreview->hide();
m_configureLayoutBtn->hide();
}
} }
else else
{ {
m_recipeSelectButton->hide(); m_recipeSelectButton->hide();
updateShipyardLayoutWidgets(type, recipeId, shipLayout); m_layoutPreview->hide();
m_configureLayoutBtn->hide();
} }
// Belt "Clear" removes items from a live belt tile; a construction site has // Belt "Clear" removes items from a live belt tile; a construction site has
@@ -338,16 +363,6 @@ void SelectedBuildingPanel::buildSingle(BuildingId id)
if (m_singleIsSite) if (m_singleIsSite)
{ {
refreshSiteProgress(s);
}
else
{
refreshBuffers(b);
}
}
void SelectedBuildingPanel::refreshSiteProgress(const ConstructionSite* s)
{
QString progress; QString progress;
if (s->completesAt == 0) if (s->completesAt == 0)
{ {
@@ -374,6 +389,11 @@ void SelectedBuildingPanel::refreshSiteProgress(const ConstructionSite* s)
} }
} }
m_buffersLabel->setText(progress); m_buffersLabel->setText(progress);
}
else
{
refreshBuffers(b);
}
} }
void SelectedBuildingPanel::refreshBuffers(const Building* b) void SelectedBuildingPanel::refreshBuffers(const Building* b)
@@ -510,38 +530,15 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
m_buffersLabel->setText(bufText); m_buffersLabel->setText(bufText);
// The recipe/schematic is applied via a queued command that only drains on a if (b->type == BuildingType::Shipyard && shipDef && !shipDef->layout.empty())
// later frame, so the per-tick refresh must own the shipyard preview and the
// Configure Layout button's visibility; otherwise they stay hidden until the
// building is re-selected (which re-runs buildSingle).
updateShipyardLayoutWidgets(b->type, b->recipeId, b->shipLayout);
}
void SelectedBuildingPanel::updateShipyardLayoutWidgets(
BuildingType type,
const std::string& recipeId,
const std::optional<ShipLayoutConfig>& shipLayout)
{
const ShipDef* shipDef = (type == BuildingType::Shipyard)
? findShipDef(recipeId)
: nullptr;
if (shipDef && !shipDef->layout.empty())
{ {
ShipLayoutConfig layout; ShipLayoutConfig layout;
if (shipLayout.has_value()) if (b->shipLayout.has_value())
{ {
layout = *shipLayout; layout = *b->shipLayout;
} }
m_layoutPreview->setShipAndLayout( m_layoutPreview->setShipAndLayout(
shipDef->layout, layout, &m_config->modules.modules); shipDef->layout, layout, &m_config->modules.modules);
m_layoutPreview->show();
m_configureLayoutBtn->show();
}
else
{
m_layoutPreview->hide();
m_configureLayoutBtn->hide();
} }
} }
@@ -566,21 +563,6 @@ const ShipDef* SelectedBuildingPanel::findShipDef(const std::string& id) const
} }
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const TickAdvancedEvent> /*event*/) void SelectedBuildingPanel::handleEvent(std::shared_ptr<const TickAdvancedEvent> /*event*/)
{
refreshSelectionDisplay(RefreshReason::PeriodicTick);
}
void SelectedBuildingPanel::handleEvent(
std::shared_ptr<const PlayerCommandsAppliedEvent> /*event*/)
{
// Player commands (e.g. choosing a shipyard schematic) are applied by a
// queued drain, not synchronously. When the game is paused no tick advances,
// so TickAdvancedEvent never fires; refresh here too, otherwise the panel
// would not reflect the change until the next tick or a re-selection.
refreshSelectionDisplay(RefreshReason::CommandApplied);
}
void SelectedBuildingPanel::refreshSelectionDisplay(RefreshReason reason)
{ {
if (m_selectedEntity.has_value()) if (m_selectedEntity.has_value())
{ {
@@ -604,19 +586,8 @@ void SelectedBuildingPanel::refreshSelectionDisplay(RefreshReason reason)
} }
const ConstructionSite* s = m_sim->buildings().findSite(m_singleBuildingId); const ConstructionSite* s = m_sim->buildings().findSite(m_singleBuildingId);
if (s) if (s)
{
// A periodic tick only advances construction progress, so update just the
// progress label. Rebuilding every tick would hide/re-show all widgets and
// cancel any in-progress click on the recipe button. An applied command
// may have changed the site's recipe/layout, so rebuild in that case.
if (reason == RefreshReason::CommandApplied)
{ {
rebuild(); rebuild();
}
else
{
refreshSiteProgress(s);
}
return; return;
} }
buildEmpty(); buildEmpty();
@@ -676,11 +647,9 @@ void SelectedBuildingPanel::onSelectRecipeClicked()
return; return;
} }
// The emit is synchronous: MainWindow pauses the game, runs the modal // The emit is synchronous: MainWindow pauses the game, runs the modal
// selection dialog, and restores the speed before this returns. The chosen // selection dialog, applies the chosen recipe/schematic, and restores the
// recipe/schematic is only *enqueued* as a command, though, and drains on a // speed before this returns. rebuild() then refreshes the button caption,
// later frame -- so this rebuild() still sees the old recipe. The per-tick // tooltip, preview, and buffers for the new selection.
// refreshBuffers() path picks up the new schematic (and shows the layout
// preview + Configure Layout button) once the command has been applied.
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<RecipeSelectionRequestedEvent>(m_singleBuildingId)); std::make_shared<RecipeSelectionRequestedEvent>(m_singleBuildingId));
rebuild(); rebuild();

View File

@@ -16,7 +16,6 @@
#include "EntitySelectedEvent.h" #include "EntitySelectedEvent.h"
#include "EventHandler.h" #include "EventHandler.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "PlayerCommandsAppliedEvent.h"
#include "RecipesConfig.h" #include "RecipesConfig.h"
#include "SelectionChangedEvent.h" #include "SelectionChangedEvent.h"
#include "ShipLayout.h" #include "ShipLayout.h"
@@ -34,7 +33,6 @@ class QVBoxLayout;
class SelectedBuildingPanel : public QWidget, class SelectedBuildingPanel : public QWidget,
public CombinedEventHandler<TickAdvancedEvent, public CombinedEventHandler<TickAdvancedEvent,
PlayerCommandsAppliedEvent,
EntitySelectedEvent, EntitySelectedEvent,
SelectionChangedEvent, SelectionChangedEvent,
DebugDrawToggledEvent> DebugDrawToggledEvent>
@@ -48,7 +46,6 @@ public:
private: private:
void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override; void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override;
void handleEvent(std::shared_ptr<const PlayerCommandsAppliedEvent> event) override;
void handleEvent(std::shared_ptr<const EntitySelectedEvent> event) override; void handleEvent(std::shared_ptr<const EntitySelectedEvent> event) override;
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override; void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event) override; void handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event) override;
@@ -59,18 +56,7 @@ private slots:
void onSplitterFilterChanged(); void onSplitterFilterChanged();
private: private:
// Why the selection display is being refreshed. A periodic tick only needs a
// lightweight content update (e.g. a construction site's progress label),
// whereas an applied player command may have changed the configuration and
// needs a full structural rebuild.
enum class RefreshReason
{
PeriodicTick,
CommandApplied
};
void onSelectionChanged(const std::vector<BuildingId>& ids); void onSelectionChanged(const std::vector<BuildingId>& ids);
void refreshSelectionDisplay(RefreshReason reason);
void rebuild(); void rebuild();
void hideAllWidgets(); void hideAllWidgets();
void clearContent(); void clearContent();
@@ -78,10 +64,6 @@ private:
void buildSingle(BuildingId id); void buildSingle(BuildingId id);
void buildMulti(const std::vector<BuildingId>& ids); void buildMulti(const std::vector<BuildingId>& ids);
void refreshBuffers(const Building* b); void refreshBuffers(const Building* b);
void refreshSiteProgress(const ConstructionSite* s);
void updateShipyardLayoutWidgets(BuildingType type,
const std::string& recipeId,
const std::optional<ShipLayoutConfig>& shipLayout);
void buildSplitterFilters(const std::optional<BeltSystem::SplitterInfo>& info); void buildSplitterFilters(const std::optional<BeltSystem::SplitterInfo>& info);
const RecipeDef* findRecipe(const Building* b) const; const RecipeDef* findRecipe(const Building* b) const;
const ShipDef* findShipDef(const std::string& id) const; const ShipDef* findShipDef(const std::string& id) const;

View File

@@ -48,7 +48,6 @@ struct OverlayVisuals
QColor selectionRect; QColor selectionRect;
QColor tileHighlight; QColor tileHighlight;
QColor selectedOutline; QColor selectedOutline;
QColor copyConfig;
}; };
struct ToastVisuals struct ToastVisuals

View File

@@ -224,7 +224,6 @@ VisualsConfig VisualsLoader::load(const std::string& path)
cfg.overlays.selectionRect = parseColor(requireString(ov, "selection_rect", "overlays"), "overlays.selection_rect"); cfg.overlays.selectionRect = parseColor(requireString(ov, "selection_rect", "overlays"), "overlays.selection_rect");
cfg.overlays.tileHighlight = parseColor(requireString(ov, "tile_highlight", "overlays"), "overlays.tile_highlight"); cfg.overlays.tileHighlight = parseColor(requireString(ov, "tile_highlight", "overlays"), "overlays.tile_highlight");
cfg.overlays.selectedOutline = parseColor(requireString(ov, "selected_outline", "overlays"), "overlays.selected_outline"); cfg.overlays.selectedOutline = parseColor(requireString(ov, "selected_outline", "overlays"), "overlays.selected_outline");
cfg.overlays.copyConfig = parseColor(requireString(ov, "copy_config", "overlays"), "overlays.copy_config");
} }
// Toast // Toast