30 Commits

Author SHA1 Message Date
271855781c add tag based version info to exe 2026-07-12 09:16:49 +02:00
5f6ecbf6c8 implement visual feedback for copy building settings with shift + click gesture 2026-07-09 21:33:24 +02:00
cd966daba9 fix issue where shipyard produces ships with modules without requiring items for the modules if the ship's layout was never changed 2026-07-09 21:29:04 +02:00
88bc4f2170 fix bug where selecting the same ship again in a shipyard clears the layout and resets the progress 2026-07-09 21:05:01 +02:00
4986c1bac8 implement copy building settings with shift + click gesture 2026-07-09 20:29:20 +02:00
e110e7e413 Add T hotkey for temporary blueprint from selection 2026-07-09 20:25:15 +02:00
6274b5ce60 List unlocked recipes with tooltips in schematic choice dialog instead of unlocked item names 2026-07-09 20:22:17 +02:00
16ed6a5695 Show schematic id for all three types of drops in choice dialog 2026-07-09 20:18:38 +02:00
13dacab274 remove save and load buttons for factory blueprints to mirror the ship-layout blueprints 2026-07-09 20:15:30 +02:00
2ddf13238c Fix Salvage Bay drop-off not working by adding config-driven buffer capacity 2026-07-09 20:11:12 +02:00
cc38bf95fa make repair_tool less op 2026-07-09 07:20:06 +02:00
b58bcfe272 turn around salvage bay in config 2026-07-09 07:19:48 +02:00
de1ebb8a5f increase scroll speed across contest zone 2026-07-08 22:25:12 +02:00
24c18f8ac6 render HP bar below the HQ 2026-07-08 21:39:11 +02:00
2cedd5d433 fix issue where building selection outline is hidden by other buildings drawn later 2026-07-08 21:29:44 +02:00
f11db0c072 Add demolish box-drag interaction 2026-07-08 21:04:59 +02:00
808e0c6a7b Fix recipe button unclickable on construction site during play 2026-07-08 20:46:26 +02:00
e4ea4ca4b4 Refresh selected-building panel when paused player commands drain 2026-07-08 20:45:41 +02:00
fef22b9f86 Fix shipyard layout preview/button not showing until re-selection 2026-07-08 20:45:25 +02:00
cdf89ce0dd Restructure balancing docs into docs/balancing/ 2026-07-08 20:33:51 +02:00
e8786c3922 implement cost formula for asteroid expansion 2026-07-08 20:33:19 +02:00
fc622670d2 continue first full balancing round 2026-07-08 20:33:11 +02:00
e32d384c99 fix bug where balancing matches did not finish until enemy hq was destroyed 2026-07-08 20:31:33 +02:00
24e0999d8a show total time in balancing target arenas 2026-07-08 20:31:14 +02:00
fcaee000fa continue first full balancing round 2026-07-08 20:30:45 +02:00
751ef27a7b show team EHP in balancing target 2026-07-08 20:29:41 +02:00
3c1376828c implement logging of arena states 2026-07-08 20:29:33 +02:00
bd0675db66 continue first full balancing round 2026-07-08 20:29:23 +02:00
5b86b15c71 Fix ThreatCostCalculator: per-unit division, scrap fallback, fixpoint, staggered-recipe max 2026-07-08 20:28:31 +02:00
c6db4bf24a first full balancing round 2026-07-08 20:27:25 +02:00
68 changed files with 2133 additions and 764 deletions

View File

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

View File

@@ -81,7 +81,8 @@ 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 = [
"SAA", "<AAS",
"SAA>", " AAS",
] ]

View File

@@ -47,7 +47,7 @@ attack_rate_hz = 2.0
[[module]] [[module]]
id = "railgun_m" id = "railgun_m"
unlock_at_station_level = 1 unlock_at_station_level = 2
surface_mask = [ surface_mask = [
"OO", "OO",
"OO"] "OO"]
@@ -57,14 +57,14 @@ fill_color = "#FF8040"
glyph = "Rm" glyph = "Rm"
[module.weapon] [module.weapon]
damage = 17 damage = 14
attack_range_m = 70 attack_range_m = 70
attack_rate_hz = 1.5 attack_rate_hz = 1.5
[[module]] [[module]]
id = "railgun_l" id = "railgun_l"
unlock_at_station_level = 1 unlock_at_station_level = 6
unlock_requires = ["railgun_m"] unlock_requires = ["railgun_m"]
surface_mask = [ surface_mask = [
"OOO", "OOO",
@@ -76,7 +76,7 @@ fill_color = "#FF8040"
glyph = "Rl" glyph = "Rl"
[module.weapon] [module.weapon]
damage = 70 damage = 52
attack_range_m = 100 attack_range_m = 100
attack_rate_hz = 0.8 attack_rate_hz = 0.8
@@ -101,7 +101,7 @@ collection_rate_hz = 0.5
[[module]] [[module]]
id = "repair_tool" id = "repair_tool"
unlock_at_station_level = -1 unlock_at_station_level = 0
surface_mask = ["O"] surface_mask = ["O"]
materials = [{item = "repair_tool_module", amount = 1}] materials = [{item = "repair_tool_module", amount = 1}]
production_time_seconds = 1 production_time_seconds = 1
@@ -110,7 +110,7 @@ glyph = "Rp"
[module.repair] [module.repair]
repair_rate_hz = 1 repair_rate_hz = 1
repair_amount_hp = 25 repair_amount_hp = 4
repair_range_m = 80 repair_range_m = 80
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -119,7 +119,7 @@ repair_range_m = 80
[[module]] [[module]]
id = "afterburner" id = "afterburner"
unlock_at_station_level = -1 unlock_at_station_level = 2
surface_mask = ["OOO"] surface_mask = ["OOO"]
materials = [{item = "afterburner_module", amount = 1}] materials = [{item = "afterburner_module", amount = 1}]
production_time_seconds = 1 production_time_seconds = 1
@@ -133,7 +133,7 @@ added_main_acceleration_mpss = 60
[[module]] [[module]]
id = "maneuvering_thrusters" id = "maneuvering_thrusters"
unlock_at_station_level = -1 unlock_at_station_level = 1
surface_mask = ["OO"] surface_mask = ["OO"]
materials = [{item = "maneuvering_thrusters_module", amount = 1}] materials = [{item = "maneuvering_thrusters_module", amount = 1}]
production_time_seconds = 1 production_time_seconds = 1
@@ -150,7 +150,7 @@ added_maneuvering_acceleration_mpss = 10
[[module]] [[module]]
id = "armor_plates" id = "armor_plates"
unlock_at_station_level = -1 unlock_at_station_level = 0
surface_mask = ["OO"] surface_mask = ["OO"]
materials = [{item = "armor_plates_module", amount = 1}] materials = [{item = "armor_plates_module", amount = 1}]
production_time_seconds = 1 production_time_seconds = 1
@@ -158,12 +158,12 @@ fill_color = "#808080"
glyph = "A" glyph = "A"
[module.health] [module.health]
added_hp = 640 added_hp = 1200
[[module]] [[module]]
id = "sensor_booster" id = "sensor_booster"
unlock_at_station_level = -1 unlock_at_station_level = 1
surface_mask = ["OO"] surface_mask = ["OO"]
materials = [{item = "sensor_booster_module", amount = 1}] materials = [{item = "sensor_booster_module", amount = 1}]
production_time_seconds = 1 production_time_seconds = 1
@@ -179,7 +179,7 @@ added_sensor_range_m = 50
[[module]] [[module]]
id = "weapon_upgrade" id = "weapon_upgrade"
unlock_at_station_level = -1 unlock_at_station_level = 4
surface_mask = [ surface_mask = [
"OO", "OO",
"OX", "OX",
@@ -195,7 +195,7 @@ multiplied_damage = 1.2
[[module]] [[module]]
id = "weapon_primer" id = "weapon_primer"
unlock_at_station_level = -1 unlock_at_station_level = 4
surface_mask = [ surface_mask = [
"OO", "OO",
"OX", "OX",
@@ -211,7 +211,7 @@ multiplied_attack_rate_hz = 1.2
[[module]] [[module]]
id = "weapon_stabilizer" id = "weapon_stabilizer"
unlock_at_station_level = -1 unlock_at_station_level = 3
surface_mask = [ surface_mask = [
"OO", "OO",
"OX", "OX",
@@ -222,7 +222,7 @@ fill_color = "#FF4040"
glyph = "Ws" glyph = "Ws"
[module.weapon] [module.weapon]
multiplied_attack_range_m = 1.5 multiplied_attack_range_m = 1.3
multiplied_attack_rate_hz = 0.8 multiplied_attack_rate_hz = 0.8
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -234,7 +234,7 @@ multiplied_attack_rate_hz = 0.8
[[module]] [[module]]
id = "drone_bay" id = "drone_bay"
unlock_at_station_level = -1 unlock_at_station_level = 5
surface_mask = [ surface_mask = [
"OO", "OO",
"OO"] "OO"]
@@ -246,7 +246,7 @@ glyph = "Db"
[[module]] [[module]]
id = "drone_hangar" id = "drone_hangar"
unlock_at_station_level = -1 unlock_at_station_level = 9
surface_mask = [ surface_mask = [
"OOOOOO", "OOOOOO",
"OOOOOO"] "OOOOOO"]

View File

@@ -1,7 +1,7 @@
# recipes.toml # recipes.toml
# #
# Production tree v2 (see docs/content_design.md, "Production tree v2" and # Production tree v2 (structure in docs/content_design.md, numbers with
# "Numbers — first pass"). Quantities and durations are tuned so that every # derivations in docs/balancing/derived.md). Quantities and durations are tuned so that every
# fitted ship lands on the threat-cost ladder and the ratio curve is # fitted ship lands on the threat-cost ladder and the ratio curve is
# realized: tier 1 ratios are 1:1, tier 2 ratios are 2:3, tier 3+ ratios # realized: tier 1 ratios are 1:1, tier 2 ratios are 2:3, tier 3+ ratios
# are deliberately strange. # are deliberately strange.
@@ -14,7 +14,7 @@
# rock IS ore) # rock IS ore)
# mid + quartz geode deposits in expansion territory # mid + quartz geode deposits in expansion territory
# (deposit gating pending — see action # (deposit gating pending — see action
# item 5 in docs/progression_design.md; # item 5 in docs/balancing/README.md;
# until then quartz mines anywhere) # until then quartz mines anywhere)
# late + voidsteel battle-forged: ONLY from reprocessing # late + voidsteel battle-forged: ONLY from reprocessing
# salvaged scrap, so capital production # salvaged scrap, so capital production
@@ -141,9 +141,12 @@ outputs = [{item = "copper_coil", amount = 1}]
duration_seconds = 1.5 duration_seconds = 1.5
# Depth-3 chain (ore -> ingot -> plate -> block) is the factory's # Depth-3 chain (ore -> ingot -> plate -> block) is the factory's
# doubling-time knob; see the block economy rules in progression_design.md. # doubling-time knob; see the block economy rules in docs/balancing/rules.md.
# Explicitly unlocked at start (-1): building blocks appear in no
# schematic's materials, so implicit unlocking can never reach this recipe.
[[recipe]] [[recipe]]
id = "building_block" id = "building_block"
unlock_at_station_level = -1
building = "assembler" building = "assembler"
inputs = [{item = "steel_plate", amount = 2}] inputs = [{item = "steel_plate", amount = 2}]
outputs = [{item = "building_block", amount = 4}] outputs = [{item = "building_block", amount = 4}]

View File

@@ -84,7 +84,7 @@ sensor_range_m = 200
# mount medium hardware. # mount medium hardware.
[[ship]] [[ship]]
id = "destroyer" id = "destroyer"
unlock_at_station_level = -1 unlock_at_station_level = 0
layout = [ layout = [
"OXOXO", "OXOXO",
"OOOOO", "OOOOO",
@@ -120,7 +120,7 @@ sensor_range_m = 220
# supports; no 3x3 area exists for an l gun. # supports; no 3x3 area exists for an l gun.
[[ship]] [[ship]]
id = "cruiser" id = "cruiser"
unlock_at_station_level = -1 unlock_at_station_level = 2
layout = [ layout = [
"XOOX", "XOOX",
"OOOO", "OOOO",
@@ -158,7 +158,8 @@ sensor_range_m = 250
# stern leave no 3x3 area for an l gun and no 2x6 area for a drone hangar. # stern leave no 3x3 area for an l gun and no 2x6 area for a drone hangar.
[[ship]] [[ship]]
id = "battlecruiser" id = "battlecruiser"
unlock_at_station_level = -1 unlock_at_station_level = 4
unlock_requires = ["cruiser"]
layout = [ layout = [
"OOXXOO", "OOXXOO",
"OOOOOO", "OOOOOO",
@@ -179,7 +180,7 @@ materials = [{item = "battlecruiser_hull", amount = 1}]
production_time_seconds = 5 production_time_seconds = 5
[ship.health] [ship.health]
hp = 2700 hp = 2400
[ship.movement] [ship.movement]
speed_mps = 20 speed_mps = 20
@@ -200,7 +201,8 @@ sensor_range_m = 260
# so no 2x6 drone hangar fits. # so no 2x6 drone hangar fits.
[[ship]] [[ship]]
id = "battleship" id = "battleship"
unlock_at_station_level = 1 unlock_at_station_level = 6
unlock_requires = ["battlecruiser"]
layout = [ layout = [
"XOOOOX", "XOOOOX",
"OOOOOO", "OOOOOO",
@@ -222,7 +224,7 @@ materials = [{item = "battleship_hull", amount = 1}]
production_time_seconds = 6 production_time_seconds = 6
[ship.health] [ship.health]
hp = 7500 hp = 6300
[ship.movement] [ship.movement]
speed_mps = 15 speed_mps = 15
@@ -242,7 +244,7 @@ sensor_range_m = 280
# stay the only hangar hull. Bow and stern strips hold supports. # stay the only hangar hull. Bow and stern strips hold supports.
[[ship]] [[ship]]
id = "dreadnought" id = "dreadnought"
unlock_at_station_level = 2 unlock_at_station_level = 8
unlock_requires = ["battleship"] unlock_requires = ["battleship"]
layout = [ layout = [
"XXXOOOOOXXX", "XXXOOOOOXXX",
@@ -267,7 +269,7 @@ materials = [{item = "dreadnought_hull", amount = 1}]
production_time_seconds = 8 production_time_seconds = 8
[ship.health] [ship.health]
hp = 15500 hp = 24000
[ship.movement] [ship.movement]
speed_mps = 10 speed_mps = 10
@@ -286,7 +288,8 @@ sensor_range_m = 300
# the lower decks hold supports and 2x2 point-defense m guns. # the lower decks hold supports and 2x2 point-defense m guns.
[[ship]] [[ship]]
id = "carrier" id = "carrier"
unlock_at_station_level = -1 unlock_at_station_level = 9
unlock_requires = ["battleship"]
layout = [ layout = [
"XOOOOOOOOX", "XOOOOOOOOX",
"OOOOOOOOOO", "OOOOOOOOOO",
@@ -308,7 +311,7 @@ materials = [{item = "carrier_hull", amount = 1}]
production_time_seconds = 8 production_time_seconds = 8
[ship.health] [ship.health]
hp = 15500 hp = 24000
[ship.movement] [ship.movement]
speed_mps = 10 speed_mps = 10

View File

@@ -1,6 +1,6 @@
# stations.toml # stations.toml
# #
# Combat-pass anchors (see docs/content_design.md, "Combat stat anchors"): # Combat-pass anchors (see docs/balancing/targets.md, "Combat anchors"):
# a fresh player defence station holds one early parity wave unaided; the # a fresh player defence station holds one early parity wave unaided; the
# enemy station at level 0 matches the player station exactly and scales # enemy station at level 0 matches the player station exactly and scales
# with the push level x. Station scrap drops stay authored (pushing rewards # with the push level x. Station scrap drops stay authored (pushing rewards
@@ -22,7 +22,7 @@ surface_mask = [
level = 1 level = 1
hp_formula = "3000" hp_formula = "3000"
damage_formula = "25" damage_formula = "25"
range_m_formula = "200" range_m_formula = "120"
fire_rate_hz_formula = "1" fire_rate_hz_formula = "1"
scrap_drop_formula = "40" scrap_drop_formula = "40"
@@ -33,6 +33,6 @@ surface_mask = [
] ]
hp_formula = "3000 + 1500*x" hp_formula = "3000 + 1500*x"
damage_formula = "25 + 12*x" damage_formula = "25 + 12*x"
range_m_formula = "200" range_m_formula = "120"
fire_rate_hz_formula = "1.0 + 0.1*x" fire_rate_hz_formula = "1.0 + 0.1*x"
scrap_drop_formula = "40 + 30*x" scrap_drop_formula = "40 + 30*x"

View File

@@ -348,6 +348,7 @@ 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

@@ -1,7 +1,7 @@
[world] [world]
height_tiles = 40 height_tiles = 40
refund_percentage = 100 refund_percentage = 100
starting_building_blocks = 1000 starting_building_blocks = 200
scrap_despawn_seconds = 120 scrap_despawn_seconds = 120
scrap_per_threat = 0.25 scrap_per_threat = 0.25
tile_size_m = 10 tile_size_m = 10
@@ -17,9 +17,18 @@ 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
cost_building_blocks = 200 # x = expansions already purchased; ~1 per cycle mid-game, decelerating
# to 2-3 cycles late (docs/balancing/derived.md).
cost_building_blocks_formula = "300 + 50*x + 10*x*x"
[push] [push]
push_expand_columns_tiles = 10 push_expand_columns_tiles = 10
@@ -32,10 +41,13 @@ target_hysteresis = 0.40 # keep current target unless
[artifacts] [artifacts]
artifact_chance_formula = "0.05 * x" # 5% chance per station level artifact_chance_formula = "0.05 * x" # 5% chance per station level
artifact_win_count = 3 artifact_win_count = 5
[waves] [waves]
threat_rate_formula = "x" # Tuned against the factory-size curve (docs/balancing/targets.md, balancing
# targets): stays below the player's achievable military output early,
# crosses it around the late boundary (~cycle 15), overwhelms by ~24.
threat_rate_formula = "2*x + 0.15*x*x"
gap_min_seconds = 15 gap_min_seconds = 15
gap_max_seconds = 45 gap_max_seconds = 45
spawn_duration_seconds = 10 spawn_duration_seconds = 10

View File

@@ -18,7 +18,7 @@
[[arena]] [[arena]]
name = "Mirror: drones 20v20" name = "Mirror: drones 20v20"
height_tiles = 30 height_tiles = 10
player_buffer_width_tiles = 10 player_buffer_width_tiles = 10
contest_zone_width_tiles = 50 contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10 enemy_buffer_width_tiles = 10
@@ -43,7 +43,7 @@ enemy_buffer_width_tiles = 10
[[arena]] [[arena]]
name = "Mirror: cruisers 6v6" name = "Mirror: cruisers 6v6"
height_tiles = 30 height_tiles = 10
player_buffer_width_tiles = 10 player_buffer_width_tiles = 10
contest_zone_width_tiles = 50 contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10 enemy_buffer_width_tiles = 10
@@ -74,7 +74,7 @@ enemy_buffer_width_tiles = 10
[[arena]] [[arena]]
name = "Mirror: battleships 2v2" name = "Mirror: battleships 2v2"
height_tiles = 30 height_tiles = 10
player_buffer_width_tiles = 10 player_buffer_width_tiles = 10
contest_zone_width_tiles = 50 contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10 enemy_buffer_width_tiles = 10
@@ -111,7 +111,7 @@ enemy_buffer_width_tiles = 10
[[arena]] [[arena]]
name = "Drone swarm vs cruisers (462 vs 467)" name = "Drone swarm vs cruisers (462 vs 467)"
height_tiles = 30 height_tiles = 10
player_buffer_width_tiles = 10 player_buffer_width_tiles = 10
contest_zone_width_tiles = 50 contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10 enemy_buffer_width_tiles = 10
@@ -139,7 +139,7 @@ enemy_buffer_width_tiles = 10
[[arena]] [[arena]]
name = "Frigates vs battleship (705 vs 723)" name = "Frigates vs battleship (705 vs 723)"
height_tiles = 30 height_tiles = 10
player_buffer_width_tiles = 10 player_buffer_width_tiles = 10
contest_zone_width_tiles = 50 contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10 enemy_buffer_width_tiles = 10
@@ -171,7 +171,7 @@ enemy_buffer_width_tiles = 10
[[arena]] [[arena]]
name = "Destroyers vs dreadnought (1485 vs 1492)" name = "Destroyers vs dreadnought (1485 vs 1492)"
height_tiles = 40 height_tiles = 10
player_buffer_width_tiles = 10 player_buffer_width_tiles = 10
contest_zone_width_tiles = 50 contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10 enemy_buffer_width_tiles = 10
@@ -207,7 +207,7 @@ enemy_buffer_width_tiles = 10
[[arena]] [[arena]]
name = "Cruisers vs carrier (1401 vs 1437, carrier expected to lose)" name = "Cruisers vs carrier (1401 vs 1437, carrier expected to lose)"
height_tiles = 40 height_tiles = 10
player_buffer_width_tiles = 10 player_buffer_width_tiles = 10
contest_zone_width_tiles = 50 contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10 enemy_buffer_width_tiles = 10
@@ -240,7 +240,7 @@ enemy_buffer_width_tiles = 10
[[arena]] [[arena]]
name = "Mixed mid vs battlecruisers (1394 vs 1418)" name = "Mixed mid vs battlecruisers (1394 vs 1418)"
height_tiles = 40 height_tiles = 10
player_buffer_width_tiles = 10 player_buffer_width_tiles = 10
contest_zone_width_tiles = 50 contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10 enemy_buffer_width_tiles = 10
@@ -285,7 +285,7 @@ enemy_buffer_width_tiles = 10
[[arena]] [[arena]]
name = "Two to one (must be decisive)" name = "Two to one (must be decisive)"
height_tiles = 30 height_tiles = 10
player_buffer_width_tiles = 10 player_buffer_width_tiles = 10
contest_zone_width_tiles = 50 contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10 enemy_buffer_width_tiles = 10
@@ -314,7 +314,7 @@ enemy_buffer_width_tiles = 10
[[arena]] [[arena]]
name = "Armored vs glass destroyers (1188 vs 1196)" name = "Armored vs glass destroyers (1188 vs 1196)"
height_tiles = 30 height_tiles = 10
player_buffer_width_tiles = 10 player_buffer_width_tiles = 10
contest_zone_width_tiles = 50 contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10 enemy_buffer_width_tiles = 10
@@ -350,7 +350,7 @@ enemy_buffer_width_tiles = 10
[[arena]] [[arena]]
name = "Repair escort vs raw numbers (444 vs 444)" name = "Repair escort vs raw numbers (444 vs 444)"
height_tiles = 30 height_tiles = 10
player_buffer_width_tiles = 10 player_buffer_width_tiles = 10
contest_zone_width_tiles = 50 contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10 enemy_buffer_width_tiles = 10
@@ -391,7 +391,7 @@ enemy_buffer_width_tiles = 10
[[arena]] [[arena]]
name = "Station assault (2 stations + 105 vs 315)" name = "Station assault (2 stations + 105 vs 315)"
height_tiles = 60 height_tiles = 10
player_buffer_width_tiles = 15 player_buffer_width_tiles = 15
contest_zone_width_tiles = 40 contest_zone_width_tiles = 40
enemy_buffer_width_tiles = 15 enemy_buffer_width_tiles = 15

View File

@@ -83,6 +83,7 @@ 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,9 +17,14 @@ 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 = 200 cost_building_blocks_formula = "400 * 2^x"
[push] [push]
push_expand_columns_tiles = 20 push_expand_columns_tiles = 20

60
cmake/version.cmake Normal file
View File

@@ -0,0 +1,60 @@
# 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}")

35
cmake/version.rc.in Normal file
View File

@@ -0,0 +1,35 @@
// 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

56
docs/balancing/README.md Normal file
View File

@@ -0,0 +1,56 @@
# Balancing Documentation
Everything about balancing Dota Factory, separated by role:
- **[rules.md](rules.md)** — the design rules and principles. Timeless;
changes only when the design changes.
- **[targets.md](targets.md)** — the base numbers (roots/anchors) chosen
by design. Change these first; everything else re-derives.
- **[derived.md](derived.md)** — the current tuned state of all derived
numbers, mirroring the configs. Updated whenever configs change.
- **[process.md](process.md)** — how balancing is done: the pass order,
tuning discipline, tools, and the checklist for the next round.
- **[history.md](history.md)** — chronological record of decisions,
findings, bugs, and arena rounds.
Related: game content (hull grids, footprint gating, tree design and
fiction) in [../content_design.md](../content_design.md); rules with
REQ-* ids in [../requirements.md](../requirements.md).
## Status
First full balancing round complete (2026-07-06): targets → tree →
numbers → threat-calculator parity → combat stats (arena-converged) →
pacing. Next step: full-game playtests against the run-shape targets in
`targets.md`.
## Open action items
Agreed changes that require edits to `requirements.md`, the code, or the
configs. Completed items are removed (their outcomes live in
`requirements.md`, `history.md`, and the git history).
1. **Fill unfillable schematic slots with artifacts.** With duplicates
removed, the schematic drop pool can run dry — previously unreachable.
Decision: every slot in the choice dialog that cannot be filled with a
schematic because the eligible pool is exhausted is filled with an
artifact option instead (in addition to any artifact option granted by
the regular artifact roll). A push therefore always awards a full
dialog. Update REQ-DEF-SCHEMATIC-DROP.
2. **Confirm wave scaling in playtests.** `threat_rate_formula` is the
only time-scaling axis; verify the tuned curve (see `derived.md`)
produces the intended difficulty race in real runs.
3. **Gate shortcut-recipe drops on their inputs.** Extend the assembler
recipe schematic pool eligibility in REQ-DEF-SCHEMATIC-DROP: in
addition to the existing station-level and output-item checks, all of
the recipe's input item types must be implicitly unlocked as well.
4. **Resource deposits.** Add a terrain deposit layer per the Resource
deposits rules (`rules.md`): deposit patches generated in expansion
columns (deterministic content per expansion, randomized placement
within the new columns), deposit rendering, and a miner condition (a
resource recipe is selectable only if the miner's footprint overlaps
at least one matching deposit tile). Touches REQ-BLD-MINER ("every
asteroid tile is equivalent" no longer holds),
REQ-GW-ASTEROID-EXPAND / REQ-EXP-*, `world.toml`, and `visuals.toml`.
Until this lands, quartz mines anywhere and the mid-game is
knowledge-gated only.

164
docs/balancing/derived.md Normal file
View File

@@ -0,0 +1,164 @@
# Derived Values (current tuned state)
Everything here is derived from `targets.md` under the rules in
`rules.md`, and mirrors the config files. Item threats, ship threats,
ratios, and belt checks are verified by `tools/threat_report.py` — re-run
it after any recipe or material change and update this file when values
move. Combat stats were tuned empirically against the arena suite in
`bin/balancing/data/balancing.toml` (round-by-round record in
`history.md`).
## Economy constants
- `scrap_per_threat = 0.25` — 1 scrap per 4 threat destroyed (a cruiser
kill drops ~59 scrap); threat(scrap) = 4.
- Scrap smelting: 1 scrap → 1 iron_ingot, 1 s — deliberately
value-losing; reprocessing is the value-preserving path.
- Reprocessing: 4 scrap per cycle, 4 s; full-pool weights iron_ingot 30 /
copper_ingot 30 / silicon 20 / voidsteel 20 → threat(voidsteel)
= (4·4 + 4)/0.2 = 100.
- `scrap_despawn_seconds = 120` (a capital kill drops hundreds of scrap,
collected one per salvage cycle).
## Recipes and item threats
(dur in seconds; threat is per output unit)
| item | recipe | dur | out | threat |
|---|---|---|---|---|
| iron_ore / copper_ore | miner | 1 | 1 | 1 |
| quartz | miner (deposit) | 2 | 1 | 2 |
| iron_ingot | 1 iron_ore | 1 | 1 | 2 |
| copper_ingot | 1 copper_ore | 1 | 1 | 2 |
| silicon | 1 quartz | 2 | 1 | 4 |
| steel_plate | 2 iron_ingot | 3 | 1 | 7 |
| copper_wire | 1 copper_ingot | 1 | 2 | 1.5 |
| copper_coil | 2 copper_wire | 1.5 | 1 | 4.5 |
| building_block | 2 steel_plate | 2 | 4 | 4 |
| control_chip | 1 silicon + 2 copper_wire | 5 | 1 | 12 |
| capacitor_bank | 2 copper_coil + 1 silicon | 5 | 1 | 18 |
| hardened_steel | 3 steel_plate | 12 | 1 | 33 |
| ceramic_plate | 2 quartz | 4 | 1 | 8 |
| drive_unit | 2 steel_plate + 2 copper_coil + 1 control_chip | 8 | 1 | 43 |
| voidsteel_plate | 1 voidsteel + 1 hardened_steel | 8 | 1 | 141 |
| capital_core | 2 voidsteel + 1 capacitor_bank + 1 control_chip | 10 | 1 | 240 |
Shortcut recipes (drop-only; item threat stays defined by the base path
via the max rule): `shortcut_steel_plate` 3 iron_ore → 1 plate (2 s,
level 1), `shortcut_control_chip` 2 quartz → 1 chip (4 s, level 2),
`shortcut_hardened_steel` 4 iron_ingot → 1 hardened (8 s, level 2).
Ratio curve realized: t1 all 1:1 (miner:smelter); t2 clean 2:3
(ingot→plate, wire→coil); t3 strange — 2:5 (silicon→chip), 3:5
(coil→capacitor), 3:4 (plate→hardened, plate→drive); t4 inverted 3:2
(hardened→voidsteel_plate). Belt check: worst input demand 1.33 items/s,
under the ~2/s single-belt cap everywhere.
## Module prefabs
(contribution = item threat + module production time)
| module | recipe | dur | mod. time | contribution |
|---|---|---|---|---|
| railgun_s | 1 copper_coil | 1 | 1 | 6.5 |
| salvager | 1 steel_plate + 2 copper_wire | 2 | 1 | 13 |
| repair_tool | 1 steel_plate + 2 copper_wire | 2 | 1 | 13 |
| armor_plates | 4 steel_plate | 3 | 1 | 32 |
| maneuvering_thrusters | 1 steel_plate + 1 copper_coil | 2 | 1 | 14.5 |
| sensor_booster | 2 copper_wire + 1 copper_coil | 2 | 1 | 10.5 |
| afterburner | 2 copper_coil + 1 steel_plate | 3 | 1 | 20 |
| weapon_stabilizer | 1 steel_plate + 1 copper_coil | 2 | 1 | 14.5 |
| weapon_primer | 1 capacitor_bank + 1 copper_coil | 4 | 2 | 28.5 |
| weapon_upgrade | 1 control_chip + 1 copper_coil | 4 | 2 | 22.5 |
| railgun_m | 1 capacitor_bank + 2 steel_plate + 1 copper_coil | 4 | 3 | 43.5 |
| drone_bay | 1 control_chip + 2 steel_plate + 1 copper_coil | 4 | 3 | 37.5 |
| railgun_l | 1 capacitor_bank + 2 hardened_steel + 1 ceramic_plate | 6 | 4 | 102 |
| drone_hangar | 1 voidsteel_plate + 2 control_chip + 1 drive_unit | 10 | 6 | 224 |
## Ships
(fitted = hull item + ship base time + default loadout; the default
loadouts are the `default_modules` used by enemy waves and are
geometry-validated against the hull grids)
| ship | hull recipe | dur | base | default loadout | fitted |
|---|---|---|---|---|---|
| drone | 1 iron_ingot | 1 | 1 | railgun_s | 10.5 |
| frigate | 2 steel_plate + 1 copper_wire | 2 | 2 | 2× railgun_s, maneuvering_thrusters | 47 |
| destroyer | 3 steel_plate + 2 copper_coil | 4 | 3 | 3× railgun_s, armor_plates, sensor_booster | 99 |
| cruiser | 2 hardened_steel + 2 control_chip | 6 | 4 | 2× railgun_m, armor_plates, maneuvering_thrusters | 233.5 |
| battlecruiser | 3 hardened_steel + 2 control_chip + 1 drive_unit | 8 | 5 | 3× railgun_m, armor_plates, 2× railgun_s | 354.5 |
| battleship | 3 voidsteel_plate + 1 drive_unit + 2 control_chip | 10 | 6 | railgun_l, 2× railgun_m, weapon_stabilizer, 2× railgun_s | 722.5 |
| dreadnought | 5 voidsteel_plate + 1 capital_core + 2 drive_unit | 12 | 8 | 3× railgun_l, 4× armor_plates, railgun_s | 1491.5 |
| carrier | 5 voidsteel_plate + 1 capital_core + 2 drive_unit | 12 | 8 | drone_hangar, 2× railgun_m, 2× armor_plates, sensor_booster | 1436.5 |
## Combat stats
(arena-converged, 2026-07; see `history.md` rounds 15)
**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.
**Hull HP** (15/threat prior + empirical trims): drone 60, frigate 300,
destroyer 550, cruiser 1500, battlecruiser 2400, battleship 6300,
dreadnought/carrier 24000.
**Mobility ladder** (speed m/s | main accel | maneuvering | angular |
max rot): drone 45|60|30|12|6, frigate 35|45|22|8|4,
destroyer 30|35|18|6|3, cruiser 24|25|12|4|2, battlecruiser 20|20|10|3|1.5,
battleship 15|14|7|2|1, dreadnought/carrier 10|8|4|1|0.5.
Sensors: 150/200/220/250/260/280/300/350 m.
**Other modules:** armor_plates +1200 HP; repair_tool 9 HP × 1 Hz,
range 80; salvager range 60, cargo 20, 0.5 collections/s; afterburner
×1.6 speed +60 accel; maneuvering_thrusters ×1.2 speed +10 maneuvering;
sensor_booster +50 m; weapon_upgrade ×1.2 damage; weapon_primer ×1.2
rate; weapon_stabilizer ×1.3 range ×0.8 rate.
**Stations:** HQ 5000 HP. Player station 3000 HP, 25 dmg × 1 Hz,
range 120, scrap 40. Enemy station: 3000+1500x HP, 25+12x dmg,
1.0+0.1x Hz, range 120, scrap 40+30x (x = push level).
## Pacing
**Unlock ladder** (level → unlocks; ← marks `unlock_requires`; starting
set at 1: drone, frigate, railgun_s, salvager, building_block recipe):
| level | ships | modules | recipes |
|---|---|---|---|
| 0 | destroyer | repair_tool, armor_plates | |
| 1 | | maneuvering_thrusters, sensor_booster | shortcut_steel_plate |
| 2 | cruiser | railgun_m, afterburner | shortcut_control_chip, shortcut_hardened_steel |
| 3 | | weapon_stabilizer | |
| 4 | battlecruiser ← cruiser | weapon_primer, weapon_upgrade | |
| 5 | | drone_bay | |
| 6 | battleship ← battlecruiser | railgun_l ← railgun_m | |
| 8 | dreadnought ← battleship | | |
| 9 | carrier ← battleship | drone_hangar | |
Level 0's pool has exactly three entries (a full first dialog). Level 2
is the quartz gate: cruiser and railgun_m are the first schematics whose
chains reach quartz; the shortcut outputs only become implicitly
unlocked alongside them, so shortcuts cannot drop early.
**Threat rate** `2*x + 0.15*x*x` (x = boss cycle counter), against the
factory-size curve with ~half the player's output assumed military:
| cycle x | rate (threat/s) | player military (≈ curve/2) |
|---|---|---|
| 2 | 4.6 | ~12 |
| 6 | 17.4 | ~30 |
| 15 | 63.8 | ~60 |
| 20 | 100 | ~75 |
| 24 | 134 | — |
**Economy:** `starting_building_blocks = 200`; expansion cost formula
`300 + 50*x + 10*x*x` (x = expansions already purchased: ~1 affordable
per cycle mid-game at ~1/3 of block income, stretching to 23 cycles
late — quadratic so costs outrun the roughly linear block income
gradually, never with a hard wall); `artifact_win_count = 5` with
`artifact_chance_formula = 0.05*x`. Building costs: belt 2, splitter 3,
tunnels 5, miner 15, smelter 20, assembler 35, reprocessing plant 40,
salvage bay 25, shipyard 60 — averaging ≈18 blocks per placed building
(belts included), which meets the 4-minute doubling target at block
threat 4.

96
docs/balancing/history.md Normal file
View File

@@ -0,0 +1,96 @@
# Balancing History
Chronological record of the balancing work: what was decided, what was
found, what changed. Current values live in `derived.md`; this file
explains how they got there.
## 2026-07-02/03 — rules and structural decisions
- Rules document written (now `rules.md`): ratio curve, shortcut
recipes, refactorability, cost archetypes, threat model, growth curve.
- Scrap derived from threat (`scrap_per_threat`), replacing authored
per-ship scrap drops; scrap threat became the constant
`1/scrap_per_threat`, removing the old min-scrap_drop derivation and
its circularity.
- Duplicate schematic drops removed (no level-ups); ship/module levels
removed entirely — all time scaling lives in the threat rate, push
scaling stays on stations. Mk2 upgrade recipes noted as the future
per-item progression.
- Growth-curve rules added: escalating expansion costs, designed
doubling time, growth limited by economy not waiting; resource
deposits designed (deposit-gated mid resource in expansion territory).
- Production tree v2 decided: iron/copper everywhere (M-type asteroid),
quartz in geodes (mid), voidsteel battle-forged from scrap (late);
titanium dropped; lasers renamed to railguns, lasers reserved as a
future weapon type.
## 2026-07-03 — targets, tree, numbers
- Balancing targets fixed: ≤2 h run, phases 15/614/15+, factory curve
25/60/120/150, threat ladder, 25-ship swarm, block roots.
- Tree structure drafted and numbers computed (recursive threat
calculator); ratio curve realized; fitted ships within 96124% of the
strawman ladder (small end hot from fixed chain overhead — ladder
later adopted the achieved values).
- **Rule bugs found by the numbers work:** the scrap→ingot smelter
recipe would inflate basic materials via the max rule (fixed:
scrap-consuming recipes are threat fallback only); recipe output
amounts were ignored (fixed: per-unit division); items downstream of
reprocessing-only items never resolved (fixed: fixpoint resolution);
a shortcut recipe resolving earlier than the base path silently
underpriced items (fixed: commit only when all eligible recipes are
computable). All four fixed in `ThreatCostCalculator` with tests, and
implemented in `tools/threat_report.py`.
- v2 tree written into the configs; `default_modules` loadouts
geometry-validated (the numbers-pass loadouts for battlecruiser and
dreadnought were geometrically impossible — L-modifiers don't fit
beside full gun complements; corrected loadouts landed closer to the
ladder).
## 2026-07-04 — combat stats, arena rounds 15
Initial stats derived from the anchors (weapon DPS ≈0.6/threat flat,
hull 15 HP/threat, armor 20/threat, repair 2 HP/s/threat, station
range 200).
- **Round 1:** concentrated fleets won all equal-threat cross-tier
matchups flawlessly; glass beat armored; repair escort flawless; two
stations shrugged off a 3× swarm. Changes: concentration tax on m/l
gun damage (railgun_m 17→14, railgun_l 70→52), armor 640→1000,
repair 25→12, station range 200→120. (Team-1 "bias" in mirrors later
shown to be noise.)
- **Round 2 (EHP-margin logging added):** battleship +33% while
dreadnought 37% (stabilizer range + opposing armor); glass still
+11%. Changes: stabilizer range ×1.5→×1.3; per-hull trims introduced
(BC 2700→2500, BS 7500→7000, DN/CV 15500→19000).
- **Round 3 (narrow lanes — geometry fixed into the fixture):**
DN closed to 11%, BS +22%, swarm flipped to +14% over cruisers,
glass +12% third time. Changes: armor 1000→1200, BC 2500→2000,
BS 7000→6300, DN/CV 19000→22500.
- **Round 4:** glass-vs-armored resolved (+3% armored); noise floor
established (~±10%/run: BS ignored a 10% EHP cut; repair drifted
14→24% untouched). Convergence policy adopted: two-round signals only,
±20% converged. Changes: BC 2000→2200, DN/CV 22500→24000; BS +23%
accepted as doctrine texture (mechanical range edge vs. pure small
fleets).
- **Round 5 (durations logged; end-condition bug fixed upstream):**
TTK anchor validated (mirrors 23/71/95 s; DN-vs-swarm 214 s outlier
accepted); dreadnought +3%, everything else inside band. Final
changes: BC 2200→2400, repair 12→9 (persistent +24% escort margin).
**Combat pass declared converged.**
## 2026-07-05/06 — pacing pass
- Unlock ladder set (starting set drone/frigate/railgun_s/salvager;
quartz gate at level 2; capitals at 89 with `unlock_requires`
chains); threat rate `2*x + 0.15*x*x`; starting blocks 1000→200;
expansion 400 flat pending the cost formula; artifacts 3→5.
- **Bug found:** the building_block recipe was silently locked at game
start (building blocks appear in no schematic's materials, so implicit
unlocking could never reach the recipe) — fixed with an explicit
`unlock_at_station_level = -1`.
- Expansion cost formula implemented and set (`300 + 50*x + 10*x*x`):
quadratic, so costs outrun the roughly linear block income gradually
— ~1 expansion per cycle mid-game, 23 cycles apart late.
- **First full balancing round complete.** Next: full-game playtests
against the run-shape targets.

98
docs/balancing/process.md Normal file
View File

@@ -0,0 +1,98 @@
# Balancing Process
How balancing is done in this project: the pass order, the tuning
discipline, and the tools. Refer to this when starting the next
balancing round.
## The pass order
Each pass depends on the ones before it; a change in an earlier pass
invalidates the later ones (but not vice versa). Redo from the earliest
pass whose inputs changed.
1. **Targets** (`targets.md`) — choose the root numbers: run shape,
factory curve, threat-cost ladder, fleet size, block roots, combat
anchors, pacing anchors. These are design decisions, not
measurements. Everything else is derived from them.
2. **Tree structure** (`../content_design.md`) — items, chains,
what-consumes-what, per the production tree rules (one input per
phase transition, generic parts, archetypes, refactorability).
Structure only, no quantities.
3. **Numbers** (`derived.md`, recipes/materials in the configs) —
quantities and durations so every fitted ship sums to its ladder
value, the ratio curve is realized, and the belt/buffer guardrails
hold. Verified computationally by `tools/threat_report.py`.
4. **Calculator/tooling parity** — the game's `ThreatCostCalculator`
and `tools/threat_report.py` must produce identical values; the
Python tool is the design reference. Any semantic change to
REQ-THREAT-* needs both updated plus tests.
5. **Combat stats** (arena-driven) — derive stats from the combat
anchors, then iterate against the arena suite
(`bin/balancing/data/balancing.toml`) until equal-threat matchups are
near-draws. Threat costs are stat-independent, so arena ship counts
stay valid across stat changes.
6. **Pacing** — unlock ladder, `unlock_requires` edges, threat rate,
block/artifact/expansion values, per the pacing anchors.
Then: **full-game playtests**, which are the only check for the pacing
pass and feed back into targets.
## Tuning discipline (learned in arena rounds 15)
- **Change anchors, not symptoms.** When a class of results is off,
adjust the anchor that explains all of them (e.g. the concentration
tax) rather than individual stats.
- **Fewest knobs per round.** Attribution dies when many knobs move at
once. Prefer one anchor change plus its mechanical compensations.
- **Shared vs. local knobs.** Guns and module stats are shared across
many hulls — changing them moves many matchups. Per-hull HP moves
exactly one matchup; it is the designated per-ship trim knob on top of
the HP-per-threat prior.
- **Mind the ride-alongs.** A module buff lands on every default loadout
containing it (e.g. an armor buff strengthens the destroyer swarm that
opposes the dreadnought). Compute the net effect per matchup before
choosing step sizes.
- **Two-round signal policy.** Single arena runs re-roll by ~±10% EHP
margin; a margin inside ±20% counts as converged for v1. Only act on
signals that persist across two rounds.
- **Arena geometry is part of the fixture.** Lane width/height changes
the results (full engagement vs. fleets slipping past); margins are
only comparable within the same geometry.
- **Accept mechanical texture.** Not every deviation is a bug: a margin
that survives a stat change is mechanical (usually range/kiting under
the orbit AI) and may be desirable doctrine texture. Document the
acceptance in `targets.md` instead of chasing it.
- **Range is the strongest stat** under the orbit AI — free approach
fire. Price range modifiers conservatively; station dominance is
controlled via range, not HP.
## Tools
- `tools/threat_report.py` — item threats, module contributions,
hull/fitted ship threats, producer:consumer ratios, belt feasibility;
reads the real configs. The design reference for threat semantics.
- `tools/verify_recipes.py` — recipe tree closure, visuals coverage,
orphans, reprocessing-only items.
- `tools/verify_layouts.py` — module footprint gating matrix per hull.
- **Balancing tool** (`balancing` target) — parallel arena simulation of
`bin/balancing/data/balancing.toml`; logs winner, surviving counts,
team EHP %, and fight duration per arena. The suite covers: class
mirrors (expect near-mutual annihilation, symmetric winners),
equal-threat cross-tier matchups (expect near-draws — power-per-threat
made empirical), a 2:1 decisiveness check, doctrine matchups
(armored-vs-glass, repair-escort), and station assault.
## Checklist for the next balancing round
1. Pull; run `verify_recipes.py`, `verify_layouts.py`,
`threat_report.py`; compare against the tables in `derived.md`.
2. If recipes/materials changed: re-check fitted threats vs. the ladder
in `targets.md`; update arena suite ship counts if fitted values
moved.
3. Run the arena suite; read EHP margins and durations against the
expectations noted in `balancing.toml` and the anchors.
4. Apply changes per the tuning discipline (two-round signals only);
record the round and its knob changes in `history.md`.
5. Update `derived.md` where values moved; if an anchor moved, update
`targets.md` and state why.
6. Commit and push (the review workflow reads the remote).

View File

@@ -1,9 +1,10 @@
# Progression & Balancing Design # Balancing & Progression Rules
Rules and principles that govern the production tree, progression pacing, Rules and principles that govern the production tree, progression pacing,
and balancing. This document contains **rules only**concrete content and balancing. This document contains **rules only**the chosen base
(item lists, recipes, unlock levels, stat numbers) lives in the config files numbers live in `targets.md`, everything derived from them in
and `content_design.md`; those numbers must follow the rules stated here. `derived.md`, and the concrete content in the config files and
`../content_design.md`. All of those must follow the rules stated here.
## Player-experience goals ## Player-experience goals
@@ -36,7 +37,7 @@ adapts later (see Refactorability).
- The fourth input is the core loop hook: capital ship production requires - The fourth input is the core loop hook: capital ship production requires
fighting (salvaging and reprocessing), not just mining. fighting (salvaging and reprocessing), not just mining.
- Every gating has a fictional reason (concrete fiction in - Every gating has a fictional reason (concrete fiction in
`content_design.md`): the asteroid is a metal-rich body, so its bulk `../content_design.md`): the asteroid is a metal-rich body, so its bulk
rock is minable anywhere; the mid resource sits in rare pockets; the rock is minable anywhere; the mid resource sits in rare pockets; the
late input is battle-forged — created only in the violence of ship late input is battle-forged — created only in the violence of ship
destruction, which is why any wreck (including the player's own) destruction, which is why any wreck (including the player's own)
@@ -122,7 +123,8 @@ adapts later (see Refactorability).
already unlocked (in addition to the station level check). The player already unlocked (in addition to the station level check). The player
is never offered a shortcut for a chain they have not built yet. The is never offered a shortcut for a chain they have not built yet. The
output-item half of this check already exists in output-item half of this check already exists in
REQ-DEF-SCHEMATIC-DROP; the input half is new (see Action items). REQ-DEF-SCHEMATIC-DROP; the input half is an open action item (see
`README.md`).
- **Shortcuts are pure rewards, never balance factors.** An item's threat - **Shortcuts are pure rewards, never balance factors.** An item's threat
value is the *maximum* across its producing recipes (REQ-THREAT-ITEM), so value is the *maximum* across its producing recipes (REQ-THREAT-ITEM), so
unlocking a cheaper recipe does not lower the item's threat accounting — unlocking a cheaper recipe does not lower the item's threat accounting —
@@ -178,8 +180,8 @@ supporting different fleet doctrines feel structurally different to build.
modules, and tiers. Higher tiers are better per *ship* and per *module modules, and tiers. Higher tiers are better per *ship* and per *module
slot*, not per invested factory-second — their advantage is slot*, not per invested factory-second — their advantage is
concentration (fewer, bigger things; slot geometry per concentration (fewer, bigger things; slot geometry per
`content_design.md`) and qualitative capabilities, not a better exchange `../content_design.md`) and qualitative capabilities, not a better
rate. Deviations from this rule are deliberate and documented. exchange rate. Deviations from this rule are deliberate and documented.
- **Difficulty race:** the enemy threat rate (`threat_rate_formula`) is - **Difficulty race:** the enemy threat rate (`threat_rate_formula`) is
tuned against the factory output (threat/s) achievable by a competent tuned against the factory output (threat/s) achievable by a competent
player — slightly below it early, crossing above it eventually. The game player — slightly below it early, crossing above it eventually. The game
@@ -188,9 +190,8 @@ supporting different fleet doctrines feel structurally different to build.
- **All time scaling lives in the threat rate** — waves get bigger, ships - **All time scaling lives in the threat rate** — waves get bigger, ships
of a given schematic never get individually stronger. There is no ship of a given schematic never get individually stronger. There is no ship
level dimension: stat formulas are plain values, and per-ship level level dimension: stat formulas are plain values, and per-ship level
scaling does not exist. Push scaling does not exist. Push scaling on enemy defence stations is the
scaling on enemy defence stations is the separate, player-triggered separate, player-triggered difficulty axis and keeps its level formulas.
difficulty axis and keeps its level formulas.
## Unlock & drop pacing ## Unlock & drop pacing
@@ -206,18 +207,18 @@ supporting different fleet doctrines feel structurally different to build.
gate, a schematic (ship, module, or assembler recipe) may list gate, a schematic (ship, module, or assembler recipe) may list
prerequisite schematics (`unlock_requires`, REQ-LOCK-PREREQ) that must prerequisite schematics (`unlock_requires`, REQ-LOCK-PREREQ) that must
already be unlocked before it enters the drop pool — e.g. the medium already be unlocked before it enters the drop pool — e.g. the medium
laser requires the small laser; a future Mk2 requires its base version. gun requires the small gun; a future Mk2 requires its base version.
Station level gates the earliest Station level gates the earliest *when*; prerequisites gate the
*when*; prerequisites gate the *order*, keeping drop offers coherent *order*, keeping drop offers coherent with what the player already
with what the player already owns. owns.
- **No duplicate drops.** Ship and module schematics leave the drop pool - **No duplicate drops.** Ship and module schematics leave the drop pool
once owned, exactly as assembler recipe schematics already do. There are once owned, exactly as assembler recipe schematics already do. There are
no schematic level-ups; player power grows through unlock breadth and no schematic level-ups; player power grows through unlock breadth and
factory scale only, which keeps power-per-threat exact on both sides. factory scale only, which keeps power-per-threat exact on both sides.
The pool therefore shrinks over a run and late pushes increasingly offer The pool therefore shrinks over a run and late pushes increasingly offer
artifacts — intended: the late game is a race for the win condition. artifacts — intended: the late game is a race for the win condition.
Per-item progression may return later as Mk2 upgrade recipes (see Open Per-item progression may return later as Mk2 upgrade recipes (see Future
tasks), never as free level-ups. work), never as free level-ups.
- **Artifacts trade power for progress.** Artifact options compete with - **Artifacts trade power for progress.** Artifact options compete with
schematic picks in the same choice dialog; the artifact chance must be schematic picks in the same choice dialog; the artifact chance must be
tuned so that taking one is a real decision (giving up an unlock), not tuned so that taking one is a real decision (giving up an unlock), not
@@ -295,7 +296,7 @@ supporting different fleet doctrines feel structurally different to build.
through the HQ's single belt port, so income is capped at belt through the HQ's single belt port, so income is capped at belt
throughput regardless of assembler count. Per-building costs should be throughput regardless of assembler count. Per-building costs should be
high enough that this cap can bind late-game (see the condensed-block high enough that this cap can bind late-game (see the condensed-block
idea under Open tasks). idea under Future work).
## Numeric guardrails ## Numeric guardrails
@@ -314,15 +315,8 @@ Constraints that every recipe must respect, independent of tuning:
- **Cycle times scale with tier** monotonically — a higher-tier item never - **Cycle times scale with tier** monotonically — a higher-tier item never
has a shorter total chain time than a lower-tier item of the same role. has a shorter total chain time than a lower-tier item of the same role.
## Open tasks / future work ## Future work
- **Rework `recipes.toml`** once the rules in this document are fixed: the
current tree feels too close to Factorio; apply the thematic-naming rule
and the ratio curve to it (renames and quantity changes, not new items).
- **Balancing pass** (see placeholders in `content_design.md`): set the
`unlock_at_station_level` ladder, real threat costs and
`default_modules`, reprocessing weights, and the threat-rate formulas
according to the rules above.
- **Condensed building blocks** — a drop-unlockable shortcut-style - **Condensed building blocks** — a drop-unlockable shortcut-style
recipe that packs several blocks' worth of value into one belt item, recipe that packs several blocks' worth of value into one belt item,
relieving the HQ intake ceiling (see Building block economy) as a relieving the HQ intake ceiling (see Building block economy) as a
@@ -337,39 +331,3 @@ Constraints that every recipe must respect, independent of tuning:
assembler), and keeps balancing one-dimensional (no level variable assembler), and keeps balancing one-dimensional (no level variable
anywhere). Enemy-side progression happens via `default_modules` anywhere). Enemy-side progression happens via `default_modules`
variants per era instead of a level formula. variants per era instead of a level formula.
## Action items — changes beyond this document
Agreed changes that require edits to `requirements.md`, the code, and the
configs. Completed items are removed from this list (their outcomes live
in `requirements.md` and the git history). Still open:
1. **Fill unfillable schematic slots with artifacts.** With duplicates
removed, the schematic drop pool can run dry — previously unreachable.
Decision: every slot in the choice dialog that cannot be filled with a
schematic because the eligible pool is exhausted is filled with an
artifact option instead (in addition to any artifact option granted by
the regular artifact roll). A push therefore always awards a full
dialog. Update REQ-DEF-SCHEMATIC-DROP.
2. **Confirm wave scaling carries alone.** With per-ship level scaling
removed, `threat_rate_formula` is the only time-scaling axis; verify
the current `world.toml` values still produce the intended difficulty
curve (likely a review, not a retune, since the config already ran
with a flat ship level before the removal).
3. **Gate shortcut-recipe drops on their inputs.** Extend the assembler
recipe schematic pool eligibility in REQ-DEF-SCHEMATIC-DROP: in
addition to the existing station-level and output-item checks, all of
the recipe's input item types must be implicitly unlocked as well.
4. **Expansion cost formula.** Replace the flat
`world.toml [expansion].cost_building_blocks` with a formula of the
number of expansions already purchased (x = expansions bought so
far), so expansion costs can escalate per the block economy rules.
Update REQ-EXP-COST and wherever the UI displays the expansion cost.
5. **Resource deposits.** Add a terrain deposit layer per the Resource
deposits rules: deposit patches generated in expansion columns
(deterministic content per expansion, randomized placement within the
new columns), deposit rendering, and a miner condition (a resource
recipe is selectable only if the miner's footprint overlaps at least
one matching deposit tile). Touches REQ-BLD-MINER ("every asteroid
tile is equivalent" no longer holds), REQ-GW-ASTEROID-EXPAND /
REQ-EXP-*, `world.toml`, and `visuals.toml`.

104
docs/balancing/targets.md Normal file
View File

@@ -0,0 +1,104 @@
# Balancing Targets (base numbers)
The root numbers of the balancing. Everything in `derived.md` is tuned to
hit these; when rebalancing, **change these first and re-derive — never
patch derived values directly**. The rules these numbers follow live in
`rules.md`.
All time targets are in **game time**. The player can pause and
accelerate, so real session length differs; playtests measure both. The
time unit is the boss cycle (`world.toml boss_countdown_seconds`, 300 s).
Destroying a station set advances the boss countdown by
`boss_advance_seconds` (60 s), so cycles run shorter than nominal when
pushing actively — targets deliberately ignore that.
## Run shape
1. **Run length** — a winning run takes up to 2 hours of game time: win
around boss cycle 2024. Losing runs end earlier.
2. **Phase boundaries** — early = cycles 15 (iron/copper, small hulls),
mid = cycles 614 (quartz, medium hulls), late = cycles 15+
(voidsteel, capitals). Push cadence: first station set around cycle
23, roughly one per cycle from mid onward — so the destroyed set's
level is reached around cycle +2.
3. **Factory size curve** — producing buildings over time; when
saturated, output threat/s equals this count, so this curve IS the
player power curve: ~25 when the starting asteroid is full (end of
cycle 2), ~60 at the start of mid (cycle 6), ~120 at the start of
late (cycle 15), ~150 near the win. `threat_rate_formula` must remain
a fraction of this curve; buildings plus belts must physically fit
the asteroid plus affordable expansions.
4. **Threat-cost ladder** — total production-seconds per *fitted* hull
(including the typical/default module loadout): drone 10.5,
frigate 47, destroyer 99, cruiser 233.5, battlecruiser 354.5,
battleship 722.5, dreadnought 1491.5, carrier 1436.5. Every
production chain must sum to its ladder value. (The original strawman
was 10/40/80/200/350/700/1500; the small end runs ~1020% hot because
fixed chain overhead dominates small hulls — accepted, and the
achieved values adopted as the ladder. The ~×2-per-class curve shape
is the invariant.)
5. **Fleet size** — swarm-leaning: ~25 player combat ships as the
standing mid-game fleet. Standing fleet = build cadence (4) × average
ship lifetime, so this target drives time-to-kill and therefore all
combat stat magnitudes.
6. **Block economy roots** — bootstrap complete (starting asteroid full)
by the end of cycle 2; a factory spending ~30% of its capacity on
blocks doubles in ~4 minutes early game; one expansion affordable per
cycle at ~1/3 of block income mid-game, decelerating to one per 23
cycles late as escalating costs outrun income.
## Combat anchors
All combat stats derive from these; per-hull HP additionally carries
empirical trims from arena rounds (values in `derived.md`).
- **Weapon DPS per threat pays a concentration tax that grows with gun
size**: small ≈ 0.62, medium ≈ 0.48, large ≈ 0.41 DPS per threat of
weapon contribution, compensated by the range ladder 50/70/100 m.
Rationale: concentration itself (focus fire, no DPS loss to attrition,
range) is worth paying for — with a flat curve, concentrated fleets
win equal-threat fights outright (arena round 1).
- **Hull HP = 15 per threat of hull contribution** as the prior; per-hull
HP is the empirical trim knob (guns are shared across hulls, hull HP
moves exactly one matchup). The arena consistently prices capitals as
*tanks with taxed guns* — capital hulls sit well above the prior.
- **Armor HP ≈ 37 per threat** — a strong premium over hull HP because
armor is pure HP with no capability, and fights snowball: killing
removes enemy DPS, surviving merely delays — HP must be cheaper than
DPS.
- **Repair ≈ 0.7 HP/s per threat** — in-combat sustain effectively
removes enemy DPS and must be priced like DPS, not like HP.
- **TTK / fight duration**: parity fights in the 3060 s band at
mid-game scale; capital mirrors ~90 s deliberately; the extreme
tank-vs-chip-damage matchup (dreadnought vs destroyer swarm, ~3.5 min)
is an accepted outlier.
- **Mobility is monotone in size** — the smallest hulls are the fastest
and nimblest. Sensor ranges (150→350 m) always exceed weapon ranges.
- **Weapon modifiers are capital economy**: a ×1.2 damage modifier at
~22.5 threat beats adding a gun once a ship carries more than ~68
threat of weapons — modifiers pay off on gun-heavy big hulls, waste on
small ones. Range modifiers are the strongest and are priced/kept
small (×1.3): range is the dominant stat under the orbit AI (free
approach fire).
- **Stations**: a fresh player station holds one early parity wave
unaided; the enemy station at level 0 matches the player station
exactly and scales per push level. Station range is the dominance
lever, not HP (at 4× a small gun's range, two stations annihilated a
3× threat swarm through approach fire alone).
- **Accepted imbalances**: the carrier loses its equal-threat fights
until the drone-launching capability exists (the hangar is dead
threat) — fix by implementing drones, not stats. A pure smallest-ship
fleet modestly loses (~1525%) to a range-fitted capital — desirable
doctrine texture; the fair anti-capital answer is the mixed fleet.
## Pacing anchors
- **Starting set** is the rule-minimum: drone, frigate, small gun,
salvager (plus the explicitly unlocked building-block recipe).
- **Threat rate shape**: below the player's achievable military output
(≈ half the factory curve) early, crossing at the late boundary
(~cycle 15), overwhelming by ~cycle 24.
- **Winning = five real decisions**: `artifact_win_count` is set so that
across a winning run's ~1518 pushes (~7 cumulative artifact offers at
the current chance formula), the player must choose the artifact over
a schematic about five times.

View File

@@ -1,9 +1,10 @@
# Content Design — Ships & Modules # Content Design — Ships, Modules & Production Tree
First real-content iterations (June 2026). Pass 1 defined ship hull grids and The designed game content: hull layout grids, module footprints and the
module surface masks; pass 2 defined the production tree (recipes). Stats and gating between them, and the production tree (items, chains, fiction).
threat costs in the config files are still placeholders for the balancing All numbers — quantities, durations, threat values, stats, unlock levels
pass. — live in the config files and are documented with their derivations in
`docs/balancing/` (see `balancing/README.md` for the index).
## Design principle: footprint gating ## Design principle: footprint gating
@@ -17,12 +18,12 @@ and makes them trivially moddable through the config files alone.
| Footprint | Modules | Smallest hull that fits it | | Footprint | Modules | Smallest hull that fits it |
|-----------|---------|----------------------------| |-----------|---------|----------------------------|
| 1x1 | laser_cannon_s, salvager, repair_tool | drone | | 1x1 | railgun_s, salvager, repair_tool | drone |
| 1x2 | maneuvering_thrusters, sensor_booster, armor_plates | frigate | | 1x2 | maneuvering_thrusters, sensor_booster, armor_plates | frigate |
| 1x3 | afterburner | frigate (eats most of it) | | 1x3 | afterburner | frigate (eats most of it) |
| L-shape (3 cells) | weapon_stabilizer, weapon_primer, weapon_upgrade | frigate | | L-shape (3 cells) | weapon_stabilizer, weapon_primer, weapon_upgrade | frigate |
| 2x2 | laser_cannon_m, drone_bay | cruiser | | 2x2 | railgun_m, drone_bay | cruiser |
| 3x3 | laser_cannon_l | battleship | | 3x3 | railgun_l | battleship |
| 2x6 | drone_hangar | carrier (only) | | 2x6 | drone_hangar | carrier (only) |
### Hull grids ### Hull grids
@@ -129,58 +130,11 @@ Maximum simultaneous (disjoint) placements: m guns — cruiser 2,
battlecruiser 3, battleship 4; l guns — battleship 1, dreadnought 3; battlecruiser 3, battleship 4; l guns — battleship 1, dreadnought 3;
drone hangar — carrier 1. drone hangar — carrier 1.
## Production tree (first pass — superseded) ## Production tree
**Superseded (July 2026):** this first-pass tree predates the rules in Designed against the rules in `docs/balancing/rules.md` (ratio curve,
`docs/progression_design.md` and will be replaced. The decisions for the cost ladder, cost archetypes, refactorability). Quantities, durations,
redesign are recorded in "Production tree v2 — decisions" below; the text and threat values live in `docs/balancing/derived.md`.
of this section is kept for reference until the new tree lands.
Design principle: each game phase adds exactly one new base input chain, so
factory complexity ramps alongside ship size.
| Phase | New input | How acquired | Unlocks |
|-------|-----------|--------------|---------|
| early | iron_ore, copper_ore | mined | drone, frigate, destroyer; small guns and basic supports |
| mid | titanium_ore | mined (3x slower than iron) | cruiser, battlecruiser; m guns, drone bay, weapon modifiers |
| late | advanced_alloy | ONLY from reprocessing salvaged scrap | battleship, dreadnought, carrier; l guns, drone hangar |
The advanced_alloy gate is the core loop hook: capital ship production
requires fighting (salvaging scrap from kills and reprocessing it), not just
mining. The reprocessing plant turns 5 scrap into iron/copper/titanium ingots
or advanced_alloy probabilistically.
Intermediate components, by tier:
- **Tier 2 (early):** copper_wire (copper), steel_plate (iron), circuit_board
(iron + wire), building_block (iron).
- **Tier 3 (mid):** mechanical_parts (steel + iron), targeting_unit (circuits
+ wire), drive_unit (steel + mechanical_parts + circuit), titanium_frame
(titanium + steel).
- **Tier 4 (late):** reinforced_plating (steel + advanced_alloy),
capital_core (targeting_unit + drive_unit + 2 advanced_alloy).
Hulls and modules consume intermediates of their tier: early items are built
from tier-2 parts, midgame items require tier-3 parts (deeper chains, more
assemblers), capital items require tier-4 parts (and therefore combat). Hull
items are named `<ship>_hull`; module items `<module>_module`. Every item has
an `[items.*]` entry in visuals.toml; hull item outlines match the ship's
fleet color from `[ships.*]`.
Consistency is checked by `tools/verify_recipes.py` — re-run it after editing
recipes, ship/module materials, or visuals:
python dota_factory/tools/verify_recipes.py
It verifies every consumed item has a producer, every item has a visuals
entry, flags orphaned items, and prints which items are reprocessing-only
(currently exactly advanced_alloy).
## Production tree v2 — decisions (July 2026)
The old tree is discarded; the new one is designed against the rules in
`docs/progression_design.md` (ratio curve, cost ladder, cost archetypes,
refactorability). Decisions fixed so far:
### Base inputs (4) and fiction ### Base inputs (4) and fiction
@@ -189,28 +143,27 @@ refactorability). Decisions fixed so far:
*is* ore, which is why the shipyard operation was built here at all. *is* ore, which is why the shipyard operation was built here at all.
- **quartz** — mid-game, minable only on geode deposit patches in - **quartz** — mid-game, minable only on geode deposit patches in
expansion territory (see the Resource deposits rules in expansion territory (see the Resource deposits rules in
`progression_design.md`). Fiction: ordinary silicate dust is everywhere `docs/balancing/rules.md`; the deposit mechanic itself is an open
and worthless; chips and optics need rare, pocket-bound optical-grade action item — until it lands, quartz mines anywhere). Fiction:
crystal. ordinary silicate dust is everywhere and worthless; chips and optics
need rare, pocket-bound optical-grade crystal.
- **voidsteel** — late-game, obtained only by reprocessing scrap. - **voidsteel** — late-game, obtained only by reprocessing scrap.
Fiction: battle-forged — formed when weapon plasma anneals hull metal Fiction: battle-forged — formed when weapon plasma anneals hull metal
in the violence of ship destruction. Any wreck yields it, including the in the violence of ship destruction. Any wreck yields it, including the
player's own; no foundry can replicate it. player's own; no foundry can replicate it.
- **titanium is dropped.** Its hull-gating role moves to quartz-era - **titanium was dropped** (v1 tree). Its hull-gating role moved to
control systems ("you can smelt all the steel you want, but you cannot quartz-era control systems ("you can smelt all the steel you want, but
steer a battlecruiser without electronics") and possibly a *quality* you cannot steer a battlecruiser without electronics") plus the
steel step — e.g. a long-running hardened-steel recipe (time-heavy hardened-steel quality step (a deliberately long-running, time-heavy
archetype). Open question for the tree draft: quality step vs. recipe) — explicitly not sheer steel quantity alone.
electronics-only gating; explicitly **not** sheer steel quantity alone.
### Material palette (fingerprints per family) ### Material palette (fingerprints per family)
- **iron/steel** — structure. - **iron/steel** — structure.
- **copper** — conduction and heat: wiring, coils, heat sinks. - **copper** — conduction and heat: wiring, coils, heat sinks.
- **silicon family** (all derived from quartz): silicon (logic, - **silicon family** (all derived from quartz): silicon (logic,
sensors), glass/optics (lenses, focusing crystals), ceramics (heat sensors), ceramics (heat shielding, insulators); glass/optics are cut
shielding, insulators). Carries the non-metal variety without extra from v1 — their only consumers would be lasers, which are deferred.
base inputs.
- **voidsteel** — capital-tier structure and exotics. - **voidsteel** — capital-tier structure and exotics.
- Deliberately skipped: carbon (mostly redundant with copper/ceramics), - Deliberately skipped: carbon (mostly redundant with copper/ceramics),
plastics (drags in Factorio-style chemical chains; ceramics read more plastics (drags in Factorio-style chemical chains; ceramics read more
@@ -219,23 +172,25 @@ refactorability). Decisions fixed so far:
### Weapons ### Weapons
- All current lasers are renamed to **railguns** (`laser_cannon_s/m/l` - All v1 weapons are **railguns** (`railgun_s/m/l`, renamed from the
`railgun_s/m/l`); footprints and the gating matrix are unchanged. laser placeholders; footprints and the gating matrix unchanged).
Implementation stays as-is (instant damage application, no projectile, Implementation is instant damage application with no projectile and no
no ammunition) — the beam visual reads as a tracer round. Materials: ammunition — the beam visual reads as a tracer round. Materials: iron
iron slugs, copper coils, steel rails — the starting-metal fingerprint. slugs, copper coils, steel rails — the starting-metal fingerprint.
- **Lasers are reserved for later** as a genuinely distinct weapon type - **Lasers are reserved for later** as a genuinely distinct weapon type
(e.g. once projectile/ammunition mechanics exist for other families), (e.g. once projectile/ammunition mechanics exist for other families),
arriving with quartz optics. More weapon types are planned; railguns arriving with quartz optics. More weapon types are planned; railguns
are simply the baseline tech that ships with v1. are simply the baseline tech that ships with v1.
- `drone_bay` and `drone_hangar` are footprint-only placeholders: the
drone-launching capability does not exist in the simulation yet, so
they define no capability section. The carrier is deliberately weak
until that capability lands (see the accepted imbalances in
`docs/balancing/targets.md`).
### Tree structure — draft ### Tree structure
Recipes are sketched as input lists only; quantities and durations come Input lists only quantities, durations, and per-item threat values are
in the numbers pass, tuned so every chain sums to its threat-ladder in `docs/balancing/derived.md` and the configs.
value and follows the ratio curve (t1 nice → t4 strange). Glass/optics
are cut from v1 — their only consumers would be lasers, which are
deferred; the silicon family ships as silicon + ceramics.
**Mined (miner):** `iron_ore`, `copper_ore` (every tile), `quartz` **Mined (miner):** `iron_ore`, `copper_ore` (every tile), `quartz`
(geode deposits in expansion territory). (geode deposits in expansion territory).
@@ -244,7 +199,7 @@ deferred; the silicon family ships as silicon + ceramics.
| output | input | ratio class | | output | input | ratio class |
|---|---|---| |---|---|---|
| iron_ingot | iron_ore | nice (1:1 or 1:2) | | iron_ingot | iron_ore | nice (1:1) |
| copper_ingot | copper_ore | nice | | copper_ingot | copper_ore | nice |
| silicon | quartz | mid entry | | silicon | quartz | mid entry |
| iron_ingot | scrap | the safe, boring scrap sink | | iron_ingot | scrap | the safe, boring scrap sink |
@@ -280,14 +235,13 @@ fully unlocked pool state.
| capital_core | voidsteel + capacitor_bank + control_chip | capital heart | | capital_core | voidsteel + capacitor_bank + control_chip | capital heart |
**Hull items** (`<ship>_hull`, assembler-made; the shipyard consumes the **Hull items** (`<ship>_hull`, assembler-made; the shipyard consumes the
hull item plus module materials). The m+ hull gate is resolved as hull item plus module materials). The m+ hull gate is **both**
**both** of the open-question options: hardened_steel (quality steel, a hardened_steel (quality steel, the time-heavy step) *and* control_chip
deliberately long-running recipe — the time-heavy step) *and* (electronics):
control_chip (electronics):
| hull | inputs | | hull | inputs |
|---|---| |---|---|
| drone_hull | steel_plate | | drone_hull | iron_ingot |
| frigate_hull | steel_plate + copper_wire | | frigate_hull | steel_plate + copper_wire |
| destroyer_hull | steel_plate + copper_coil | | destroyer_hull | steel_plate + copper_coil |
| cruiser_hull | hardened_steel + control_chip | | cruiser_hull | hardened_steel + control_chip |
@@ -302,7 +256,7 @@ stockpiled):
| module | inputs | archetype | | module | inputs | archetype |
|---|---|---| |---|---|---|
| railgun_s | steel_plate + copper_coil | balanced | | railgun_s | copper_coil | lean |
| salvager | steel_plate + copper_wire | balanced | | salvager | steel_plate + copper_wire | balanced |
| repair_tool | steel_plate + copper_wire | balanced | | repair_tool | steel_plate + copper_wire | balanced |
| armor_plates | steel_plate (many) | material-heavy, fast | | armor_plates | steel_plate (many) | material-heavy, fast |
@@ -317,238 +271,24 @@ stockpiled):
| railgun_l | capacitor_bank + hardened_steel + ceramic_plate | late | | railgun_l | capacitor_bank + hardened_steel + ceramic_plate | late |
| drone_hangar | voidsteel_plate + control_chip + drive_unit | late (carrier only) | | drone_hangar | voidsteel_plate + control_chip + drive_unit | late (carrier only) |
Refactorability check (the default technique holds): railgun_s → m **Refactorability check** (the default technique holds): railgun_s → m
introduces capacitor_bank, built from a subset of the small gun's introduces capacitor_bank, built from a subset of the small gun's inputs
inputs (copper_coil) plus the new base resource (silicon); the m gun (copper_coil) plus the new base resource (silicon); the m gun otherwise
otherwise reuses the small gun's inputs. Hulls likewise: cruiser adds reuses the small gun's inputs. Hulls likewise: the cruiser adds
hardening (fed by the existing steel line) and chips (fed by the new hardening (fed by the existing steel line) and chips (fed by the new
quartz territory) without touching the iron/copper core. quartz territory) without touching the iron/copper core.
**Shortcut recipe candidates** (drop-only assembler schematics; not **Shortcut recipes** (drop-only assembler schematics; not every strange
every strange chain gets one): chain gets one): `iron_ore → steel_plate` (skips the ingot step on the
highest-volume chain), `quartz → control_chip` (skips silicon),
`iron_ingot → hardened_steel` (a nicer-ratio route past the deliberately
awkward hardening step).
- `iron_ore → steel_plate` — skips the ingot step on the highest-volume Consistency is checked by `tools/verify_recipes.py` — re-run it after
chain in the game. editing recipes, ship/module materials, or visuals:
- `quartz → control_chip` — skips silicon on the electronics chain.
- `iron_ingot → hardened_steel` — a nicer-ratio route past the
deliberately awkward hardening step.
Item count: 3 mined + scrap + 3 smelted + 4 t2 + 5 t3 + 3 t4 (incl. python dota_factory/tools/verify_recipes.py
voidsteel) + 8 hulls + 14 modules ≈ 41 item types — same scale as the
first-pass tree. `verify_recipes.py` must be re-run once this lands in
recipes.toml; the numbers pass should add a threat-report tool that
prints per-item threat values and producer:consumer ratio tables.
### Numbers — first pass It verifies every consumed item has a producer, every item has a visuals
entry, flags orphaned items, and prints which items are
These numbers are live in the config files and verified by reprocessing-only (currently exactly voidsteel).
`tools/threat_report.py`; quantities and durations are tuned so fitted
ships land on the threat-cost ladder and the ratio curve is realized.
The typical loadouts are geometry-validated against the hull grids and
configured as each ship's `default_modules`: the L-shaped weapon
modifiers turned out not to fit alongside the full gun complement on
the battlecruiser and dreadnought, so their loadouts use armor and
small-railgun fillers instead — which moved all three heavy ships
closer to their ladder targets.
**Economy constants:** `scrap_per_threat = 0.25` (1 scrap per 4 threat
destroyed — a cruiser kill drops ~59 scrap). Reprocessing: 4 scrap per
cycle, 4 s, full-pool weights iron_ingot 30 / copper_ingot 30 /
silicon 20 / voidsteel 20 → threat(voidsteel) = (4·4 + 4)/0.2 = 100.
Scrap smelting: 1 scrap → 1 iron_ingot, 1 s — deliberately
value-losing (4 threat of scrap becomes a 2-threat ingot); reprocessing
is the value-preserving path.
**Recipes** (dur in seconds; threat is per output item):
| item | recipe | dur | out | threat |
|---|---|---|---|---|
| iron_ore / copper_ore | miner | 1 | 1 | 1 |
| quartz | miner (deposit) | 2 | 1 | 2 |
| iron_ingot | 1 iron_ore | 1 | 1 | 2 |
| copper_ingot | 1 copper_ore | 1 | 1 | 2 |
| silicon | 1 quartz | 2 | 1 | 4 |
| steel_plate | 2 iron_ingot | 3 | 1 | 7 |
| copper_wire | 1 copper_ingot | 1 | 2 | 1.5 |
| copper_coil | 2 copper_wire | 1.5 | 1 | 4.5 |
| building_block | 2 steel_plate | 2 | 4 | 4 |
| control_chip | 1 silicon + 2 copper_wire | 5 | 1 | 12 |
| capacitor_bank | 2 copper_coil + 1 silicon | 5 | 1 | 18 |
| hardened_steel | 3 steel_plate | 12 | 1 | 33 |
| ceramic_plate | 2 quartz | 4 | 1 | 8 |
| drive_unit | 2 steel_plate + 2 copper_coil + 1 control_chip | 8 | 1 | 43 |
| voidsteel_plate | 1 voidsteel + 1 hardened_steel | 8 | 1 | 141 |
| capital_core | 2 voidsteel + 1 capacitor_bank + 1 control_chip | 10 | 1 | 240 |
**Module prefabs** (contribution = item threat + module production
time):
| module | recipe | dur | mod. time | contribution |
|---|---|---|---|---|
| railgun_s | 1 copper_coil | 1 | 1 | 6.5 |
| salvager | 1 steel_plate + 2 copper_wire | 2 | 1 | 13 |
| repair_tool | 1 steel_plate + 2 copper_wire | 2 | 1 | 13 |
| armor_plates | 4 steel_plate | 3 | 1 | 32 |
| maneuvering_thrusters | 1 steel_plate + 1 copper_coil | 2 | 1 | 14.5 |
| sensor_booster | 2 copper_wire + 1 copper_coil | 2 | 1 | 10.5 |
| afterburner | 2 copper_coil + 1 steel_plate | 3 | 1 | 20 |
| weapon_stabilizer | 1 steel_plate + 1 copper_coil | 2 | 1 | 14.5 |
| weapon_primer | 1 capacitor_bank + 1 copper_coil | 4 | 2 | 28.5 |
| weapon_upgrade | 1 control_chip + 1 copper_coil | 4 | 2 | 22.5 |
| railgun_m | 1 capacitor_bank + 2 steel_plate + 1 copper_coil | 4 | 3 | 43.5 |
| drone_bay | 1 control_chip + 2 steel_plate + 1 copper_coil | 4 | 3 | 37.5 |
| railgun_l | 1 capacitor_bank + 2 hardened_steel + 1 ceramic_plate | 6 | 4 | 102 |
| drone_hangar | 1 voidsteel_plate + 2 control_chip + 1 drive_unit | 10 | 6 | 224 |
**Ships** (fitted = hull item + ship base time + typical loadout; the
typical loadouts double as the `default_modules` for enemy waves):
| ship | hull recipe | dur | base | typical loadout | fitted (target) |
|---|---|---|---|---|---|
| drone | 1 iron_ingot | 1 | 1 | railgun_s | 10 (10) |
| frigate | 2 steel_plate + 1 copper_wire | 2 | 2 | 2× railgun_s, maneuvering_thrusters | 47 (40) |
| destroyer | 3 steel_plate + 2 copper_coil | 4 | 3 | 3× railgun_s, armor_plates, sensor_booster | 99 (80) |
| cruiser | 2 hardened_steel + 2 control_chip | 6 | 4 | 2× railgun_m, armor_plates, maneuvering_thrusters | 234 (200) |
| battlecruiser | 3 hardened_steel + 2 control_chip + 1 drive_unit | 8 | 5 | 3× railgun_m, armor_plates, 2× railgun_s | 355 (350) |
| battleship | 3 voidsteel_plate + 1 drive_unit + 2 control_chip | 10 | 6 | railgun_l, 2× railgun_m, weapon_stabilizer, 2× railgun_s | 723 (700) |
| dreadnought | 5 voidsteel_plate + 1 capital_core + 2 drive_unit | 12 | 8 | 3× railgun_l, 4× armor_plates, railgun_s | 1492 (1500) |
| carrier | 5 voidsteel_plate + 1 capital_core + 2 drive_unit | 12 | 8 | drone_hangar, 2× railgun_m, 2× armor_plates, sensor_booster | 1436 (1500) |
**Checks:**
- *Ratio curve realized:* t1 all 1:1 (miner:smelter); t2 clean 2:3
(ingot→plate, wire→coil); t3 strange — 2:5 (silicon→chip), 3:5
(coil→capacitor), 3:4 (plate→hardened, plate→drive); t4 inverted 3:2
(hardened→voidsteel_plate).
- *Belt feasibility:* worst input demand is 1.33 items/s (wire→coil,
plate→armor) — under the ~2/s single-belt cap everywhere; no
accidental multi-belt recipes.
- *Block economy:* building_block = 4 threat ⇒ for the 4-minute
doubling at 30% capacity, the average building must cost ≈ 18 blocks
(0.3 × 240 / 4). buildings.toml costs should be set around that mean
(belts cheap, producers ~2030).
- *Small-end deviation:* frigatecruiser land 624% hot because the
fixed chain overhead dominates small hulls. Recommendation: accept
and adjust the ladder targets to the achieved values (the ~×2-per-
class curve shape is preserved) rather than thinning the early
chains below readability.
- *Rule bug discovered:* the scrap→iron_ingot smelter recipe combined
with REQ-THREAT-ITEM's max-across-recipes rule would set
threat(iron_ingot) to the scrap path (1 + 4 = 5, or more at other
scrap values) instead of 2, inflating every downstream item. Fix:
scrap-consuming recipes count toward an item's threat only when no
scrap-free recipe produces that item, mirroring the reprocessing-path
rule (see progression_design.md action items).
### Combat stat anchors — first pass
All combat stats derive from a few anchors so that power-per-threat holds
by construction; the arena suite in `bin/balancing/data/balancing.toml`
verifies it empirically. Change the anchors first, re-derive stats second.
- **Weapon DPS ≈ 0.6 per threat of weapon contribution**, mildly
diminishing with gun size, compensated by the range ladder:
railgun_s 2 dmg × 2.0 Hz = 4.0 DPS at 6.5 threat (0.62), range 50;
railgun_m 17 × 1.5 = 25.5 at 43.5 (0.59), range 70;
railgun_l 70 × 0.8 = 56 at 102 (0.55), range 100.
- **Hull HP = 15 per threat of hull contribution** (incl. ship base
time): drone 60, frigate 300, destroyer 550, cruiser 1500,
battlecruiser 2700, battleship 7500, dreadnought/carrier 15500.
- **Armor HP = 20 per threat** — a premium over hull HP because armor is
pure HP with no capability: armor_plates adds 640 HP at 32 threat.
- **TTK check:** a mirror cruiser duel (51 DPS vs 2140 EHP) lasts ~42 s —
inside the 3060 s parity-fight band implied by the 25-ship fleet
target. Capital mirrors run longer (~90 s), deliberately.
- **Repair ≈ 2 HP/s per threat:** repair_tool heals 25 HP × 1 Hz at 13
threat. Salvager: collection range 60 (was a nonsensical 500, beyond
any sensor), cargo 20, 0.5 collections/s. `scrap_despawn_seconds`
raised 30 → 120: a capital kill drops hundreds of scrap collected one
per cycle, so 30 s despawned nearly everything.
- **Mobility ladder is monotone in size** — small = fast and nimble
(drone 45 m/s, 60 accel) down to capitals (10 m/s, 8 accel); the old
placeholder had the drone as the least agile ship. Sensor ranges keep
the existing 150→350 ladder; all weapon ranges stay below sensor
ranges.
- **Weapon modifiers are capital economy:** ×1.2 damage at 22.5 threat
beats adding a gun once a ship carries more than ~68 threat of weapons
— so modifiers pay off on gun-heavy big hulls and are a waste on small
ones. Kept at ×1.2/×1.2/(×1.5 range, ×0.8 rate) deliberately.
- **Stations:** a fresh player station (3000 HP, 25 dmg × 1 Hz,
range 200) holds one early parity wave unaided; the enemy station at
level 0 matches it exactly and scales per push level
(+1500 HP, +12 dmg, +0.1 Hz per level). HQ 5000 HP.
- **Known imbalance, accepted:** the carrier loses its equal-threat
arena until the drone-launching capability exists — the hangar is 224
threat of dead weight. Do not compensate with stats; fix by
implementing drones.
## Balancing targets (first pass, July 2026)
The six root numbers for the balancing pass. Every derived value (threat
rate, recipe quantities, block costs, scrap rates, unlock ladder) is tuned
to hit these; when rebalancing later, change these first and re-derive,
never the other way around. The rules they follow live in
`docs/progression_design.md`.
All targets are in **game time**. The player can pause and accelerate, so
real session length differs; playtests should measure both. The time unit
is the boss cycle (`world.toml boss_countdown_seconds`, 300 s). Destroying
a station set advances the boss countdown by `boss_advance_seconds`
(60 s), so cycles run shorter than nominal when pushing actively — these
targets deliberately ignore that; playtesting will show real run lengths.
1. **Run length** — a winning run takes up to 2 hours of game time: win
around boss cycle 2024. Losing runs end earlier.
2. **Phase boundaries** — early = cycles 15 (iron/copper, small hulls),
mid = cycles 614 (quartz, medium hulls), late = cycles 15+
(voidsteel, capitals). Push cadence roughly one station set per
cycle from mid onward. This fixes the `unlock_at_station_level`
ladder and, with the artifact win count, the `artifact_chance_formula`
pacing.
3. **Factory size curve** — producing buildings over time; when
saturated, output threat/s equals this count, so this curve IS the
player power curve. Targets: ~25 when the starting asteroid is full
(end of cycle 2), ~60 at the start of mid (cycle 6), ~120 at the
start of late (cycle 15), ~150 near the win. `threat_rate_formula`
must remain a fraction of this curve, and buildings plus belts must
physically fit the asteroid plus affordable expansions.
4. **Threat-cost ladder** — total production-seconds per *fitted* hull
(including a typical module loadout): drone ~10, frigate ~40,
destroyer ~80, cruiser ~200, battlecruiser ~350, battleship ~700,
dreadnought/carrier ~1500. Every production chain must sum to its
ladder value. Cross-check against (3): fitted-ship cadence =
ladder value / (factory threat/s devoted to military) — e.g. a mid
factory spending half of 60 threat/s on ships fields a fitted
cruiser roughly every 7 s.
5. **Fleet size** — swarm-leaning: ~25 player combat ships as the
standing mid-game fleet. Standing fleet = build cadence (4) × average
ship lifetime, so this target drives time-to-kill and therefore the
combat stat magnitudes tuned in the arena pass.
6. **Block economy roots** — bootstrap complete (starting asteroid full)
by the end of cycle 2; a factory spending ~30% of its capacity on
blocks doubles in ~4 minutes early game; one expansion affordable per
cycle at ~1/3 of block income, with escalating costs that eventually
outrun any income (see the growth curve rules in
`progression_design.md`).
## Deliberate placeholders / open questions for later passes
- All new hulls have `threat.cost_formula = "0"` so enemy waves do not spawn
them yet (WaveSystem treats any ship with positive threat cost as wave-
eligible, regardless of unlock level). The balancing pass should set real
threat costs together with `default_modules` loadouts so waves spawn them
armed.
- All new hulls and all assembler recipes are `unlock_at_station_level = -1`
(available from the start) to make testing easy; the balancing pass should
stagger these so mid/lategame recipes drop as schematics from enemy defence
stations.
- Recipe quantities and durations are a first guess, deliberately roughly
tiered (capital hulls ~60 s, drones 4 s); the balancing pass tunes them.
- `drone_bay` and `drone_hangar` are footprint-only placeholders: the drone
launching capability does not exist in the simulation yet, so they define
no capability section.
- Renames in this pass: `laser_cannon_xs``laser_cannon_s` (the old 2x2
`laser_cannon_s` became `laser_cannon_m`), `armor_plate``armor_plates`,
`manuvering_thrusters``maneuvering_thrusters` (typo fix). Test data
under `bin/test/data/config` intentionally still uses the old ids — it is
an independent fixture set.

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, and artifact win count. - **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).
- **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,7 +109,15 @@ 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
@@ -118,7 +126,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. Scrap delivered here is placed onto connected output belts. - REQ-BLD-SALVAGE-BAY: **Salvage Bay** (3×2): A dedicated drop-off point for salvage ships. It has an output buffer whose holding capacity is defined by the `output_buffer_capacity` field of the `salvage_bay` entry in `buildings.toml` (rather than by a production cycle, since the Salvage Bay has no recipe). A ship at the bay hands over one unit of scrap per tick while the buffer has free space; a full buffer blocks further drop-off until space frees up (consistent with the buffer-full semantics of REQ-MAT-OUTPUT-BUFFER). Held scrap is pushed onto connected output belts.
- REQ-BLD-BELT: **Belt** (1×1): Transports items. A belt tile has one direction (N, S, E, W) set at placement (modified by rotation). Curved belts are auto-derived: when a belt tile's outgoing direction leads into another belt whose direction is orthogonal, the downstream belt is rendered and behaves as a curve. Belt speed is defined in `world.toml [world].belt_speed_tiles_per_second` (REQ-GW-BELT-SPEED). - 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.
@@ -311,13 +319,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 `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 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 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: 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:
- 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.
Item names are deduplicated and sorted alphabetically. If no recipes would be newly unlocked, the list shows "None". 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".
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:
@@ -367,7 +375,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
## Asteroid Expansion ## Asteroid Expansion
- REQ-EXP-UNLOCK: The player can unlock additional asteroid tile columns to the left of the existing asteroid by spending building blocks from the global stock. - REQ-EXP-UNLOCK: The player can unlock additional asteroid tile columns to the left of the existing asteroid by spending building blocks from the global stock.
- REQ-EXP-COST: Each expansion adds `world.toml [expansion].columns_per_expansion` columns and costs `[expansion].cost_building_blocks` building blocks. - REQ-EXP-COST: Each expansion adds `world.toml [expansion].columns_per_expansion` columns. The building block cost of an expansion is defined by the formula `world.toml [expansion].cost_building_blocks_formula`, where `x` is the number of expansions already purchased (0 for the first expansion, incrementing by 1 for each subsequent expansion). The formula is evaluated at purchase time and its result is floored to an integer number of building blocks.
## UI ## UI
@@ -392,15 +400,22 @@ The screen is divided into two columns: a main column (75% width) containing the
(75% width) (25% width) (75% width) (25% width)
``` ```
- REQ-UI-HEADER: The header bar spans the width of the game world column (75% of the screen width) and always shows the elapsed survival time, the current global building blocks stock, and the artifact count (REQ-WIN-ARTIFACT-COUNT) displayed as `Artifacts: x/y` (where `x` is the current artifact count and `y` is `world.toml [world].artifact_win_count`) on the left, the boss wave counter and boss countdown (REQ-UI-BOSS-STATUS) to the left of the speed buttons, and game speed controls on the right. - REQ-UI-HEADER: The header bar spans the width of the game world column (75% of the screen width) and always shows the elapsed survival time, the current global building blocks stock, and the artifact count (REQ-WIN-ARTIFACT-COUNT) displayed as `Artifacts: x/y` (where `x` is the current artifact count and `y` is `world.toml [world].artifact_win_count`) on the left, the boss wave counter and boss countdown (REQ-UI-BOSS-STATUS) and an asteroid expansion button (REQ-UI-EXPAND-BUTTON) to the left of the speed buttons, and game speed controls on the right.
- REQ-UI-BOSS-STATUS: The header bar displays, to the left of the speed buttons, the current boss wave counter (REQ-WAV-BOSS-COUNTER) and the time remaining on the boss countdown (REQ-WAV-BOSS-COUNTDOWN). The boss wave counter is shown as `Boss Wave #<x>` and the countdown as `Next boss: <M:SS>`, where `<M:SS>` is the remaining seconds formatted as whole minutes and two-digit seconds. Both values update continuously as the simulation runs. - REQ-UI-BOSS-STATUS: The header bar displays, to the left of the speed buttons, the current boss wave counter (REQ-WAV-BOSS-COUNTER) and the time remaining on the boss countdown (REQ-WAV-BOSS-COUNTDOWN). The boss wave counter is shown as `Boss Wave #<x>` and the countdown as `Next boss: <M:SS>`, where `<M:SS>` is the remaining seconds formatted as whole minutes and two-digit seconds. Both values update continuously as the simulation runs.
- REQ-UI-SPEED: The game speed controls in the header bar are buttons for 0×, 0.5×, 1×, 2×, and 4× speed. The currently active speed is shown as selected. All game simulation (production, movement, threat accumulation, wave timing) scales with the selected speed. 0× pauses the game. - REQ-UI-SPEED: The game speed controls in the header bar are buttons for 0×, 0.5×, 1×, 2×, and 4× speed. The currently active speed is shown as selected. All game simulation (production, movement, threat accumulation, wave timing) scales with the selected speed. 0× pauses the game.
- REQ-UI-EXPAND-BUTTON: The header bar shows an asteroid expansion button captioned `Expand: <x> Blocks`, where `<x>` is the current expansion cost computed from `world.toml [expansion].cost_building_blocks_formula` at the current number of purchased expansions (REQ-EXP-COST). Clicking the button unlocks the next asteroid expansion (REQ-EXP-UNLOCK, REQ-GW-ASTEROID-EXPAND), spending that many building blocks from the global stock. The button is disabled when the player cannot currently afford the cost (consistent with REQ-UI-BUILD-DISABLED). The caption updates as the cost changes with each purchased expansion.
- REQ-UI-WORLD-SIZE: The game world view occupies the full height below the header bar in the main column (75% of the screen width). - REQ-UI-WORLD-SIZE: The game world view occupies the full height below the header bar in the main column (75% of the screen width).
- REQ-UI-PANEL-COLUMN: The side panel column occupies 25% of the screen width and the full screen height. It is divided into three equal-height panels stacked top to bottom: selected building panel (top), build button grid (middle), and blueprint panel (bottom). - REQ-UI-PANEL-COLUMN: The side panel column occupies 25% of the screen width and the full screen height. It is divided into three equal-height panels stacked top to bottom: selected building panel (top), build button grid (middle), and blueprint panel (bottom).
### 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). - REQ-UI-SCROLL: The player scrolls the view horizontally across the scrollable area by pressing A (scroll left) and D (scroll right). The pan speed is not constant; it varies with the view's position per REQ-UI-SCROLL-SPEED.
- REQ-UI-SCROLL-SPEED: The horizontal pan speed varies with position so the player crosses the empty middle of the world quickly while retaining fine control near the asteroid and near the front line. Two pan speeds are read from `world.toml [scroll]`: `pan_speed_slow_tiles_per_second` (the base speed, used while the view is over the asteroid and player buffer zone) and `pan_speed_fast_tiles_per_second` (the faster speed, used while the view is over the contest zone). Both are expressed in tiles per second and apply equally to the A and D scroll directions. The current pan speed is a function of the view's horizontal center X (REQ-GW-REGIONS defines the regions):
- **Flat regions:** while the view center is left of the contest zone (over the asteroid or player buffer zone) and outside any ramp band, the pan speed is the slow speed; while the view center is inside the contest zone and outside any ramp band, the pan speed is the fast speed.
- **Ramp bands:** a transition ramp band of width `world.toml [scroll].pan_ramp_band_width_tiles` tiles straddles each contest-zone boundary (the player defence stations on the left, the enemy defence stations on the right), centered on the boundary with half the band width on each side. While the view center is within a ramp band, the pan speed is linearly interpolated between the slow speed at the band's outer (non-contest-zone) edge and the fast speed at the band's inner (contest-zone) edge, by the view center's fractional position across the band. This produces smooth speed changes when entering and exiting the fast contest-zone range rather than an abrupt jump.
- **Narrow contest zone:** should the two ramp bands overlap (a contest zone narrower than the band width), each ramp is clamped at the contest-zone center so the bands do not cross; the fast plateau then reduces to a single point at the center and the peak speed there may be below the fast speed.
Because the contest-zone boundaries shift as the scrollable area grows with each push (REQ-GW-PUSH-EXPAND, REQ-GW-SCROLL-LIMIT), the ramp bands are recomputed from the current contest-zone boundaries. This is a presentation-only concern and does not affect the simulation, consistent with REQ-UI-NO-ZOOM.
- REQ-UI-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.
@@ -412,6 +427,7 @@ 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.
@@ -462,10 +478,12 @@ 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). - 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-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.
@@ -476,9 +494,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: 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-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-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. - 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.
## Balancing Tool ## Balancing Tool
@@ -516,13 +534,13 @@ A separate executable target (`balancing`) that links against `lib` but contains
- REQ-BAL-UI-RELOAD: The "Reload Config" button reloads all config files from disk (`balancing.toml`, `ships.toml`, `stations.toml`), stops any running simulations, and replaces the arena widget list with freshly created widgets from the reloaded config. The button is disabled while any arena simulation is currently running. - REQ-BAL-UI-RELOAD: The "Reload Config" button reloads all config files from disk (`balancing.toml`, `ships.toml`, `stations.toml`), stops any running simulations, and replaces the arena widget list with freshly created widgets from the reloaded config. The button is disabled while any arena simulation is currently running.
- REQ-BAL-UI-START-ALL: The "Start All" button is placed above the scrollable arena list, to the right of the "Reload Config" button. Clicking it starts (or restarts) the simulation for every arena that is not currently running. The button is disabled when all arenas are currently running. - REQ-BAL-UI-START-ALL: The "Start All" button is placed above the scrollable arena list, to the right of the "Reload Config" button. Clicking it starts (or restarts) the simulation for every arena that is not currently running. The button is disabled when all arenas are currently running.
- REQ-BAL-UI-LOG: The "Log" button is placed in the header to the right of the "Start All" button. It is enabled whenever the main window's controls are enabled — including while simulations are running (it captures a live snapshot) — and, like all main window controls, is disabled only while an arena is being inspected (REQ-BAL-UI-WINDOW). Clicking it writes the current state of every arena to a file named `balancing_log.md` in the tool's current working directory, replacing (overwriting) any previous content of that file. The log captures, for every arena in `balancing.toml` order, exactly the information shown in that arena's widget (REQ-BAL-UI-WIDGET) at the moment the button is clicked. Each arena is written as its own section: - REQ-BAL-UI-LOG: The "Log" button is placed in the header to the right of the "Start All" button. It is enabled whenever the main window's controls are enabled — including while simulations are running (it captures a live snapshot) — and, like all main window controls, is disabled only while an arena is being inspected (REQ-BAL-UI-WINDOW). Clicking it writes the current state of every arena to a file named `balancing_log.md` in the tool's current working directory, replacing (overwriting) any previous content of that file. The log captures, for every arena in `balancing.toml` order, exactly the information shown in that arena's widget (REQ-BAL-UI-WIDGET) at the moment the button is clicked. Each arena is written as its own section:
- A heading with the arena name followed by the arena's current state — `not started`, `running`, or `ended` (corresponding to the widget border colors of REQ-BAL-UI-WIDGET-BORDER). - A heading with the arena name followed by the arena's current state — `not started`, `running`, or `ended` (corresponding to the widget border colors of REQ-BAL-UI-WIDGET-BORDER). For an `ended` arena, the heading also includes the battle duration (REQ-BAL-UI-WIDGET), for example `ended, 42.3 s`.
- A markdown table with one column per team (team 1 left, team 2 right). Each team's column header shows the team name — prefixed with `[WON]` when that team won, matching REQ-BAL-UI-WIDGET — the team's accumulated threat level, and the team's remaining EHP percentage (REQ-BAL-UI-WIDGET). - A markdown table with one column per team (team 1 left, team 2 right). Each team's column header shows the team name — prefixed with `[WON]` when that team won, matching REQ-BAL-UI-WIDGET — the team's accumulated threat level, and the team's remaining EHP percentage (REQ-BAL-UI-WIDGET).
- Below the header, each table row holds one of that team's entries, in the same order and text format as the widget (REQ-BAL-UI-WIDGET): the HQ first, then ship types, then defence stations, formatted `surviving/total TypeName` for ship entries and `surviving/total TypeName Llevel` for the HQ and defence station entries. When the two teams have different numbers of entries, the shorter column's remaining cells are left blank. - Below the header, each table row holds one of that team's entries, in the same order and text format as the widget (REQ-BAL-UI-WIDGET): the HQ first, then ship types, then defence stations, formatted `surviving/total TypeName` for ship entries and `surviving/total TypeName Llevel` for the HQ and defence station entries. When the two teams have different numbers of entries, the shorter column's remaining cells are left blank.
- REQ-BAL-UI-WIDGET: Each arena widget displays the arena name, an "Inspect" button (to the right of the arena name), and two columns (one per team). Each column shows the team name as a header, then directly below the header the team's **accumulated threat level** — the sum, across the team's configured ship entries, of each entry's `count` multiplied by the threat cost (REQ-MOD-THREAT) of one ship of that entry computed from its level-independent module layout. Only ships contribute; the HQ and defence stations are excluded. This value is static: it is computed once from the full configured roster and does not change as ships are destroyed during the fight. Directly below the threat level, the column shows the team's **remaining EHP percentage** — the sum of the current HP of all of the team's ships and defence stations, divided by the sum of their maximum HP, expressed as a percentage rounded to a whole number. Maximum HP is the final per-entity maximum (REQ-MOD-STAT-CALC), so module HP bonuses such as armor plates are included. (The game has no damage mitigation, so effective HP equals raw HP.) The HQ is excluded from both sums. A destroyed ship or station contributes 0 to the numerator and its maximum HP to the denominator, so the value measures how much of the team's fielded durability remains: it starts at 100% and decreases as units take damage or are destroyed. Unlike the static threat level, this value updates live as the simulation progresses. If the team has neither ships nor defence stations (the denominator is 0), the percentage is shown as `n/a`. Below the EHP percentage, the column shows a list of entries. The HQ is always the first entry in each column. Below the HQ, ship types are listed, followed by defence stations (if any). Each entry uses the format `surviving/total TypeName` for ship entries and `surviving/total TypeName Llevel` for the HQ and defence station entries — for example `2/3 Fighter`, `1/1 HQ L1`, or `2/2 Enemy Station L3`. The surviving count updates live as the simulation progresses. When the fight ends, the winning team's name header is prefixed with `[WON]`. - REQ-BAL-UI-WIDGET: Each arena widget displays the arena name, an "Inspect" button (to the right of the arena name), and two columns (one per team). Each column shows the team name as a header, then directly below the header the team's **accumulated threat level** — the sum, across the team's configured ship entries, of each entry's `count` multiplied by the threat cost (REQ-MOD-THREAT) of one ship of that entry computed from its level-independent module layout. Only ships contribute; the HQ and defence stations are excluded. This value is static: it is computed once from the full configured roster and does not change as ships are destroyed during the fight. Directly below the threat level, the column shows the team's **remaining EHP percentage** — the sum of the current HP of all of the team's ships and defence stations, divided by the sum of their maximum HP, expressed as a percentage rounded to a whole number. Maximum HP is the final per-entity maximum (REQ-MOD-STAT-CALC), so module HP bonuses such as armor plates are included. (The game has no damage mitigation, so effective HP equals raw HP.) The HQ is excluded from both sums. A destroyed ship or station contributes 0 to the numerator and its maximum HP to the denominator, so the value measures how much of the team's fielded durability remains: it starts at 100% and decreases as units take damage or are destroyed. Unlike the static threat level, this value updates live as the simulation progresses. If the team has neither ships nor defence stations (the denominator is 0), the percentage is shown as `n/a`. Below the EHP percentage, the column shows a list of entries. The HQ is always the first entry in each column. Below the HQ, ship types are listed, followed by defence stations (if any). Each entry uses the format `surviving/total TypeName` for ship entries and `surviving/total TypeName Llevel` for the HQ and defence station entries — for example `2/3 Fighter`, `1/1 HQ L1`, or `2/2 Enemy Station L3`. The surviving count updates live as the simulation progresses. When the fight ends, the winning team's name header is prefixed with `[WON]`. When the fight has ended, the widget also displays the arena's **battle duration** — an arena-level value (not per team) giving the game time the fight lasted, computed as the number of simulated ticks at completion multiplied by the simulation's fixed tick duration (the same tick-based simulation as the main game, REQ-BAL-SIM-ENV). This is game time, not wall-clock time, so it is independent of how fast the arena was simulated (non-inspected arenas run at maximum tick rate, REQ-BAL-SIM-SPEED). It is shown in seconds with one decimal place, for example `Duration: 42.3 s`. The battle duration is shown only for completed (ended) runs; it is not shown while the arena is not started or running.
- REQ-BAL-UI-WIDGET-START: Each arena widget contains a "Start" button that starts the simulation for that arena. The button is disabled while the arena's simulation is running. When a finished arena's Start button is clicked, a fresh simulation is created and started (the widget resets to initial unit counts, the border returns to blue, and the previous results are replaced). - REQ-BAL-UI-WIDGET-START: Each arena widget contains a "Start" button that starts the simulation for that arena. The button is disabled while the arena's simulation is running. When a finished arena's Start button is clicked, a fresh simulation is created and started (the widget resets to initial unit counts, the border returns to blue, and the previous results are replaced).
- REQ-BAL-UI-WIDGET-BORDER: Each arena widget has a colored border indicating its state: grey when not yet started, blue while its simulation is running, and green when the fight has ended. - REQ-BAL-UI-WIDGET-BORDER: Each arena widget has a colored border indicating its state: grey when not yet started, blue while its simulation is running, and green when the fight has ended.
- REQ-BAL-UI-INSPECT: Clicking an arena widget's "Inspect" button opens a new inspect window for that arena. Any previously open inspect window is closed first (its arena's simulation is aborted and its widget border returns to grey). The inspected arena is restarted with a fresh simulation that runs at controllable game speed with full rendering (REQ-BAL-SIM-SPEED). The arena widget updates live during inspection (surviving counts, border color, `[WON]` prefix) as it does for non-inspected arenas. Only one inspect window may be open at a time. - REQ-BAL-UI-INSPECT: Clicking an arena widget's "Inspect" button opens a new inspect window for that arena. Any previously open inspect window is closed first (its arena's simulation is aborted and its widget border returns to grey). The inspected arena is restarted with a fresh simulation that runs at controllable game speed with full rendering (REQ-BAL-SIM-SPEED). The arena widget updates live during inspection (surviving counts, border color, `[WON]` prefix) as it does for non-inspected arenas. Only one inspect window may be open at a time.
- REQ-BAL-UI-INSPECT-WINDOW: The inspect window consists of three sections, top to bottom: a title bar area containing the arena name and game speed controls (same buttons as the main game: 0×, 0.5×, 1×, 2×, 4×, with Space to toggle pause — see REQ-UI-SPEED and REQ-UI-HOTKEYS), the arena view in the center, and an info panel at the bottom displaying the same team columns and entry format as the arena widget in the main window (REQ-BAL-UI-WIDGET), updated live. - REQ-BAL-UI-INSPECT-WINDOW: The inspect window consists of three sections, top to bottom: a title bar area containing the arena name and game speed controls (same buttons as the main game: 0×, 0.5×, 1×, 2×, 4×, with Space to toggle pause — see REQ-UI-SPEED and REQ-UI-HOTKEYS), the arena view in the center, and an info panel at the bottom displaying the same team columns and entry format as the arena widget in the main window (REQ-BAL-UI-WIDGET), updated live, including the arena's battle duration once the fight has ended (REQ-BAL-UI-WIDGET).
- REQ-BAL-UI-INSPECT-VIEW: The arena view renders all tiles of the arena and displays ships, HQs, defence stations, and laser beams using the same visual elements and `visuals.toml` colors as the main game. Team 1 uses player visual styles; team 2 uses enemy visual styles. The view has a fixed zoom level — no zoom or scroll is possible. The tile size is derived so that the full arena (all tiles) fits within the view. - REQ-BAL-UI-INSPECT-VIEW: The arena view renders all tiles of the arena and displays ships, HQs, defence stations, and laser beams using the same visual elements and `visuals.toml` colors as the main game. Team 1 uses player visual styles; team 2 uses enemy visual styles. The view has a fixed zoom level — no zoom or scroll is possible. The tile size is derived so that the full arena (all tiles) fits within the view.
- REQ-BAL-UI-INSPECT-CLOSE: Closing the inspect window (via the window's close button) aborts the inspected arena's simulation. The arena widget's border returns to grey and its surviving counts are left as they were at the moment of closing. All main window buttons and controls are re-enabled. - REQ-BAL-UI-INSPECT-CLOSE: Closing the inspect window (via the window's close button) aborts the inspected arena's simulation. The arena widget's border returns to grey and its surviving counts are left as they were at the moment of closing. All main window buttons and controls are re-enabled.

View File

@@ -1,4 +1,4 @@
set(TARGET_BASE_NAME "DotaFactory") set(TARGET_BASE_NAME "${PRODUCT_NAME}")
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,6 +183,19 @@ 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

@@ -433,10 +433,13 @@ void ArenaSimulation::tickDeaths()
}); });
m_admin.forEach<StationBodyComponent, FactionComponent>( m_admin.forEach<StationBodyComponent, FactionComponent>(
[&team1HasUnits, &team2HasUnits](entt::entity /*e*/, [this, &team1HasUnits, &team2HasUnits](entt::entity e,
const StationBodyComponent& /*sb*/, const StationBodyComponent& /*sb*/,
const FactionComponent& f) const FactionComponent& f)
{ {
// The HQ carries a StationBodyComponent but is not a defence station;
// its destruction is a separate end condition (REQ-BAL-SIM-END).
if (m_admin.hasAll<HqProxyComponent>(e)) { return; }
if (f.isEnemy) { team2HasUnits = true; } if (f.isEnemy) { team2HasUnits = true; }
else { team1HasUnits = true; } else { team1HasUnits = true; }
}); });
@@ -515,6 +518,7 @@ void ArenaSimulation::updateStatus()
ArenaStatus newStatus; ArenaStatus newStatus;
newStatus.finished = m_finished; newStatus.finished = m_finished;
newStatus.winnerTeam = m_winnerTeam; newStatus.winnerTeam = m_winnerTeam;
newStatus.durationSeconds = ticksToSeconds(m_currentTick);
// Live remaining HP of each team's ships and defence stations (HQ excluded); // Live remaining HP of each team's ships and defence stations (HQ excluded);
// the EHP-percentage numerator (denominator is the fixed m_teamMaxEhp). // the EHP-percentage numerator (denominator is the fixed m_teamMaxEhp).

View File

@@ -59,6 +59,9 @@ struct ArenaStatus
TeamStatus teams[2]; TeamStatus teams[2];
bool finished = false; bool finished = false;
int winnerTeam = -1; // 0 or 1 when finished; -1 while running int winnerTeam = -1; // 0 or 1 when finished; -1 while running
// Game time the fight has lasted (simulated ticks * fixed tick duration).
// Meaningful once finished; the battle duration shown for completed runs.
double durationSeconds = 0.0;
}; };
class ArenaSimulation class ArenaSimulation

View File

@@ -33,6 +33,9 @@ void ArenaWidget::buildLayout(const std::string& arenaName)
m_titleLabel->setFont(titleFont); m_titleLabel->setFont(titleFont);
titleRow->addWidget(m_titleLabel); titleRow->addWidget(m_titleLabel);
m_durationLabel = new QLabel(this);
titleRow->addWidget(m_durationLabel);
titleRow->addStretch(); titleRow->addStretch();
m_inspectButton = new QPushButton(tr("Inspect"), this); m_inspectButton = new QPushButton(tr("Inspect"), this);
@@ -159,6 +162,10 @@ void ArenaWidget::updateStatus(const ArenaStatus& status)
content->setText(lines); content->setText(lines);
} }
m_durationLabel->setText(status.finished
? tr("Duration: %1 s").arg(QString::number(status.durationSeconds, 'f', 1))
: QString());
if (status.finished && !m_wasFinished) if (status.finished && !m_wasFinished)
{ {
m_wasFinished = true; m_wasFinished = true;

View File

@@ -36,6 +36,7 @@ private:
int m_arenaIndex; int m_arenaIndex;
ArenaStatus m_lastStatus; ArenaStatus m_lastStatus;
QLabel* m_titleLabel; QLabel* m_titleLabel;
QLabel* m_durationLabel;
QLabel* m_team1Header; QLabel* m_team1Header;
QLabel* m_team2Header; QLabel* m_team2Header;
QLabel* m_team1Threat; QLabel* m_team1Threat;

View File

@@ -373,9 +373,17 @@ void BalancingWindow::writeLog()
for (const ArenaEntry& entry : m_arenas) for (const ArenaEntry& entry : m_arenas)
{ {
const ArenaStatus& status = entry.widget->getLastStatus(); const ArenaStatus& status = entry.widget->getLastStatus();
const ArenaWidget::State state = entry.widget->getState();
QString stateSuffix = stateText(state);
if (state == ArenaWidget::State::Ended)
{
stateSuffix += QStringLiteral(", %1 s")
.arg(QString::number(status.durationSeconds, 'f', 1));
}
out << "\n## Arena: " << QString::fromStdString(entry.config.name) out << "\n## Arena: " << QString::fromStdString(entry.config.name)
<< " (" << stateText(entry.widget->getState()) << ")\n\n"; << " (" << stateSuffix << ")\n\n";
out << "| " << teamHeaderCell(status, 0) << " | " out << "| " << teamHeaderCell(status, 0) << " | "
<< teamHeaderCell(status, 1) << " |\n"; << teamHeaderCell(status, 1) << " |\n";

View File

@@ -52,6 +52,9 @@ InspectWindow::InspectWindow(ArenaSimulation* sim, const GameConfig* config,
nameLabel->setFont(nameFont); nameLabel->setFont(nameFont);
headerLayout->addWidget(nameLabel); headerLayout->addWidget(nameLabel);
m_durationLabel = new QLabel(header);
headerLayout->addWidget(m_durationLabel);
headerLayout->addStretch(); headerLayout->addStretch();
const char* labels[] = { "0x", "0.5x", "1x", "2x", "10x" }; const char* labels[] = { "0x", "0.5x", "1x", "2x", "10x" };
@@ -202,6 +205,10 @@ void InspectWindow::pollStatus()
void InspectWindow::updateInfoPanel(const ArenaStatus& status) void InspectWindow::updateInfoPanel(const ArenaStatus& status)
{ {
m_durationLabel->setText(status.finished
? tr("Duration: %1 s").arg(QString::number(status.durationSeconds, 'f', 1))
: QString());
for (int ti = 0; ti < 2; ++ti) for (int ti = 0; ti < 2; ++ti)
{ {
const ArenaStatus::TeamStatus& team = status.teams[ti]; const ArenaStatus::TeamStatus& team = status.teams[ti];

View File

@@ -54,6 +54,7 @@ private:
ArenaView* m_arenaView; ArenaView* m_arenaView;
std::vector<QPushButton*> m_speedButtons; std::vector<QPushButton*> m_speedButtons;
QLabel* m_durationLabel;
QLabel* m_team1Header; QLabel* m_team1Header;
QLabel* m_team2Header; QLabel* m_team2Header;
QLabel* m_team1Threat; QLabel* m_team1Threat;

View File

@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <optional>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -18,6 +19,10 @@ 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

@@ -279,7 +279,7 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path)
cfg.regions.enemyBufferWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["enemy_buffer_width_tiles"], file, "regions.enemy_buffer_width_tiles")); cfg.regions.enemyBufferWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["enemy_buffer_width_tiles"], file, "regions.enemy_buffer_width_tiles"));
cfg.expansion.columnsPerExpansion_tiles = static_cast<int>(requireInt(tbl["expansion"]["columns_per_expansion_tiles"], file, "expansion.columns_per_expansion_tiles")); cfg.expansion.columnsPerExpansion_tiles = static_cast<int>(requireInt(tbl["expansion"]["columns_per_expansion_tiles"], file, "expansion.columns_per_expansion_tiles"));
cfg.expansion.costBuildingBlocks = static_cast<int>(requireInt(tbl["expansion"]["cost_building_blocks"], file, "expansion.cost_building_blocks")); cfg.expansion.costBuildingBlocksFormula = requireFormula(tbl["expansion"]["cost_building_blocks_formula"], file, "expansion.cost_building_blocks_formula");
cfg.push.pushExpandColumns_tiles = static_cast<int>(requireInt(tbl["push"]["push_expand_columns_tiles"], file, "push.push_expand_columns_tiles")); cfg.push.pushExpandColumns_tiles = static_cast<int>(requireInt(tbl["push"]["push_expand_columns_tiles"], file, "push.push_expand_columns_tiles"));
cfg.push.bossAdvanceSeconds = requireDouble(tbl["push"]["boss_advance_seconds"], file, "push.boss_advance_seconds"); cfg.push.bossAdvanceSeconds = requireDouble(tbl["push"]["boss_advance_seconds"], file, "push.boss_advance_seconds");
@@ -305,6 +305,10 @@ 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;
} }
@@ -333,6 +337,12 @@ 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

@@ -15,7 +15,7 @@ struct WorldRegions
struct WorldExpansion struct WorldExpansion
{ {
int columnsPerExpansion_tiles; int columnsPerExpansion_tiles;
int costBuildingBlocks; Formula costBuildingBlocksFormula; // cost in building blocks; x = expansions already purchased
}; };
// Push effects (REQ-PSH-*, REQ-WAV-BOSS-ADVANCE). // Push effects (REQ-PSH-*, REQ-WAV-BOSS-ADVANCE).
@@ -53,6 +53,14 @@ 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
@@ -73,4 +81,5 @@ 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;
// Display names of items produced by recipes that would newly become // Ids of miner/assembler recipes that would newly become implicitly
// implicitly unlocked (REQ-LOCK-IMPLICIT) if this option is selected. // unlocked (REQ-LOCK-IMPLICIT) if this option is selected. Sorted
// Deduplicated and sorted alphabetically; empty if none. // alphabetically by display name; empty if none.
std::vector<std::string> newlyUnlockedItemNames; std::vector<std::string> newlyUnlockedRecipeIds;
}; };

View File

@@ -3,6 +3,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/TracePrintRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/TracePrintRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/TickAdvancedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/TickAdvancedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingBlocksChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingBlocksChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/ExpansionCostChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/EntitySelectedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/EntitySelectedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/GameSpeedChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/GameSpeedChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BossWaveUpdatedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BossWaveUpdatedEvent.h
@@ -21,6 +22,7 @@ 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
@@ -30,6 +32,7 @@ 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

@@ -0,0 +1,17 @@
#ifndef EXPANSION_COST_CHANGED_EVENT_H
#define EXPANSION_COST_CHANGED_EVENT_H
#include "Event.h"
// Fired when the current asteroid-expansion cost changes (REQ-EXP-COST): once at
// startup and again after each expansion is purchased. Carries the cost in
// building blocks so the header Expand button can update its caption/enabled
// state (REQ-UI-EXPAND-BUTTON).
class ExpansionCostChangedEvent : public Event
{
public:
explicit ExpansionCostChangedEvent(int cost) : cost(cost) {}
const int cost;
};
#endif // EXPANSION_COST_CHANGED_EVENT_H

View File

@@ -0,0 +1,13 @@
#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

@@ -0,0 +1,9 @@
#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

@@ -0,0 +1,51 @@
#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

@@ -0,0 +1,40 @@
#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

@@ -24,6 +24,7 @@ BuildingSystem::BuildingSystem(const GameConfig& config,
, m_spawnShip(std::move(spawnShip)) , m_spawnShip(std::move(spawnShip))
, m_isItemUnlocked(std::move(isItemUnlocked)) , m_isItemUnlocked(std::move(isItemUnlocked))
, m_rng(rng) , m_rng(rng)
, m_asteroidWidth_tiles(config.world.regions.asteroidWidth_tiles)
{ {
} }
@@ -153,6 +154,16 @@ 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.
@@ -281,7 +292,7 @@ bool BuildingSystem::bodyCellsWithinWorldBounds(const std::vector<QPoint>& bodyC
QPoint anchor) const QPoint anchor) const
{ {
const int heightTiles = m_config.world.heightTiles; const int heightTiles = m_config.world.heightTiles;
const int leftEdgeX = -m_config.world.regions.asteroidWidth_tiles; const int leftEdgeX = -m_asteroidWidth_tiles;
for (const QPoint& cell : bodyCells) for (const QPoint& cell : bodyCells)
{ {
const QPoint worldCell = anchor + cell; const QPoint worldCell = anchor + cell;
@@ -408,6 +419,12 @@ 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;
@@ -419,6 +436,12 @@ 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();
@@ -576,7 +599,11 @@ void BuildingSystem::tickConstruction(Tick currentTick)
} }
building.inputPorts = computeInputPorts(building); building.inputPorts = computeInputPorts(building);
if (!building.recipeId.empty()) if (building.type == BuildingType::SalvageBay)
{
initSalvageBayBuffer(building);
}
else if (!building.recipeId.empty())
{ {
if (building.type == BuildingType::Shipyard) if (building.type == BuildingType::Shipyard)
{ {
@@ -843,7 +870,16 @@ 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);
m_spawnShip(building.recipeId, spawnPos, building.shipLayout); // A shipyard builds exactly what the player configured and
// paid for. When no layout is set it produces a bare hull, so
// pass an explicit empty layout rather than nullopt: the latter
// would make ShipSystem fall back to the schematic's
// defaultModules (a wave-only loadout) and yield free weapons.
const std::optional<ShipLayoutConfig> layout =
building.shipLayout.has_value()
? building.shipLayout
: std::make_optional<ShipLayoutConfig>();
m_spawnShip(building.recipeId, spawnPos, layout);
} }
building.production = std::nullopt; building.production = std::nullopt;
} }
@@ -1237,6 +1273,11 @@ 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

@@ -59,6 +59,11 @@ public:
bool isPlacementValid(BuildingType type, QPoint anchor, bool isPlacementValid(BuildingType type, QPoint anchor,
Rotation rotation) const; Rotation rotation) const;
// Sets the current buildable asteroid width in tiles. Grows the left
// placement bound as the player unlocks asteroid expansions (REQ-EXP-UNLOCK).
// Defaults to world.regions.asteroid_width_tiles at construction.
void setAsteroidWidth_tiles(int widthTiles) { m_asteroidWidth_tiles = widthTiles; }
// Remove a building or construction site by id. Returns the refund in // Remove a building or construction site by id. Returns the refund in
// building blocks (floor(cost * refundPercentage / 100)). Returns 0 for // building blocks (floor(cost * refundPercentage / 100)). Returns 0 for
// unknown ids. // unknown ids.
@@ -165,6 +170,7 @@ 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(
@@ -179,6 +185,7 @@ private:
const std::optional<ShipLayoutConfig>&)> m_spawnShip; const std::optional<ShipLayoutConfig>&)> m_spawnShip;
std::function<bool(const std::string&)> m_isItemUnlocked; std::function<bool(const std::string&)> m_isItemUnlocked;
std::mt19937& m_rng; std::mt19937& m_rng;
int m_asteroidWidth_tiles;
std::vector<Building> m_buildings; std::vector<Building> m_buildings;
std::deque<ConstructionSite> m_constructionQueue; std::deque<ConstructionSite> m_constructionQueue;

View File

@@ -10,6 +10,7 @@ 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
@@ -31,6 +32,7 @@ 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

@@ -35,6 +35,7 @@ enum class CommandKind
SetSplitterFilters, SetSplitterFilters,
ClearBeltTiles, ClearBeltTiles,
ApplySchematicChoice, ApplySchematicChoice,
ExpandAsteroid,
Reset Reset
}; };
@@ -129,6 +130,14 @@ struct ApplySchematicChoiceCommand : Command
int choiceIndex = 0; int choiceIndex = 0;
}; };
// Unlocks the next asteroid expansion (REQ-EXP-UNLOCK). Carries no payload: the
// simulation derives the cost and column count from its own expansion counter
// and config, so a recorded command replays identically.
struct ExpandAsteroidCommand : Command
{
ExpandAsteroidCommand() : Command(CommandKind::ExpandAsteroid) {}
};
// Restart boundary: reinitializes the simulation with a fresh seed and, if // Restart boundary: reinitializes the simulation with a fresh seed and, if
// config is set, a reloaded config (GameConfig is move-only, so it is carried by // config is set, a reloaded config (GameConfig is move-only, so it is carried by
// shared_ptr and moved into the sim on apply). A null config keeps the current // shared_ptr and moved into the sim on apply). A null config keeps the current

View File

@@ -2,6 +2,7 @@
#include <algorithm> #include <algorithm>
#include <cassert> #include <cassert>
#include <cmath>
#include "AiSystem.h" #include "AiSystem.h"
#include "Command.h" #include "Command.h"
@@ -132,6 +133,7 @@ void Simulation::reset(unsigned int seed)
m_nextDepartureTick = secondsToTicks(m_config.world.departureIntervalSeconds); m_nextDepartureTick = secondsToTicks(m_config.world.departureIntervalSeconds);
m_nextBuildingId = 1; m_nextBuildingId = 1;
m_buildingBlocksStock = m_config.world.startingBuildingBlocks; m_buildingBlocksStock = m_config.world.startingBuildingBlocks;
m_expansionsPurchased = 0;
m_gameOver = false; m_gameOver = false;
m_isWon = false; m_isWon = false;
m_artifactCount = 0; m_artifactCount = 0;
@@ -275,6 +277,9 @@ void Simulation::apply(const Command& command)
case CommandKind::ApplySchematicChoice: case CommandKind::ApplySchematicChoice:
applySchematicChoice(static_cast<const ApplySchematicChoiceCommand&>(command).choiceIndex); applySchematicChoice(static_cast<const ApplySchematicChoiceCommand&>(command).choiceIndex);
break; break;
case CommandKind::ExpandAsteroid:
tryExpandAsteroid();
break;
case CommandKind::Reset: case CommandKind::Reset:
{ {
const ResetCommand& c = static_cast<const ResetCommand&>(command); const ResetCommand& c = static_cast<const ResetCommand&>(command);
@@ -702,14 +707,7 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
else else
{ {
option.type = SchematicType::Recipe; option.type = SchematicType::Recipe;
for (const RecipeDef& def : m_config.recipes.recipes) option.displayName = toDisplayName(entry.id);
{
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.
@@ -732,7 +730,7 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
const UnlockedSets hypothetical = computeUnlockedSets( const UnlockedSets hypothetical = computeUnlockedSets(
hypotheticalShipIds, hypotheticalModuleIds, hypotheticalRecipeSchematicIds); hypotheticalShipIds, hypotheticalModuleIds, hypotheticalRecipeSchematicIds);
option.newlyUnlockedItemNames = computeNewlyUnlockedItemNames(hypothetical); option.newlyUnlockedRecipeIds = computeNewlyUnlockedRecipeIds(hypothetical);
m_pendingSchematicChoices.push_back(option); m_pendingSchematicChoices.push_back(option);
} }
@@ -920,23 +918,20 @@ Simulation::UnlockedSets Simulation::computeUnlockedSets(
return result; return result;
} }
std::vector<std::string> Simulation::computeNewlyUnlockedItemNames(const UnlockedSets& hypothetical) const std::vector<std::string> Simulation::computeNewlyUnlockedRecipeIds(const UnlockedSets& hypothetical) const
{ {
std::set<std::string> itemNames; std::vector<std::string> recipeIds;
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; }
for (const RecipeDef& def : m_config.recipes.recipes) recipeIds.push_back(recipeId);
}
std::sort(recipeIds.begin(), recipeIds.end(),
[](const std::string& lhs, const std::string& rhs)
{ {
if (def.id != recipeId) { continue; } return toDisplayName(lhs) < toDisplayName(rhs);
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
@@ -993,6 +988,7 @@ unsigned long long Simulation::computeStateChecksum() const
hasher.append(m_gameOver); hasher.append(m_gameOver);
hasher.append(m_isWon); hasher.append(m_isWon);
hasher.append(m_artifactCount); hasher.append(m_artifactCount);
hasher.append(m_expansionsPurchased);
// WaveSystem scalar state, reached through existing accessors. // WaveSystem scalar state, reached through existing accessors.
hasher.append(threatLevel()); hasher.append(threatLevel());
@@ -1100,6 +1096,31 @@ int Simulation::buildingBlocksStock() const
return m_buildingBlocksStock; return m_buildingBlocksStock;
} }
int Simulation::currentAsteroidWidth_tiles() const
{
return m_config.world.regions.asteroidWidth_tiles
+ m_expansionsPurchased * m_config.world.expansion.columnsPerExpansion_tiles;
}
int Simulation::currentExpansionCost() const
{
const double cost = m_config.world.expansion.costBuildingBlocksFormula.evaluate(
static_cast<double>(m_expansionsPurchased));
return static_cast<int>(std::floor(cost));
}
void Simulation::tryExpandAsteroid()
{
const int cost = currentExpansionCost();
if (m_buildingBlocksStock < cost)
{
return;
}
m_buildingBlocksStock -= cost;
++m_expansionsPurchased;
m_buildingSystem->setAsteroidWidth_tiles(currentAsteroidWidth_tiles());
}
bool Simulation::isGameOver() const bool Simulation::isGameOver() const
{ {
return m_gameOver; return m_gameOver;

View File

@@ -72,6 +72,12 @@ public:
// The seed this run was (re)initialized with; written to the replay header. // The seed this run was (re)initialized with; written to the replay header.
unsigned int getSeed() const; unsigned int getSeed() const;
int buildingBlocksStock() const; int buildingBlocksStock() const;
// Current asteroid width in tiles = base width + purchased expansions
// (REQ-EXP-UNLOCK, REQ-GW-ASTEROID-EXPAND).
int currentAsteroidWidth_tiles() const;
// Building block cost of the next expansion, floored to an integer
// (REQ-EXP-COST); x = number of expansions already purchased.
int currentExpansionCost() const;
bool isGameOver() const; bool isGameOver() const;
bool isWon() const; bool isWon() const;
int artifactCount() const; int artifactCount() const;
@@ -136,6 +142,11 @@ private:
// Clears the pending choices after application. // Clears the pending choices after application.
void applySchematicChoice(int choiceIndex); void applySchematicChoice(int choiceIndex);
// Unlocks one asteroid expansion if affordable (REQ-EXP-UNLOCK): checks the
// current cost against the stock, deducts it, increments the expansion
// counter, and widens the buildable asteroid. No-op if blocks are short.
void tryExpandAsteroid();
// Mutable subsystem accessors; same chokepoint rule as the mutators above. // Mutable subsystem accessors; same chokepoint rule as the mutators above.
BuildingSystem& buildingsMutable(); BuildingSystem& buildingsMutable();
BeltSystem& beltsMutable(); BeltSystem& beltsMutable();
@@ -165,6 +176,7 @@ private:
Tick m_nextDepartureTick; Tick m_nextDepartureTick;
BuildingId m_nextBuildingId; BuildingId m_nextBuildingId;
int m_buildingBlocksStock; int m_buildingBlocksStock;
int m_expansionsPurchased = 0; // REQ-EXP-COST formula variable x
bool m_gameOver = false; bool m_gameOver = false;
bool m_isWon = false; bool m_isWon = false;
int m_artifactCount = 0; int m_artifactCount = 0;
@@ -221,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;
// Display names (deduplicated, alphabetical) of output items of recipes in // Ids (sorted alphabetically by display name) of the recipes in
// hypothetical.recipeIds that are not yet in m_unlockedRecipeIds. // hypothetical.recipeIds that are not yet in m_unlockedRecipeIds.
std::vector<std::string> computeNewlyUnlockedItemNames(const UnlockedSets& hypothetical) const; std::vector<std::string> computeNewlyUnlockedRecipeIds(const UnlockedSets& hypothetical) const;
EntityAdmin m_admin; EntityAdmin m_admin;
BeltSystem m_beltSystem; BeltSystem m_beltSystem;

View File

@@ -984,6 +984,45 @@ 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

@@ -0,0 +1,133 @@
#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,6 +9,7 @@ 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

@@ -76,6 +76,8 @@ TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[c
REQUIRE(cfg.world.regions.playerBufferWidth_tiles == 10); REQUIRE(cfg.world.regions.playerBufferWidth_tiles == 10);
REQUIRE(cfg.world.regions.enemyBufferWidth_tiles == 15); REQUIRE(cfg.world.regions.enemyBufferWidth_tiles == 15);
REQUIRE(cfg.world.expansion.columnsPerExpansion_tiles == 10); REQUIRE(cfg.world.expansion.columnsPerExpansion_tiles == 10);
REQUIRE(cfg.world.expansion.costBuildingBlocksFormula.evaluate(0) == Approx(400.0));
REQUIRE(cfg.world.expansion.costBuildingBlocksFormula.evaluate(1) == Approx(800.0));
REQUIRE(cfg.world.push.bossAdvanceSeconds == Approx(60.0)); REQUIRE(cfg.world.push.bossAdvanceSeconds == Approx(60.0));
REQUIRE(cfg.world.orbitFactor == Approx(0.8)); REQUIRE(cfg.world.orbitFactor == Approx(0.8));
REQUIRE(cfg.world.rallyOrbitRadius_tiles == Approx(5.0)); REQUIRE(cfg.world.rallyOrbitRadius_tiles == Approx(5.0));
@@ -101,6 +103,15 @@ 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(
@@ -186,7 +197,7 @@ contest_zone_width_tiles = 30
[expansion] [expansion]
columns_per_expansion_tiles = 10 columns_per_expansion_tiles = 10
cost_building_blocks = 200 cost_building_blocks_formula = "400 * 2^x"
[push] [push]
push_expand_columns_tiles = 20 push_expand_columns_tiles = 20
@@ -237,7 +248,7 @@ enemy_buffer_width_tiles = 15
[expansion] [expansion]
columns_per_expansion_tiles = 10 columns_per_expansion_tiles = 10
cost_building_blocks = 200 cost_building_blocks_formula = "400 * 2^x"
[push] [push]
push_expand_columns_tiles = 20 push_expand_columns_tiles = 20
@@ -284,7 +295,7 @@ enemy_buffer_width_tiles = 15
[expansion] [expansion]
columns_per_expansion_tiles = 10 columns_per_expansion_tiles = 10
cost_building_blocks = 200 cost_building_blocks_formula = "400 * 2^x"
[push] [push]
push_expand_columns_tiles = 20 push_expand_columns_tiles = 20

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

View File

@@ -6,6 +6,7 @@
#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"
@@ -13,6 +14,7 @@
#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"
@@ -264,6 +266,50 @@ 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);
@@ -292,6 +338,38 @@ 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,6 +19,7 @@
#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"
@@ -53,23 +54,15 @@ BlueprintPanel::BlueprintPanel(Simulation* sim, const GameConfig* config, QWidge
connect(m_createBtn, &QPushButton::clicked, this, &BlueprintPanel::onCreateClicked); connect(m_createBtn, &QPushButton::clicked, this, &BlueprintPanel::onCreateClicked);
QHBoxLayout* ioLayout = new QHBoxLayout(); loadFromDisk();
m_saveBtn = new QPushButton(tr("Save"), this); rebuildButtons();
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();
} }
@@ -199,17 +192,16 @@ 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;
bb.recipeId = e.building->recipeId; // Recipe / schematic / layout / splitter-filter capture is shared with the
bb.shipLayout = e.building->shipLayout; // copy-settings gesture (REQ-BLD-COPY-CONFIG) via readBuildingConfig.
if (e.building->type == BuildingType::Splitter) const std::optional<BuildingConfig> config =
readBuildingConfig(*m_sim, e.building->id);
if (config.has_value())
{ {
const std::optional<BeltSystem::SplitterInfo> info = bb.recipeId = config->recipeId.value_or(std::string());
m_sim->belts().getSplitterInfo(e.building->anchor); bb.shipLayout = config->shipLayout;
if (info.has_value()) bb.splitterFilterA = config->splitterFilterA;
{ bb.splitterFilterB = config->splitterFilterB;
bb.splitterFilterA = info->filterA;
bb.splitterFilterB = info->filterB;
}
} }
bp.buildings.push_back(bb); bp.buildings.push_back(bb);
} }
@@ -281,65 +273,35 @@ void BlueprintPanel::rebuildButtons()
refreshButtonStates(); refreshButtonStates();
} }
void BlueprintPanel::onSaveClicked() void BlueprintPanel::saveToDisk() const
{ {
// 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 (const std::exception& e) catch (...) {}
{
QMessageBox::critical(this, tr("Save Failed"),
tr("Failed to save blueprints:\n%1").arg(e.what()));
}
} }
void BlueprintPanel::onLoadClicked() void BlueprintPanel::loadFromDisk()
{ {
QMessageBox box(this); // Load at startup (REQ-UI-BLUEPRINT-LOAD). Missing file: start empty, no error.
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
{ {
QFile file(path); m_blueprints = BlueprintSerializer::deserialize(file.readAll().toStdString());
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();
} }
} }
@@ -377,3 +339,17 @@ 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,6 +11,7 @@
#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;
@@ -21,7 +22,8 @@ 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
@@ -33,13 +35,12 @@ 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);
@@ -48,6 +49,8 @@ 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;
@@ -57,8 +60,6 @@ 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,6 +13,7 @@ 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
) )
@@ -30,5 +31,6 @@ 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,6 +39,7 @@
#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"
@@ -55,12 +56,15 @@
#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"
#include "BuildingBlocksChangedEvent.h" #include "BuildingBlocksChangedEvent.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
@@ -211,6 +215,7 @@ 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.
@@ -220,6 +225,17 @@ 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)
@@ -255,9 +271,25 @@ 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
{ {
const float delta = kScrollSpeedTilesPerSec // Pan speed depends on where the view is centered (REQ-UI-SCROLL-SPEED).
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; }
@@ -276,6 +308,7 @@ void GameWorldView::onFrame()
{ {
const Tick newTick = m_sim->currentTick(); const Tick newTick = m_sim->currentTick();
const int newBlocks = m_sim->buildingBlocksStock(); const int newBlocks = m_sim->buildingBlocksStock();
const int newExpCost = m_sim->currentExpansionCost();
const int newBoss = m_sim->bossWaveCounter(); const int newBoss = m_sim->bossWaveCounter();
const Tick newCountdown = m_sim->bossCountdownTicks(); const Tick newCountdown = m_sim->bossCountdownTicks();
@@ -291,6 +324,12 @@ void GameWorldView::onFrame()
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<BuildingBlocksChangedEvent>(newBlocks)); std::make_shared<BuildingBlocksChangedEvent>(newBlocks));
} }
if (newExpCost != m_lastExpansionCost)
{
m_lastExpansionCost = newExpCost;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExpansionCostChangedEvent>(newExpCost));
}
if (newBoss != m_lastBossCounter || newCountdown != m_lastBossCountdown) if (newBoss != m_lastBossCounter || newCountdown != m_lastBossCountdown)
{ {
m_lastBossCounter = newBoss; m_lastBossCounter = newBoss;
@@ -359,6 +398,7 @@ 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);
@@ -435,7 +475,7 @@ QRect GameWorldView::viewportRect() const
float GameWorldView::asteroidLeftEdge() const float GameWorldView::asteroidLeftEdge() const
{ {
float leftX = -static_cast<float>(m_config->world.regions.asteroidWidth_tiles); float leftX = -static_cast<float>(m_sim->currentAsteroidWidth_tiles());
for (const Building& b : m_sim->buildings().allBuildings()) for (const Building& b : m_sim->buildings().allBuildings())
{ {
for (const QPoint& cell : b.bodyCells) for (const QPoint& cell : b.bodyCells)
@@ -466,6 +506,39 @@ 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();
@@ -549,6 +622,41 @@ 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))
@@ -816,16 +924,19 @@ void GameWorldView::drawBuildings(QPainter& painter)
port.direction, bv.outline); port.direction, bv.outline);
} }
bool selected = false; // HP bar below the HQ footprint; the HQ's HP lives on its proxy entity.
for (BuildingId selId : m_selectedBuildingIds) if (b.type == BuildingType::Hq)
{ {
if (selId == b.id) { selected = true; break; } m_sim->admin().forEach<HqProxyComponent, FactionComponent, HealthComponent>(
[&](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));
} }
} }
@@ -850,20 +961,6 @@ 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)
{ {
@@ -908,6 +1005,86 @@ 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)
@@ -985,14 +1162,8 @@ void GameWorldView::drawStations(QPainter& painter)
// HP bar below footprint. // HP bar below footprint.
if (h.maxHp > 0.0f) if (h.maxHp > 0.0f)
{ {
const float fraction = std::max(0.0f, h.hp / h.maxHp); drawHpBar(painter, bboxRect.left(), bboxRect.bottom() + 1.0,
const qreal barH = static_cast<qreal>(tilePx()) * 0.12; bboxRect.width(), h.hp / h.maxHp, f.isEnemy);
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));
} }
}); });
} }
@@ -1038,18 +1209,24 @@ 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;
painter.fillRect(QRectF(barX, barY, barW, barH), QColor(60, 60, 60)); drawHpBar(painter, barX, barY, barW, h.hp / h.maxHp, fac.isEnemy);
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);
@@ -1215,8 +1392,28 @@ void GameWorldView::drawOverlays(QPainter& painter)
} }
} }
// Demolish hover tint // Demolish tint: while dragging a demolish box, tint every covered
if (m_demolishMode && m_demolishHoverBuildingId != kInvalidBuildingId) // building/site (REQ-BLD-DEMOLISH-BOX); otherwise tint the hovered one.
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)
@@ -1445,6 +1642,13 @@ 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>());
@@ -1472,6 +1676,8 @@ 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);
} }
@@ -1484,6 +1690,15 @@ 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;
} }
@@ -1510,27 +1725,28 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
} }
else if (m_demolishMode) else if (m_demolishMode)
{ {
BuildingId hovered = buildingAtTile(tile); // Start a demolish box drag; a plain click resolves as a 1x1 box on
if (hovered == kInvalidBuildingId) // release (REQ-BLD-DEMOLISH-CLICK, REQ-BLD-DEMOLISH-BOX).
{ m_boxSelecting = true;
hovered = siteAtTile(tile); m_boxStartTile = 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);
@@ -1615,6 +1831,7 @@ 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)
{ {
@@ -1636,44 +1853,33 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
{ {
m_boxSelecting = false; m_boxSelecting = false;
const int x0 = std::min(m_boxStartTile.x(), m_boxCurrentTile.x()); const std::vector<BuildingId> boxIds =
const int y0 = std::min(m_boxStartTile.y(), m_boxCurrentTile.y()); buildingsInBox(m_boxStartTile, m_boxCurrentTile);
const int x1 = std::max(m_boxStartTile.x(), m_boxCurrentTile.x());
const int y1 = std::max(m_boxStartTile.y(), m_boxCurrentTile.y());
std::vector<BuildingId> boxSel; if (m_demolishMode)
for (const Building& b : m_sim->buildings().allBuildings())
{ {
for (const QPoint& cell : b.bodyCells) // Demolish every covered building/site; the HQ is protected
// (REQ-BLD-DEMOLISH, REQ-BLD-DEMOLISH-BOX).
for (BuildingId id : boxIds)
{ {
if (cell.x() >= x0 && cell.x() <= x1 const Building* b = m_sim->buildings().findBuilding(id);
&& cell.y() >= y0 && cell.y() <= y1) if (b && b->type == BuildingType::Hq) { continue; }
{ std::shared_ptr<DemolishCommand> command =
boxSel.push_back(b.id); std::make_shared<DemolishCommand>();
break; command->id = id;
} 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 = boxSel; m_selectedBuildingIds = boxIds;
} }
else else
{ {
for (BuildingId id : boxSel) for (BuildingId id : boxIds)
{ {
bool found = false; bool found = false;
for (BuildingId sel : m_selectedBuildingIds) for (BuildingId sel : m_selectedBuildingIds)
@@ -1746,6 +1952,85 @@ 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;
@@ -1818,6 +2103,8 @@ 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;
@@ -1827,6 +2114,7 @@ void GameWorldView::resetForNewGame()
m_prevNonZeroSpeed = 1.0; m_prevNonZeroSpeed = 1.0;
m_lastTick = Tick(-1); m_lastTick = Tick(-1);
m_lastBlocks = -1; m_lastBlocks = -1;
m_lastExpansionCost = -1;
m_lastBossCounter = -1; m_lastBossCounter = -1;
m_lastBossCountdown = Tick(-1); m_lastBossCountdown = Tick(-1);
m_lastArtifactCount = -1; m_lastArtifactCount = -1;

View File

@@ -14,6 +14,7 @@
#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"
@@ -115,10 +116,14 @@ 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);
@@ -134,15 +139,24 @@ 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,
@@ -157,6 +171,12 @@ 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);
@@ -173,7 +193,6 @@ 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;
@@ -208,6 +227,22 @@ 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;
@@ -226,6 +261,7 @@ private:
Tick m_lastTick = Tick(-1); Tick m_lastTick = Tick(-1);
int m_lastBlocks = -1; int m_lastBlocks = -1;
int m_lastExpansionCost = -1;
int m_lastBossCounter = -1; int m_lastBossCounter = -1;
Tick m_lastBossCountdown = Tick(-1); Tick m_lastBossCountdown = Tick(-1);
int m_lastArtifactCount = -1; int m_lastArtifactCount = -1;

View File

@@ -8,6 +8,8 @@
#include <QPushButton> #include <QPushButton>
#include <QSignalMapper> #include <QSignalMapper>
#include "Command.h"
#include "CommandRequestedEvent.h"
#include "EventManager.h" #include "EventManager.h"
#include "SpeedChangeRequestedEvent.h" #include "SpeedChangeRequestedEvent.h"
#include "Tick.h" #include "Tick.h"
@@ -32,6 +34,17 @@ HeaderBar::HeaderBar(QWidget* parent)
layout->addStretch(); layout->addStretch();
layout->addWidget(m_bossLabel); layout->addWidget(m_bossLabel);
// Asteroid expansion button, to the left of the speed buttons (REQ-UI-HEADER,
// REQ-UI-EXPAND-BUTTON). Caption/enabled state are set on the first
// ExpansionCostChangedEvent; clicking requests an ExpandAsteroidCommand.
m_expandButton = new QPushButton(tr("Expand"), this);
layout->addWidget(m_expandButton);
connect(m_expandButton, &QPushButton::clicked, this, []() {
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(
std::make_shared<ExpandAsteroidCommand>()));
});
const char* labels[] = { "0x", "0.5x", "1x", "2x", "10x" }; const char* labels[] = { "0x", "0.5x", "1x", "2x", "10x" };
QSignalMapper* mapper = new QSignalMapper(this); QSignalMapper* mapper = new QSignalMapper(this);
for (int i = 0; i < kSpeedCount; ++i) for (int i = 0; i < kSpeedCount; ++i)
@@ -67,7 +80,21 @@ void HeaderBar::handleEvent(std::shared_ptr<const TickAdvancedEvent> event)
void HeaderBar::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) void HeaderBar::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event)
{ {
m_blocks = event->blocks;
m_blocksLabel->setText(tr("Blocks: %1").arg(event->blocks)); m_blocksLabel->setText(tr("Blocks: %1").arg(event->blocks));
updateExpandButton();
}
void HeaderBar::handleEvent(std::shared_ptr<const ExpansionCostChangedEvent> event)
{
m_expansionCost = event->cost;
updateExpandButton();
}
void HeaderBar::updateExpandButton()
{
m_expandButton->setText(tr("Expand: %1 Blocks").arg(m_expansionCost));
m_expandButton->setEnabled(m_blocks >= m_expansionCost);
} }
void HeaderBar::handleEvent(std::shared_ptr<const GameSpeedChangedEvent> event) void HeaderBar::handleEvent(std::shared_ptr<const GameSpeedChangedEvent> event)

View File

@@ -8,6 +8,7 @@
#include "BossWaveUpdatedEvent.h" #include "BossWaveUpdatedEvent.h"
#include "BuildingBlocksChangedEvent.h" #include "BuildingBlocksChangedEvent.h"
#include "EventHandler.h" #include "EventHandler.h"
#include "ExpansionCostChangedEvent.h"
#include "GameSpeedChangedEvent.h" #include "GameSpeedChangedEvent.h"
#include "Tick.h" #include "Tick.h"
#include "TickAdvancedEvent.h" #include "TickAdvancedEvent.h"
@@ -18,6 +19,7 @@ class QPushButton;
class HeaderBar : public QWidget, class HeaderBar : public QWidget,
public CombinedEventHandler<TickAdvancedEvent, public CombinedEventHandler<TickAdvancedEvent,
BuildingBlocksChangedEvent, BuildingBlocksChangedEvent,
ExpansionCostChangedEvent,
GameSpeedChangedEvent, GameSpeedChangedEvent,
BossWaveUpdatedEvent, BossWaveUpdatedEvent,
ArtifactCountChangedEvent> ArtifactCountChangedEvent>
@@ -34,16 +36,25 @@ private slots:
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 BuildingBlocksChangedEvent> event) override; void handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) override;
void handleEvent(std::shared_ptr<const ExpansionCostChangedEvent> event) override;
void handleEvent(std::shared_ptr<const GameSpeedChangedEvent> event) override; void handleEvent(std::shared_ptr<const GameSpeedChangedEvent> event) override;
void handleEvent(std::shared_ptr<const BossWaveUpdatedEvent> event) override; void handleEvent(std::shared_ptr<const BossWaveUpdatedEvent> event) override;
void handleEvent(std::shared_ptr<const ArtifactCountChangedEvent> event) override; void handleEvent(std::shared_ptr<const ArtifactCountChangedEvent> event) override;
// Refreshes the Expand button caption and enabled state from the current
// expansion cost and building block stock (REQ-UI-EXPAND-BUTTON).
void updateExpandButton();
QLabel* m_timeLabel; QLabel* m_timeLabel;
QLabel* m_blocksLabel; QLabel* m_blocksLabel;
QLabel* m_artifactsLabel; QLabel* m_artifactsLabel;
QLabel* m_bossLabel; QLabel* m_bossLabel;
QPushButton* m_expandButton;
std::vector<QPushButton*> m_speedButtons; std::vector<QPushButton*> m_speedButtons;
int m_blocks = 0;
int m_expansionCost = 0;
static const double kSpeeds[]; static const double kSpeeds[];
static const int kSpeedCount; static const int kSpeedCount;
}; };

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, this); SchematicChoiceDialog dialog(event->choices, m_sim->config().recipes, this);
dialog.exec(); dialog.exec();
std::shared_ptr<ApplySchematicChoiceCommand> command = std::shared_ptr<ApplySchematicChoiceCommand> command =

View File

@@ -12,6 +12,7 @@
#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"
@@ -25,39 +26,6 @@ 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));
@@ -116,7 +84,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)),
recipeTooltip(recipe)}); buildRecipeTooltip(recipe)});
} }
} }

52
src/ui/RecipeTooltip.cpp Normal file
View File

@@ -0,0 +1,52 @@
#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');
}

11
src/ui/RecipeTooltip.h Normal file
View File

@@ -0,0 +1,11 @@
#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,11 +3,29 @@
#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)
@@ -70,30 +88,33 @@ SchematicChoiceDialog::SchematicChoiceDialog(
typeLabel->setAlignment(Qt::AlignCenter); typeLabel->setAlignment(Qt::AlignCenter);
cardLayout->addWidget(typeLabel); cardLayout->addWidget(typeLabel);
QLabel* unlocksHeaderLabel = new QLabel(tr("Unlocks recipes for:"), card); QLabel* unlocksHeaderLabel = new QLabel(tr("Unlocks recipes:"), 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);
QString unlocksText; if (option.newlyUnlockedRecipeIds.empty())
if (option.newlyUnlockedItemNames.empty())
{ {
unlocksText = tr("None"); QLabel* noneLabel = new QLabel(tr("None"), card);
noneLabel->setAlignment(Qt::AlignCenter);
cardLayout->addWidget(noneLabel);
} }
else else
{ {
QStringList itemLines; for (const std::string& recipeId : option.newlyUnlockedRecipeIds)
for (const std::string& itemName : option.newlyUnlockedItemNames)
{ {
itemLines << QString::fromStdString(itemName); QLabel* recipeLabel = new QLabel(
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,12 +6,15 @@
#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,6 +33,7 @@
#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"
@@ -294,38 +295,12 @@ void SelectedBuildingPanel::buildSingle(BuildingId id)
} }
m_recipeSelectButton->show(); m_recipeSelectButton->show();
if (type == BuildingType::Shipyard && !recipeId.empty()) updateShipyardLayoutWidgets(type, recipeId, shipLayout);
{
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();
m_layoutPreview->hide(); updateShipyardLayoutWidgets(type, recipeId, shipLayout);
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
@@ -363,6 +338,16 @@ 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)
{ {
@@ -389,11 +374,6 @@ void SelectedBuildingPanel::buildSingle(BuildingId id)
} }
} }
m_buffersLabel->setText(progress); m_buffersLabel->setText(progress);
}
else
{
refreshBuffers(b);
}
} }
void SelectedBuildingPanel::refreshBuffers(const Building* b) void SelectedBuildingPanel::refreshBuffers(const Building* b)
@@ -530,15 +510,38 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
m_buffersLabel->setText(bufText); m_buffersLabel->setText(bufText);
if (b->type == BuildingType::Shipyard && shipDef && !shipDef->layout.empty()) // The recipe/schematic is applied via a queued command that only drains on a
// 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 (b->shipLayout.has_value()) if (shipLayout.has_value())
{ {
layout = *b->shipLayout; layout = *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();
} }
} }
@@ -563,6 +566,21 @@ 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())
{ {
@@ -586,8 +604,19 @@ void SelectedBuildingPanel::handleEvent(std::shared_ptr<const TickAdvancedEvent>
} }
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();
@@ -647,9 +676,11 @@ 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, applies the chosen recipe/schematic, and restores the // selection dialog, and restores the speed before this returns. The chosen
// speed before this returns. rebuild() then refreshes the button caption, // recipe/schematic is only *enqueued* as a command, though, and drains on a
// tooltip, preview, and buffers for the new selection. // later frame -- so this rebuild() still sees the old recipe. The per-tick
// 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,6 +16,7 @@
#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"
@@ -33,6 +34,7 @@ class QVBoxLayout;
class SelectedBuildingPanel : public QWidget, class SelectedBuildingPanel : public QWidget,
public CombinedEventHandler<TickAdvancedEvent, public CombinedEventHandler<TickAdvancedEvent,
PlayerCommandsAppliedEvent,
EntitySelectedEvent, EntitySelectedEvent,
SelectionChangedEvent, SelectionChangedEvent,
DebugDrawToggledEvent> DebugDrawToggledEvent>
@@ -46,6 +48,7 @@ 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;
@@ -56,7 +59,18 @@ 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();
@@ -64,6 +78,10 @@ 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,6 +48,7 @@ struct OverlayVisuals
QColor selectionRect; QColor selectionRect;
QColor tileHighlight; QColor tileHighlight;
QColor selectedOutline; QColor selectedOutline;
QColor copyConfig;
}; };
struct ToastVisuals struct ToastVisuals

View File

@@ -224,6 +224,7 @@ 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