62 Commits

Author SHA1 Message Date
d465671cfc draw beam wider at the source than at the target 2026-07-14 21:52:05 +02:00
76812ba0c5 show "+ installed modules" in ship selection dialog tooltip 2026-07-14 21:23:05 +02:00
9c1e948ab4 show selected ship's current behavior in sidebar panel 2026-07-14 21:19:14 +02:00
23ff406101 display faction in selected object name for hq and defence stations 2026-07-14 21:00:02 +02:00
2ea0cb815d fix issue where items on building's output port were not counted to output display in building panel 2026-07-14 20:50:43 +02:00
876b344b20 fix issue where splitters were draggable during placement like belts 2026-07-14 20:35:47 +02:00
cd75492796 Reveal port items in a thin margin at machine edges 2026-07-14 20:32:27 +02:00
486296feee Allow direct output-to-input port coupling between adjacent buildings 2026-07-14 20:23:24 +02:00
c9f14970a1 Animate items entering building input ports 2026-07-14 20:21:25 +02:00
6a8c456aa1 Animate items emerging from building output ports 2026-07-14 20:18:47 +02:00
af8a2224c0 fix issue where items were accepted by a belt from opposite travel direction 2026-07-14 20:15:39 +02:00
4b149d97a7 dim the game behind dialogs and escape menu 2026-07-13 22:11:21 +02:00
6a8b6acd3b make panning faster 2026-07-13 21:44:12 +02:00
3ce6e6d599 Tint not-yet-buildable asteroid area 2026-07-13 21:42:59 +02:00
80a2622267 Redefine camera scroll as view center 2026-07-13 21:31:52 +02:00
535d4f8f24 allow to (multi) select scrap 2026-07-13 21:09:01 +02:00
8c4fb78fc9 Allow creating blueprints from construction sites 2026-07-13 20:50:13 +02:00
698dd4d13d auto-open layout dialog on manual schematic change 2026-07-13 20:47:37 +02:00
9d28175b17 Always show shipyard layout preview and Configure button, disabled until schematic selected 2026-07-13 20:45:03 +02:00
ac4d56764c Draw a thin border around each right-sidebar panel 2026-07-13 20:44:47 +02:00
69fe607157 Show total building block cost in multi-selection panel (relevant for temporary blueprint) 2026-07-13 20:31:40 +02:00
92e896b973 fix tooltip text rendering issue 2026-07-12 21:56:59 +02:00
dd7c997816 Auto-process smelter and reprocessing plant (no recipe selection) 2026-07-12 21:35:23 +02:00
ad3e73fdd8 Add tooltip for building blocks in header bar 2026-07-12 21:24:11 +02:00
177809fe1a Add tooltips for building buttons and module selection buttons 2026-07-12 21:21:14 +02:00
69f655d179 Rename UI "Blocks" labels to "Building Blocks" 2026-07-12 21:17:10 +02:00
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
6ea0655eaf implement unlock dependencies 2026-07-03 08:35:45 +02:00
58d4586d00 update concept.md 2026-07-02 21:36:42 +02:00
18e732ae99 Remove schematic upgrades and module and ship levels 2026-07-02 21:32:36 +02:00
81b1c7a66b Derive ship scrap drop from threat 2026-07-02 21:30:38 +02:00
5bc581bcb8 write replays next to the executable 2026-07-01 22:43:04 +02:00
0a7e9a34ef Add artifact win condition (#3)
Reviewed-on: #3
Co-authored-by: Malte Langkabel <malte.langkabel@gmail.com>
Co-committed-by: Malte Langkabel <malte.langkabel@gmail.com>
2026-07-01 20:27:29 +00:00
118 changed files with 7723 additions and 2187 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

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

@@ -1,21 +1,29 @@
# modules.toml # modules.toml
# #
# First real-content iteration: module ids and surface masks are the designed # Production tree v2: all weapons are railguns for now — the implementation
# content; stats, materials, and threat costs are placeholders until the # (instant damage, no projectile, no ammunition) stays as-is and the beam
# recipe and balancing passes. # visual reads as a tracer round. Lasers are reserved for a future distinct
# weapon type (see docs/content_design.md, "Production tree v2 — Weapons").
# Combat stats are placeholders until the arena balancing pass;
# production_time_seconds values come from the numbers pass.
#
# Unlock progression is mostly disabled (unlock_at_station_level = -1) pending
# the pacing pass. The railguns are the exception: railgun_m is gated to
# station level 1, and railgun_l requires railgun_m to be unlocked first
# (unlock_requires) — a demonstration of the prerequisite chain.
# #
# Surface mask footprint ladder — footprints gate which hulls can mount a # Surface mask footprint ladder — footprints gate which hulls can mount a
# module, purely through geometry (see ships.toml for the matching hull # module, purely through geometry (see ships.toml for the matching hull
# grids): # grids):
# #
# 1x1 laser_cannon_s, salvager, repair_tool fits every hull, incl. drones # 1x1 railgun_s, salvager, repair_tool fits every hull, incl. drones
# 1x2 maneuvering_thrusters, sensor_booster, # 1x2 maneuvering_thrusters, sensor_booster,
# armor_plates frigate and up # armor_plates frigate and up
# 1x3 afterburner frigate and up (eats most of a frigate) # 1x3 afterburner frigate and up (eats most of a frigate)
# L-shape weapon_stabilizer, weapon_primer, # L-shape weapon_stabilizer, weapon_primer,
# weapon_upgrade frigate and up # weapon_upgrade frigate and up
# 2x2 laser_cannon_m, drone_bay cruiser and up (no 2x2 area on s hulls) # 2x2 railgun_m, drone_bay cruiser and up (no 2x2 area on s hulls)
# 3x3 laser_cannon_l battleship and up (no 3x3 area on m hulls) # 3x3 railgun_l battleship and up (no 3x3 area on m hulls)
# 2x6 drone_hangar carrier only # 2x6 drone_hangar carrier only
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -23,56 +31,57 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
[[module]] [[module]]
id = "laser_cannon_s" id = "railgun_s"
tooltip = "Small railgun. Fast-firing, short range, low damage; fits any hull."
unlock_at_station_level = -1 unlock_at_station_level = -1
surface_mask = ["O"] surface_mask = ["O"]
materials = [{item = "laser_cannon_s_module", amount = 1}] materials = [{item = "railgun_s_module", amount = 1}]
player_production_level = 1 production_time_seconds = 1
production_time_seconds = 0.5
fill_color = "#FF8040" fill_color = "#FF8040"
glyph = "Ls" glyph = "Rs"
[module.weapon] [module.weapon]
damage_formula = "2" damage = 2
attack_range_m_formula = "50" attack_range_m = 50
attack_rate_hz_formula = "2.0" attack_rate_hz = 2.0
[[module]] [[module]]
id = "laser_cannon_m" id = "railgun_m"
unlock_at_station_level = -1 tooltip = "Medium railgun. Higher damage at longer range; needs a 2x2 slot."
unlock_at_station_level = 2
surface_mask = [ surface_mask = [
"OO", "OO",
"OO"] "OO"]
materials = [{item = "laser_cannon_m_module", amount = 1}] materials = [{item = "railgun_m_module", amount = 1}]
player_production_level = 1 production_time_seconds = 3
production_time_seconds = 2
fill_color = "#FF8040" fill_color = "#FF8040"
glyph = "Lm" glyph = "Rm"
[module.weapon] [module.weapon]
damage_formula = "10" damage = 14
attack_range_m_formula = "70" attack_range_m = 70
attack_rate_hz_formula = "1.5" attack_rate_hz = 1.5
[[module]] [[module]]
id = "laser_cannon_l" id = "railgun_l"
unlock_at_station_level = -1 tooltip = "Large railgun. Heavy damage at long range; needs a 3x3 slot."
unlock_at_station_level = 6
unlock_requires = ["railgun_m"]
surface_mask = [ surface_mask = [
"OOO", "OOO",
"OOO", "OOO",
"OOO"] "OOO"]
materials = [{item = "laser_cannon_l_module", amount = 1}] materials = [{item = "railgun_l_module", amount = 1}]
player_production_level = 1 production_time_seconds = 4
production_time_seconds = 8
fill_color = "#FF8040" fill_color = "#FF8040"
glyph = "Ll" glyph = "Rl"
[module.weapon] [module.weapon]
damage_formula = "40" damage = 52
attack_range_m_formula = "100" attack_range_m = 100
attack_rate_hz_formula = "0.8" attack_rate_hz = 0.8
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Utility tools # Utility tools
@@ -80,34 +89,34 @@ attack_rate_hz_formula = "0.8"
[[module]] [[module]]
id = "salvager" id = "salvager"
tooltip = "Collects scrap from wrecks and stores it in the ship's cargo hold."
unlock_at_station_level = -1 unlock_at_station_level = -1
surface_mask = ["O"] surface_mask = ["O"]
materials = [{item = "salvager_module", amount = 1}] materials = [{item = "salvager_module", amount = 1}]
player_production_level = 1 production_time_seconds = 1
production_time_seconds = 2
fill_color = "#AACC44" fill_color = "#AACC44"
glyph = "Sv" glyph = "Sv"
[module.salvage] [module.salvage]
collection_range_m_formula = "500" collection_range_m = 60
cargo_capacity_formula = "10" cargo_capacity = 20
collection_rate_hz_formula = "0.5" collection_rate_hz = 0.5
[[module]] [[module]]
id = "repair_tool" id = "repair_tool"
unlock_at_station_level = -1 tooltip = "Repairs damaged friendly ships and defence stations within range."
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}]
player_production_level = 1 production_time_seconds = 1
production_time_seconds = 2
fill_color = "#66CCFF" fill_color = "#66CCFF"
glyph = "Rp" glyph = "Rp"
[module.repair] [module.repair]
repair_rate_hz_formula = "1" repair_rate_hz = 1
repair_amount_hp_formula = "5 + x" repair_amount_hp = 4
repair_range_m_formula = "80" repair_range_m = 80
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Propulsion # Propulsion
@@ -115,32 +124,32 @@ repair_range_m_formula = "80"
[[module]] [[module]]
id = "afterburner" id = "afterburner"
unlock_at_station_level = -1 tooltip = "Greatly boosts top speed and forward acceleration."
unlock_at_station_level = 2
surface_mask = ["OOO"] surface_mask = ["OOO"]
materials = [{item = "afterburner_module", amount = 1}] materials = [{item = "afterburner_module", amount = 1}]
player_production_level = 1 production_time_seconds = 1
production_time_seconds = 2
fill_color = "#40A0FF" fill_color = "#40A0FF"
glyph = "Ab" glyph = "Ab"
[module.movement] [module.movement]
multiplied_speed_mps_formula = "1.6" multiplied_speed_mps = 1.6
added_main_acceleration_mpss_formula = "60" added_main_acceleration_mpss = 60
[[module]] [[module]]
id = "maneuvering_thrusters" id = "maneuvering_thrusters"
unlock_at_station_level = -1 tooltip = "Improves top speed and lateral/braking acceleration."
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}]
player_production_level = 1 production_time_seconds = 1
production_time_seconds = 2
fill_color = "#40A0FF" fill_color = "#40A0FF"
glyph = "Mt" glyph = "Mt"
[module.movement] [module.movement]
multiplied_speed_mps_formula = "1.2" multiplied_speed_mps = 1.2
added_maneuvering_acceleration_mpss_formula = "10" added_maneuvering_acceleration_mpss = 10
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Defense & sensors # Defense & sensors
@@ -148,30 +157,30 @@ added_maneuvering_acceleration_mpss_formula = "10"
[[module]] [[module]]
id = "armor_plates" id = "armor_plates"
unlock_at_station_level = -1 tooltip = "Adds a large flat bonus to the ship's hit points."
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}]
player_production_level = 1 production_time_seconds = 1
production_time_seconds = 3
fill_color = "#808080" fill_color = "#808080"
glyph = "A" glyph = "A"
[module.health] [module.health]
added_hp_formula = "40" added_hp = 1200
[[module]] [[module]]
id = "sensor_booster" id = "sensor_booster"
unlock_at_station_level = -1 tooltip = "Extends the ship's sensor range."
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}]
player_production_level = 1 production_time_seconds = 1
production_time_seconds = 2
fill_color = "#40A0FF" fill_color = "#40A0FF"
glyph = "S" glyph = "S"
[module.sensor] [module.sensor]
added_sensor_range_m_formula = "50" added_sensor_range_m = 50
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Weapon modifiers # Weapon modifiers
@@ -179,54 +188,54 @@ added_sensor_range_m_formula = "50"
[[module]] [[module]]
id = "weapon_upgrade" id = "weapon_upgrade"
unlock_at_station_level = -1 tooltip = "Increases the damage of all weapons on the ship."
unlock_at_station_level = 4
surface_mask = [ surface_mask = [
"OO", "OO",
"OX", "OX",
] ]
materials = [{item = "weapon_upgrade_module", amount = 1}] materials = [{item = "weapon_upgrade_module", amount = 1}]
player_production_level = 1 production_time_seconds = 2
production_time_seconds = 4
fill_color = "#FF4040" fill_color = "#FF4040"
glyph = "Wu" glyph = "Wu"
[module.weapon] [module.weapon]
multiplied_damage_formula = "1.2" multiplied_damage = 1.2
[[module]] [[module]]
id = "weapon_primer" id = "weapon_primer"
unlock_at_station_level = -1 tooltip = "Increases the fire rate of all weapons on the ship."
unlock_at_station_level = 4
surface_mask = [ surface_mask = [
"OO", "OO",
"OX", "OX",
] ]
materials = [{item = "weapon_primer_module", amount = 1}] materials = [{item = "weapon_primer_module", amount = 1}]
player_production_level = 1 production_time_seconds = 2
production_time_seconds = 4
fill_color = "#FF4040" fill_color = "#FF4040"
glyph = "Wp" glyph = "Wp"
[module.weapon] [module.weapon]
multiplied_attack_rate_hz_formula = "1.2" multiplied_attack_rate_hz = 1.2
[[module]] [[module]]
id = "weapon_stabilizer" id = "weapon_stabilizer"
unlock_at_station_level = -1 tooltip = "Extends weapon range at the cost of some fire rate."
unlock_at_station_level = 3
surface_mask = [ surface_mask = [
"OO", "OO",
"OX", "OX",
] ]
materials = [{item = "weapon_stabilizer_module", amount = 1}] materials = [{item = "weapon_stabilizer_module", amount = 1}]
player_production_level = 1 production_time_seconds = 1
production_time_seconds = 4
fill_color = "#FF4040" fill_color = "#FF4040"
glyph = "Ws" glyph = "Ws"
[module.weapon] [module.weapon]
multiplied_attack_range_m_formula = "1.5" multiplied_attack_range_m = 1.3
multiplied_attack_rate_hz_formula = "0.8" multiplied_attack_rate_hz = 0.8
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Drone modules # Drone modules
@@ -237,25 +246,25 @@ multiplied_attack_rate_hz_formula = "0.8"
[[module]] [[module]]
id = "drone_bay" id = "drone_bay"
unlock_at_station_level = -1 tooltip = "Drone launch bay (capability not yet implemented)."
unlock_at_station_level = 5
surface_mask = [ surface_mask = [
"OO", "OO",
"OO"] "OO"]
materials = [{item = "drone_bay_module", amount = 1}] materials = [{item = "drone_bay_module", amount = 1}]
player_production_level = 1 production_time_seconds = 3
production_time_seconds = 5
fill_color = "#CC66FF" fill_color = "#CC66FF"
glyph = "Db" glyph = "Db"
[[module]] [[module]]
id = "drone_hangar" id = "drone_hangar"
unlock_at_station_level = -1 tooltip = "Large drone hangar (capability not yet implemented)."
unlock_at_station_level = 9
surface_mask = [ surface_mask = [
"OOOOOO", "OOOOOO",
"OOOOOO"] "OOOOOO"]
materials = [{item = "drone_hangar_module", amount = 1}] materials = [{item = "drone_hangar_module", amount = 1}]
player_production_level = 1 production_time_seconds = 6
production_time_seconds = 20
fill_color = "#9933CC" fill_color = "#9933CC"
glyph = "Dh" glyph = "Dh"

View File

@@ -1,24 +1,26 @@
# recipes.toml # recipes.toml
# #
# First real-content iteration of the production tree. Quantities and # Production tree v2 (structure in docs/content_design.md, numbers with
# durations are a first guess; the balancing pass will tune them and assign # derivations in docs/balancing/derived.md). Quantities and durations are tuned so that every
# real unlock_at_station_level values (everything is unlocked for now so the # fitted ship lands on the threat-cost ladder and the ratio curve is
# full tree is testable). # realized: tier 1 ratios are 1:1, tier 2 ratios are 2:3, tier 3+ ratios
# are deliberately strange.
# #
# Input chain per game phase — each phase adds exactly one new base input: # Input chain per game phase — each phase transition adds exactly one new
# base input:
# #
# early iron_ore + copper_ore -> ingots -> copper_wire, steel_plate, # early iron_ore + copper_ore minable on every asteroid tile (the
# circuit_board # asteroid is an M-type body — its bulk
# mid + titanium_ore -> titanium_frame; assembler-made # rock IS ore)
# mechanical_parts, targeting_unit, # mid + quartz geode deposits in expansion territory
# drive_unit # (deposit gating pending — see action
# late + advanced_alloy -> reinforced_plating, capital_core. # item 5 in docs/balancing/README.md;
# advanced_alloy CANNOT be mined; it only # until then quartz mines anywhere)
# comes from reprocessing salvaged scrap, # late + voidsteel battle-forged: ONLY from reprocessing
# so capital production requires combat. # salvaged scrap, so capital production
# requires combat
# #
# Run tools/verify_recipes.py after editing to check that every consumed # Run tools/verify_recipes.py and tools/threat_report.py after editing.
# item has a producer and every item has a visuals.toml entry.
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Mining (tier 0) # Mining (tier 0)
@@ -36,404 +38,409 @@ id = "mine_copper_ore"
building = "miner" building = "miner"
inputs = [] inputs = []
outputs = [{item = "copper_ore", amount = 1}] outputs = [{item = "copper_ore", amount = 1}]
duration_seconds = 1.5 duration_seconds = 1.0
# Titanium is the midgame ore: mined three times slower than iron.
[[recipe]] [[recipe]]
id = "mine_titanium_ore" id = "mine_quartz"
building = "miner" building = "miner"
inputs = [] inputs = []
outputs = [{item = "titanium_ore", amount = 1}] outputs = [{item = "quartz", amount = 1}]
duration_seconds = 3.0 duration_seconds = 2.0
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Smelting (tier 1) # Smelting (tier 1) — one recipe per input item; ratios are 1:1 with miners.
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
[[recipe]] [[recipe]]
id = "iron_ingot" id = "iron_ingot"
building = "smelter" building = "smelter"
inputs = [{item = "iron_ore", amount = 2}] inputs = [{item = "iron_ore", amount = 1}]
outputs = [{item = "iron_ingot", amount = 1}] outputs = [{item = "iron_ingot", amount = 1}]
duration_seconds = 2.0 duration_seconds = 1.0
[[recipe]] [[recipe]]
id = "copper_ingot" id = "copper_ingot"
building = "smelter" building = "smelter"
inputs = [{item = "copper_ore", amount = 2}] inputs = [{item = "copper_ore", amount = 1}]
outputs = [{item = "copper_ingot", amount = 1}] outputs = [{item = "copper_ingot", amount = 1}]
duration_seconds = 2.5 duration_seconds = 1.0
[[recipe]] [[recipe]]
id = "titanium_ingot" id = "silicon"
building = "smelter" building = "smelter"
inputs = [{item = "titanium_ore", amount = 3}] inputs = [{item = "quartz", amount = 1}]
outputs = [{item = "titanium_ingot", amount = 1}] outputs = [{item = "silicon", amount = 1}]
duration_seconds = 4.0 duration_seconds = 2.0
# Scrap smelting: the safe, boring sink. Deliberately value-losing (4 threat
# of scrap becomes a 2-threat ingot) — reprocessing is the value-preserving
# path.
[[recipe]]
id = "scrap_smelting"
building = "smelter"
inputs = [{item = "scrap", amount = 1}]
outputs = [{item = "iron_ingot", amount = 1}]
duration_seconds = 1.0
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Reprocessing # Reprocessing — the only source of voidsteel (battle-forged; formed when
# # weapon plasma anneals hull metal in the violence of ship destruction).
# The only source of advanced_alloy: salvaged scrap from destroyed ships. # Weights are authored for the fully unlocked pool state; the pool
# renormalizes over implicitly unlocked items early game.
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
[[recipe]] [[recipe]]
id = "reprocessing_cycle" id = "reprocessing_cycle"
building = "reprocessing_plant" building = "reprocessing_plant"
inputs = [{item = "scrap", amount = 5}] inputs = [{item = "scrap", amount = 4}]
duration_seconds = 3.0 duration_seconds = 4.0
[[recipe.outputs]] [[recipe.outputs]]
item = "iron_ingot" item = "iron_ingot"
amount = 2 amount = 1
probability = 0.45 probability = 0.3
[[recipe.outputs]] [[recipe.outputs]]
item = "copper_ingot" item = "copper_ingot"
amount = 1 amount = 1
probability = 0.25 probability = 0.3
[[recipe.outputs]] [[recipe.outputs]]
item = "titanium_ingot" item = "silicon"
amount = 1 amount = 1
probability = 0.15 probability = 0.2
[[recipe.outputs]] [[recipe.outputs]]
item = "advanced_alloy" item = "voidsteel"
amount = 1 amount = 1
probability = 0.15 probability = 0.2
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Basic components (tier 2, early game) # Tier 2 early intermediates (clean ratios, ~2:3)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
[[recipe]]
id = "copper_wire"
unlock_at_station_level = -1
building = "assembler"
inputs = [{item = "copper_ingot", amount = 1}]
outputs = [{item = "copper_wire", amount = 2}]
duration_seconds = 1.5
[[recipe]] [[recipe]]
id = "steel_plate" id = "steel_plate"
unlock_at_station_level = -1
building = "assembler" building = "assembler"
inputs = [{item = "iron_ingot", amount = 2}] inputs = [{item = "iron_ingot", amount = 2}]
outputs = [{item = "steel_plate", amount = 1}] outputs = [{item = "steel_plate", amount = 1}]
duration_seconds = 3.0
[[recipe]]
id = "copper_wire"
building = "assembler"
inputs = [{item = "copper_ingot", amount = 1}]
outputs = [{item = "copper_wire", amount = 2}]
duration_seconds = 1.0
[[recipe]]
id = "copper_coil"
building = "assembler"
inputs = [{item = "copper_wire", amount = 2}]
outputs = [{item = "copper_coil", amount = 1}]
duration_seconds = 1.5
# Depth-3 chain (ore -> ingot -> plate -> block) is the factory's
# 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]]
id = "building_block"
unlock_at_station_level = -1
building = "assembler"
inputs = [{item = "steel_plate", amount = 2}]
outputs = [{item = "building_block", amount = 4}]
duration_seconds = 2.0
# -----------------------------------------------------------------------------
# Tier 3 — mid intermediates (strange ratios begin; need quartz)
# -----------------------------------------------------------------------------
[[recipe]]
id = "control_chip"
building = "assembler"
inputs = [{item = "silicon", amount = 1}, {item = "copper_wire", amount = 2}]
outputs = [{item = "control_chip", amount = 1}]
duration_seconds = 5.0
[[recipe]]
id = "capacitor_bank"
building = "assembler"
inputs = [{item = "copper_coil", amount = 2}, {item = "silicon", amount = 1}]
outputs = [{item = "capacitor_bank", amount = 1}]
duration_seconds = 5.0
# The quality gate for m+ hulls: a deliberately long-running recipe
# (time-heavy archetype).
[[recipe]]
id = "hardened_steel"
building = "assembler"
inputs = [{item = "steel_plate", amount = 3}]
outputs = [{item = "hardened_steel", amount = 1}]
duration_seconds = 12.0
[[recipe]]
id = "ceramic_plate"
building = "assembler"
inputs = [{item = "quartz", amount = 2}]
outputs = [{item = "ceramic_plate", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "drive_unit"
building = "assembler"
inputs = [
{item = "steel_plate", amount = 2},
{item = "copper_coil", amount = 2},
{item = "control_chip", amount = 1},
]
outputs = [{item = "drive_unit", amount = 1}]
duration_seconds = 8.0
# -----------------------------------------------------------------------------
# Tier 4 — late intermediates (need voidsteel)
# -----------------------------------------------------------------------------
[[recipe]]
id = "voidsteel_plate"
building = "assembler"
inputs = [{item = "voidsteel", amount = 1}, {item = "hardened_steel", amount = 1}]
outputs = [{item = "voidsteel_plate", amount = 1}]
duration_seconds = 8.0
[[recipe]]
id = "capital_core"
building = "assembler"
inputs = [
{item = "voidsteel", amount = 2},
{item = "capacitor_bank", amount = 1},
{item = "control_chip", amount = 1},
]
outputs = [{item = "capital_core", amount = 1}]
duration_seconds = 10.0
# -----------------------------------------------------------------------------
# Shortcut recipes — drop-only assembler recipe schematics
# (unlock_at_station_level >= 0). Pure rewards: item threat stays defined by
# the base (expensive) path via the max rule, so shortcuts give real factory
# efficiency without shifting any balance.
# -----------------------------------------------------------------------------
[[recipe]]
id = "shortcut_steel_plate"
unlock_at_station_level = 1
building = "assembler"
inputs = [{item = "iron_ore", amount = 3}]
outputs = [{item = "steel_plate", amount = 1}]
duration_seconds = 2.0 duration_seconds = 2.0
[[recipe]] [[recipe]]
id = "circuit_board" id = "shortcut_control_chip"
unlock_at_station_level = -1 unlock_at_station_level = 2
building = "assembler" building = "assembler"
inputs = [{item = "iron_ingot", amount = 1}, {item = "copper_wire", amount = 2}] inputs = [{item = "quartz", amount = 2}]
outputs = [{item = "circuit_board", amount = 1}] outputs = [{item = "control_chip", amount = 1}]
duration_seconds = 2.0 duration_seconds = 4.0
[[recipe]] [[recipe]]
id = "building_blocks" id = "shortcut_hardened_steel"
unlock_at_station_level = -1 unlock_at_station_level = 2
building = "assembler" building = "assembler"
inputs = [{item = "iron_ingot", amount = 4}] inputs = [{item = "iron_ingot", amount = 4}]
outputs = [{item = "building_block", amount = 10}] outputs = [{item = "hardened_steel", amount = 1}]
duration_seconds = 4.0
# -----------------------------------------------------------------------------
# Advanced components (tier 3, midgame)
# -----------------------------------------------------------------------------
[[recipe]]
id = "mechanical_parts"
unlock_at_station_level = -1
building = "assembler"
inputs = [{item = "steel_plate", amount = 1}, {item = "iron_ingot", amount = 1}]
outputs = [{item = "mechanical_parts", amount = 2}]
duration_seconds = 2.5
[[recipe]]
id = "targeting_unit"
unlock_at_station_level = -1
building = "assembler"
inputs = [{item = "circuit_board", amount = 2}, {item = "copper_wire", amount = 1}]
outputs = [{item = "targeting_unit", amount = 1}]
duration_seconds = 3.0
[[recipe]]
id = "drive_unit"
unlock_at_station_level = -1
building = "assembler"
inputs = [
{item = "steel_plate", amount = 1},
{item = "mechanical_parts", amount = 1},
{item = "circuit_board", amount = 1},
]
outputs = [{item = "drive_unit", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "titanium_frame"
unlock_at_station_level = -1
building = "assembler"
inputs = [{item = "titanium_ingot", amount = 2}, {item = "steel_plate", amount = 1}]
outputs = [{item = "titanium_frame", amount = 1}]
duration_seconds = 4.0
# -----------------------------------------------------------------------------
# Capital components (tier 4, lategame — gated on advanced_alloy)
# -----------------------------------------------------------------------------
[[recipe]]
id = "reinforced_plating"
unlock_at_station_level = -1
building = "assembler"
inputs = [{item = "steel_plate", amount = 2}, {item = "advanced_alloy", amount = 1}]
outputs = [{item = "reinforced_plating", amount = 1}]
duration_seconds = 5.0
[[recipe]]
id = "capital_core"
unlock_at_station_level = -1
building = "assembler"
inputs = [
{item = "targeting_unit", amount = 1},
{item = "drive_unit", amount = 1},
{item = "advanced_alloy", amount = 2},
]
outputs = [{item = "capital_core", amount = 1}]
duration_seconds = 8.0 duration_seconds = 8.0
# -----------------------------------------------------------------------------
# Module items — early game
# -----------------------------------------------------------------------------
[[recipe]]
id = "laser_cannon_s_module"
unlock_at_station_level = -1
building = "assembler"
inputs = [{item = "iron_ingot", amount = 2}, {item = "circuit_board", amount = 1}]
outputs = [{item = "laser_cannon_s_module", amount = 1}]
duration_seconds = 3.0
[[recipe]]
id = "salvager_module"
unlock_at_station_level = -1
building = "assembler"
inputs = [{item = "steel_plate", amount = 1}, {item = "circuit_board", amount = 1}]
outputs = [{item = "salvager_module", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "repair_tool_module"
unlock_at_station_level = -1
building = "assembler"
inputs = [{item = "circuit_board", amount = 2}, {item = "copper_wire", amount = 1}]
outputs = [{item = "repair_tool_module", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "armor_plates_module"
unlock_at_station_level = -1
building = "assembler"
inputs = [{item = "steel_plate", amount = 2}]
outputs = [{item = "armor_plates_module", amount = 1}]
duration_seconds = 3.0
[[recipe]]
id = "sensor_booster_module"
unlock_at_station_level = -1
building = "assembler"
inputs = [{item = "circuit_board", amount = 1}, {item = "copper_wire", amount = 2}]
outputs = [{item = "sensor_booster_module", amount = 1}]
duration_seconds = 3.0
[[recipe]]
id = "maneuvering_thrusters_module"
unlock_at_station_level = -1
building = "assembler"
inputs = [{item = "mechanical_parts", amount = 1}, {item = "copper_wire", amount = 1}]
outputs = [{item = "maneuvering_thrusters_module", amount = 1}]
duration_seconds = 3.0
# -----------------------------------------------------------------------------
# Module items — midgame
# -----------------------------------------------------------------------------
[[recipe]]
id = "afterburner_module"
unlock_at_station_level = -1
building = "assembler"
inputs = [{item = "drive_unit", amount = 1}, {item = "steel_plate", amount = 1}]
outputs = [{item = "afterburner_module", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "weapon_upgrade_module"
unlock_at_station_level = -1
building = "assembler"
inputs = [{item = "targeting_unit", amount = 1}, {item = "steel_plate", amount = 1}]
outputs = [{item = "weapon_upgrade_module", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "weapon_primer_module"
unlock_at_station_level = -1
building = "assembler"
inputs = [{item = "targeting_unit", amount = 1}, {item = "copper_wire", amount = 2}]
outputs = [{item = "weapon_primer_module", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "weapon_stabilizer_module"
unlock_at_station_level = -1
building = "assembler"
inputs = [{item = "targeting_unit", amount = 1}, {item = "mechanical_parts", amount = 1}]
outputs = [{item = "weapon_stabilizer_module", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "laser_cannon_m_module"
unlock_at_station_level = -1
building = "assembler"
inputs = [{item = "targeting_unit", amount = 1}, {item = "titanium_frame", amount = 1}]
outputs = [{item = "laser_cannon_m_module", amount = 1}]
duration_seconds = 6.0
[[recipe]]
id = "drone_bay_module"
unlock_at_station_level = -1
building = "assembler"
inputs = [
{item = "titanium_frame", amount = 1},
{item = "mechanical_parts", amount = 1},
{item = "circuit_board", amount = 1},
]
outputs = [{item = "drone_bay_module", amount = 1}]
duration_seconds = 6.0
# -----------------------------------------------------------------------------
# Module items — lategame
# -----------------------------------------------------------------------------
[[recipe]]
id = "laser_cannon_l_module"
unlock_at_station_level = -1
building = "assembler"
inputs = [
{item = "targeting_unit", amount = 2},
{item = "reinforced_plating", amount = 2},
{item = "titanium_frame", amount = 1},
]
outputs = [{item = "laser_cannon_l_module", amount = 1}]
duration_seconds = 12.0
[[recipe]]
id = "drone_hangar_module"
unlock_at_station_level = -1
building = "assembler"
inputs = [
{item = "capital_core", amount = 1},
{item = "titanium_frame", amount = 2},
{item = "reinforced_plating", amount = 2},
]
outputs = [{item = "drone_hangar_module", amount = 1}]
duration_seconds = 20.0
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Ship hulls # Ship hulls
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
[[recipe]] [[recipe]]
id = "drone_hull" id = "drone_hull"
unlock_at_station_level = -1
building = "assembler" building = "assembler"
inputs = [{item = "iron_ingot", amount = 5}, {item = "circuit_board", amount = 1}] inputs = [{item = "iron_ingot", amount = 1}]
outputs = [{item = "drone_hull", amount = 1}] outputs = [{item = "drone_hull", amount = 1}]
duration_seconds = 4.0 duration_seconds = 1.0
[[recipe]] [[recipe]]
id = "frigate_hull" id = "frigate_hull"
unlock_at_station_level = -1
building = "assembler" building = "assembler"
inputs = [ inputs = [{item = "steel_plate", amount = 2}, {item = "copper_wire", amount = 1}]
{item = "steel_plate", amount = 3},
{item = "mechanical_parts", amount = 1},
{item = "circuit_board", amount = 1},
]
outputs = [{item = "frigate_hull", amount = 1}] outputs = [{item = "frigate_hull", amount = 1}]
duration_seconds = 8.0 duration_seconds = 2.0
[[recipe]] [[recipe]]
id = "destroyer_hull" id = "destroyer_hull"
unlock_at_station_level = -1
building = "assembler" building = "assembler"
inputs = [ inputs = [{item = "steel_plate", amount = 3}, {item = "copper_coil", amount = 2}]
{item = "steel_plate", amount = 5},
{item = "mechanical_parts", amount = 2},
{item = "circuit_board", amount = 1},
]
outputs = [{item = "destroyer_hull", amount = 1}] outputs = [{item = "destroyer_hull", amount = 1}]
duration_seconds = 10.0 duration_seconds = 4.0
[[recipe]] [[recipe]]
id = "cruiser_hull" id = "cruiser_hull"
unlock_at_station_level = -1
building = "assembler" building = "assembler"
inputs = [ inputs = [{item = "hardened_steel", amount = 2}, {item = "control_chip", amount = 2}]
{item = "titanium_frame", amount = 2},
{item = "steel_plate", amount = 4},
{item = "drive_unit", amount = 1},
]
outputs = [{item = "cruiser_hull", amount = 1}] outputs = [{item = "cruiser_hull", amount = 1}]
duration_seconds = 15.0 duration_seconds = 6.0
[[recipe]] [[recipe]]
id = "battlecruiser_hull" id = "battlecruiser_hull"
unlock_at_station_level = -1
building = "assembler" building = "assembler"
inputs = [ inputs = [
{item = "titanium_frame", amount = 3}, {item = "hardened_steel", amount = 3},
{item = "steel_plate", amount = 6}, {item = "control_chip", amount = 2},
{item = "drive_unit", amount = 1}, {item = "drive_unit", amount = 1},
{item = "targeting_unit", amount = 1},
] ]
outputs = [{item = "battlecruiser_hull", amount = 1}] outputs = [{item = "battlecruiser_hull", amount = 1}]
duration_seconds = 20.0 duration_seconds = 8.0
[[recipe]] [[recipe]]
id = "battleship_hull" id = "battleship_hull"
unlock_at_station_level = -1
building = "assembler" building = "assembler"
inputs = [ inputs = [
{item = "titanium_frame", amount = 4}, {item = "voidsteel_plate", amount = 3},
{item = "reinforced_plating", amount = 2}, {item = "drive_unit", amount = 1},
{item = "drive_unit", amount = 2}, {item = "control_chip", amount = 2},
] ]
outputs = [{item = "battleship_hull", amount = 1}] outputs = [{item = "battleship_hull", amount = 1}]
duration_seconds = 30.0 duration_seconds = 10.0
[[recipe]] [[recipe]]
id = "dreadnought_hull" id = "dreadnought_hull"
unlock_at_station_level = -1
building = "assembler" building = "assembler"
inputs = [ inputs = [
{item = "voidsteel_plate", amount = 5},
{item = "capital_core", amount = 1}, {item = "capital_core", amount = 1},
{item = "titanium_frame", amount = 6},
{item = "reinforced_plating", amount = 4},
{item = "drive_unit", amount = 2}, {item = "drive_unit", amount = 2},
] ]
outputs = [{item = "dreadnought_hull", amount = 1}] outputs = [{item = "dreadnought_hull", amount = 1}]
duration_seconds = 60.0 duration_seconds = 12.0
[[recipe]] [[recipe]]
id = "carrier_hull" id = "carrier_hull"
unlock_at_station_level = -1
building = "assembler" building = "assembler"
inputs = [ inputs = [
{item = "voidsteel_plate", amount = 5},
{item = "capital_core", amount = 1}, {item = "capital_core", amount = 1},
{item = "titanium_frame", amount = 5},
{item = "reinforced_plating", amount = 3},
{item = "drive_unit", amount = 2}, {item = "drive_unit", amount = 2},
] ]
outputs = [{item = "carrier_hull", amount = 1}] outputs = [{item = "carrier_hull", amount = 1}]
duration_seconds = 60.0 duration_seconds = 12.0
# -----------------------------------------------------------------------------
# Module prefabs
# -----------------------------------------------------------------------------
[[recipe]]
id = "railgun_s_module"
building = "assembler"
inputs = [{item = "copper_coil", amount = 1}]
outputs = [{item = "railgun_s_module", amount = 1}]
duration_seconds = 1.0
[[recipe]]
id = "salvager_module"
building = "assembler"
inputs = [{item = "steel_plate", amount = 1}, {item = "copper_wire", amount = 2}]
outputs = [{item = "salvager_module", amount = 1}]
duration_seconds = 2.0
[[recipe]]
id = "repair_tool_module"
building = "assembler"
inputs = [{item = "steel_plate", amount = 1}, {item = "copper_wire", amount = 2}]
outputs = [{item = "repair_tool_module", amount = 1}]
duration_seconds = 2.0
# Material-heavy, fast: the armor archetype.
[[recipe]]
id = "armor_plates_module"
building = "assembler"
inputs = [{item = "steel_plate", amount = 4}]
outputs = [{item = "armor_plates_module", amount = 1}]
duration_seconds = 3.0
[[recipe]]
id = "maneuvering_thrusters_module"
building = "assembler"
inputs = [{item = "steel_plate", amount = 1}, {item = "copper_coil", amount = 1}]
outputs = [{item = "maneuvering_thrusters_module", amount = 1}]
duration_seconds = 2.0
[[recipe]]
id = "sensor_booster_module"
building = "assembler"
inputs = [{item = "copper_wire", amount = 2}, {item = "copper_coil", amount = 1}]
outputs = [{item = "sensor_booster_module", amount = 1}]
duration_seconds = 2.0
[[recipe]]
id = "afterburner_module"
building = "assembler"
inputs = [{item = "copper_coil", amount = 2}, {item = "steel_plate", amount = 1}]
outputs = [{item = "afterburner_module", amount = 1}]
duration_seconds = 3.0
[[recipe]]
id = "weapon_stabilizer_module"
building = "assembler"
inputs = [{item = "steel_plate", amount = 1}, {item = "copper_coil", amount = 1}]
outputs = [{item = "weapon_stabilizer_module", amount = 1}]
duration_seconds = 2.0
[[recipe]]
id = "weapon_primer_module"
building = "assembler"
inputs = [{item = "capacitor_bank", amount = 1}, {item = "copper_coil", amount = 1}]
outputs = [{item = "weapon_primer_module", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "weapon_upgrade_module"
building = "assembler"
inputs = [{item = "control_chip", amount = 1}, {item = "copper_coil", amount = 1}]
outputs = [{item = "weapon_upgrade_module", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "railgun_m_module"
building = "assembler"
inputs = [
{item = "capacitor_bank", amount = 1},
{item = "steel_plate", amount = 2},
{item = "copper_coil", amount = 1},
]
outputs = [{item = "railgun_m_module", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "drone_bay_module"
building = "assembler"
inputs = [
{item = "control_chip", amount = 1},
{item = "steel_plate", amount = 2},
{item = "copper_coil", amount = 1},
]
outputs = [{item = "drone_bay_module", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "railgun_l_module"
building = "assembler"
inputs = [
{item = "capacitor_bank", amount = 1},
{item = "hardened_steel", amount = 2},
{item = "ceramic_plate", amount = 1},
]
outputs = [{item = "railgun_l_module", amount = 1}]
duration_seconds = 6.0
[[recipe]]
id = "drone_hangar_module"
building = "assembler"
inputs = [
{item = "voidsteel_plate", amount = 1},
{item = "control_chip", amount = 2},
{item = "drive_unit", amount = 1},
]
outputs = [{item = "drone_hangar_module", amount = 1}]
duration_seconds = 10.0

View File

@@ -4,6 +4,11 @@
# content; stats, materials, and production times are placeholders until the # content; stats, materials, and production times are placeholders until the
# recipe and balancing passes. # recipe and balancing passes.
# #
# Unlock progression is mostly disabled (unlock_at_station_level = -1) pending
# the balancing pass. The capital hulls are the exception: battleship is gated
# to station level 1, and dreadnought (level 2) requires battleship to be
# unlocked first (unlock_requires) — a demonstration of the prerequisite chain.
#
# Size classes: # Size classes:
# xs drone 1 cell — exactly one 1x1 module # xs drone 1 cell — exactly one 1x1 module
# s frigate, destroyer no 2x2 area anywhere: only 1x1/1x2/1x3/L modules fit # s frigate, destroyer no 2x2 area anywhere: only 1x1/1x2/1x3/L modules fit
@@ -19,28 +24,24 @@
id = "drone" id = "drone"
unlock_at_station_level = -1 unlock_at_station_level = -1
layout = ["O"] layout = ["O"]
default_modules = [{type = "laser_cannon_s", x = 0, y = 0, rotation = "east"}] default_modules = [{type = "railgun_s", x = 0, y = 0, rotation = "east"}]
[ship.schematic] [ship.schematic]
materials = [{item = "iron_ore", amount = 1}] materials = [{item = "drone_hull", amount = 1}]
player_production_level = 1 production_time_seconds = 1
production_time_seconds = 5
[ship.health] [ship.health]
hp_formula = "3" hp = 60
[ship.movement] [ship.movement]
speed_mps_formula = "40" speed_mps = 45
main_acceleration_mpss_formula = "2" main_acceleration_mpss = 60
maneuvering_acceleration_mpss_formula = "1" maneuvering_acceleration_mpss = 30
angular_acceleration_radpss_formula = "12.56" angular_acceleration_radpss = 12
max_rotation_speed_radps_formula = "6.28" max_rotation_speed_radps = 6
[ship.sensor] [ship.sensor]
sensor_range_m_formula = "150" sensor_range_m = 150
[ship.loot]
scrap_drop = 2
# Frigate — 5 cells in a plus shape. Holds a couple of small guns plus at # Frigate — 5 cells in a plus shape. Holds a couple of small guns plus at
@@ -54,27 +55,28 @@ layout = [
"OOO", "OOO",
"XOX", "XOX",
] ]
default_modules = [
{type = "railgun_s", x = 1, y = 0, rotation = "east"},
{type = "railgun_s", x = 2, y = 1, rotation = "east"},
{type = "maneuvering_thrusters", x = 0, y = 1, rotation = "east"},
]
[ship.schematic] [ship.schematic]
materials = [{item = "frigate_hull", amount = 1}] materials = [{item = "frigate_hull", amount = 1}]
player_production_level = 1 production_time_seconds = 2
production_time_seconds = 10
[ship.health] [ship.health]
hp_formula = "30" hp = 300
[ship.movement] [ship.movement]
speed_mps_formula = "30" speed_mps = 35
main_acceleration_mpss_formula = "50" main_acceleration_mpss = 45
maneuvering_acceleration_mpss_formula = "25" maneuvering_acceleration_mpss = 22
angular_acceleration_radpss_formula = "8" angular_acceleration_radpss = 8
max_rotation_speed_radps_formula = "4" max_rotation_speed_radps = 4
[ship.sensor] [ship.sensor]
sensor_range_m_formula = "200" sensor_range_m = 200
[ship.loot]
scrap_drop = 5
# Destroyer — 8 cells: a long gun deck with three turret bumps on top. # Destroyer — 8 cells: a long gun deck with three turret bumps on top.
@@ -82,32 +84,35 @@ scrap_drop = 5
# 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",
] ]
default_modules = [
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
{type = "railgun_s", x = 2, y = 0, rotation = "east"},
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
{type = "armor_plates", x = 0, y = 1, rotation = "east"},
{type = "sensor_booster", x = 3, y = 1, rotation = "east"},
]
[ship.schematic] [ship.schematic]
materials = [{item = "destroyer_hull", amount = 1}] materials = [{item = "destroyer_hull", amount = 1}]
player_production_level = 1 production_time_seconds = 3
production_time_seconds = 15
[ship.health] [ship.health]
hp_formula = "50" hp = 550
[ship.movement] [ship.movement]
speed_mps_formula = "25" speed_mps = 30
main_acceleration_mpss_formula = "40" main_acceleration_mpss = 35
maneuvering_acceleration_mpss_formula = "20" maneuvering_acceleration_mpss = 18
angular_acceleration_radpss_formula = "6" angular_acceleration_radpss = 6
max_rotation_speed_radps_formula = "3" max_rotation_speed_radps = 3
[ship.sensor] [ship.sensor]
sensor_range_m_formula = "220" sensor_range_m = 220
[ship.loot]
scrap_drop = 8
# Cruiser — 12 cells with notched corners. Fits at most two 2x2 m guns # Cruiser — 12 cells with notched corners. Fits at most two 2x2 m guns
@@ -115,34 +120,36 @@ scrap_drop = 8
# 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",
"OOOO", "OOOO",
"XOOX", "XOOX",
] ]
default_modules = [
{type = "railgun_m", x = 0, y = 1, rotation = "east"},
{type = "railgun_m", x = 2, y = 1, rotation = "east"},
{type = "armor_plates", x = 1, y = 0, rotation = "east"},
{type = "maneuvering_thrusters", x = 1, y = 3, rotation = "east"},
]
[ship.schematic] [ship.schematic]
materials = [{item = "cruiser_hull", amount = 1}] materials = [{item = "cruiser_hull", amount = 1}]
player_production_level = 1 production_time_seconds = 4
production_time_seconds = 25
[ship.health] [ship.health]
hp_formula = "120" hp = 1500
[ship.movement] [ship.movement]
speed_mps_formula = "20" speed_mps = 24
main_acceleration_mpss_formula = "30" main_acceleration_mpss = 25
maneuvering_acceleration_mpss_formula = "15" maneuvering_acceleration_mpss = 12
angular_acceleration_radpss_formula = "4" angular_acceleration_radpss = 4
max_rotation_speed_radps_formula = "2" max_rotation_speed_radps = 2
[ship.sensor] [ship.sensor]
sensor_range_m_formula = "250" sensor_range_m = 250
[ship.loot]
scrap_drop = 15
# Battlecruiser — 16 cells: a wide bow split into two gun cheeks, tapering # Battlecruiser — 16 cells: a wide bow split into two gun cheeks, tapering
@@ -151,34 +158,39 @@ scrap_drop = 15
# 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",
"XOOOOX", "XOOOOX",
"XXOOXX", "XXOOXX",
] ]
default_modules = [
{type = "railgun_m", x = 0, y = 0, rotation = "east"},
{type = "railgun_m", x = 4, y = 0, rotation = "east"},
{type = "railgun_m", x = 2, y = 1, rotation = "east"},
{type = "armor_plates", x = 2, y = 3, rotation = "east"},
{type = "railgun_s", x = 1, y = 2, rotation = "east"},
{type = "railgun_s", x = 4, y = 2, rotation = "east"},
]
[ship.schematic] [ship.schematic]
materials = [{item = "battlecruiser_hull", amount = 1}] materials = [{item = "battlecruiser_hull", amount = 1}]
player_production_level = 1 production_time_seconds = 5
production_time_seconds = 35
[ship.health] [ship.health]
hp_formula = "180" hp = 2400
[ship.movement] [ship.movement]
speed_mps_formula = "18" speed_mps = 20
main_acceleration_mpss_formula = "25" main_acceleration_mpss = 20
maneuvering_acceleration_mpss_formula = "12" maneuvering_acceleration_mpss = 10
angular_acceleration_radpss_formula = "3" angular_acceleration_radpss = 3
max_rotation_speed_radps_formula = "1.5" max_rotation_speed_radps = 1.5
[ship.sensor] [ship.sensor]
sensor_range_m_formula = "260" sensor_range_m = 260
[ship.loot]
scrap_drop = 20
# Battleship — 24 cells: a broadside hull with notched flanks on every other # Battleship — 24 cells: a broadside hull with notched flanks on every other
@@ -189,7 +201,8 @@ scrap_drop = 20
# 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",
@@ -197,27 +210,31 @@ layout = [
"OOOOOO", "OOOOOO",
"XOOOOX", "XOOOOX",
] ]
default_modules = [
{type = "railgun_l", x = 1, y = 0, rotation = "east"},
{type = "railgun_m", x = 1, y = 3, rotation = "east"},
{type = "railgun_m", x = 3, y = 3, rotation = "east"},
{type = "weapon_stabilizer", x = 4, y = 1, rotation = "east"},
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
{type = "railgun_s", x = 0, y = 1, rotation = "east"},
]
[ship.schematic] [ship.schematic]
materials = [{item = "battleship_hull", amount = 1}] materials = [{item = "battleship_hull", amount = 1}]
player_production_level = 1 production_time_seconds = 6
production_time_seconds = 60
[ship.health] [ship.health]
hp_formula = "350" hp = 6300
[ship.movement] [ship.movement]
speed_mps_formula = "14" speed_mps = 15
main_acceleration_mpss_formula = "18" main_acceleration_mpss = 14
maneuvering_acceleration_mpss_formula = "8" maneuvering_acceleration_mpss = 7
angular_acceleration_radpss_formula = "2" angular_acceleration_radpss = 2
max_rotation_speed_radps_formula = "1" max_rotation_speed_radps = 1
[ship.sensor] [ship.sensor]
sensor_range_m_formula = "280" sensor_range_m = 280
[ship.loot]
scrap_drop = 35
# Dreadnought — 36 cells: the main battery deck is split into three 3x3 gun # Dreadnought — 36 cells: the main battery deck is split into three 3x3 gun
@@ -227,7 +244,8 @@ scrap_drop = 35
# 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 = -1 unlock_at_station_level = 8
unlock_requires = ["battleship"]
layout = [ layout = [
"XXXOOOOOXXX", "XXXOOOOOXXX",
"OOOXOOOXOOO", "OOOXOOOXOOO",
@@ -235,27 +253,33 @@ layout = [
"OOOXOOOXOOO", "OOOXOOOXOOO",
"XXOOXXXOOXX", "XXOOXXXOOXX",
] ]
default_modules = [
{type = "railgun_l", x = 0, y = 1, rotation = "east"},
{type = "railgun_l", x = 4, y = 1, rotation = "east"},
{type = "railgun_l", x = 8, y = 1, rotation = "east"},
{type = "armor_plates", x = 3, y = 0, rotation = "east"},
{type = "armor_plates", x = 5, y = 0, rotation = "east"},
{type = "armor_plates", x = 2, y = 4, rotation = "east"},
{type = "armor_plates", x = 7, y = 4, rotation = "east"},
{type = "railgun_s", x = 7, y = 0, rotation = "east"},
]
[ship.schematic] [ship.schematic]
materials = [{item = "dreadnought_hull", amount = 1}] materials = [{item = "dreadnought_hull", amount = 1}]
player_production_level = 1 production_time_seconds = 8
production_time_seconds = 120
[ship.health] [ship.health]
hp_formula = "800" hp = 24000
[ship.movement] [ship.movement]
speed_mps_formula = "8" speed_mps = 10
main_acceleration_mpss_formula = "10" main_acceleration_mpss = 8
maneuvering_acceleration_mpss_formula = "5" maneuvering_acceleration_mpss = 4
angular_acceleration_radpss_formula = "1" angular_acceleration_radpss = 1
max_rotation_speed_radps_formula = "0.5" max_rotation_speed_radps = 0.5
[ship.sensor] [ship.sensor]
sensor_range_m_formula = "300" sensor_range_m = 300
[ship.loot]
scrap_drop = 60
# Carrier — 37 cells: the top flight deck (rows 0-1) is the only place wide # Carrier — 37 cells: the top flight deck (rows 0-1) is the only place wide
@@ -264,7 +288,8 @@ scrap_drop = 60
# 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",
@@ -272,24 +297,28 @@ layout = [
"XOOOOOOOOX", "XOOOOOOOOX",
"XXXOOOOXXX", "XXXOOOOXXX",
] ]
default_modules = [
{type = "drone_hangar", x = 2, y = 0, rotation = "east"},
{type = "railgun_m", x = 3, y = 2, rotation = "east"},
{type = "railgun_m", x = 6, y = 2, rotation = "east"},
{type = "armor_plates", x = 0, y = 1, rotation = "east"},
{type = "armor_plates", x = 8, y = 1, rotation = "east"},
{type = "sensor_booster", x = 3, y = 4, rotation = "east"},
]
[ship.schematic] [ship.schematic]
materials = [{item = "carrier_hull", amount = 1}] materials = [{item = "carrier_hull", amount = 1}]
player_production_level = 1 production_time_seconds = 8
production_time_seconds = 120
[ship.health] [ship.health]
hp_formula = "700" hp = 24000
[ship.movement] [ship.movement]
speed_mps_formula = "9" speed_mps = 10
main_acceleration_mpss_formula = "10" main_acceleration_mpss = 8
maneuvering_acceleration_mpss_formula = "5" maneuvering_acceleration_mpss = 4
angular_acceleration_radpss_formula = "1" angular_acceleration_radpss = 1
max_rotation_speed_radps_formula = "0.5" max_rotation_speed_radps = 0.5
[ship.sensor] [ship.sensor]
sensor_range_m_formula = "350" sensor_range_m = 350
[ship.loot]
scrap_drop = 60

View File

@@ -1,10 +1,18 @@
# stations.toml
#
# Combat-pass anchors (see docs/balancing/targets.md, "Combat anchors"):
# a fresh player defence station holds one early parity wave unaided; the
# enemy station at level 0 matches the player station exactly and scales
# with the push level x. Station scrap drops stay authored (pushing rewards
# are tuned independently of ship production costs, REQ-RES-SCRAP-DROP).
[hq] [hq]
surface_mask = [ surface_mask = [
"AAA", "AAA",
"AAA", "AAA",
"AAA", "AAA",
] ]
hp_formula = "1000" hp_formula = "5000"
[player_station] [player_station]
surface_mask = [ surface_mask = [
@@ -12,19 +20,19 @@ surface_mask = [
"SS", "SS",
] ]
level = 1 level = 1
hp_formula = "300" hp_formula = "3000"
damage_formula = "5" damage_formula = "25"
range_m_formula = "200" range_m_formula = "120"
fire_rate_hz_formula = "1" fire_rate_hz_formula = "1"
scrap_drop_formula = "10" scrap_drop_formula = "40"
[enemy_station] [enemy_station]
surface_mask = [ surface_mask = [
"SS", "SS",
"SS", "SS",
] ]
hp_formula = "300 + 150*x" hp_formula = "3000 + 1500*x"
damage_formula = "2 + 1*x" damage_formula = "25 + 12*x"
range_m_formula = "200" range_m_formula = "120"
fire_rate_hz_formula = "1.0 + 0.2*x" fire_rate_hz_formula = "1.0 + 0.1*x"
scrap_drop_formula = "10 + 5*x" scrap_drop_formula = "40 + 30*x"

View File

@@ -116,11 +116,11 @@ outline = "#201010"
fill = "#c47a3a" fill = "#c47a3a"
outline = "#3a1a0a" outline = "#3a1a0a"
[items.titanium_ore] [items.quartz]
fill = "#9aa3ad" fill = "#e0d4f0"
outline = "#2a2e33" outline = "#40345a"
# --- ingots --- # --- smelted basics ---
[items.iron_ingot] [items.iron_ingot]
fill = "#b0b0b8" fill = "#b0b0b8"
@@ -130,9 +130,9 @@ outline = "#202028"
fill = "#d48a4a" fill = "#d48a4a"
outline = "#402010" outline = "#402010"
[items.titanium_ingot] [items.silicon]
fill = "#c8d2dc" fill = "#33415e"
outline = "#3a4048" outline = "#0e1420"
# --- salvage loop --- # --- salvage loop ---
@@ -140,9 +140,9 @@ outline = "#3a4048"
fill = "#7a7268" fill = "#7a7268"
outline = "#201a14" outline = "#201a14"
[items.advanced_alloy] [items.voidsteel]
fill = "#a06acc" fill = "#4a3a6a"
outline = "#201030" outline = "#151020"
# --- basic components --- # --- basic components ---
@@ -154,9 +154,9 @@ outline = "#3a2008"
fill = "#8a92a0" fill = "#8a92a0"
outline = "#22262c" outline = "#22262c"
[items.circuit_board] [items.copper_coil]
fill = "#2ea35a" fill = "#d07030"
outline = "#0a2a14" outline = "#381808"
[items.building_block] [items.building_block]
fill = "#c8b070" fill = "#c8b070"
@@ -164,26 +164,30 @@ outline = "#302810"
# --- advanced components --- # --- advanced components ---
[items.mechanical_parts] [items.control_chip]
fill = "#6f7a66" fill = "#2ea35a"
outline = "#1c2018" outline = "#0a2a14"
[items.targeting_unit] [items.capacitor_bank]
fill = "#3a9e8c" fill = "#d0a030"
outline = "#0c2824" outline = "#302408"
[items.hardened_steel]
fill = "#6a7280"
outline = "#181c22"
[items.ceramic_plate]
fill = "#e0d8c8"
outline = "#3a3428"
[items.drive_unit] [items.drive_unit]
fill = "#4a6ad0" fill = "#4a6ad0"
outline = "#101a38" outline = "#101a38"
[items.titanium_frame]
fill = "#b8c4d4"
outline = "#343c48"
# --- capital components --- # --- capital components ---
[items.reinforced_plating] [items.voidsteel_plate]
fill = "#8a6ad0" fill = "#7a5aaa"
outline = "#1c1038" outline = "#1c1038"
[items.capital_core] [items.capital_core]
@@ -192,15 +196,15 @@ outline = "#280c30"
# --- module items --- # --- module items ---
[items.laser_cannon_s_module] [items.railgun_s_module]
fill = "#691313" fill = "#691313"
outline = "#f3ff4f" outline = "#f3ff4f"
[items.laser_cannon_m_module] [items.railgun_m_module]
fill = "#892020" fill = "#892020"
outline = "#f3ff4f" outline = "#f3ff4f"
[items.laser_cannon_l_module] [items.railgun_l_module]
fill = "#a92d2d" fill = "#a92d2d"
outline = "#f3ff4f" outline = "#f3ff4f"
@@ -344,6 +348,9 @@ 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)
locked_asteroid = "#0000007f" # tint over the asteroid left of the buildable edge (not yet unlocked by expansion)
modal_dim = "#00000099" # semi-transparent black dim behind modal dialogs/menus (REQ-UI-MODAL-DIM)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Schematic-drop toasts (REQ-UI-SCHEMATIC-TOAST) # Schematic-drop toasts (REQ-UI-SCHEMATIC-TOAST)

View File

@@ -1,14 +1,16 @@
[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 = 30 scrap_despawn_seconds = 120
scrap_per_threat = 0.25
tile_size_m = 10 tile_size_m = 10
belt_speed_mps = 20 belt_speed_mps = 20
tunnel_max_distance_tiles = 10 tunnel_max_distance_tiles = 10
departure_interval_seconds = 20 departure_interval_seconds = 20
orbit_factor = 0.8 orbit_factor = 0.8
rally_orbit_radius_tiles = 5.0 rally_orbit_radius_tiles = 5.0
building_blocks_tooltip = "Building blocks are the currency for construction. Spend them to place buildings and to expand the asteroid. Produce building blocks in your factory and deliver them to the HQ on a belt to grow your stock."
[regions] [regions]
asteroid_width_tiles = 60 asteroid_width_tiles = 60
@@ -16,9 +18,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 = 16.0
pan_speed_fast_tiles_per_second = 32.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
@@ -31,11 +42,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
ship_level_formula = "1" # 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

@@ -1,73 +1,397 @@
# balancing.toml — canonical arena suite for the combat stats pass.
#
# Ship counts are chosen so both teams have (near-)equal total threat,
# using the verified fitted threat values from tools/threat_report.py:
# drone 10.5, frigate 47, destroyer 99, cruiser 233.5,
# battlecruiser 354.5, battleship 722.5, dreadnought 1491.5,
# carrier 1436.5, glass destroyer (8 small guns) 92, repair drone 17.
# Module arrays mirror the ships' default_modules loadouts unless a
# doctrine variant is the point of the arena.
#
# Expectations: mirror matches and equal-threat cross-tier matchups should
# be near-draws (power-per-threat rule); the two-to-one arena must be a
# decisive win for the larger team. "Cruisers vs carrier" is EXPECTED to be
# a loss for the carrier until the drone-launching capability exists — the
# hangar is 224 threat of dead weight.
# --- mirrors (sanity: symmetric outcomes, fight duration in the 30-60 s band) ---
[[arena]] [[arena]]
name = "Fighters vs Sniper" name = "Mirror: drones 20v20"
height_tiles = 20 height_tiles = 10
player_buffer_width_tiles = 10 player_buffer_width_tiles = 10
contest_zone_width_tiles = 60 contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10 enemy_buffer_width_tiles = 10
[[arena.team]] [[arena.team]]
name = "Alpha" name = "Alpha"
[[arena.team.ship]] [[arena.team.ship]]
schematic = "drone" schematic = "drone"
level = 1 count = 20
count = 5
modules = [ modules = [
{type = "laser_cannon_s", x = 1, y = 1, rotation = "east"}, {type = "railgun_s", x = 0, y = 0, rotation = "east"},
] ]
[[arena.team]] [[arena.team]]
name = "Beta" name = "Beta"
[[arena.team.ship]] [[arena.team.ship]]
schematic = "drone" schematic = "drone"
level = 1 count = 20
count = 2
modules = [ modules = [
{type = "laser_cannon_s", x = 1, y = 1, rotation = "east"}, {type = "railgun_s", x = 0, y = 0, rotation = "east"},
{type = "weapon_stabilizer", x = 1, y = 1, rotation = "east"},
{type = "weapon_stabilizer", x = 1, y = 1, rotation = "east"},
{type = "weapon_upgrade", x = 1, y = 1, rotation = "east"},
{type = "sensor_booster", x = 1, y = 1, rotation = "east"},
{type = "sensor_booster", x = 1, y = 1, rotation = "east"},
] ]
[[arena]] [[arena]]
name = "Fighters vs Supported" name = "Mirror: cruisers 6v6"
height_tiles = 20 height_tiles = 10
player_buffer_width_tiles = 10 player_buffer_width_tiles = 10
contest_zone_width_tiles = 60 contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10 enemy_buffer_width_tiles = 10
[[arena.team]] [[arena.team]]
name = "Fighters" name = "Alpha"
[[arena.team.ship]] [[arena.team.ship]]
schematic = "drone" schematic = "cruiser"
level = 1 count = 6
count = 5
modules = [ modules = [
{type = "laser_cannon_s", x = 1, y = 1, rotation = "east"}, {type = "railgun_m", x = 0, y = 1, rotation = "east"},
{type = "railgun_m", x = 2, y = 1, rotation = "east"},
{type = "armor_plates", x = 1, y = 0, rotation = "east"},
{type = "maneuvering_thrusters", x = 1, y = 3, rotation = "east"},
] ]
[[arena.team]] [[arena.team]]
name = "Supported" name = "Beta"
[[arena.team.ship]] [[arena.team.ship]]
schematic = "drone" schematic = "cruiser"
level = 1 count = 6
count = 3
modules = [ modules = [
{type = "laser_cannon_s", x = 1, y = 1, rotation = "east"}, {type = "railgun_m", x = 0, y = 1, rotation = "east"},
] {type = "railgun_m", x = 2, y = 1, rotation = "east"},
{type = "armor_plates", x = 1, y = 0, rotation = "east"},
[[arena.team.ship]] {type = "maneuvering_thrusters", x = 1, y = 3, rotation = "east"},
schematic = "drone"
level = 1
count = 2
modules = [
{type = "repair_tool", x = 1, y = 1, rotation = "east"},
] ]
[[arena]] [[arena]]
name = "Stations and Ships" name = "Mirror: battleships 2v2"
height_tiles = 60 height_tiles = 10
player_buffer_width_tiles = 10
contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10
[[arena.team]]
name = "Alpha"
[[arena.team.ship]]
schematic = "battleship"
count = 2
modules = [
{type = "railgun_l", x = 1, y = 0, rotation = "east"},
{type = "railgun_m", x = 1, y = 3, rotation = "east"},
{type = "railgun_m", x = 3, y = 3, rotation = "east"},
{type = "weapon_stabilizer", x = 4, y = 1, rotation = "east"},
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
{type = "railgun_s", x = 0, y = 1, rotation = "east"},
]
[[arena.team]]
name = "Beta"
[[arena.team.ship]]
schematic = "battleship"
count = 2
modules = [
{type = "railgun_l", x = 1, y = 0, rotation = "east"},
{type = "railgun_m", x = 1, y = 3, rotation = "east"},
{type = "railgun_m", x = 3, y = 3, rotation = "east"},
{type = "weapon_stabilizer", x = 4, y = 1, rotation = "east"},
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
{type = "railgun_s", x = 0, y = 1, rotation = "east"},
]
# --- equal-threat cross-tier matchups (power-per-threat: expect near-draws) ---
[[arena]]
name = "Drone swarm vs cruisers (462 vs 467)"
height_tiles = 10
player_buffer_width_tiles = 10
contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10
[[arena.team]]
name = "Swarm"
[[arena.team.ship]]
schematic = "drone"
count = 44
modules = [
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
]
[[arena.team]]
name = "Cruisers"
[[arena.team.ship]]
schematic = "cruiser"
count = 2
modules = [
{type = "railgun_m", x = 0, y = 1, rotation = "east"},
{type = "railgun_m", x = 2, y = 1, rotation = "east"},
{type = "armor_plates", x = 1, y = 0, rotation = "east"},
{type = "maneuvering_thrusters", x = 1, y = 3, rotation = "east"},
]
[[arena]]
name = "Frigates vs battleship (705 vs 723)"
height_tiles = 10
player_buffer_width_tiles = 10
contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10
[[arena.team]]
name = "Frigates"
[[arena.team.ship]]
schematic = "frigate"
count = 15
modules = [
{type = "railgun_s", x = 1, y = 0, rotation = "east"},
{type = "railgun_s", x = 2, y = 1, rotation = "east"},
{type = "maneuvering_thrusters", x = 0, y = 1, rotation = "east"},
]
[[arena.team]]
name = "Battleship"
[[arena.team.ship]]
schematic = "battleship"
count = 1
modules = [
{type = "railgun_l", x = 1, y = 0, rotation = "east"},
{type = "railgun_m", x = 1, y = 3, rotation = "east"},
{type = "railgun_m", x = 3, y = 3, rotation = "east"},
{type = "weapon_stabilizer", x = 4, y = 1, rotation = "east"},
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
{type = "railgun_s", x = 0, y = 1, rotation = "east"},
]
[[arena]]
name = "Destroyers vs dreadnought (1485 vs 1492)"
height_tiles = 10
player_buffer_width_tiles = 10
contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10
[[arena.team]]
name = "Destroyers"
[[arena.team.ship]]
schematic = "destroyer"
count = 15
modules = [
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
{type = "railgun_s", x = 2, y = 0, rotation = "east"},
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
{type = "armor_plates", x = 0, y = 1, rotation = "east"},
{type = "sensor_booster", x = 3, y = 1, rotation = "east"},
]
[[arena.team]]
name = "Dreadnought"
[[arena.team.ship]]
schematic = "dreadnought"
count = 1
modules = [
{type = "railgun_l", x = 0, y = 1, rotation = "east"},
{type = "railgun_l", x = 4, y = 1, rotation = "east"},
{type = "railgun_l", x = 8, y = 1, rotation = "east"},
{type = "armor_plates", x = 3, y = 0, rotation = "east"},
{type = "armor_plates", x = 5, y = 0, rotation = "east"},
{type = "armor_plates", x = 2, y = 4, rotation = "east"},
{type = "armor_plates", x = 7, y = 4, rotation = "east"},
{type = "railgun_s", x = 7, y = 0, rotation = "east"},
]
[[arena]]
name = "Cruisers vs carrier (1401 vs 1437, carrier expected to lose)"
height_tiles = 10
player_buffer_width_tiles = 10
contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10
[[arena.team]]
name = "Cruisers"
[[arena.team.ship]]
schematic = "cruiser"
count = 6
modules = [
{type = "railgun_m", x = 0, y = 1, rotation = "east"},
{type = "railgun_m", x = 2, y = 1, rotation = "east"},
{type = "armor_plates", x = 1, y = 0, rotation = "east"},
{type = "maneuvering_thrusters", x = 1, y = 3, rotation = "east"},
]
[[arena.team]]
name = "Carrier"
[[arena.team.ship]]
schematic = "carrier"
count = 1
modules = [
{type = "drone_hangar", x = 2, y = 0, rotation = "east"},
{type = "railgun_m", x = 3, y = 2, rotation = "east"},
{type = "railgun_m", x = 6, y = 2, rotation = "east"},
{type = "armor_plates", x = 0, y = 1, rotation = "east"},
{type = "armor_plates", x = 8, y = 1, rotation = "east"},
{type = "sensor_booster", x = 3, y = 4, rotation = "east"},
]
[[arena]]
name = "Mixed mid vs battlecruisers (1394 vs 1418)"
height_tiles = 10
player_buffer_width_tiles = 10
contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10
[[arena.team]]
name = "Mixed"
[[arena.team.ship]]
schematic = "destroyer"
count = 7
modules = [
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
{type = "railgun_s", x = 2, y = 0, rotation = "east"},
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
{type = "armor_plates", x = 0, y = 1, rotation = "east"},
{type = "sensor_booster", x = 3, y = 1, rotation = "east"},
]
[[arena.team.ship]]
schematic = "cruiser"
count = 3
modules = [
{type = "railgun_m", x = 0, y = 1, rotation = "east"},
{type = "railgun_m", x = 2, y = 1, rotation = "east"},
{type = "armor_plates", x = 1, y = 0, rotation = "east"},
{type = "maneuvering_thrusters", x = 1, y = 3, rotation = "east"},
]
[[arena.team]]
name = "Battlecruisers"
[[arena.team.ship]]
schematic = "battlecruiser"
count = 4
modules = [
{type = "railgun_m", x = 0, y = 0, rotation = "east"},
{type = "railgun_m", x = 4, y = 0, rotation = "east"},
{type = "railgun_m", x = 2, y = 1, rotation = "east"},
{type = "armor_plates", x = 2, y = 3, rotation = "east"},
{type = "railgun_s", x = 1, y = 2, rotation = "east"},
{type = "railgun_s", x = 4, y = 2, rotation = "east"},
]
# --- asymmetric checks ---
[[arena]]
name = "Two to one (must be decisive)"
height_tiles = 10
player_buffer_width_tiles = 10
contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10
[[arena.team]]
name = "Six"
[[arena.team.ship]]
schematic = "frigate"
count = 6
modules = [
{type = "railgun_s", x = 1, y = 0, rotation = "east"},
{type = "railgun_s", x = 2, y = 1, rotation = "east"},
{type = "maneuvering_thrusters", x = 0, y = 1, rotation = "east"},
]
[[arena.team]]
name = "Three"
[[arena.team.ship]]
schematic = "frigate"
count = 3
modules = [
{type = "railgun_s", x = 1, y = 0, rotation = "east"},
{type = "railgun_s", x = 2, y = 1, rotation = "east"},
{type = "maneuvering_thrusters", x = 0, y = 1, rotation = "east"},
]
[[arena]]
name = "Armored vs glass destroyers (1188 vs 1196)"
height_tiles = 10
player_buffer_width_tiles = 10
contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10
[[arena.team]]
name = "Armored"
[[arena.team.ship]]
schematic = "destroyer"
count = 12
modules = [
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
{type = "railgun_s", x = 2, y = 0, rotation = "east"},
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
{type = "armor_plates", x = 0, y = 1, rotation = "east"},
{type = "sensor_booster", x = 3, y = 1, rotation = "east"},
]
[[arena.team]]
name = "Glass"
[[arena.team.ship]]
schematic = "destroyer"
count = 13
modules = [
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
{type = "railgun_s", x = 2, y = 0, rotation = "east"},
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
{type = "railgun_s", x = 0, y = 1, rotation = "east"},
{type = "railgun_s", x = 1, y = 1, rotation = "east"},
{type = "railgun_s", x = 2, y = 1, rotation = "east"},
{type = "railgun_s", x = 3, y = 1, rotation = "east"},
{type = "railgun_s", x = 4, y = 1, rotation = "east"},
]
[[arena]]
name = "Repair escort vs raw numbers (444 vs 444)"
height_tiles = 10
player_buffer_width_tiles = 10
contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10
[[arena.team]]
name = "Escorted"
[[arena.team.ship]]
schematic = "frigate"
count = 8
modules = [
{type = "railgun_s", x = 1, y = 0, rotation = "east"},
{type = "railgun_s", x = 2, y = 1, rotation = "east"},
{type = "maneuvering_thrusters", x = 0, y = 1, rotation = "east"},
]
[[arena.team.ship]]
schematic = "drone"
count = 4
modules = [
{type = "repair_tool", x = 0, y = 0, rotation = "east"},
]
[[arena.team]]
name = "Raw"
[[arena.team.ship]]
schematic = "frigate"
count = 9
modules = [
{type = "railgun_s", x = 1, y = 0, rotation = "east"},
{type = "railgun_s", x = 2, y = 1, rotation = "east"},
{type = "maneuvering_thrusters", x = 0, y = 1, rotation = "east"},
]
[[arena.team.ship]]
schematic = "drone"
count = 2
modules = [
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
]
[[arena]]
name = "Station assault (2 stations + 105 vs 315)"
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
@@ -76,10 +400,9 @@ enemy_buffer_width_tiles = 15
name = "Fortified" name = "Fortified"
[[arena.team.ship]] [[arena.team.ship]]
schematic = "drone" schematic = "drone"
level = 1 count = 10
count = 3
modules = [ modules = [
{type = "laser_cannon_s", x = 1, y = 1, rotation = "east"}, {type = "railgun_s", x = 0, y = 0, rotation = "east"},
] ]
[[arena.team.station]] [[arena.team.station]]
type = "player_station" type = "player_station"
@@ -96,8 +419,7 @@ enemy_buffer_width_tiles = 15
name = "Swarm" name = "Swarm"
[[arena.team.ship]] [[arena.team.ship]]
schematic = "drone" schematic = "drone"
level = 1 count = 30
count = 8
modules = [ modules = [
{type = "laser_cannon_s", x = 1, y = 1, rotation = "east"}, {type = "railgun_s", x = 0, y = 0, rotation = "east"},
] ]

View File

@@ -83,6 +83,8 @@ 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
tooltip = "Drop-off point for salvage ships."
surface_mask = [ surface_mask = [
"SAA", "SAA",
"SAA>", "SAA>",

View File

@@ -1,138 +1,129 @@
[[module]] [[module]]
id = "armor_plate" id = "armor_plate"
tooltip = "Adds a large flat bonus to hit points."
unlock_at_station_level = -1 unlock_at_station_level = -1
surface_mask = ["OO"] surface_mask = ["OO"]
materials = [{item = "iron_ingot", amount = 2}] materials = [{item = "iron_ingot", amount = 2}]
player_production_level = 1
production_time_seconds = 3 production_time_seconds = 3
fill_color = "#808080" fill_color = "#808080"
glyph = "A" glyph = "A"
[module.health] [module.health]
multiplied_hp_formula = "1.5" multiplied_hp = 1.5
[[module]] [[module]]
id = "sensor_booster" id = "sensor_booster"
unlock_at_station_level = -1 unlock_at_station_level = -1
surface_mask = ["O"] surface_mask = ["O"]
materials = [{item = "circuit_board", amount = 1}] materials = [{item = "circuit_board", amount = 1}]
player_production_level = 1
production_time_seconds = 2 production_time_seconds = 2
fill_color = "#40A0FF" fill_color = "#40A0FF"
glyph = "S" glyph = "S"
[module.sensor] [module.sensor]
added_sensor_range_m_formula = "100" added_sensor_range_m = 100
[[module]] [[module]]
id = "weapon_upgrade" id = "weapon_upgrade"
unlock_at_station_level = -1 unlock_at_station_level = -1
surface_mask = ["O"] surface_mask = ["O"]
materials = [{item = "iron_ingot", amount = 1}, {item = "circuit_board", amount = 1}] materials = [{item = "iron_ingot", amount = 1}, {item = "circuit_board", amount = 1}]
player_production_level = 1
production_time_seconds = 4 production_time_seconds = 4
fill_color = "#FF4040" fill_color = "#FF4040"
glyph = "W" glyph = "W"
[module.weapon] [module.weapon]
multiplied_damage_formula = "1.2" multiplied_damage = 1.2
[[module]] [[module]]
id = "laser_cannon" id = "laser_cannon"
unlock_at_station_level = -1 unlock_at_station_level = -1
surface_mask = ["O"] surface_mask = ["O"]
materials = [{item = "iron_ingot", amount = 1}] materials = [{item = "iron_ingot", amount = 1}]
player_production_level = 1
production_time_seconds = 5 production_time_seconds = 5
fill_color = "#FF8040" fill_color = "#FF8040"
glyph = "L" glyph = "L"
[module.weapon] [module.weapon]
damage_formula = "2" damage = 2
attack_range_m_formula = "50" attack_range_m = 50
attack_rate_hz_formula = "2.0" attack_rate_hz = 2.0
[[module]] [[module]]
id = "salvager" id = "salvager"
unlock_at_station_level = -1 unlock_at_station_level = -1
surface_mask = ["OO"] surface_mask = ["OO"]
materials = [{item = "iron_ingot", amount = 2}] materials = [{item = "iron_ingot", amount = 2}]
player_production_level = 1
production_time_seconds = 5 production_time_seconds = 5
fill_color = "#AACC44" fill_color = "#AACC44"
glyph = "Sv" glyph = "Sv"
[module.salvage] [module.salvage]
collection_range_m_formula = "500" collection_range_m = 500
cargo_capacity_formula = "10" cargo_capacity = 10
collection_rate_hz_formula = "0.5" collection_rate_hz = 0.5
[[module]] [[module]]
id = "repair_tool" id = "repair_tool"
unlock_at_station_level = -1 unlock_at_station_level = -1
surface_mask = ["O"] surface_mask = ["O"]
materials = [{item = "circuit_board", amount = 2}] materials = [{item = "circuit_board", amount = 2}]
player_production_level = 1
production_time_seconds = 5 production_time_seconds = 5
fill_color = "#66CCFF" fill_color = "#66CCFF"
glyph = "Rp" glyph = "Rp"
[module.repair] [module.repair]
repair_rate_hz_formula = "1" repair_rate_hz = 1
repair_amount_hp_formula = "5 + x" repair_amount_hp = 6
repair_range_m_formula = "800" repair_range_m = 800
[[module]] [[module]]
id = "weapon_primer" id = "weapon_primer"
unlock_at_station_level = -1 unlock_at_station_level = -1
surface_mask = ["O"] surface_mask = ["O"]
materials = [{item = "iron_ingot", amount = 1}] materials = [{item = "iron_ingot", amount = 1}]
player_production_level = 1
production_time_seconds = 4 production_time_seconds = 4
fill_color = "#FF4040" fill_color = "#FF4040"
glyph = "Wp" glyph = "Wp"
[module.weapon] [module.weapon]
multiplied_attack_rate_hz_formula = "1.2" multiplied_attack_rate_hz = 1.2
[[module]] [[module]]
id = "weapon_stabilizer" id = "weapon_stabilizer"
unlock_at_station_level = -1 unlock_at_station_level = -1
surface_mask = ["O"] surface_mask = ["O"]
materials = [{item = "iron_ingot", amount = 1}] materials = [{item = "iron_ingot", amount = 1}]
player_production_level = 1
production_time_seconds = 4 production_time_seconds = 4
fill_color = "#FF4040" fill_color = "#FF4040"
glyph = "Ws" glyph = "Ws"
[module.weapon] [module.weapon]
multiplied_attack_range_m_formula = "1.5" multiplied_attack_range_m = 1.5
multiplied_attack_rate_hz_formula = "0.8" multiplied_attack_rate_hz = 0.8
[[module]] [[module]]
id = "afterburner" id = "afterburner"
unlock_at_station_level = -1 unlock_at_station_level = -1
surface_mask = ["O"] surface_mask = ["O"]
materials = [{item = "iron_ingot", amount = 1}] materials = [{item = "iron_ingot", amount = 1}]
player_production_level = 1
production_time_seconds = 2 production_time_seconds = 2
fill_color = "#40A0FF" fill_color = "#40A0FF"
glyph = "Ab" glyph = "Ab"
[module.movement] [module.movement]
multiplied_speed_mps_formula = "1.6" multiplied_speed_mps = 1.6
added_main_acceleration_mpss_formula = "60" 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 = ["O"] surface_mask = ["O"]
materials = [{item = "iron_ingot", amount = 1}] materials = [{item = "iron_ingot", amount = 1}]
player_production_level = 1
production_time_seconds = 2 production_time_seconds = 2
fill_color = "#40A0FF" fill_color = "#40A0FF"
glyph = "Mt" glyph = "Mt"
[module.movement] [module.movement]
multiplied_speed_mps_formula = "1.2" multiplied_speed_mps = 1.2
added_maneuvering_acceleration_mpss_formula = "10" added_maneuvering_acceleration_mpss = 10

View File

@@ -92,3 +92,60 @@ duration_seconds = 3.0
item = "advanced_alloy" item = "advanced_alloy"
amount = 1 amount = 1
probability = 0.1 probability = 0.1
# -------------------------------------------------------------------
# Extra recipes for ThreatCostCalculator unit tests (fixes 6-9)
# -------------------------------------------------------------------
# Fix 6: scrap-consuming smelter recipe for iron_ingot. Because iron_ingot
# already has a scrap-free smelter recipe above, this recipe must be excluded
# from iron_ingot's threat computation.
[[recipe]]
id = "scrap_iron"
building = "smelter"
inputs = [{item = "scrap", amount = 1}]
outputs = [{item = "iron_ingot", amount = 1}]
duration_seconds = 1.0
# Fix 7: a recipe that produces 2 items per cycle. Per-unit threat must
# divide by the output amount.
# dual_wire: (duration=3.0 + iron_ore(1.0)*1) / 2 = 4.0 / 2 = 2.0 per unit.
[[recipe]]
id = "dual_wire"
building = "assembler"
inputs = [{item = "iron_ore", amount = 1}]
outputs = [{item = "dual_wire", amount = 2}]
duration_seconds = 3.0
# Fix 8: an item downstream of a reprocessing-only item (advanced_alloy).
# advanced_alloy is resolved only by the reprocessing pass; downstream_product
# can only resolve in a non-reprocessing pass that runs AFTER the reprocessing
# pass, requiring proper fixpoint iteration.
# downstream_product: 2.0 + advanced_alloy(80.0)*1 = 82.0
[[recipe]]
id = "downstream_product"
building = "assembler"
inputs = [{item = "advanced_alloy", amount = 1}]
outputs = [{item = "downstream_product", amount = 1}]
duration_seconds = 2.0
# Fix 9: two recipes producing the same staggered_item. The cheap recipe
# resolves before circuit_board is known; the expensive one requires
# circuit_board. The item must be committed only once BOTH are computable,
# so the result is max(cheap, expensive).
# staggered_item_cheap: 1.0 + iron_ore(1.0)*1 = 2.0 (resolves early)
# staggered_item_expensive: 1.0 + circuit_board(28.0)*1 = 29.0 (resolves later)
# expected: max = 29.0
[[recipe]]
id = "staggered_item_cheap"
building = "assembler"
inputs = [{item = "iron_ore", amount = 1}]
outputs = [{item = "staggered_item", amount = 1}]
duration_seconds = 1.0
[[recipe]]
id = "staggered_item_expensive"
building = "assembler"
inputs = [{item = "circuit_board", amount = 1}]
outputs = [{item = "staggered_item", amount = 1}]
duration_seconds = 1.0

View File

@@ -6,24 +6,20 @@ default_modules = [{type = "laser_cannon", x = 1, y = 1, rotation = "east"}]
[ship.schematic] [ship.schematic]
materials = [{item = "iron_ingot", amount = 3}, {item = "circuit_board", amount = 1}] materials = [{item = "iron_ingot", amount = 3}, {item = "circuit_board", amount = 1}]
player_production_level = 3
production_time_seconds = 10 production_time_seconds = 10
[ship.health] [ship.health]
hp_formula = "40 + 5*x" hp = 45
[ship.movement] [ship.movement]
speed_mps_formula = "2000 + 50*x" speed_mps = 2050
main_acceleration_mpss_formula = "1000000" main_acceleration_mpss = 1000000
maneuvering_acceleration_mpss_formula = "1000000" maneuvering_acceleration_mpss = 1000000
angular_acceleration_radpss_formula = "100000" angular_acceleration_radpss = 100000
max_rotation_speed_radps_formula = "100000" max_rotation_speed_radps = 100000
[ship.sensor] [ship.sensor]
sensor_range_m_formula = "2000" sensor_range_m = 2000
[ship.loot]
scrap_drop = 2
[[ship]] [[ship]]
@@ -34,24 +30,20 @@ default_modules = [{type = "laser_cannon", x = 1, y = 1, rotation = "east"}]
[ship.schematic] [ship.schematic]
materials = [{item = "iron_ingot", amount = 5}, {item = "circuit_board", amount = 2}] materials = [{item = "iron_ingot", amount = 5}, {item = "circuit_board", amount = 2}]
player_production_level = 5
production_time_seconds = 20 production_time_seconds = 20
[ship.health] [ship.health]
hp_formula = "120 + 15*x" hp = 135
[ship.movement] [ship.movement]
speed_mps_formula = "1200" speed_mps = 1200
main_acceleration_mpss_formula = "1000000" main_acceleration_mpss = 1000000
maneuvering_acceleration_mpss_formula = "1000000" maneuvering_acceleration_mpss = 1000000
angular_acceleration_radpss_formula = "100000" angular_acceleration_radpss = 100000
max_rotation_speed_radps_formula = "100000" max_rotation_speed_radps = 100000
[ship.sensor] [ship.sensor]
sensor_range_m_formula = "3000" sensor_range_m = 3000
[ship.loot]
scrap_drop = 4
[[ship]] [[ship]]
@@ -61,24 +53,20 @@ layout = ["OOO", "OOO"]
[ship.schematic] [ship.schematic]
materials = [{item = "iron_ingot", amount = 4}] materials = [{item = "iron_ingot", amount = 4}]
player_production_level = 3
production_time_seconds = 10 production_time_seconds = 10
[ship.health] [ship.health]
hp_formula = "40 + 4*x" hp = 44
[ship.movement] [ship.movement]
speed_mps_formula = "1100" speed_mps = 1100
main_acceleration_mpss_formula = "1000000" main_acceleration_mpss = 1000000
maneuvering_acceleration_mpss_formula = "1000000" maneuvering_acceleration_mpss = 1000000
angular_acceleration_radpss_formula = "100000" angular_acceleration_radpss = 100000
max_rotation_speed_radps_formula = "100000" max_rotation_speed_radps = 100000
[ship.sensor] [ship.sensor]
sensor_range_m_formula = "2500" sensor_range_m = 2500
[ship.loot]
scrap_drop = 2
[[ship]] [[ship]]
@@ -88,21 +76,17 @@ layout = ["XOX", "OOO", "XOX"]
[ship.schematic] [ship.schematic]
materials = [{item = "iron_ingot", amount = 4}, {item = "circuit_board", amount = 2}] materials = [{item = "iron_ingot", amount = 4}, {item = "circuit_board", amount = 2}]
player_production_level = 3
production_time_seconds = 15 production_time_seconds = 15
[ship.health] [ship.health]
hp_formula = "60 + 5*x" hp = 65
[ship.movement] [ship.movement]
speed_mps_formula = "1300" speed_mps = 1300
main_acceleration_mpss_formula = "1000000" main_acceleration_mpss = 1000000
maneuvering_acceleration_mpss_formula = "1000000" maneuvering_acceleration_mpss = 1000000
angular_acceleration_radpss_formula = "100000" angular_acceleration_radpss = 100000
max_rotation_speed_radps_formula = "100000" max_rotation_speed_radps = 100000
[ship.sensor] [ship.sensor]
sensor_range_m_formula = "2500" sensor_range_m = 2500
[ship.loot]
scrap_drop = 2

View File

@@ -3,12 +3,14 @@ height_tiles = 60
refund_percentage = 75 refund_percentage = 75
starting_building_blocks = 100 starting_building_blocks = 100
scrap_despawn_seconds = 30 scrap_despawn_seconds = 30
scrap_per_threat = 1.0
tile_size_m = 10 tile_size_m = 10
belt_speed_mps = 20 belt_speed_mps = 20
tunnel_max_distance_tiles = 10 tunnel_max_distance_tiles = 10
departure_interval_seconds = 20 departure_interval_seconds = 20
orbit_factor = 0.8 orbit_factor = 0.8
rally_orbit_radius_tiles = 5.0 rally_orbit_radius_tiles = 5.0
building_blocks_tooltip = "Spend building blocks to build; deliver them to the HQ to gain more."
[regions] [regions]
asteroid_width_tiles = 40 asteroid_width_tiles = 40
@@ -16,9 +18,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
@@ -35,7 +42,6 @@ artifact_win_count = 3
[waves] [waves]
threat_rate_formula = "x" threat_rate_formula = "x"
ship_level_formula = "1 + x / 10"
gap_min_seconds = 15 gap_min_seconds = 15
gap_max_seconds = 45 gap_max_seconds = 45
spawn_duration_seconds = 10 spawn_duration_seconds = 10

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).

333
docs/balancing/rules.md Normal file
View File

@@ -0,0 +1,333 @@
# Balancing & Progression Rules
Rules and principles that govern the production tree, progression pacing,
and balancing. This document contains **rules only** — the chosen base
numbers live in `targets.md`, everything derived from them in
`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
What each phase of a run should feel like:
- **Early:** learning belts and ratios with forgiving chains. The building
block economy is the main constraint; the player bootstraps a
self-sustaining factory from the starting stock.
- **Mid:** deeper chains, the first real ratio puzzles, and the first
meaningful drop decisions (which schematic, when to push).
- **Late:** combat feeds the factory — capital production requires salvage.
Progress means extending and refactoring the existing factory, not
rebuilding it. Strange ratios are deliberate optimization puzzles.
Overarching: an experienced player gains efficiency through **knowledge**
layout foresight, understanding chains, exploiting shortcut recipes — never
through hidden mechanics. An inexperienced setup should not cost much more
than an experienced one; experience pays off in how easily the factory
adapts later (see Refactorability).
## Resource phases
- A run has exactly **four base inputs**:
1. Two mined resources available from the start, minable on **every**
asteroid tile.
2. A third mined resource unlocked mid-game, minable **only on deposit
patches** found in expansion territory (see Resource deposits).
3. A fourth input unlocked late-game, obtainable **only** from
reprocessing salvaged scrap.
- The fourth input is the core loop hook: capital ship production requires
fighting (salvaging and reprocessing), not just mining.
- Every gating has a fictional reason (concrete fiction in
`../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
late input is battle-forged — created only in the violence of ship
destruction, which is why any wreck (including the player's own)
yields it and no foundry can make it.
- The mid resource is **dual-gated**: schematics (knowledge, via drops)
and territory (deposits, via expansions). Tuning must guarantee the
deposit-bearing expansion is comfortably affordable by the time the
first mid-tier schematics drop, or those drops are dead picks.
- There is no direct "resource unlock" mechanism. Miner recipes unlock
**implicitly** (REQ-LOCK-IMPLICIT) when some unlocked schematic's material
chain reaches that resource. Resource pacing is therefore controlled
through the `unlock_at_station_level` values of ships, modules, and
assembler recipe schematics — and the content must guarantee that the
chains actually connect (a mid-game schematic must require an item whose
chain reaches the mid resource, or it never unlocks).
### Resource deposits
- **Rule: freedom first, geography later.** The starting resources are
minable everywhere, so the player has full layout freedom while
learning. Later mined resources are bound to deposit patches — fixed
geography as a layout puzzle, introduced once the player is competent.
- **Rule: deposits exist only in expansion territory.** Expansions buy
space *and* access to resource tiers — the second leg of the growth
curve (see Building block economy).
- **Rule: patch area is the throughput cap.** Deposits never deplete but
are finite in area; the number of deposit tiles caps how many miners
the chain supports. Buying deeper expansions raises the throughput
ceiling of high-tier chains.
- **Rule: no empty expansions.** Deposit content per expansion is
deterministic and config-defined; only the placement within the new
columns is randomized. Buying an expansion never rolls "nothing".
- **Rule: mining is binary.** A miner whose footprint overlaps at least
one deposit tile of a resource can select that resource's recipe; no
partial-coverage rate scaling.
- Deposits arrive at the periphery (expansions add columns on the left),
so each new chain starts in fresh space — supporting the
refactorability property — and high-tier chains have the longest belt
runs to the shipyards, escalating the logistics puzzle with tier.
## Production tree rules
### Structure
- **Each phase transition adds exactly one new base input chain.** A base
input is a bottom-level resource entering the factory from outside — a
mined resource or the scrap-only input. The early game starts with two
ores as the baseline; the transition to mid adds one (the deposit-bound
mid resource), the transition to late adds one (the scrap-only input).
No transition ever introduces more than one unfamiliar bottom-level
chain, so the factory grows in one direction at a time.
- **Intermediates are generic shared parts.** Keep the item count low —
modules and hulls of a tier draw from a shared pool of that tier's and
lower tiers' intermediates rather than each having bespoke inputs.
- **Thematic naming over thematic items.** Inputs should be plausible for
what the recipe produces (crystals for lasers, heat sinks for bigger
lasers). Achieve this through naming and chain membership, not by adding
item types: rename a generic part, don't add a parallel one.
### Ratios
- **Ratio "niceness" degrades with tier.** The producer:consumer ratios
needed for 100% throughput follow a curve:
- Tier 1 (ore → basic material): trivially nice (e.g. 1:1 or 1:2
miner:smelter).
- Tier 2: slightly complex but still clean (e.g. 2:3).
- Higher tiers: increasingly strange ratios, as deliberate optimization
puzzles.
- Exceptions in both directions are allowed when there is a reason — a
clean late chain as a breather, an odd early chain as a teaser — but the
curve is the default.
### Shortcut recipes
- Some strange chains get a **shortcut recipe**: an explicitly unlockable
assembler recipe schematic (`unlock_at_station_level ≥ 0`, drop-only per
REQ-LOCK-EXPLICIT) that skips a step (e.g. t1 → t3 directly) and yields
nice ratios for a chain whose base path is strange.
- **Not every strange chain gets a shortcut.** Some strangeness is
permanent; the absence of a fix is a valid design choice.
- **Shortcuts drop only for known chains.** A shortcut recipe enters the
drop pool only when both its input items and its output item are
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
output-item half of this check already exists in
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
value is the *maximum* across its producing recipes (REQ-THREAT-ITEM), so
unlocking a cheaper recipe does not lower the item's threat accounting —
the player gains real factory efficiency without their ships being
valued cheaper and without enemy wave budgets shifting. Consequently:
**balance every chain around its base (expensive) path**; the shortcut's
savings define the size of the reward.
### Refactorability
- **Rule (the property):** unlocking the next tier or size of a thing must
be a *local edit* of the existing production line — adding assemblers
and belts, or replacing a machine or two in place — never a rebuild of
the line.
- **What this buys the player:** foresight pays off in space, not blocks.
An experienced player leaves a little slack in the middle of a line,
knowing the next size or tier upgrade means tearing out one assembler
and a few belts there and inserting the new step — plus maybe swapping
a recipe or two elsewhere — while the rest of the line keeps running
untouched.
- **Default technique:** the bigger version introduces one new intermediate
that is produced from a subset of the smaller version's inputs (possibly
plus one additional low-tier material), and otherwise reuses the smaller
version's inputs. Existing lines keep running and feed the new
intermediate's assemblers.
- The property is the rule; the technique is only the default. It may be
broken where it fights thematic plausibility, as long as the property
still holds.
## Cost archetypes
Every item has two cost knobs: **material quantity** and **cycle time**.
Both feed the threat value identically (threat = recursive
production-seconds, REQ-MOD-THREAT), so the split between them does not
change what an item is *worth* — it changes what kind of **factory
pressure** it creates:
- **Material-heavy, fast** (e.g. armor plates): simple items; stress belt
throughput, splitter logistics, and miner/smelter counts.
- **Time-heavy, lean** (e.g. shield modules): technically complex items;
few inputs — possibly higher-tier ones — but long cycles; stress
assembler counts and parallelization.
**Rule:** each module family commits to a clear archetype, so factories
supporting different fleet doctrines feel structurally different to build.
## Threat model (balancing backbone)
- Threat cost = total recursive production-seconds (REQ-MOD-THREAT). One
factory-second equals one threat; player output and enemy wave budgets
are denominated in the same currency.
- **Rule: combat power per threat is roughly constant** across all ships,
modules, and tiers. Higher tiers are better per *ship* and per *module
slot*, not per invested factory-second — their advantage is
concentration (fewer, bigger things; slot geometry per
`../content_design.md`) and qualitative capabilities, not a better
exchange rate. Deviations from this rule are deliberate and documented.
- **Difficulty race:** the enemy threat rate (`threat_rate_formula`) is
tuned against the factory output (threat/s) achievable by a competent
player — slightly below it early, crossing above it eventually. The game
is endless; enemy scaling must ultimately outpace any factory, and
player skill shifts *when*, not *whether*.
- **All time scaling lives in the threat rate** — waves get bigger, ships
of a given schematic never get individually stronger. There is no ship
level dimension: stat formulas are plain values, and per-ship level
scaling does not exist. Push scaling on enemy defence stations is the
separate, player-triggered difficulty axis and keeps its level formulas.
## Unlock & drop pacing
- **Starting set rule:** the schematics unlocked at game start
(`unlock_at_station_level = -1`) must be exactly enough to reach the
first push unaided — a functioning block loop, small hulls, a basic
weapon, and the salvage loop. Nothing more.
- The `unlock_at_station_level` ladder mirrors the resource phases:
mid-tier hulls/modules/recipes at low station levels, capital content at
higher levels. A schematic must not become available before the chains
its materials need can be unlocked alongside it.
- **Schematics can require other schematics.** Beyond the station-level
gate, a schematic (ship, module, or assembler recipe) may list
prerequisite schematics (`unlock_requires`, REQ-LOCK-PREREQ) that must
already be unlocked before it enters the drop pool — e.g. the medium
gun requires the small gun; a future Mk2 requires its base version.
Station level gates the earliest *when*; prerequisites gate the
*order*, keeping drop offers coherent with what the player already
owns.
- **No duplicate drops.** Ship and module schematics leave the drop pool
once owned, exactly as assembler recipe schematics already do. There are
no schematic level-ups; player power grows through unlock breadth and
factory scale only, which keeps power-per-threat exact on both sides.
The pool therefore shrinks over a run and late pushes increasingly offer
artifacts — intended: the late game is a race for the win condition.
Per-item progression may return later as Mk2 upgrade recipes (see Future
work), never as free level-ups.
- **Artifacts trade power for progress.** Artifact options compete with
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
automatic in either direction.
## Scrap & reprocessing economy
- Scrap is the bridge from combat back into the factory, with two sinks:
**smelting** (same basic materials as ore — the safe, boring option) and
**reprocessing** (probabilistic higher intermediates, including the
late-game input — the gamble that eventually becomes mandatory).
- The reprocessing output pool renormalizes over implicitly unlocked items
(REQ-LOCK-REPROCESSING-POOL), so its output quality improves
automatically as the run progresses. **Rule:** weights are authored for
the *fully unlocked* pool state; early-game behavior falls out of
renormalization for free and needs no separate staging.
- **Rule: ship scrap drops are derived, never authored.** A destroyed ship
drops `threat cost × scrap_per_threat` (a `world.toml` key), with the
threat cost computed from its actual hull plus installed modules
(REQ-MOD-THREAT) — a kitted-out ship drops more scrap than a bare hull
automatically. `ships.toml` carries no scrap value. Defence stations are
the exception: they keep authored `scrap_drop_formula`s, because pushing
rewards are tuned independently of ship production costs.
- Consequence: the threat value of scrap is the constant
`1 / scrap_per_threat` (REQ-THREAT-SCRAP). The former min-`scrap_drop`
schematic derivation and its potential circularity are gone.
- **Rule:** the late-game input's income rate meaningfully gates capital
production — unlocking a capital hull must not mean spamming it; the
input trickles in slowly enough that every capital ship is a noticeable
investment. The tuning target is relative, not absolute: assume a
reference player who destroys and salvages roughly the threat the game
spawns ("fighting at parity"), and tune `scrap_per_threat`, the
reprocessing weights, and capital material costs so that this player
affords roughly N capital ships per boss cycle. An absolute income rate
would be meaningless (income depends entirely on how much the player
fights) and would not self-scale; per boss cycle, the target tracks the
threat rate as it steps up.
## Building block economy
- Building blocks are the only global currency and the early game's
central constraint. The early game is a bootstrap problem: convert the
starting stock into a self-sustaining block loop before the first waves
bite.
- **Rule:** the starting stock suffices for a minimal block loop plus the
first shipyard — with a little slack for beginner mistakes, but not
enough to skip the loop entirely.
- **Rule: the growth curve lives here.** A saturated building produces
exactly 1 threat/s, so the player's output curve *is* their
building-count curve — shaping growth over a run means shaping the
block and space economy, there is nowhere else it can live. Intended
shape: exponential bootstrap (block-limited) → ramp
(expansion-limited) → asymptotic squeeze as expansion costs outrun
income, racing the enemy threat rate throughout.
- **Rule: escalating expansion costs.** Expansion cost is a formula of
the number of expansions already purchased, rising steeply enough that
expansions eventually outrun any block income. The starting asteroid
is deliberately small — filled within the first boss cycle or two, so
the early exponential burst is a satisfying ramp, not a balance hole —
and from then on the output curve is the expansion curve. Blocks keep
a meaningful sink for the entire run, and "grow vs. army" stays a live
decision at every moment.
- **Rule: designed doubling time.** Block production is a positive
feedback loop (blocks buy assemblers, assemblers make blocks); its
time constant is a designed quantity, never an accident of quantity
choice. The block chain's depth and the per-building costs are tuned
against a stated target of the form: "a factory spending X% of its
capacity on blocks doubles in ~T minutes."
- **Rule: growth is limited by economy, never by waiting.** Construction
times stay short; the serial build queue must not be used as a growth
brake. Waiting for placed buildings to become operational — especially
at the start of a run — is frustration, not gameplay. All growth
limiting comes from block income and expansion pricing.
- Note: block income has a structural ceiling — blocks enter the stock
through the HQ's single belt port, so income is capped at belt
throughput regardless of assembler count. Per-building costs should be
high enough that this cap can bind late-game (see the condensed-block
idea under Future work).
## Numeric guardrails
Constraints that every recipe must respect, independent of tuning:
- **Belt throughput:** belt speed and per-tile capacity cap how fast a
single belt can feed an input. A recipe whose per-cycle inputs cannot be
sustained by one belt per input at 100% duty cycle is a *deliberate*
design (forcing parallel belts/splitters as part of a high-tier puzzle)
— never an accident of quantity choice.
- **Buffer burstiness:** input buffers hold 2× the per-cycle amount
(REQ-MAT-INPUT-BUFFER), so large per-cycle quantities create bursty belt
demand. Low tiers prefer small quantities with short cycles; big-batch
recipes are reserved for high tiers where burstiness is part of the
puzzle.
- **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.
## Future work
- **Condensed building blocks** — a drop-unlockable shortcut-style
recipe that packs several blocks' worth of value into one belt item,
relieving the HQ intake ceiling (see Building block economy) as a
late-game reward. The ceiling is the puzzle, the drop is the fix —
same philosophy as shortcut recipes.
- **Mk2 upgrade recipes** — the deferred design for per-item progression,
to revisit once the config has stabilized. A duplicate-style drop
unlocks a distinct `*_mk2` item whose recipe consumes the Mk1 item plus
higher-tier parts. This preserves power-per-threat (the extra power is
paid in real production-seconds, since threat is recursive), satisfies
the refactorability rule (the Mk1 line keeps running and feeds one new
assembler), and keeps balancing one-dimensional (no level variable
anywhere). Enemy-side progression happens via `default_modules`
variants per era instead of a level formula.

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

@@ -2,7 +2,7 @@
## Overview ## Overview
A single-player asymmetric game inspired by DOTA's wave/tower structure, combined with a Factorio-style factory builder. The player builds a factory on an asteroid to supply shipyards that produce autonomous combat ships. Those ships fight off endless enemy waves advancing from the right. The goal is to survive as long as possible; elapsed time is always displayed. A single-player asymmetric game inspired by DOTA's wave/tower structure, combined with a Factorio-style factory builder. The player builds a factory on an asteroid to supply shipyards that produce autonomous combat ships. Those ships fight off enemy waves advancing from the right, with tougher boss waves arriving periodically. Pushing into enemy territory and destroying their defence stations occasionally yields artifacts, which are used to upgrade the HQ; the goal is to upgrade the HQ enough to launch it into space, winning the game.
## Setting & Visuals ## Setting & Visuals
@@ -62,21 +62,26 @@ Two sources feed the same production tree:
- Waves consist of a single enemy ship type whose stats scale with difficulty. - Waves consist of a single enemy ship type whose stats scale with difficulty.
- Waves spawn over several seconds; a gap follows before the next wave begins spawning. The previous wave may still be approaching or fighting during the gap. - Waves spawn over several seconds; a gap follows before the next wave begins spawning. The previous wave may still be approaching or fighting during the gap.
- Difficulty scales multiplicatively from two sources: - A tougher **boss wave** spawns periodically on its own countdown, on top of normal waves.
- **Time scaling** — enemy strength increases gradually over elapsed time. - Enemy strength increases gradually over time and with each boss wave that occurs.
- **Push scaling** — destroying a set of enemy defence stations multiplies enemy strength by a configurable factor. The replacement stations are scaled by the same factor.
## Push Mechanic ## Push Mechanic
- The player is not forced to push; purely defensive play is valid. - The player must push — destroying enemy defence stations is the only way to earn artifacts, which are required to win.
- Destroying enemy defence stations applies the push scaling multiplier to all future waves, extends the scrollable area, and places a new (stronger) set of stations at the new boundary. - Destroying a set of enemy defence stations advances the boss countdown (bringing the next, stronger boss wave sooner), extends the scrollable area, and places a new (stronger) set of stations at the new boundary.
- Destroyed enemy defence stations drop ship schematics. - Destroyed enemy defence stations drop either a ship/module schematic or, occasionally, an artifact.
## Win Condition
- Artifacts are gathered by defeating enemy defence stations instead of taking a schematic reward.
- Artifacts are used to upgrade the HQ. Once the HQ is upgraded enough, the player can launch it into space — this is how the game is won.
## Starting Conditions & Game Over ## Starting Conditions & Game Over
- The player starts with the HQ and player defence stations pre-placed and a stock of building blocks; no other buildings are pre-placed. - The player starts with the HQ and player defence stations pre-placed and a stock of building blocks; no other buildings are pre-placed.
- There is a grace period before the first wave to allow initial setup. - There is a grace period before the first wave to allow initial setup.
- If all ships and player defence stations are destroyed, enemies attack the HQ. The game is lost when the HQ is destroyed. Factory buildings are never targeted. - If all ships and player defence stations are destroyed, enemies attack the HQ. The game is lost when the HQ is destroyed. Factory buildings are never targeted.
- The game is won when the player launches the HQ into space (see Win Condition).
## Asteroid Expansion ## Asteroid Expansion

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
@@ -131,64 +132,163 @@ drone hangar — carrier 1.
## Production tree ## Production tree
Design principle: each game phase adds exactly one new base input chain, so Designed against the rules in `docs/balancing/rules.md` (ratio curve,
factory complexity ramps alongside ship size. cost ladder, cost archetypes, refactorability). Quantities, durations,
and threat values live in `docs/balancing/derived.md`.
| Phase | New input | How acquired | Unlocks | ### Base inputs (4) and fiction
|-------|-----------|--------------|---------|
| 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 - **iron_ore, copper_ore** — from the start, minable on every asteroid
requires fighting (salvaging scrap from kills and reprocessing it), not just tile. Fiction: the asteroid is an M-type (metal) body — its bulk rock
mining. The reprocessing plant turns 5 scrap into iron/copper/titanium ingots *is* ore, which is why the shipyard operation was built here at all.
or advanced_alloy probabilistically. - **quartz** — mid-game, minable only on geode deposit patches in
expansion territory (see the Resource deposits rules in
`docs/balancing/rules.md`; the deposit mechanic itself is an open
action item — until it lands, quartz mines anywhere). Fiction:
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.
Fiction: battle-forged — formed when weapon plasma anneals hull metal
in the violence of ship destruction. Any wreck yields it, including the
player's own; no foundry can replicate it.
- **titanium was dropped** (v1 tree). Its hull-gating role moved to
quartz-era control systems ("you can smelt all the steel you want, but
you cannot steer a battlecruiser without electronics") plus the
hardened-steel quality step (a deliberately long-running, time-heavy
recipe) — explicitly not sheer steel quantity alone.
Intermediate components, by tier: ### Material palette (fingerprints per family)
- **Tier 2 (early):** copper_wire (copper), steel_plate (iron), circuit_board - **iron/steel** — structure.
(iron + wire), building_block (iron). - **copper** — conduction and heat: wiring, coils, heat sinks.
- **Tier 3 (mid):** mechanical_parts (steel + iron), targeting_unit (circuits - **silicon family** (all derived from quartz): silicon (logic,
+ wire), drive_unit (steel + mechanical_parts + circuit), titanium_frame sensors), ceramics (heat shielding, insulators); glass/optics are cut
(titanium + steel). from v1 — their only consumers would be lasers, which are deferred.
- **Tier 4 (late):** reinforced_plating (steel + advanced_alloy), - **voidsteel** — capital-tier structure and exotics.
capital_core (targeting_unit + drive_unit + 2 advanced_alloy). - Deliberately skipped: carbon (mostly redundant with copper/ceramics),
plastics (drags in Factorio-style chemical chains; ceramics read more
sci-fi anyway), volatiles/ice (materials are build costs only — no
consumption mechanic to justify fuel).
Hulls and modules consume intermediates of their tier: early items are built ### Weapons
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 - All v1 weapons are **railguns** (`railgun_s/m/l`, renamed from the
recipes, ship/module materials, or visuals: laser placeholders; footprints and the gating matrix unchanged).
Implementation is instant damage application with no projectile and no
ammunition — the beam visual reads as a tracer round. Materials: iron
slugs, copper coils, steel rails — the starting-metal fingerprint.
- **Lasers are reserved for later** as a genuinely distinct weapon type
(e.g. once projectile/ammunition mechanics exist for other families),
arriving with quartz optics. More weapon types are planned; railguns
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
Input lists only — quantities, durations, and per-item threat values are
in `docs/balancing/derived.md` and the configs.
**Mined (miner):** `iron_ore`, `copper_ore` (every tile), `quartz`
(geode deposits in expansion territory).
**Smelted (smelter — exactly one recipe per input item):**
| output | input | ratio class |
|---|---|---|
| iron_ingot | iron_ore | nice (1:1) |
| copper_ingot | copper_ore | nice |
| silicon | quartz | mid entry |
| iron_ingot | scrap | the safe, boring scrap sink |
**Reprocessing pool (scrap):** `iron_ingot`, `copper_ingot`, `silicon`,
`voidsteel` — the only source of voidsteel. Weights authored for the
fully unlocked pool state.
**Tier 2 — early intermediates (clean ratios, ~2:3):**
| item | inputs | role |
|---|---|---|
| steel_plate | iron_ingot | structure backbone, highest volume |
| copper_wire | copper_ingot | conductors |
| copper_coil | copper_wire | electromagnets: railguns, thrusters |
| building_block | steel_plate | depth-3 chain = the doubling-time knob |
**Tier 3 — mid intermediates (strange ratios begin, need quartz):**
| item | inputs | role |
|---|---|---|
| control_chip | silicon + copper_wire | electronics gate for m+ hulls |
| capacitor_bank | copper_coil + silicon | power for railgun m/l |
| hardened_steel | steel_plate (long cycle) | quality gate for m+ hulls; time-heavy |
| ceramic_plate | quartz | heat shielding: drives, l guns, capitals |
| drive_unit | steel_plate + copper_coil + control_chip | propulsion for m+ hulls |
**Tier 4 — late intermediates (need voidsteel):**
| item | inputs | role |
|---|---|---|
| voidsteel_plate | voidsteel + hardened_steel | capital structure |
| capital_core | voidsteel + capacitor_bank + control_chip | capital heart |
**Hull items** (`<ship>_hull`, assembler-made; the shipyard consumes the
hull item plus module materials). The m+ hull gate is **both**
hardened_steel (quality steel, the time-heavy step) *and* control_chip
(electronics):
| hull | inputs |
|---|---|
| drone_hull | iron_ingot |
| frigate_hull | steel_plate + copper_wire |
| destroyer_hull | steel_plate + copper_coil |
| cruiser_hull | hardened_steel + control_chip |
| battlecruiser_hull | hardened_steel + control_chip + drive_unit |
| battleship_hull | voidsteel_plate + drive_unit + control_chip |
| dreadnought_hull | voidsteel_plate + capital_core + drive_unit |
| carrier_hull | voidsteel_plate + capital_core + drive_unit |
**Module items** (`<module>_module`, assembler-made prefabs — kept as
items so shipyard belt inputs stay simple and module production can be
stockpiled):
| module | inputs | archetype |
|---|---|---|
| railgun_s | copper_coil | lean |
| salvager | steel_plate + copper_wire | balanced |
| repair_tool | steel_plate + copper_wire | balanced |
| armor_plates | steel_plate (many) | material-heavy, fast |
| maneuvering_thrusters | steel_plate + copper_coil | balanced |
| sensor_booster | copper_wire + copper_coil | lean (an antenna, no chip) |
| afterburner | copper_coil + steel_plate | balanced |
| weapon_stabilizer | steel_plate + copper_coil | balanced |
| weapon_primer | capacitor_bank + copper_coil | mid; time-heavy |
| weapon_upgrade | control_chip + copper_coil | mid; time-heavy |
| railgun_m | capacitor_bank + steel_plate + copper_coil | mid |
| drone_bay | control_chip + steel_plate + copper_coil | mid |
| railgun_l | capacitor_bank + hardened_steel + ceramic_plate | late |
| drone_hangar | voidsteel_plate + control_chip + drive_unit | late (carrier only) |
**Refactorability check** (the default technique holds): railgun_s → m
introduces capacitor_bank, built from a subset of the small gun's inputs
(copper_coil) plus the new base resource (silicon); the m gun otherwise
reuses the small gun's inputs. Hulls likewise: the cruiser adds
hardening (fed by the existing steel line) and chips (fed by the new
quartz territory) without touching the iron/copper core.
**Shortcut recipes** (drop-only assembler schematics; not every strange
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).
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 python dota_factory/tools/verify_recipes.py
It verifies every consumed item has a producer, every item has a visuals It verifies every consumed item has a producer, every item has a visuals
entry, flags orphaned items, and prints which items are reprocessing-only entry, flags orphaned items, and prints which items are
(currently exactly advanced_alloy). reprocessing-only (currently exactly voidsteel).
## 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,11 +4,11 @@
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, enemy ship level formula, belt speed, starting building blocks, departure interval, ship orbit factor, rally orbit radius, 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, view pan speeds (slow and fast horizontal pan speed and pan ramp band width), and an optional building blocks tooltip string (shown as the header bar's building blocks stock hover tooltip, REQ-UI-BLOCKS-TOOLTIP; omitted when unset).
- **buildings.toml** — building block cost and construction time per building type. - **buildings.toml** — building block cost and construction time per building type, plus an optional tooltip description string per building type (shown as the build button's hover tooltip, REQ-UI-BUILD-TOOLTIP; omitted when unset).
- **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities. Assembler recipe entries may optionally define `unlock_at_station_level` (integer): -1 means the recipe is explicitly unlocked at game start; a value ≥ 0 means the recipe starts locked and a schematic for it can be awarded via defence station destruction (see REQ-LOCK-EXPLICIT, REQ-DEF-SCHEMATIC-DROP). - **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 formulas of ship level, required build materials, player production level, 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), a layout grid defining the ship's module slots, a `scrap_drop` loot value, 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).
- **modules.toml** — per module type: id, surface mask, materials list, initial player production level, production time, fill color, glyph, the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the module schematic already unlocked), and an optional capability section and/or stat modifier formulas. A module with a capability section (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat formulas is a **capability module** that grants the ship a weapon, salvage bay, or repair tool per instance (see REQ-MOD-CONFIG for the full list of formulas per capability type). A module with only `added_*`/`multiplied_*` formulas is a **passive module** that modifies stats on the ship or on capability module instances (see REQ-MOD-STAT-CALC). - **modules.toml** — per module type: id, surface mask, materials list, production time, fill color, glyph, an optional tooltip description string (shown as the module selection button's hover tooltip, REQ-MOD-UI-MODULE-TOOLTIP; omitted when unset), the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the module schematic already unlocked), an optional `unlock_requires` prerequisite list (REQ-LOCK-PREREQ), and an optional capability section and/or stat modifier formulas. A module with a capability section (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat formulas is a **capability module** that grants the ship a weapon, salvage bay, or repair tool per instance (see REQ-MOD-CONFIG for the full list of formulas per capability type). A module with only `added_*`/`multiplied_*` formulas is a **passive module** that modifies stats on the ship or on capability module instances (see REQ-MOD-STAT-CALC).
- **stations.toml** — HP, damage, range, fire rate, and scrap drop for player and enemy defence stations, defined as formulas of station level. - **stations.toml** — HP, damage, range, fire rate, and scrap drop for player and enemy defence stations, defined as formulas of station level.
- **visuals.toml** — rendering-only config (not game parameters): fill and outline colors and glyphs for every building type, item type, ship schematic, and station type; a distinct beam color per tool type (weapon, repair, salvage) and beam width; overlay and toast colors. Loaded by the UI at startup; the simulation does not read it. - **visuals.toml** — rendering-only config (not game parameters): fill and outline colors and glyphs for every building type, item type, ship schematic, and station type; a distinct beam color per tool type (weapon, repair, salvage) and beam width; overlay and toast colors. Loaded by the UI at startup; the simulation does not read it.
- **ship_layouts.toml** — named layout blueprints per ship type; written and read by the application to persist the layout blueprint panel (REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD). Not a game parameter file; the simulation does not read it. - **ship_layouts.toml** — named layout blueprints per ship type; written and read by the application to persist the layout blueprint panel (REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD). Not a game parameter file; the simulation does not read it.
@@ -68,7 +68,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
## Game World ## Game World
- REQ-GW-COORDS: Tile coordinates are integer `(x, y)`. The origin `(0, 0)` is the first column of space — the tile immediately to the right of the asteroid's right edge at game start, at the top of the world. X grows right; Y grows down. All asteroid tiles have `x < 0`; asteroid left-expansions add tiles at increasingly negative X. The origin never shifts. - REQ-GW-COORDS: Tile coordinates are integer `(x, y)`. The origin `(0, 0)` is the first column of space — the tile immediately to the right of the asteroid's right edge at game start, at the top of the world. X grows right; Y grows down. All asteroid tiles have `x < 0`; asteroid left-expansions add tiles at increasingly negative X. The origin never shifts.
- REQ-GW-TILE-SIZE: Tiles are square. The tile size in pixels is derived automatically so that the world height (in tiles) exactly fills the game world view's height in pixels. Items on belts are rendered at half-tile size; when multiple items occupy the same tile they are spaced quarter-tile apart along the direction of travel and overlap, rendered in ascending order of progress — the least-progressed item is drawn first (bottom) and the furthest-progressed item is drawn last (on top). - REQ-GW-TILE-SIZE: Tiles are square. The tile size in pixels is derived automatically so that the world height (in tiles) exactly fills the game world view's height in pixels. Items on belts are rendered at half-tile size; when multiple items occupy the same tile they are spaced quarter-tile apart along the direction of travel and overlap, rendered in ascending order of progress — the least-progressed item is drawn first (bottom) and the furthest-progressed item is drawn last (on top). Items emerging from a building's output port are rendered by these same rules on that port's output belt (REQ-MAT-OUTPUT-EMERGE).
- REQ-GW-BELT-CAPACITY: Belt tiles and tunnel entry/exit tiles each hold up to four items simultaneously, queued one behind the other in the direction of travel. Splitter tiles hold up to four items: two unassigned items (progress < 0.5, not yet routed to an output) and one item per output slot (progress ≥ 0.5, committed to a specific output direction). Output-slot items are rendered on top of unassigned items; when both output slots are occupied, their rendering order follows the clockwise port order starting from East. - REQ-GW-BELT-CAPACITY: Belt tiles and tunnel entry/exit tiles each hold up to four items simultaneously, queued one behind the other in the direction of travel. Splitter tiles hold up to four items: two unassigned items (progress < 0.5, not yet routed to an output) and one item per output slot (progress ≥ 0.5, committed to a specific output direction). Output-slot items are rendered on top of unassigned items; when both output slots are occupied, their rendering order follows the clockwise port order starting from East.
- REQ-GW-BELT-SPEED: Items on belts move at `world.toml [world].belt_speed_tiles_per_second` tiles per second (default 2). - REQ-GW-BELT-SPEED: Items on belts move at `world.toml [world].belt_speed_tiles_per_second` tiles per second (default 2).
- REQ-GW-HEIGHT: The world height (in tiles) is read from `world.toml [world].height_tiles`. - REQ-GW-HEIGHT: The world height (in tiles) is read from `world.toml [world].height_tiles`.
@@ -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
@@ -117,16 +125,16 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- REQ-BLD-SMELTER: **Smelter** (2×2): Converts ore or scrap into basic materials. No recipe selection required. Inputs, outputs, and rates are defined in `recipes.toml [[recipe]]` entries with `building = "smelter"`. - REQ-BLD-SMELTER: **Smelter** (2×2): Converts ore or scrap into basic materials. No recipe selection required. Inputs, outputs, and rates are defined in `recipes.toml [[recipe]]` entries with `building = "smelter"`.
- 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 at `ships.toml [ship.schematic].player_production_level` (initial value 5, incremented by duplicate schematic drops per REQ-DEF-SCHEMATIC-DROP) 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). A belt accepts items only through a non-output edge (REQ-MAT-ACCEPT-DIR).
- REQ-BLD-SPLITTER: **Splitter** (1×1): Distributes incoming items between two output directions. 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. Incoming items are accepted only through the splitter's non-output edges (REQ-MAT-ACCEPT-DIR). Each output can optionally have a filter (a list of item types), configurable via the selected building panel; only implicitly unlocked item types are available as filter options (REQ-LOCK-UI-SPLITTER). Routing rules:
- 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.
- An item matching both outputs' filters is distributed by strict alternation between those outputs. - An item matching both outputs' filters is distributed by strict alternation between those outputs.
- An item matching neither output's filter is routed to the unfiltered output. If both outputs have a filter and the item matches neither, the splitter stalls and moves no items until the situation is resolved. - An item matching neither output's filter is routed to the unfiltered output. If both outputs have a filter and the item matches neither, the splitter stalls and moves no items until the situation is resolved.
- If neither output has a filter, items are distributed by strict alternation. - If neither output has a filter, items are distributed by strict alternation.
- In all alternation cases, if one output is blocked the item goes to the other output until it unblocks. - In all alternation cases, if one output is blocked the item goes to the other output until it unblocks.
- REQ-BLD-TUNNEL-ENTRY: **Tunnel Entry** (1×1): The sending end of a tunnel pair. The player sets a direction (N, S, E, W) at placement, rotatable with R/Shift+R. Items arriving from an adjacent belt tile whose direction points into the entry are forwarded through the tunnel to the paired Tunnel Exit (see REQ-BLD-TUNNEL-PAIR, REQ-BLD-TUNNEL-TRANSIT). If the entry is unpaired, or if the paired exit's output is blocked, the entry blocks like a full belt tile. - REQ-BLD-TUNNEL-ENTRY: **Tunnel Entry** (1×1): The sending end of a tunnel pair. The player sets a direction (N, S, E, W) at placement, rotatable with R/Shift+R. Items arriving from an adjacent belt tile on a non-output edge (i.e. not the mouth edge in the entry's facing direction — see REQ-MAT-ACCEPT-DIR) whose direction points into the entry are forwarded through the tunnel to the paired Tunnel Exit (see REQ-BLD-TUNNEL-PAIR, REQ-BLD-TUNNEL-TRANSIT). If the entry is unpaired, or if the paired exit's output is blocked, the entry blocks like a full belt tile.
- REQ-BLD-TUNNEL-EXIT: **Tunnel Exit** (1×1): The receiving end of a tunnel pair. The player sets a direction at placement, rotatable with R/Shift+R. Items received from the paired Tunnel Entry emerge from the output side of the exit tile — the tile adjacent in the exit's facing direction — continuing in that direction. If the exit is unpaired or its output is blocked, it holds received items until they can advance. - REQ-BLD-TUNNEL-EXIT: **Tunnel Exit** (1×1): The receiving end of a tunnel pair. The player sets a direction at placement, rotatable with R/Shift+R. Items received from the paired Tunnel Entry emerge from the output side of the exit tile — the tile adjacent in the exit's facing direction — continuing in that direction. If the exit is unpaired or its output is blocked, it holds received items until they can advance.
- REQ-BLD-TUNNEL-PAIR: **Tunnel pairing rules.** Pairing is re-evaluated for all Tunnel Entries whenever any Tunnel Entry or Tunnel Exit is placed or demolished. - REQ-BLD-TUNNEL-PAIR: **Tunnel pairing rules.** Pairing is re-evaluated for all Tunnel Entries whenever any Tunnel Entry or Tunnel Exit is placed or demolished.
- A Tunnel Entry searches tile-by-tile in its facing direction for a partner. Any tunnel building (entry or exit) that faces a *different* direction is ignored and skipped. The search stops at the first tunnel building that faces the *same* direction as the searching entry. - A Tunnel Entry searches tile-by-tile in its facing direction for a partner. Any tunnel building (entry or exit) that faces a *different* direction is ignored and skipped. The search stops at the first tunnel building that faces the *same* direction as the searching entry.
@@ -138,9 +146,27 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
## Material Transport & Buffers ## Material Transport & Buffers
- REQ-MAT-BELT-ONLY: Materials are transported exclusively via belts, splitters, and tunnels. - REQ-MAT-BELT-ONLY: Materials are transported exclusively via belts, splitters, and tunnels, with one exception: two directly adjacent buildings whose output and input ports meet transfer items straight between them without an intervening transport tile (REQ-MAT-DIRECT-COUPLE).
- REQ-MAT-INPUT-PORTS: A building accepts items from any adjacent belt tile on any edge of its footprint (excluding cells occupied by output port(s)) whose direction points toward the building, provided the item is an input required by the currently selected recipe and the matching per-material input buffer has free space. - REQ-MAT-INPUT-PORTS: A building accepts items from any adjacent belt tile on any edge of its footprint (excluding cells occupied by output port(s)) whose direction points toward the building, provided the item is an input required by the currently selected recipe and the matching per-material input buffer has free space. An accepted item does not enter the building instantly; it is removed from the belt and travels inward across the input port's footprint cell on that port's own input belt before being added to the buffer (REQ-MAT-INPUT-INTAKE).
- REQ-MAT-OUTPUT-PORT: Each building has one or more fixed output port(s) defined by its surface_mask (direction determined by rotation). Produced items are placed onto the belt at the output port tile regardless of that belt's direction. - REQ-MAT-INPUT-INTAKE: Accepted input items travel into a building as an animation rather than vanishing off the belt instantly — the input-side mirror of REQ-MAT-OUTPUT-EMERGE. Each input port has its own **input belt** — a virtual belt tile occupying the input port's footprint cell (the body cell the feeding belt points into), oriented in the port's inward flow direction, with progress 0.0 at the outer edge adjacent to the feeding belt and 0.5 at the tile centre. It reuses the belt subsystem: movement at belt speed (REQ-GW-BELT-SPEED), item rendering and spacing (REQ-GW-TILE-SIZE), and capacity/packing (REQ-GW-BELT-CAPACITY), but restricted to the 0.0→0.5 half of the tile. This applies to every building that pulls items from adjacent belts into an input buffer (Smelter, Assembler, Reprocessing Plant, Shipyard); a building may run several input belts at once when belts feed it from more than one side. The HQ is included with the one difference noted below.
- **Acceptance & reservation.** The acceptance test of REQ-MAT-INPUT-PORTS is unchanged — an item is accepted only if it is a required input whose per-material input buffer has space — except that "has space" now counts both the items already buffered **and** the items of that material currently travelling on the building's input belts (reserved but not yet arrived), so the total (buffered + in-transit) never exceeds that material's buffer cap (REQ-MAT-INPUT-BUFFER). An item that fails this test is not placed on an input belt and stays on the feeding belt exactly as before, so items that are not required inputs never enter the building.
- **Feeding.** An accepted item is removed from the feeding belt on the same tick it would have been taken without this animation, and placed on the input belt at progress 0.0, reserving a slot in its per-material buffer. (An input belt may also be fed directly by an adjacent producer's output belt rather than by a real belt — see REQ-MAT-DIRECT-COUPLE — with the same reservation and entry rules.) A new item is placed only when the input belt's entry slot at progress 0.0 is free (per REQ-GW-BELT-CAPACITY spacing — no in-transit item within a quarter tile of 0.0). The 0.0→0.5 span holds at most three in-transit items (progress 0.0, 0.25, 0.5); the reservation limit above may permit fewer.
- **Travel & arrival.** An in-transit item advances from progress 0.0 to 0.5 at belt speed. On reaching progress 0.5 it leaves the input belt and is added to its per-material input buffer, turning its reservation into buffered stock; only then does it count toward starting a production cycle (REQ-MAT-CYCLE). Because the slot was reserved on entry, arrival always succeeds — there is no deadlock.
- **Reservation may delay production.** A reserved item occupies buffer capacity for its whole 0.0→0.5 travel without yet being consumable, so an input-starved building may briefly wait for an in-transit item to arrive before it can start a cycle. This is accepted.
- **Clearing.** Clearing the input buffers on a recipe or schematic change (REQ-MAT-INPUT-BUFFER) also discards any items currently travelling on the input belts and releases their reservations.
- **HQ.** The HQ has no input buffer (REQ-HQ-BELT-INPUT); a building block accepted at an HQ input port travels its input belt the same way but reserves nothing, and is added to the global building blocks stock (REQ-MAT-GLOBAL-STOCK) on reaching progress 0.5.
- **Intake rendering (no pop-out).** Mirror of the emergence rendering in REQ-MAT-OUTPUT-EMERGE: the building is rendered over the input belt, so an in-transit item is occluded while inside the footprint and is only visible as it crosses the outer edge — appearing to sink into the port. The portion inside the footprint is hidden, and the item disappears at the tile centre (progress 0.5) as it enters the buffer.
- REQ-MAT-OUTPUT-PORT: Each building has one or more fixed output port(s) defined by its surface_mask (direction determined by rotation). Produced items do not appear on the outgoing belt instantly; each item leaves the building by first emerging across the output port tile on that port's own output belt and then transferring onto the adjacent real belt tile (REQ-MAT-OUTPUT-EMERGE). The adjacent belt's direction is otherwise unconstrained (it may flow away from the building or perpendicular to it), except that a belt oriented with its own output edge facing back into the building refuses the transfer and the item stays stuck at the port (REQ-MAT-ACCEPT-DIR, REQ-MAT-OUTPUT-EMERGE).
- REQ-MAT-OUTPUT-EMERGE: Items emerge from a building output port as an animation rather than popping directly onto the outgoing belt. Each output port has its own **output belt** — a virtual belt tile occupying the output port tile, oriented in the port's facing direction, with progress 0.0 at the tile's inner edge and 1.0 at the outer (port) edge adjacent to the next real belt tile. It reuses the belt subsystem: movement at belt speed (REQ-GW-BELT-SPEED), item rendering and spacing (REQ-GW-TILE-SIZE), and capacity/packing (REQ-GW-BELT-CAPACITY), but restricted to the 0.5→1.0 half of the tile. This applies to every building that outputs items onto belts (Miner, Smelter, Assembler, Reprocessing Plant, Salvage Bay); it does not apply to the Shipyard, which spawns a ship rather than a belt item (REQ-SHP-SPAWN-PLAYER).
- **Feeding.** While the output buffer (REQ-MAT-OUTPUT-BUFFER) holds an item that has not yet begun emerging and the output belt's entry slot at progress 0.5 is free (per REQ-GW-BELT-CAPACITY spacing — no emerging item within a quarter tile of progress 0.5), the next buffered item is placed on the output belt at progress 0.5. Because only the 0.5→1.0 span is used, the output belt holds at most three emerging items (progress 0.5, 0.75, 1.0); once that span is full the building places no further items on it even if the output buffer still holds more.
- **Cosmetic hold.** An emerging item still counts as residing in the output buffer (REQ-MAT-GLOBAL-STOCK) for the whole animation; it only leaves the building when it transfers onto a real belt tile at progress 1.0. The output belt therefore adds no inventory capacity beyond the output buffer, and clearing the output buffer on a recipe or schematic change (REQ-MAT-OUTPUT-BUFFER) also removes any items currently emerging.
- **Travel & handoff.** An emerging item advances from progress 0.5 to 1.0 at belt speed. At progress 1.0 it attempts to transfer onto the adjacent real belt tile using the normal belt hand-off and accept-direction rules (REQ-MAT-OUTPUT-PORT, REQ-MAT-ACCEPT-DIR): the transfer succeeds only if a transport tile exists there, is not oriented with its output edge facing back into the building, and has free space. On success the item leaves the output buffer and becomes an ordinary item on that belt tile. If instead the output port tile is a directly adjacent building's input edge, the item transfers straight into that building (REQ-MAT-DIRECT-COUPLE).
- **Stuck items.** If there is no next real belt tile and no directly-coupled building (REQ-MAT-DIRECT-COUPLE), or the transfer is refused or blocked, the emerging item stops at progress 1.0 and is rendered there (still counted in the output buffer). Following items pile up behind it at progress 0.75 and 0.5 per the packing above, and once the 0.5→1.0 span is full no further items emerge until the front item transfers.
- **Emergence rendering (no pop-in).** An emerging item must not simply appear at progress 0.5. The output port tile's building is rendered over the output belt, so an emerging item is occluded while inside the footprint and is revealed progressively as it slides past the port edge — appearing to physically emerge from the building. The portion of the item still within the output port tile is hidden; the portion past the outer edge is drawn.
- REQ-MAT-DIRECT-COUPLE: **Direct port coupling.** Two directly adjacent buildings whose ports meet transfer items between them with no intervening transport tile. A direct coupling exists at a shared edge where a producer building's output port tile (the tile it pushes toward, REQ-MAT-OUTPUT-PORT) is a body cell of a consumer building, and the producer's output direction carries the item across that edge into the consumer through one of the consumer's input edges (any perimeter edge other than the consumer's own output port, per REQ-MAT-INPUT-PORTS). Over a direct coupling the two virtual belts chain end to end: an item that reaches progress 1.0 on the producer's output belt at the shared edge (REQ-MAT-OUTPUT-EMERGE) is handed, instead of onto a real belt tile, directly onto the consumer's input belt at progress 0.0 (REQ-MAT-INPUT-INTAKE) and continues inward to the consumer's buffer — so the item appears to slide continuously across the shared edge from one building into the next.
- **Acceptance.** The hand-off obeys the consumer's normal input rules (REQ-MAT-INPUT-PORTS, REQ-MAT-INPUT-INTAKE): it succeeds only if the item is a required input of the consumer whose per-material buffer has space (reservation-aware — buffered + in-transit below the cap) and the consumer's input belt entry at progress 0.0 is free. On success the item leaves the producer's output buffer and reserves a slot in the consumer's input buffer, exactly as a belt-fed intake would. If the consumer does not accept the item — it is not one of its inputs, or the buffer is full, or the input-belt entry is occupied — the item stays stuck at the producer's output port at progress 1.0, exactly as when a downstream belt is blocked (REQ-MAT-OUTPUT-EMERGE stuck items).
- **Scope.** Direct coupling is the only case in which materials move between buildings without a belt, splitter, or tunnel (REQ-MAT-BELT-ONLY); it bridges only two buildings that are directly adjacent with meeting output/input ports. Transport tiles feeding a building (belt, splitter, or tunnel exit) continue to work through the normal pull, and a producer still hands off to a transport tile placed in the gap as before; a single such tile between two buildings is unaffected by this requirement.
- REQ-MAT-ACCEPT-DIR: A transport tile (belt, splitter, tunnel entry, or tunnel exit) accepts an incoming item only through a non-output edge; an item that would enter through one of the tile's output edges is refused. For a belt or a tunnel entry/exit the sole output edge is the one in its facing direction; for a splitter either of its two output directions is an output edge. This applies both to items pushed from an adjacent transport tile and to items deposited by a building's output port (REQ-MAT-OUTPUT-PORT).
- REQ-MAT-INPUT-BUFFER: Each building has one input buffer per required input material. Each per-material buffer holds up to twice that material's per-cycle requirement. When the player selects a new recipe or schematic, all items in all input buffers are cleared. - REQ-MAT-INPUT-BUFFER: Each building has one input buffer per required input material. Each per-material buffer holds up to twice that material's per-cycle requirement. When the player selects a new recipe or schematic, all items in all input buffers are cleared.
- REQ-MAT-OUTPUT-BUFFER: Each building has an output buffer that holds up to twice the quantity produced by one production cycle. If the output buffer is full, production stops until space is available. When the player selects a new recipe or schematic, all items in the output buffer are cleared (relevant when the adjacent belt is jammed and items have accumulated). - REQ-MAT-OUTPUT-BUFFER: Each building has an output buffer that holds up to twice the quantity produced by one production cycle. If the output buffer is full, production stops until space is available. When the player selects a new recipe or schematic, all items in the output buffer are cleared (relevant when the adjacent belt is jammed and items have accumulated).
- REQ-MAT-OUTPUT-BUFFER-REPROCESSING: Exception to REQ-MAT-OUTPUT-BUFFER — the Reprocessing Plant's output buffer holds at most one cycle's output. This prevents exploits where the player stalls the output belt to force the plant to reroll. - REQ-MAT-OUTPUT-BUFFER-REPROCESSING: Exception to REQ-MAT-OUTPUT-BUFFER — the Reprocessing Plant's output buffer holds at most one cycle's output. This prevents exploits where the player stalls the output belt to force the plant to reroll.
@@ -149,16 +175,16 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
## Resources ## Resources
- REQ-RES-SCRAP-DROP: Destroyed ships (both player and enemy) and destroyed defence stations (both player and enemy) drop scrap at their location. The scrap amount per ship is defined in `ships.toml [ship.loot].scrap_drop`; for stations it is defined as `stations.toml [player_station].scrap_drop_formula` and `[enemy_station].scrap_drop_formula`. A scrap drop carries an amount; salvage modules collect it one scrap per cycle (REQ-SHP-SALVAGE), and the drop is removed from the world once its remaining amount reaches zero or `world.toml [world].scrap_despawn_seconds` seconds have elapsed since it was dropped, whichever comes first. - REQ-RES-SCRAP-DROP: Destroyed ships (both player and enemy) and destroyed defence stations (both player and enemy) drop scrap at their location. The scrap amount per ship is derived from the ship's threat cost (REQ-MOD-THREAT) for its as-built layout, multiplied by `world.toml [world].scrap_per_threat` (default 0.01) and rounded to the nearest integer (at least 1 for any ship whose threat cost is greater than 0); for stations it is defined as `stations.toml [player_station].scrap_drop_formula` and `[enemy_station].scrap_drop_formula`. A scrap drop carries an amount; salvage modules collect it one scrap per cycle (REQ-SHP-SALVAGE), and the drop is removed from the world once its remaining amount reaches zero or `world.toml [world].scrap_despawn_seconds` seconds have elapsed since it was dropped, whichever comes first.
- REQ-RES-SCRAP-COLLECT: Scrap is collected by salvage ships and delivered to a Salvage Bay on the asteroid. From there it can be fed via belt into a smelter (same output as ore) or a Reprocessing Plant. - REQ-RES-SCRAP-COLLECT: Scrap is collected by salvage ships and delivered to a Salvage Bay on the asteroid. From there it can be fed via belt into a smelter (same output as ore) or a Reprocessing Plant.
## Ships ## Ships
- REQ-SHP-AUTONOMOUS: Ships are produced by shipyards and are fully autonomous once produced. - REQ-SHP-AUTONOMOUS: Ships are produced by shipyards and are fully autonomous once produced.
- REQ-SHP-STATS: Base hull stats are defined as formulas of ship level in `ships.toml`: HP (`[ship.health].hp_formula`), max linear speed (`[ship.movement].speed_formula`), sensor range (`[ship.sensors].range_formula`), main acceleration (`[ship.movement].main_acceleration_formula`, tiles/s²), maneuvering acceleration (`[ship.movement].maneuvering_acceleration_formula`, tiles/s²), angular acceleration (`[ship.movement].angular_acceleration_formula`, rad/s²), max rotation speed (`[ship.movement].max_rotation_speed_formula`, rad/s). Required build materials (`[ship.schematic].materials`) and the station level at which the schematic becomes available for unlock (`[[ship]].unlock_at_station_level`; -1 = player starts with the schematic already unlocked) are also defined there. Combat, salvage, and repair capabilities are provided by modules (see REQ-MOD-CONFIG). Final hull stats incorporate passive module modifiers per REQ-MOD-STAT-CALC. - REQ-SHP-STATS: Base hull stats are defined as plain values in `ships.toml`: HP (`[ship.health].hp`), max linear speed (`[ship.movement].speed`), sensor range (`[ship.sensors].range`), main acceleration (`[ship.movement].main_acceleration`, tiles/s²), maneuvering acceleration (`[ship.movement].maneuvering_acceleration`, tiles/s²), angular acceleration (`[ship.movement].angular_acceleration`, rad/s²), max rotation speed (`[ship.movement].max_rotation_speed`, rad/s). Required build materials (`[ship.schematic].materials`) and the station level at which the schematic becomes available for unlock (`[[ship]].unlock_at_station_level`; -1 = player starts with the schematic already unlocked) are also defined there. Combat, salvage, and repair capabilities are provided by modules (see REQ-MOD-CONFIG). Final hull stats incorporate passive module modifiers per REQ-MOD-STAT-CALC.
- REQ-SHP-SPAWN-PLAYER: A ship produced by a shipyard spawns centered on the shipyard's output port tile. - REQ-SHP-SPAWN-PLAYER: A ship produced by a shipyard spawns centered on the shipyard's output port tile.
- REQ-SHP-SPAWN-ENEMY: Enemy ships spawn at a uniformly random position within the current enemy buffer zone — random X across the buffer's width and random Y across the world height. - REQ-SHP-SPAWN-ENEMY: Enemy ships spawn at a uniformly random position within the current enemy buffer zone — random X across the buffer's width and random Y across the world height.
- REQ-SHP-MOVEMENT: Ships move using a physics-based model. Each ship has a velocity and a facing direction, both updated each tick. The main acceleration (`main_acceleration_formula`) is applied along the ship's current facing direction only. The maneuvering acceleration (`maneuvering_acceleration_formula`) can be applied in any direction independently of the facing direction, enabling lateral or braking movement without rotating. The angular acceleration (`angular_acceleration_formula`) controls how quickly the ship rotates. Linear speed is capped at the ship's `speed_formula` value; rotation rate is capped at the ship's `max_rotation_speed_formula` value. Ship position refers to the ship's center for all range, sensor, and attack checks. - REQ-SHP-MOVEMENT: Ships move using a physics-based model. Each ship has a velocity and a facing direction, both updated each tick. The main acceleration (`main_acceleration`) is applied along the ship's current facing direction only. The maneuvering acceleration (`maneuvering_acceleration`) can be applied in any direction independently of the facing direction, enabling lateral or braking movement without rotating. The angular acceleration (`angular_acceleration`) controls how quickly the ship rotates. Linear speed is capped at the ship's `speed` value; rotation rate is capped at the ship's `max_rotation_speed` value. Ship position refers to the ship's center for all range, sensor, and attack checks.
- REQ-SHP-ORBIT: Several behaviors keep a ship circling its target at a fixed standoff distance (an **orbit**) rather than approaching a fixed point. The orbit radius depends on the behavior: - REQ-SHP-ORBIT: Several behaviors keep a ship circling its target at a fixed standoff distance (an **orbit**) rather than approaching a fixed point. The orbit radius depends on the behavior:
- **Combat engagement** (REQ-SHP-COMBAT, REQ-SHP-ENEMY-AI): `world.toml [world].orbit_factor` multiplied by the maximum weapon `attack_range` across the ship's weapon module instances. - **Combat engagement** (REQ-SHP-COMBAT, REQ-SHP-ENEMY-AI): `world.toml [world].orbit_factor` multiplied by the maximum weapon `attack_range` across the ship's weapon module instances.
- **Repair** (REQ-SHP-REPAIR): `orbit_factor` multiplied by the maximum `repair_range` across the ship's repair module instances. - **Repair** (REQ-SHP-REPAIR): `orbit_factor` multiplied by the maximum `repair_range` across the ship's repair module instances.
@@ -196,16 +222,16 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- `id` — unique identifier, also used as the display name in the UI. - `id` — unique identifier, also used as the display name in the UI.
- `surface_mask` — footprint within the ship layout grid (see Module Surface Mask Format). - `surface_mask` — footprint within the ship layout grid (see Module Surface Mask Format).
- `materials` — list of materials required per instance (added to the ship's build cost). - `materials` — list of materials required per instance (added to the ship's build cost).
- `player_production_level` — initial level for this module type; used as `x` in its stat formulas. Incremented by 1 on each duplicate schematic drop (REQ-DEF-SCHEMATIC-DROP).
- `unlock_at_station_level` — the enemy defence station level at which this module's schematic becomes available for unlock; -1 means the player starts with the module schematic already unlocked. - `unlock_at_station_level` — the enemy defence station level at which this module's schematic becomes available for unlock; -1 means the player starts with the module schematic already unlocked.
- `unlock_requires` — optional list of prerequisite schematic ids that must already be unlocked before this module's schematic can drop (REQ-LOCK-PREREQ). Defaults to empty.
- `production_time_seconds` — time added to the ship's production cycle per instance. - `production_time_seconds` — time added to the ship's production cycle per instance.
- `fill_color` — fill color used to render this module's cells in the layout grid. - `fill_color` — fill color used to render this module's cells in the layout grid.
- `glyph` — single character rendered on this module's cells in the layout grid and preview widget. - `glyph` — single character rendered on this module's cells in the layout grid and preview widget.
- An optional **capability section** (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat formulas. A module with base stat formulas is a capability module — each placed instance grants the ship an independent weapon, salvage bay, or repair tool with its own state (cooldown, target). A ship may have multiple capability module instances of the same or different types. Base stat formulas per capability type: - An optional **capability section** (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat values. A module with base stat values is a capability module — each placed instance grants the ship an independent weapon, salvage bay, or repair tool with its own state (cooldown, target). A ship may have multiple capability module instances of the same or different types. Base stat values per capability type:
- **Weapon** (`[module.weapon]`): `damage_formula`, `attack_range_formula`, `attack_rate_formula`. - **Weapon** (`[module.weapon]`): `damage`, `attack_range`, `attack_rate`.
- **Salvage** (`[module.salvage]`): `collection_range_formula` (tiles), `cargo_capacity_formula` (integer scrap units; contributes to the ship's cargo capacity stat per REQ-MOD-CARGO-CAPACITY), `collection_rate_formula` (collection cycles per second; each cycle collects 1 scrap). - **Salvage** (`[module.salvage]`): `collection_range` (tiles), `cargo_capacity` (integer scrap units; contributes to the ship's cargo capacity stat per REQ-MOD-CARGO-CAPACITY), `collection_rate` (collection cycles per second; each cycle collects 1 scrap).
- **Repair** (`[module.repair]`): `repair_rate_formula` (repair cycles per second), `repair_amount_hp_formula` (HP restored per repair cycle), `repair_range_formula` (tiles). - **Repair** (`[module.repair]`): `repair_rate` (repair cycles per second), `repair_amount_hp` (HP restored per repair cycle), `repair_range` (tiles).
- Zero or more **passive stat modifier formulas** (`added_*`/`multiplied_*`) that boost stats on the ship hull or on capability module instances (see REQ-MOD-STAT-CALC). A single module may be both a capability module and provide passive modifiers. - Zero or more **passive stat modifiers** (`added_*`/`multiplied_*`) that boost stats on the ship hull or on capability module instances (see REQ-MOD-STAT-CALC). A single module may be both a capability module and provide passive modifiers.
- REQ-MOD-LAYOUT: Each ship in `ships.toml` defines a `layout` — a list of strings representing the ship's module grid (see Ship Layout Format). All ships define a layout. - REQ-MOD-LAYOUT: Each ship in `ships.toml` defines a `layout` — a list of strings representing the ship's module grid (see Ship Layout Format). All ships define a layout.
@@ -224,33 +250,34 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
2. The `production_time_seconds` of every module instance in the configured layout. 2. The `production_time_seconds` of every module instance in the configured layout.
3. For every material required (the union of the ship's base materials and all module instance materials, with quantities summed per item type): the recursive production time of that material multiplied by the required quantity (see REQ-THREAT-ITEM). 3. For every material required (the union of the ship's base materials and all module instance materials, with quantities summed per item type): the recursive production time of that material multiplied by the required quantity (see REQ-THREAT-ITEM).
- REQ-THREAT-ITEM: The threat value of an item type (in seconds) is determined by the recipe that produces it: - REQ-THREAT-ITEM: The threat value of an item type (in production-seconds **per unit**) is determined by the recipe that produces it:
- **Miner recipe**: the recipe's `duration_seconds`. - **Miner recipe**: `duration_seconds / output_amount`, where `output_amount` is the number of units produced per cycle.
- **Smelter recipe**: the recipe's `duration_seconds` plus the sum of each input's threat value multiplied by that input's required quantity. - **Smelter recipe**: `(duration_seconds + Σ (input_threat × input_amount)) / output_amount`, where the sum is over all inputs.
- **Assembler recipe**: the recipe's `duration_seconds` plus the sum of each input's threat value multiplied by that input's required quantity. - **Assembler recipe**: `(duration_seconds + Σ (input_threat × input_amount)) / output_amount`, where the sum is over all inputs.
- **Reprocessing-only item** (an item type that has no miner, smelter, or assembler recipe producing it, and is only obtainable via reprocessing): `(scrap_threat × scrap_per_cycle + duration_seconds) / probability`, where `scrap_threat` is the threat value of scrap (see REQ-THREAT-SCRAP), `scrap_per_cycle` is the number of scrap consumed per reprocessing cycle, `duration_seconds` is the reprocessing cycle time, and `probability` is the normalized weight of that item in the reprocessing output pool. - **Reprocessing-only item** (an item type that has no miner, smelter, or assembler recipe producing it, and is only obtainable via reprocessing): `(scrap_threat × scrap_per_cycle + duration_seconds) / probability`, where `scrap_threat` is the threat value of scrap (see REQ-THREAT-SCRAP), `scrap_per_cycle` is the number of scrap consumed per reprocessing cycle, `duration_seconds` is the reprocessing cycle time, and `probability` is the normalized weight of that item in the reprocessing output pool. (Reprocessing output amounts are 1 in practice, so per-unit division is already implicit in the formula.)
- **Multiple recipes**: if an item type can be produced by more than one non-reprocessing recipe (miner, smelter, or assembler), its threat value is the **maximum** across all such recipes. The reprocessing path is only used when no other recipe exists. - **Multiple recipes**: if an item type can be produced by more than one non-reprocessing recipe (miner, smelter, or assembler), its threat value is the **maximum** across **all** such eligible recipes, and the threat is committed only once every eligible recipe is computable (so a shallow shortcut recipe that resolves earlier than a deeper base recipe cannot lower the item's threat). The reprocessing path is only used when no other recipe exists. If recipe cycles prevent full resolution, the max over the currently computable subset is used as a fallback.
- **Scrap-consuming recipe fallback**: a non-reprocessing recipe that takes `scrap` as an input participates in an item's threat computation only if no scrap-free recipe (miner, smelter, or assembler) produces that item. This mirrors the reprocessing fallback rule and prevents the scrap-to-ingot smelter recipe from inflating basic material threats via the max rule.
- REQ-THREAT-SCRAP: The threat value of scrap is derived from the ship schematic with the smallest configured `scrap_drop` value (from `ships.toml [ship.loot].scrap_drop`). Scrap threat = that ship's threat cost (REQ-MOD-THREAT) / that ship's `scrap_drop` value. If multiple schematics share the same smallest `scrap_drop`, any one of them may be used. - REQ-THREAT-SCRAP: The threat value of scrap is the constant `1 / world.toml [world].scrap_per_threat`. This is the exact inverse of the scrap-drop conversion in REQ-RES-SCRAP-DROP, so a destroyed ship drops scrap worth precisely its own threat cost. Because scrap threat is now a fixed constant, it no longer depends on any ship's threat cost, removing the potential circularity with REQ-MOD-THREAT for ships built from reprocessing-only materials.
- REQ-MOD-STAT-CALC: For each stat (on the ship hull or on a capability module instance), the final value is computed as: `final = base × total_multiplier + total_additive`, where: - REQ-MOD-STAT-CALC: For each stat (on the ship hull or on a capability module instance), the final value is computed as: `final = base × total_multiplier + total_additive`, where:
- `base` is the stat's base formula evaluated at the ship's production level (for hull stats) or at the capability module's `player_production_level` (for capability module stats). - `base` is the stat's base value — the hull stat value (for hull stats) or the capability module's base stat value (for capability module stats).
- `total_multiplier` = 1 + sum of (m_i 1) for each multiplicative modifier m_i from all passive module instances. Each m_i is evaluated from the module's multiplicative formula at the module's `player_production_level`. - `total_multiplier` = 1 + sum of (m_i 1) for each multiplicative modifier m_i from all passive module instances. Each m_i is the module's multiplicative modifier value.
- `total_additive` = sum of all additive modifier values from all passive module instances. Each additive value is evaluated from the module's additive formula at the module's `player_production_level`. - `total_additive` = sum of all additive modifier values from all passive module instances. Each additive value is the module's additive modifier value.
Passive modifier formulas follow the naming convention: a module may define `added_<stat>_formula` (additive) and/or `multiplied_<stat>_formula` (multiplicative) under `[module.<category>]`. The category determines what the modifier targets: Passive modifiers follow the naming convention: a module may define `added_<stat>` (additive) and/or `multiplied_<stat>` (multiplicative) under `[module.<category>]`. The category determines what the modifier targets:
- `[module.health]`, `[module.movement]`, `[module.sensor]` — modifiers apply to the ship hull's stats. - `[module.health]`, `[module.movement]`, `[module.sensor]` — modifiers apply to the ship hull's stats.
- `[module.weapon]` — modifiers apply to every weapon module instance on the ship. - `[module.weapon]` — modifiers apply to every weapon module instance on the ship.
- `[module.salvage]` — modifiers apply to every salvage module instance on the ship. - `[module.salvage]` — modifiers apply to every salvage module instance on the ship.
- `[module.repair]` — modifiers apply to every repair module instance on the ship. - `[module.repair]` — modifiers apply to every repair module instance on the ship.
- `[module.cargo]` — modifiers (`added_cargo_capacity`/`multiplied_cargo_capacity`) apply to the ship's **cargo capacity**, a ship-level stat (see REQ-MOD-CARGO-CAPACITY). - `[module.cargo]` — modifiers (`added_cargo_capacity`/`multiplied_cargo_capacity`) apply to the ship's **cargo capacity**, a ship-level stat (see REQ-MOD-CARGO-CAPACITY).
Example: `[module.sensor].added_sensor_range_formula` adds to the ship's sensor range. `[module.weapon].multiplied_damage_formula` multiplies the damage of every weapon module instance on the ship. Example: `[module.sensor].added_sensor_range` adds to the ship's sensor range. `[module.weapon].multiplied_damage` multiplies the damage of every weapon module instance on the ship.
- REQ-MOD-CARGO-CAPACITY: **Cargo capacity** is a first-class ship stat — the total number of scrap units the ship can hold in the single shared cargo pool used by its salvage modules (REQ-SHP-SALVAGE). Unlike other ship stats it has no hull base formula; its `base` (per REQ-MOD-STAT-CALC) is the **sum** of the `cargo_capacity` base values of every cargo-providing capability module instance on the ship — currently each salvage module's `cargo_capacity_formula`, evaluated at that module instance's `player_production_level`. Passive modifiers targeting `cargo_capacity` are declared under the `[module.cargo]` category and apply to this ship-level sum (`final = base × total_multiplier + total_additive`); they are not salvage-category modifiers and therefore scale the whole pool rather than any single instance. A ship whose cargo capacity is 0 (no cargo-providing module) is given no cargo pool. - REQ-MOD-CARGO-CAPACITY: **Cargo capacity** is a first-class ship stat — the total number of scrap units the ship can hold in the single shared cargo pool used by its salvage modules (REQ-SHP-SALVAGE). Unlike other ship stats it has no hull base formula; its `base` (per REQ-MOD-STAT-CALC) is the **sum** of the `cargo_capacity` base values of every cargo-providing capability module instance on the ship — currently each salvage module's `cargo_capacity` value. Passive modifiers targeting `cargo_capacity` are declared under the `[module.cargo]` category and apply to this ship-level sum (`final = base × total_multiplier + total_additive`); they are not salvage-category modifiers and therefore scale the whole pool rather than any single instance. A ship whose cargo capacity is 0 (no cargo-providing module) is given no cargo pool.
### Module UI ### Module UI
- REQ-MOD-UI-PREVIEW: When a schematic is selected in a shipyard's selected building panel, a small non-interactive **ship layout preview** widget is shown below the schematic selection button (REQ-UI-SELECT-BUTTON). The preview renders the ship's layout grid at a reduced scale: buildable cells without a module are shown as white, non-buildable cells are shown as black, and cells occupied by a module are shown in that module's `fill_color` with the module's `glyph` character. Below the preview, a "Configure" button is shown. - REQ-MOD-UI-PREVIEW: For a selected shipyard (operational building or construction site), the selected building panel always shows a small non-interactive **ship layout preview** widget below the schematic selection button (REQ-UI-SELECT-BUTTON) and a "Configure" button below the preview. Both are **disabled while no schematic is selected**, and enabled once one is; the preview then shows an empty placeholder in place of a layout grid. When a schematic is selected, the preview renders the ship's layout grid at a reduced scale: buildable cells without a module are shown as white, non-buildable cells are shown as black, and cells occupied by a module are shown in that module's `fill_color` with the module's `glyph` character. For non-shipyard buildings, neither the preview nor the "Configure" button is shown.
- REQ-MOD-UI-DIALOG: Clicking the "Configure" button opens the **layout configuration dialog** as a modal. While the dialog is open, the game is paused (speed set to 0×). On close, the game speed is restored to what it was before the dialog was opened. - REQ-MOD-UI-DIALOG: Clicking the "Configure" button opens the **layout configuration dialog** as a modal. While the dialog is open, the game is paused (speed set to 0×). On close, the game speed is restored to what it was before the dialog was opened.
The dialog contains: The dialog contains:
@@ -260,7 +287,11 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- **Right** (below the grid): The layout blueprint panel (see REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD). - **Right** (below the grid): The layout blueprint panel (see REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD).
- **Bottom**: A "Confirm" button and a "Cancel" button. Cancel discards all changes made in this dialog session and closes the dialog. Confirm applies the changes: the shipyard's configured layout is updated, the required materials and cycle time displayed in the selected building panel are recalculated, and the ship layout preview is refreshed. - **Bottom**: A "Confirm" button and a "Cancel" button. Cancel discards all changes made in this dialog session and closes the dialog. Confirm applies the changes: the shipyard's configured layout is updated, the required materials and cycle time displayed in the selected building panel are recalculated, and the ship layout preview is refreshed.
- REQ-MOD-UI-STATS-PANEL: The **ship stats panel** in the layout configuration dialog shows the stats of the currently configured ship layout as they would be computed at the schematic's `player_production_level`, incorporating all passive module modifiers per REQ-MOD-STAT-CALC. The panel updates in real time whenever modules are placed or removed in the layout grid. - REQ-MOD-UI-AUTO-DIALOG: When the player selects a schematic for a shipyard (operational building or construction site) through the schematic selection dialog (REQ-UI-SELECT-BUTTON), and the chosen schematic **differs** from the shipyard's current schematic, the layout configuration dialog (REQ-MOD-UI-DIALOG) opens automatically and immediately once the selection dialog closes — exactly as if the player had then clicked "Configure". Re-selecting the schematic already set does not reopen the dialog. This auto-open applies only to the manual schematic selection dialog; schematic changes applied via the copy-settings gesture (REQ-BLD-COPY-CONFIG) or blueprint placement (REQ-UI-BLUEPRINT-PLACE) do **not** auto-open the dialog. The player may still cancel the auto-opened dialog (REQ-MOD-UI-DIALOG), which leaves the newly selected schematic in place with its default empty layout; the "Configure" button (REQ-MOD-UI-PREVIEW) remains available to open the dialog again later.
- REQ-MOD-UI-MODULE-TOOLTIP: Each module selection button in the layout configuration dialog (REQ-MOD-UI-DIALOG) shows a hover tooltip with the descriptive text defined for that module type in `modules.toml` (the optional per-module tooltip field). If a module type defines no tooltip text, its button shows no tooltip. The "Remove" button is not a module type and has no config-defined tooltip.
- REQ-MOD-UI-STATS-PANEL: The **ship stats panel** in the layout configuration dialog shows the stats of the currently configured ship layout as they would be computed, incorporating all passive module modifiers per REQ-MOD-STAT-CALC. The panel updates in real time whenever modules are placed or removed in the layout grid.
The panel always shows all hull stats as final computed values: The panel always shows all hull stats as final computed values:
- HP - HP
@@ -277,7 +308,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- **Salvage** (shown only if at least one salvage module is placed): combined collection rate = Σ(collection_rate_i) across all salvage module instances; maximum range = max(collection_range_i) across all salvage module instances. - **Salvage** (shown only if at least one salvage module is placed): combined collection rate = Σ(collection_rate_i) across all salvage module instances; maximum range = max(collection_range_i) across all salvage module instances.
- **Repair** (shown only if at least one repair module is placed): combined repair rate (HP/s) = Σ(repair_rate_i × repair_amount_hp_i) across all repair module instances; maximum range = max(repair_range_i) across all repair module instances. - **Repair** (shown only if at least one repair module is placed): combined repair rate (HP/s) = Σ(repair_rate_i × repair_amount_hp_i) across all repair module instances; maximum range = max(repair_range_i) across all repair module instances.
All capability module stat values incorporate passive modifiers targeting the relevant capability category per REQ-MOD-STAT-CALC. Each capability module instance uses its own `player_production_level` for formula evaluation. All capability module stat values incorporate passive modifiers targeting the relevant capability category per REQ-MOD-STAT-CALC.
While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT) for the current layout configuration. This value updates in real time as modules are placed or removed. While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT) for the current layout configuration. This value updates in real time as modules are placed or removed.
@@ -305,21 +336,22 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- REQ-DEF-NO-CROSSFIRE: Enemy and player defence stations are never in each other's firing range. - REQ-DEF-NO-CROSSFIRE: Enemy and player defence stations are never in each other's firing range.
- REQ-DEF-PUSH: When both enemy defence stations in a set are destroyed, the boss countdown is advanced (REQ-WAV-BOSS-ADVANCE), the scrollable area is extended (REQ-GW-PUSH-EXPAND), a new set of enemy defence stations is placed at the new boundary, and exactly one schematic drop is awarded for the destroyed set (REQ-DEF-SCHEMATIC-DROP). - REQ-DEF-PUSH: When both enemy defence stations in a set are destroyed, the boss countdown is advanced (REQ-WAV-BOSS-ADVANCE), the scrollable area is extended (REQ-GW-PUSH-EXPAND), a new set of enemy defence stations is placed at the new boundary, and exactly one schematic drop is awarded for the destroyed set (REQ-DEF-SCHEMATIC-DROP).
- REQ-DEF-SCHEMATIC-DROP: Each destroyed set of enemy defence stations awards exactly one schematic drop (not one per station). The drop opens a **schematic choice dialog** — a modal dialog that pauses the game (speed set to 0×; on close, speed is restored to what it was before the dialog opened). Before drawing schematic picks, an artifact roll is made: evaluate `world.toml [world].artifact_chance_formula` with `x` set to the level of the destroyed station set, clamp the result to [0, 1], then compare against a uniform random value in [0, 1). If the roll succeeds, the dialog presents one **artifact option** plus two schematic picks drawn from the eligible pool; otherwise it presents three schematic picks. Up to three (or two, if an artifact option is present) schematic options are drawn uniformly at random **without replacement** from the eligible drop pool. If the pool contains fewer than the required number of entries, only that many schematic options are shown (the artifact option is always shown if the roll succeeded). The eligible drop pool contains: - REQ-DEF-SCHEMATIC-DROP: Each destroyed set of enemy defence stations awards exactly one schematic drop (not one per station). The drop opens a **schematic choice dialog** — a modal dialog that pauses the game (speed set to 0×; on close, speed is restored to what it was before the dialog opened). Before drawing schematic picks, an artifact roll is made: evaluate `world.toml [world].artifact_chance_formula` with `x` set to the level of the destroyed station set, clamp the result to [0, 1], then compare against a uniform random value in [0, 1). If the roll succeeds, the dialog presents one **artifact option** plus two schematic picks drawn from the eligible pool; otherwise it presents three schematic picks. Up to three (or two, if an artifact option is present) schematic options are drawn uniformly at random **without replacement** from the eligible drop pool. If the pool contains fewer than the required number of entries, only that many schematic options are shown (the artifact option is always shown if the roll succeeded). The eligible drop pool contains:
- All **ship schematics** and **module schematics** whose `unlock_at_station_level` is -1 or is ≤ the level of the destroyed station set. - All **ship schematics** and **module schematics** whose `unlock_at_station_level` is ≥ 0 and ≤ the level of the destroyed station set, and which have not yet been unlocked.
- All **assembler recipe schematics** whose `unlock_at_station_level` is ≥ 0 and ≤ the level of the destroyed station set, whose output item is currently implicitly unlocked (REQ-LOCK-IMPLICIT), and which have not yet been awarded. - All **assembler recipe schematics** whose `unlock_at_station_level` is ≥ 0 and ≤ the level of the destroyed station set, whose output item is currently implicitly unlocked (REQ-LOCK-IMPLICIT), and which have not yet been awarded.
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), the schematic type (ship, module, or assembler recipe), and whether selecting it would be a **new unlock** or a **level-up** (showing the target level for level-ups). Assembler recipe schematics are always new unlocks since they are removed from the pool once awarded. The artifact option (if present) is displayed as a distinct entry with the name "Artifact" and no unlock/level-up annotation. 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 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 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".
- For a ship or module schematic that would be a **new unlock**, its `materials` are added to the base set per REQ-LOCK-IMPLICIT step 1a before recomputation.
- For a ship or module schematic **level-up**, the implicit unlock set is unchanged, so the list is always empty. 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 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:
For a **ship or module schematic**: if the player does not yet have the schematic, it is unlocked (ship schematics unlock the corresponding shipyard selection; module schematics unlock the module type for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG)). If the player already has it, the schematic's `player_production_level` is incremented by 1 — for ship schematics, subsequent ships of that type are produced at a higher level; for module schematics, all instances of that module type use the higher level in their stat formulas. For a **ship or module schematic**: it is unlocked (ship schematics unlock the corresponding shipyard selection; module schematics unlock the module type for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG)).
For an **assembler recipe schematic**: the recipe is explicitly unlocked and becomes available in the assembler recipe-selection dialog (subject to REQ-LOCK-UI-RECIPE). The schematic is removed from the drop pool permanently (REQ-LOCK-EXPLICIT). The implicit unlock set is recomputed (REQ-LOCK-IMPLICIT). For an **assembler recipe schematic**: the recipe is explicitly unlocked and becomes available in the assembler recipe-selection dialog (subject to REQ-LOCK-UI-RECIPE). The schematic is removed from the drop pool permanently (REQ-LOCK-EXPLICIT). The implicit unlock set is recomputed (REQ-LOCK-IMPLICIT).
@@ -327,6 +359,8 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- REQ-LOCK-EXPLICIT: Ship schematics, module schematics, and **assembler recipe schematics** (assembler recipes in `recipes.toml` that define `unlock_at_station_level`) are **explicitly** locked or unlocked. A schematic starts unlocked if its `unlock_at_station_level` is -1; all others start locked. Locked schematics are unlocked only by REQ-DEF-SCHEMATIC-DROP. Once unlocked, a schematic is never re-locked within a run; lock states reset to their initial values on Restart (REQ-CFG-RELOAD). Unlike ship and module schematics, an assembler recipe schematic is removed from the drop pool permanently once awarded and cannot be dropped again. - REQ-LOCK-EXPLICIT: Ship schematics, module schematics, and **assembler recipe schematics** (assembler recipes in `recipes.toml` that define `unlock_at_station_level`) are **explicitly** locked or unlocked. A schematic starts unlocked if its `unlock_at_station_level` is -1; all others start locked. Locked schematics are unlocked only by REQ-DEF-SCHEMATIC-DROP. Once unlocked, a schematic is never re-locked within a run; lock states reset to their initial values on Restart (REQ-CFG-RELOAD). Unlike ship and module schematics, an assembler recipe schematic is removed from the drop pool permanently once awarded and cannot be dropped again.
- REQ-LOCK-PREREQ: A ship schematic, module schematic, or assembler recipe schematic may optionally define `unlock_requires` — a list of prerequisite schematic ids (a ship `id`, module `id`, or assembler recipe `id`) that must already be unlocked before this schematic may enter the drop pool. A prerequisite is **satisfied** only when the schematic it names is currently **explicitly unlocked** (REQ-LOCK-EXPLICIT) — that is, the referenced schematic either started unlocked with `unlock_at_station_level = -1` or has been awarded via a drop. This prerequisite check is applied in addition to the per-schematic conditions in REQ-DEF-SCHEMATIC-DROP: a schematic enters the eligible drop pool only when its `unlock_at_station_level` condition is met, it has not yet been unlocked/awarded, and every id in its `unlock_requires` is satisfied. `unlock_requires` defaults to empty (no prerequisites), which reproduces the prior behaviour. The check is re-evaluated against the current explicit-unlock set every time a drop pool is built (after each REQ-DEF-SCHEMATIC-DROP and on Restart per REQ-CFG-RELOAD), so a gated schematic becomes eligible in the first drop after its last prerequisite is unlocked. Every id listed in any `unlock_requires` must resolve to a schematic that is itself explicitly unlockable (a ship, module, or assembler recipe schematic defined in config); an id that names no such schematic is a configuration error that fails config load with a descriptive message (config is loaded at startup and reloaded on Restart, REQ-CFG-RELOAD). A schematic that lists itself, or a cycle of mutually dependent prerequisites, is not a load error but can never become eligible, since no schematic in the cycle can be the first to unlock.
- REQ-LOCK-IMPLICIT: Item types and miner/assembler recipes are **implicitly** unlocked or locked based on the current set of unlocked ship, module, and assembler recipe schematics. The implicit unlock set is recomputed whenever any schematic changes lock state (on Restart or after REQ-DEF-SCHEMATIC-DROP). Computation: - REQ-LOCK-IMPLICIT: Item types and miner/assembler recipes are **implicitly** unlocked or locked based on the current set of unlocked ship, module, and assembler recipe schematics. The implicit unlock set is recomputed whenever any schematic changes lock state (on Restart or after REQ-DEF-SCHEMATIC-DROP). Computation:
1. Start with the union of: (a) all item types listed in `materials` across all currently unlocked ship schematics and all currently unlocked module schematics, and (b) the output item type of every currently explicitly unlocked assembler recipe schematic (REQ-LOCK-EXPLICIT). 1. Start with the union of: (a) all item types listed in `materials` across all currently unlocked ship schematics and all currently unlocked module schematics, and (b) the output item type of every currently explicitly unlocked assembler recipe schematic (REQ-LOCK-EXPLICIT).
2. For each item type in the current set: for every recipe (miner, smelter, or assembler) that produces it — skipping any assembler recipe schematic that defines `unlock_at_station_level` and is not yet explicitly unlocked — add each of that recipe's input item types to the set. If the recipe is a miner recipe or an assembler recipe that does not define `unlock_at_station_level`, mark it as implicitly unlocked. Explicitly unlocked assembler recipe schematics are available in the assembler recipe-selection dialog by virtue of REQ-LOCK-EXPLICIT; their inputs are also added to the implicit set in this step. 2. For each item type in the current set: for every recipe (miner, smelter, or assembler) that produces it — skipping any assembler recipe schematic that defines `unlock_at_station_level` and is not yet explicitly unlocked — add each of that recipe's input item types to the set. If the recipe is a miner recipe or an assembler recipe that does not define `unlock_at_station_level`, mark it as implicitly unlocked. Explicitly unlocked assembler recipe schematics are available in the assembler recipe-selection dialog by virtue of REQ-LOCK-EXPLICIT; their inputs are also added to the implicit set in this step.
@@ -345,11 +379,10 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
## Threat Level & Enemy Waves ## Threat Level & Enemy Waves
- REQ-WAV-BOSS-COUNTER: A global **boss wave counter** `x` starts at 1 at game start and increments by 1 immediately after each boss wave fires. It represents the current boss wave cycle number and is used as the variable in the threat rate and ship level formulas. - REQ-WAV-BOSS-COUNTER: A global **boss wave counter** `x` starts at 1 at game start and increments by 1 immediately after each boss wave fires. It represents the current boss wave cycle number and is used as the variable in the threat rate formula.
- REQ-WAV-THREAT-RATE: A global **threat level** accumulates continuously over real game time. The rate of increase per second is determined by `world.toml [waves].threat_rate_formula` where `x` is the boss wave counter (REQ-WAV-BOSS-COUNTER), clamped to a minimum of 0 (negative formula values are treated as 0). The rate is constant within each boss wave cycle and steps up each time `x` increments. Threat accumulation is paused during quiet windows (REQ-WAV-QUIET). Example: `1*x - 30` yields 0 threat/s when x ≤ 30 and increases linearly beyond that. - REQ-WAV-THREAT-RATE: A global **threat level** accumulates continuously over real game time. The rate of increase per second is determined by `world.toml [waves].threat_rate_formula` where `x` is the boss wave counter (REQ-WAV-BOSS-COUNTER), clamped to a minimum of 0 (negative formula values are treated as 0). The rate is constant within each boss wave cycle and steps up each time `x` increments. Threat accumulation is paused during quiet windows (REQ-WAV-QUIET). Example: `1*x - 30` yields 0 threat/s when x ≤ 30 and increases linearly beyond that.
- REQ-WAV-GAP: At game start and immediately after each normal wave is triggered, a random inter-wave gap is drawn uniformly from [`world.toml [waves].gap_min_seconds`, `gap_max_seconds`]. The gap timer does not advance while inside a quiet window (REQ-WAV-QUIET); if a gap would expire inside a quiet window, its expiry is deferred until the quiet window ends. - REQ-WAV-GAP: At game start and immediately after each normal wave is triggered, a random inter-wave gap is drawn uniformly from [`world.toml [waves].gap_min_seconds`, `gap_max_seconds`]. The gap timer does not advance while inside a quiet window (REQ-WAV-QUIET); if a gap would expire inside a quiet window, its expiry is deferred until the quiet window ends.
- REQ-WAV-TRIGGER: When the gap timer expires outside a quiet window, a normal wave is triggered. Ships are selected one at a time: from all schematics whose threat cost (REQ-MOD-THREAT) is > 0, uniformly randomly pick one whose cost fits the remaining threat budget. For wave ship selection, the threat cost is computed using the schematic's `default_modules` layout (REQ-WAV-DEFAULT-MODULES). Repeat until no eligible schematic fits. Any remaining threat carries over to the next normal wave. A longer gap results in a larger wave. - REQ-WAV-TRIGGER: When the gap timer expires outside a quiet window, a normal wave is triggered. Ships are selected one at a time: from all schematics whose threat cost (REQ-MOD-THREAT) is > 0, uniformly randomly pick one whose cost fits the remaining threat budget. For wave ship selection, the threat cost is computed using the schematic's `default_modules` layout (REQ-WAV-DEFAULT-MODULES). Repeat until no eligible schematic fits. Any remaining threat carries over to the next normal wave. A longer gap results in a larger wave.
- REQ-WAV-SHIP-LEVEL: Each wave's (normal and boss) enemy ships are assigned a level determined by `world.toml [waves].ship_level_formula` where `x` is the boss wave counter (REQ-WAV-BOSS-COUNTER). Per-ship stats are computed from the ship level via the formulas in `ships.toml` (see REQ-SHP-STATS). Threat cost is level-independent (REQ-MOD-THREAT).
- REQ-WAV-BOSS-COUNTDOWN: A **boss countdown** timer starts at `world.toml [waves].boss_countdown_seconds` (default 300) at game start and counts down continuously in real game-time seconds. It is not paused during quiet windows. When it reaches 0, a boss wave is triggered (REQ-WAV-BOSS-TRIGGER). Immediately after the boss wave fires, `x` increments (REQ-WAV-BOSS-COUNTER) and a fresh countdown starts at the same configured value. - REQ-WAV-BOSS-COUNTDOWN: A **boss countdown** timer starts at `world.toml [waves].boss_countdown_seconds` (default 300) at game start and counts down continuously in real game-time seconds. It is not paused during quiet windows. When it reaches 0, a boss wave is triggered (REQ-WAV-BOSS-TRIGGER). Immediately after the boss wave fires, `x` increments (REQ-WAV-BOSS-COUNTER) and a fresh countdown starts at the same configured value.
- REQ-WAV-BOSS-ADVANCE: When the player destroys a set of enemy defence stations, the boss countdown is reduced by `world.toml [push].boss_advance_seconds` (default 60), clamped to a minimum of 0. Threat that would have accumulated during the skipped time is not added. If the countdown reaches 0 by this reduction, the boss wave is triggered immediately. - REQ-WAV-BOSS-ADVANCE: When the player destroys a set of enemy defence stations, the boss countdown is reduced by `world.toml [push].boss_advance_seconds` (default 60), clamped to a minimum of 0. Threat that would have accumulated during the skipped time is not added. If the countdown reaches 0 by this reduction, the boss wave is triggered immediately.
- REQ-WAV-QUIET: A **quiet window** suppresses normal wave spawning around each boss wave. The pre-boss quiet window begins when the boss countdown falls to or below `world.toml [waves].boss_quiet_before_seconds` and ends when the countdown reaches 0. The post-boss quiet window begins immediately when the boss wave fires and lasts `world.toml [waves].boss_quiet_after_seconds` seconds. Threat accumulation is paused during both windows. The normal wave gap timer does not advance during either window (REQ-WAV-GAP). The new boss countdown runs during the post-boss quiet window. - REQ-WAV-QUIET: A **quiet window** suppresses normal wave spawning around each boss wave. The pre-boss quiet window begins when the boss countdown falls to or below `world.toml [waves].boss_quiet_before_seconds` and ends when the countdown reaches 0. The post-boss quiet window begins immediately when the boss wave fires and lasts `world.toml [waves].boss_quiet_after_seconds` seconds. Threat accumulation is paused during both windows. The normal wave gap timer does not advance during either window (REQ-WAV-GAP). The new boss countdown runs during the post-boss quiet window.
@@ -364,7 +397,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
@@ -389,15 +422,24 @@ 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-BLOCKS-TOOLTIP: The header bar's building blocks stock display (REQ-UI-HEADER) shows a hover tooltip with the descriptive text defined in `world.toml [world].building_blocks_tooltip` — intended to tell the player what building blocks are used for and how to obtain them. If the field is unset, the stock display shows no tooltip. This tooltip is distinct from the build/module button tooltips (REQ-UI-BUILD-TOOLTIP, REQ-MOD-UI-MODULE-TOOLTIP).
- REQ-UI-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).
- REQ-UI-MODAL-DIM: While a modal dialog, menu, or full-screen state screen is open on top of the game, a transparent black overlay (a dim/scrim) is drawn over the **entire game window** — the header bar, the game world view, and the side panel column — behind that modal, so the game reads as inactive while the modal holds focus. The overlay is shown for every modal that auto-pauses the simulation — the escape menu (REQ-UI-GAME-MENU), the recipe/schematic selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), and the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP) — as well as the game-over screen (REQ-HQ-GAME-OVER) and the win screen (REQ-WIN-SCREEN), which end rather than pause the game. When modals are nested (for example the Create Blueprint name dialog (REQ-MOD-UI-BLUEPRINT-CREATE) opened from the layout configuration dialog), only a single dim is shown over the game window; nested modals do not stack additional overlays. The dim color and opacity are read from `visuals.toml [overlays]` (a semi-transparent black modal-dim color), consistent with the other overlay colors. The overlay is presentation-only and has no effect on the simulation.
### Game World ### 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.
@@ -409,6 +451,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.
@@ -435,35 +478,49 @@ The screen is divided into two columns: a main column (75% width) containing the
### Selected Building Panel ### Selected Building Panel
- REQ-UI-EMPTY-SELECTION: When no building is selected, the panel is empty. - REQ-UI-EMPTY-SELECTION: When nothing is selected (no building, construction site, ship, defence station, or scrap pile), the panel is empty.
- REQ-UI-SINGLE-SELECTION: When one building is selected, the panel shows: building name, current recipe or schematic selection, input buffer contents, and output buffer contents. Buffer counts are displayed as `a/b` where `a` is the current item count and `b` is the per-cycle amount (items consumed per run for inputs; items produced per run for outputs). For a selected construction site, the recipe/schematic selection (and, for a shipyard, the layout preview and "Configure" button) are shown but the buffer rows are omitted (REQ-BLD-SITE-CONFIG). - REQ-UI-SINGLE-SELECTION: When one building is selected, the panel shows: building name, current recipe or schematic selection, input buffer contents, and output buffer contents. Buffer counts are displayed as `a/b` where `a` is the current item count and `b` is the per-cycle amount (items consumed per run for inputs; items produced per run for outputs). For a selected construction site, the recipe/schematic selection (and, for a shipyard, the layout preview and "Configure" button) are shown but the buffer rows are omitted (REQ-BLD-SITE-CONFIG).
- REQ-UI-PRODUCTION-PROGRESS: For buildings that produce items or ships (miner, smelter, assembler, reprocessing plant, shipyard), the selected building panel also shows: (a) the cycle time of the currently selected recipe or schematic in seconds, and (b) the completion percentage of the active production cycle as an integer (e.g. `42%`), or the text `idle` when no production cycle is active. When no recipe or schematic is selected, neither the cycle time nor the progress indicator is shown. - REQ-UI-PRODUCTION-PROGRESS: For buildings that produce items or ships (miner, smelter, assembler, reprocessing plant, shipyard), the selected building panel also shows: (a) the cycle time of the currently selected recipe or schematic in seconds, and (b) the completion percentage of the active production cycle as an integer (e.g. `42%`), or the text `idle` when no production cycle is active. When no recipe or schematic is selected, neither the cycle time nor the progress indicator is shown.
- REQ-UI-MULTI-SELECT: The player selects multiple buildings by box-drag or by Ctrl+clicking individual buildings to add or remove them from the selection. - REQ-UI-MULTI-SELECT: The player selects multiple buildings by box-drag or by Ctrl+clicking individual buildings to add or remove them from the selection. A box-drag that covers at least one building selects buildings (any scrap piles within the box are ignored); a box-drag that covers no buildings but does cover scrap piles selects those scrap piles instead (REQ-UI-SCRAP-MULTI-SELECT).
- REQ-UI-MULTI-SELECTION: When multiple buildings are selected, the panel shows how many of each building type are selected. No per-building detail is shown. - REQ-UI-MULTI-SELECTION: When multiple buildings are selected, the panel shows how many of each building type are selected. No per-building detail is shown. The panel additionally shows the **total building block cost** of the selection — the sum of each selected building's placement cost (`buildings.toml [[building]].cost`, per REQ-BLD-COST), counting only player-placeable buildings (buildings with a button in the build button grid); non-player-placeable buildings (the HQ and defence stations) are excluded from the total, consistent with the blueprint total (REQ-UI-BLUEPRINT-BUTTON). Construction sites count at their building type's full placement cost regardless of construction progress.
- REQ-UI-CONFIG-INLINE: Recipe and schematic configuration for a selected building is shown within this panel. Recipe selection (miner, assembler) and schematic selection (shipyard) use the selection button and dialog (REQ-UI-SELECT-BUTTON) rather than an inline control. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic selection button (REQ-MOD-UI-PREVIEW). - REQ-UI-CONFIG-INLINE: Recipe and schematic configuration for a selected building is shown within this panel. Recipe selection (miner, assembler) and schematic selection (shipyard) use the selection button and dialog (REQ-UI-SELECT-BUTTON) rather than an inline control. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic selection button (REQ-MOD-UI-PREVIEW).
- REQ-UI-SELECT-BUTTON: **Recipe and schematic selection control.** Recipe selection (Miner ore type, Assembler recipe) and schematic selection (Shipyard) are each presented in the selected building panel as a single **selection button** whose caption is the name of the currently selected recipe or schematic, or a placeholder ("Select recipe" / "Select schematic") when none is selected. Clicking the button opens a modal **selection dialog** that pauses the game (speed set to 0×; on close, the speed is restored to what it was before the dialog was opened). The dialog contains a grid of option buttons, one per selectable option — only options that are currently unlocked are shown (REQ-LOCK-UI-RECIPE for recipes, REQ-LOCK-UI-SCHEMATIC for schematics). Hovering an option button shows the selection info tooltip (REQ-UI-SELECT-TOOLTIP). Clicking an option button selects that recipe/schematic, closes the dialog, and updates the selection button's caption in the selected building panel. The dialog can be dismissed without changing the current selection (e.g. closing it without clicking an option). Selecting a new recipe or schematic has the same effects as before (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD). - REQ-UI-SELECT-BUTTON: **Recipe and schematic selection control.** Recipe selection (Miner ore type, Assembler recipe) and schematic selection (Shipyard) are each presented in the selected building panel as a single **selection button** whose caption is the name of the currently selected recipe or schematic, or a placeholder ("Select recipe" / "Select schematic") when none is selected. Clicking the button opens a modal **selection dialog** that pauses the game (speed set to 0×; on close, the speed is restored to what it was before the dialog was opened). The dialog contains a grid of option buttons, one per selectable option — only options that are currently unlocked are shown (REQ-LOCK-UI-RECIPE for recipes, REQ-LOCK-UI-SCHEMATIC for schematics). Hovering an option button shows the selection info tooltip (REQ-UI-SELECT-TOOLTIP). Clicking an option button selects that recipe/schematic, closes the dialog, and updates the selection button's caption in the selected building panel. The dialog can be dismissed without changing the current selection (e.g. closing it without clicking an option). Selecting a new recipe or schematic has the same effects as before (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD).
- REQ-UI-SELECT-TOOLTIP: **Selection info tooltip.** Hovering an option button in the selection dialog (REQ-UI-SELECT-BUTTON), and hovering the selection button in the selected building panel when a selection is set, displays an info tooltip: - REQ-UI-SELECT-TOOLTIP: **Selection info tooltip.** Hovering an option button in the selection dialog (REQ-UI-SELECT-BUTTON), and hovering the selection button in the selected building panel when a selection is set, displays an info tooltip:
- For a **recipe** (Miner or Assembler): the recipe name; the name and quantity of each input item (no inputs are listed for miner recipes, which consume nothing); the completion time (`duration_seconds`); and the name and quantity of the produced output item. - For a **recipe** (Miner or Assembler): the recipe name; the name and quantity of each input item (no inputs are listed for miner recipes, which consume nothing); the completion time (`duration_seconds`); and the name and quantity of the produced output item.
- For a **ship schematic** (Shipyard): the ship's `display_name`; the name and quantity of each base required material (`[ship.schematic].materials`, excluding any module contributions); the base production time (`[ship.schematic].production_time_seconds`); and "Produces: 1 <ship display name>". - For a **ship schematic** (Shipyard): the ship's `display_name`; the name and quantity of each base required material (`[ship.schematic].materials`, excluding any module contributions); the base production time (`[ship.schematic].production_time_seconds`); and "Produces: 1 <ship display name>".
- REQ-UI-BELT-CLEAR: When one or more belt, splitter, tunnel entry, or tunnel exit tiles are selected, the panel shows a "Clear" button that removes all items from the selected tiles. Clearing a tunnel entry or exit also discards all items currently in transit through that tunnel (REQ-BLD-TUNNEL-TRANSIT). This can be used to resolve stalled belts, splitters, and tunnels. - REQ-UI-BELT-CLEAR: When one or more belt, splitter, tunnel entry, or tunnel exit tiles are selected, the panel shows a "Clear" button that removes all items from the selected tiles. Clearing a tunnel entry or exit also discards all items currently in transit through that tunnel (REQ-BLD-TUNNEL-TRANSIT). This can be used to resolve stalled belts, splitters, and tunnels.
- REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. Clicking a ship or defence station clears any existing selection and establishes a single-entity selection containing only that entity. Ships and defence stations cannot participate in multi-select together with buildings. Clicking empty world space (no building, ship, or defence station) clears the selection. - REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. Clicking a ship or defence station clears any existing selection and establishes a single-entity selection containing only that entity. Ships and defence stations cannot participate in multi-select together with buildings. Clicking a scrap pile instead establishes a scrap selection (REQ-UI-SCRAP-CLICK-SELECT). Clicking empty world space (no building, ship, defence station, or scrap pile) clears the selection.
- REQ-UI-SHIP-STATS-PANEL: When a single ship is selected (REQ-UI-ENTITY-CLICK-SELECT), the selected building panel shows a **ship stats panel**. The panel structure mirrors REQ-MOD-UI-STATS-PANEL but reflects the ship's actual live state: stats are computed at the ship's actual level with its installed modules per REQ-MOD-STAT-CALC. The panel always shows all hull stats: HP (current / maximum), max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, and max rotation speed. In addition, capability module summaries are shown conditioned on which module types are installed, using the same aggregation rules as REQ-MOD-UI-STATS-PANEL: weapons (combined DPS, maximum range), salvage (combined collection rate, maximum range), and repair (combined repair rate, maximum range), each section appearing only if at least one instance of that module type is installed. While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT). - REQ-UI-SHIP-STATS-PANEL: When a single ship is selected (REQ-UI-ENTITY-CLICK-SELECT), the selected building panel shows a **ship stats panel**. The panel structure mirrors REQ-MOD-UI-STATS-PANEL but reflects the ship's actual live state: stats are computed from its installed modules per REQ-MOD-STAT-CALC. The panel always shows all hull stats: HP (current / maximum), max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, and max rotation speed. In addition, capability module summaries are shown conditioned on which module types are installed, using the same aggregation rules as REQ-MOD-UI-STATS-PANEL: weapons (combined DPS, maximum range), salvage (combined collection rate, maximum range), and repair (combined repair rate, maximum range), each section appearing only if at least one instance of that module type is installed. While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT).
- REQ-UI-SHIP-BEHAVIOR: The ship stats panel (REQ-UI-SHIP-STATS-PANEL) additionally displays the selected ship's **current behavior** — a single label naming the top-priority behavior currently governing the ship's navigation, as resolved by the fixed-priority behavior arbitration. Only the winning behavior is named; lower-priority behaviors that are suppressed are not shown, and neither are the salvage/repair cycles that run regardless of the active behavior (REQ-SHP-SALVAGE, REQ-SHP-REPAIR). The label updates live as the ship's behavior changes, and it is always shown (independent of debug draw mode, unlike the threat-cost line of REQ-UI-SHIP-STATS-PANEL). This applies to both player and enemy ships (REQ-UI-ENTITY-CLICK-SELECT); enemy ships only ever show **Engaging** or **Advancing**. The behavior labels (all wrapped in `tr()`) are:
- **Retreating** — the ship is retreating (REQ-SHP-RETREAT).
- **Engaging** — the ship is engaging a combat target (player: REQ-SHP-COMBAT; enemy: REQ-SHP-ENEMY-AI).
- **Salvaging** — the ship is executing salvage navigation: seeking scrap, collecting, or delivering to a Salvage Bay (REQ-SHP-SALVAGE).
- **Repairing** — the ship is navigating to a repair target (REQ-SHP-REPAIR).
- **Rallying** — the ship is moving to or orbiting the rally point (REQ-SHP-RALLY).
- **Standby** — the ship is holding with its fleet (REQ-SHP-STANDBY).
- **Advancing** — the ship is executing the baseline forward advance with no higher-priority behavior active (player: REQ-SHP-COMBAT advance toward the enemy; enemy: REQ-SHP-ENEMY-AI advance toward the asteroid).
- REQ-UI-STATION-STATS-PANEL: When a single defence station is selected (REQ-UI-ENTITY-CLICK-SELECT), the selected building panel shows a **station stats panel** displaying the station's stats computed at its current level: HP (current / maximum), damage, range, and fire rate. - REQ-UI-STATION-STATS-PANEL: When a single defence station is selected (REQ-UI-ENTITY-CLICK-SELECT), the selected building panel shows a **station stats panel** displaying the station's stats computed at its current level: HP (current / maximum), damage, range, and fire rate.
- REQ-UI-SCRAP-CLICK-SELECT: The player can click any scrap pile (REQ-RES-SCRAP-DROP) in the game world to select it. Scrap forms its own selection category: clicking a scrap pile clears any existing selection and establishes a scrap selection containing only that pile. Scrap piles cannot participate in a selection together with buildings, ships, or defence stations. Hit-testing prioritizes actors over scrap: when a building, ship, or defence station lies under the cursor at the same point as a scrap pile, that object is selected in preference to the scrap; a scrap pile is selected only when no building, ship, or defence station is under the cursor. A selected scrap pile that despawns or is fully collected (REQ-RES-SCRAP-DROP) is removed from the selection; once the last selected pile is gone, the selection becomes empty (REQ-UI-EMPTY-SELECTION).
- REQ-UI-SCRAP-MULTI-SELECT: Multiple scrap piles can be selected by box-drag or by Ctrl+clicking individual piles to add or remove them from the selection, mirroring building multi-select (REQ-UI-MULTI-SELECT). A scrap selection contains only scrap piles. Because scrap cannot mix with other object types (REQ-UI-SCRAP-CLICK-SELECT), Ctrl+clicking a scrap pile while a building, ship, or defence station selection is active first clears that selection and begins a scrap selection; conversely, selecting a building, ship, or defence station while a scrap selection is active clears the scrap selection. Box-drag disambiguation between buildings and scrap follows REQ-UI-MULTI-SELECT (a box covering any building selects buildings; a box covering scrap but no buildings selects the scrap).
- REQ-UI-SCRAP-PANEL: When one or more scrap piles are selected, the selected building panel shows the **total remaining scrap amount** across all selected piles — the sum of the piles' current remaining amounts (REQ-RES-SCRAP-DROP), e.g. "Scrap: 47". The same summed-amount display is used whether one pile or many are selected; no per-pile detail and no pile count are shown. The displayed total updates as selected piles are partially collected or despawn (REQ-UI-SCRAP-CLICK-SELECT).
### Build Button Grid ### Build Button Grid
- REQ-UI-BUILD-GRID: All placeable building types are shown as a flat grid of buttons with no grouping. - REQ-UI-BUILD-GRID: All placeable building types are shown as a flat grid of buttons with no grouping.
- REQ-UI-BUILD-COST: Each button caption shows the building name and its building block cost, e.g. "Belt: 2 Blocks". - REQ-UI-BUILD-COST: Each button caption shows the building name and its building block cost, e.g. "Belt: 2 Blocks".
- REQ-UI-BUILD-TOOLTIP: Each building-type button shows a hover tooltip with the descriptive text defined for that building type in `buildings.toml` (the optional per-building tooltip field). This tooltip is distinct from the recipe/schematic selection tooltip (REQ-UI-SELECT-TOOLTIP). If a building type defines no tooltip text, its button shows no tooltip. The Demolish button (REQ-UI-DEMOLISH-BUTTON) is not a building type and has no config-defined tooltip.
- REQ-UI-BUILD-DISABLED: Buttons for buildings the player cannot currently afford are shown as disabled. - REQ-UI-BUILD-DISABLED: Buttons for buildings the player cannot currently afford are shown as disabled.
- REQ-UI-DEMOLISH-BUTTON: A dedicated **Demolish** button is shown in the build button grid. Clicking it toggles demolish mode on and off, equivalent to the Q demolish toggle (REQ-UI-HOTKEYS). The button is shown in a visually active/pressed state while demolish mode is active. - REQ-UI-DEMOLISH-BUTTON: A dedicated **Demolish** button is shown in the build button grid. Clicking it toggles demolish mode on and off, equivalent to the Q demolish toggle (REQ-UI-HOTKEYS). The button is shown in a visually active/pressed state while demolish mode is active.
### Blueprint Panel ### 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. A selected player-placeable building may be either an operational building or a construction site (a building placed but not yet fully built, REQ-BLD-SITE-CONFIG); both count toward this condition and are captured identically (REQ-UI-BLUEPRINT-STORAGE). When clicked, a modal dialog appears prompting the player to enter a name. The dialog has Confirm and Cancel buttons. Clicking Cancel closes the dialog with no effect. Clicking Confirm with a non-empty name creates a blueprint from the current selection, silently excluding any non-player-placeable buildings, and appends its button to the blueprint list.
- REQ-UI-BLUEPRINT-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-TEMP: Pressing the **T** key (REQ-UI-HOTKEYS) creates a **temporary blueprint** from the current selection and immediately enters blueprint placement mode for it, without opening the naming dialog. It has effect only when at least one player-placeable building is currently selected — the same condition as REQ-UI-BLUEPRINT-CREATE; pressing T with an empty selection, or a selection containing only non-player-placeable buildings (HQ, defence stations), does nothing. Entering this mode replaces any currently active build, blueprint placement, or demolish mode. The temporary blueprint is captured exactly as a saved blueprint (REQ-UI-BLUEPRINT-STORAGE), silently excluding any non-player-placeable buildings from the selection, but it is never named, never shown in the blueprint panel (REQ-UI-BLUEPRINT-PANEL), and never persisted to `blueprints.toml` (REQ-UI-BLUEPRINT-SAVE). Placement behaves identically to a saved blueprint's placement mode (REQ-UI-BLUEPRINT-MODE, REQ-UI-BLUEPRINT-PLACE): a ghost is rendered per building, R / Shift+R rotate the entire constellation, placement follows the same per-building validity and total-cost rules, and after a successful placement the mode stays active so the blueprint can be placed again. Right-clicking in the game world exits placement mode, at which point the temporary blueprint is discarded.
- REQ-UI-BLUEPRINT-STORAGE: A blueprint stores its name and, for each building in the selection, the building type, its rotation, its tile offset (integer dx, dy) from the center of the bounding box of all selected buildings' footprints, and — where applicable — the selected recipe ID (miners and assemblers) or schematic ID (shipyards), and for splitters the two output filters (each a list of item types; an empty list means accept-all), at the time of capture. A source building may be either an operational building or a construction site (REQ-BLD-SITE-CONFIG); a construction site is captured identically, storing whatever configuration it currently holds and never any buffer or construction-progress state. If no recipe or schematic was selected at capture time, none is stored; for a splitter with no filters set, no filter lists are stored. This structure maps directly to a TOML representation (e.g. one `[[building]]` array entry per constituent building, with the splitter filters as `filter_a`/`filter_b` arrays of item-type ids).
- REQ-UI-BLUEPRINT-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.
@@ -473,9 +530,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
@@ -484,7 +541,7 @@ A separate executable target (`balancing`) that links against `lib` but contains
### Config ### Config
- REQ-BAL-CONFIG: The balancing tool reads arena definitions from a `balancing.toml` file. The file is read at startup and again each time the player triggers a config reload (REQ-BAL-UI-RELOAD). If parsing fails or required fields are missing at startup, the tool aborts with a clear error message. If parsing fails during a reload, a modal error dialog is shown describing the failure and the current arena list is left unchanged. - REQ-BAL-CONFIG: The balancing tool reads arena definitions from a `balancing.toml` file. The file is read at startup and again each time the player triggers a config reload (REQ-BAL-UI-RELOAD). If parsing fails or required fields are missing at startup, the tool aborts with a clear error message. If parsing fails during a reload, a modal error dialog is shown describing the failure and the current arena list is left unchanged.
- REQ-BAL-CONFIG-GAME: Ship stats are read from `ships.toml` and defence station stats are read from `stations.toml`, using the same config loading as the main game. Formula evaluation uses the levels specified in the arena config. - REQ-BAL-CONFIG-GAME: Ship stats are read from `ships.toml` and defence station stats are read from `stations.toml`, using the same config loading as the main game. Ship stats are the plain values from `ships.toml`; defence station formula evaluation uses the station levels specified in the arena config.
### Arena Definition ### Arena Definition
@@ -494,7 +551,7 @@ A separate executable target (`balancing`) that links against `lib` but contains
- **World height** (in tiles). - **World height** (in tiles).
- Exactly **two teams**, each with a human-readable **team name**. - Exactly **two teams**, each with a human-readable **team name**.
- REQ-BAL-TEAM: Each team defines: - REQ-BAL-TEAM: Each team defines:
- A list of **ship entries**, each specifying: ship schematic (type), level, count, and an optional `modules` array defining the module layout applied to every ship of that entry. The `modules` array format is identical to that used in `ship_layouts.toml` (see Layout Blueprint TOML Format). If `modules` is omitted, ships of that entry have no modules. Invalid module instances (unknown type, position outside the grid, position on a non-buildable cell, or overlapping another module in the same entry) are silently skipped during loading. - A list of **ship entries**, each specifying: ship schematic (type), count, and an optional `modules` array defining the module layout applied to every ship of that entry. The `modules` array format is identical to that used in `ship_layouts.toml` (see Layout Blueprint TOML Format). If `modules` is omitted, ships of that entry have no modules. Invalid module instances (unknown type, position outside the grid, position on a non-buildable cell, or overlapping another module in the same entry) are silently skipped during loading.
- An optional list of **defence station entries**, each specifying: station type (`player_station` or `enemy_station` from `stations.toml`), level, and tile position (x, y). - An optional list of **defence station entries**, each specifying: station type (`player_station` or `enemy_station` from `stations.toml`), level, and tile position (x, y).
- REQ-BAL-HQ: Each team has an HQ placed automatically at the vertical center of the arena at the far end of that team's buffer zone. HQ stats are read from `stations.toml [hq]` at level 1. Team 1's HQ is at the left edge; team 2's HQ is at the right edge. - REQ-BAL-HQ: Each team has an HQ placed automatically at the vertical center of the arena at the far end of that team's buffer zone. HQ stats are read from `stations.toml [hq]` at level 1. Team 1's HQ is at the left edge; team 2's HQ is at the right edge.
- REQ-BAL-SPAWN: Team 1's ships spawn in team 1's buffer zone (left side); team 2's ships spawn in team 2's buffer zone (right side). Spawn positions are uniformly random within the respective buffer zone. - REQ-BAL-SPAWN: Team 1's ships spawn in team 1's buffer zone (left side); team 2's ships spawn in team 2's buffer zone (right side). Spawn positions are uniformly random within the respective buffer zone.
@@ -509,13 +566,17 @@ A separate executable target (`balancing`) that links against `lib` but contains
### UI ### UI
- REQ-BAL-UI-WINDOW: On startup the tool displays a window containing a "Reload Config" button and a "Start All" button at the top (in that order, left to right), followed by a scrollable vertical list of arena widgets, one per arena defined in `balancing.toml`. Simulations do not start automatically on startup. All buttons and controls in the main window are disabled while an arena is being inspected (REQ-BAL-UI-INSPECT). - REQ-BAL-UI-WINDOW: On startup the tool displays a window containing a "Reload Config" button, a "Start All" button, and a "Log" button at the top (in that order, left to right), followed by a scrollable vertical list of arena widgets, one per arena defined in `balancing.toml`. Simulations do not start automatically on startup. All buttons and controls in the main window are disabled while an arena is being inspected (REQ-BAL-UI-INSPECT).
- 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-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. Below the threat level, 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 Llevel` — for example `2/3 Fighter L5` or `1/1 HQ L1`. 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-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). 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).
- 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]`. 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

@@ -2,6 +2,8 @@
#include <algorithm> #include <algorithm>
#include <cassert> #include <cassert>
#include <cmath>
#include <string>
#include <QVector2D> #include <QVector2D>
@@ -86,12 +88,49 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
placeStructures(); placeStructures();
spawnShips(); spawnShips();
computeTeamMaxEhp();
m_shipSystem->triggerRallyDeparture(); m_shipSystem->triggerRallyDeparture();
updateStatus(); updateStatus();
} }
std::string ArenaStatus::TeamStatus::ehpPercentText() const
{
if (maxEhp <= 0.0)
{
return "n/a";
}
const int percent = static_cast<int>(std::lround(100.0 * currentEhp / maxEhp));
return std::to_string(percent) + "%";
}
void ArenaSimulation::computeTeamMaxEhp()
{
m_teamMaxEhp[0] = 0.0;
m_teamMaxEhp[1] = 0.0;
// Ships contribute their full max HP.
m_admin.forEach<ShipIdentityComponent, FactionComponent, HealthComponent>(
[this](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
const FactionComponent& f, const HealthComponent& h)
{
m_teamMaxEhp[f.isEnemy ? 1 : 0] += static_cast<double>(h.maxHp);
});
// Defence stations contribute their full max HP; the HQ is excluded.
m_admin.forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[this](entt::entity e, const StationBodyComponent& /*sb*/,
const FactionComponent& f, const HealthComponent& h)
{
if (m_admin.hasAll<HqProxyComponent>(e))
{
return;
}
m_teamMaxEhp[f.isEnemy ? 1 : 0] += static_cast<double>(h.maxHp);
});
}
ArenaSimulation::~ArenaSimulation() = default; ArenaSimulation::~ArenaSimulation() = default;
BuildingId ArenaSimulation::allocateBuildingId() BuildingId ArenaSimulation::allocateBuildingId()
@@ -231,7 +270,7 @@ void ArenaSimulation::spawnShips()
for (int i = 0; i < entry.count; ++i) for (int i = 0; i < entry.count; ++i)
{ {
const QVector2D pos(xDist(m_rng), yDist(m_rng)); const QVector2D pos(xDist(m_rng), yDist(m_rng));
m_shipSystem->spawn(entry.schematicId, entry.level, pos, false, m_shipSystem->spawn(entry.schematicId, pos, false,
entry.layout); entry.layout);
} }
} }
@@ -248,7 +287,7 @@ void ArenaSimulation::spawnShips()
for (int i = 0; i < entry.count; ++i) for (int i = 0; i < entry.count; ++i)
{ {
const QVector2D pos(xDist(m_rng), yDist(m_rng)); const QVector2D pos(xDist(m_rng), yDist(m_rng));
m_shipSystem->spawn(entry.schematicId, entry.level, pos, true, m_shipSystem->spawn(entry.schematicId, pos, true,
entry.layout); entry.layout);
} }
} }
@@ -330,15 +369,11 @@ void ArenaSimulation::tickDeaths()
{ {
const ShipIdentityComponent& si = m_admin.get<ShipIdentityComponent>(deadEntity); const ShipIdentityComponent& si = m_admin.get<ShipIdentityComponent>(deadEntity);
const PositionComponent& pos = m_admin.get<PositionComponent>(deadEntity); const PositionComponent& pos = m_admin.get<PositionComponent>(deadEntity);
for (const ShipDef& def : m_gameConfig.ships.ships) if (si.scrapDrop > 0)
{ {
if (def.id == si.schematicId && def.loot.scrapDrop > 0) const Tick despawnAt = m_currentTick
{ + secondsToTicks(m_gameConfig.world.scrapDespawnSeconds);
const Tick despawnAt = m_currentTick m_scrapSystem->spawn(pos.value, si.scrapDrop, despawnAt);
+ secondsToTicks(m_gameConfig.world.scrapDespawnSeconds);
m_scrapSystem->spawn(pos.value, def.loot.scrapDrop, despawnAt);
break;
}
} }
m_shipSystem->despawn(deadEntity); m_shipSystem->despawn(deadEntity);
} }
@@ -398,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; }
}); });
@@ -480,12 +518,41 @@ 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);
// the EHP-percentage numerator (denominator is the fixed m_teamMaxEhp).
double currentEhp[2] = {0.0, 0.0};
m_admin.forEach<ShipIdentityComponent, FactionComponent, HealthComponent>(
[&currentEhp](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
const FactionComponent& f, const HealthComponent& h)
{
if (h.hp > 0.0f)
{
currentEhp[f.isEnemy ? 1 : 0] += static_cast<double>(h.hp);
}
});
m_admin.forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[this, &currentEhp](entt::entity e, const StationBodyComponent& /*sb*/,
const FactionComponent& f, const HealthComponent& h)
{
if (m_admin.hasAll<HqProxyComponent>(e))
{
return;
}
if (h.hp > 0.0f)
{
currentEhp[f.isEnemy ? 1 : 0] += static_cast<double>(h.hp);
}
});
for (int ti = 0; ti < 2; ++ti) for (int ti = 0; ti < 2; ++ti)
{ {
ArenaStatus::TeamStatus& teamStatus = newStatus.teams[ti]; ArenaStatus::TeamStatus& teamStatus = newStatus.teams[ti];
teamStatus.name = m_arenaConfig.teams[ti].name; teamStatus.name = m_arenaConfig.teams[ti].name;
teamStatus.threatLevel = m_teamThreat[ti]; teamStatus.threatLevel = m_teamThreat[ti];
teamStatus.currentEhp = currentEhp[ti];
teamStatus.maxEhp = m_teamMaxEhp[ti];
// HQ entry (always first). // HQ entry (always first).
{ {
@@ -504,7 +571,7 @@ void ArenaSimulation::updateStatus()
{ {
ArenaStatus::Entry entry; ArenaStatus::Entry entry;
entry.displayName = shipEntry.schematicId; entry.displayName = shipEntry.schematicId;
entry.level = shipEntry.level; // Ships no longer carry a level (level suffix stays empty).
entry.total = shipEntry.count; entry.total = shipEntry.count;
int surviving = 0; int surviving = 0;
@@ -516,7 +583,6 @@ void ArenaSimulation::updateStatus()
{ {
if (f.isEnemy == isEnemyTeam if (f.isEnemy == isEnemyTeam
&& si.schematicId == shipEntry.schematicId && si.schematicId == shipEntry.schematicId
&& si.level == shipEntry.level
&& h.hp > 0.0f) && h.hp > 0.0f)
{ {
++surviving; ++surviving;

View File

@@ -3,6 +3,7 @@
#include <atomic> #include <atomic>
#include <memory> #include <memory>
#include <mutex> #include <mutex>
#include <optional>
#include <random> #include <random>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -32,7 +33,9 @@ struct ArenaStatus
struct Entry struct Entry
{ {
std::string displayName; std::string displayName;
int level; // Level suffix shown in the widget/inspect display. Set for the HQ and
// defence stations; empty for ships, which no longer have a level.
std::optional<int> level;
int total; int total;
int surviving; int surviving;
}; };
@@ -41,12 +44,24 @@ struct ArenaStatus
{ {
std::string name; std::string name;
double threatLevel = 0.0; // accumulated threat of the team's configured ships double threatLevel = 0.0; // accumulated threat of the team's configured ships
// Remaining durability of the team's ships and defence stations (HQ
// excluded). currentEhp is summed live; maxEhp is the fixed full-HP
// baseline. See ehpPercentText() for the displayed value.
double currentEhp = 0.0;
double maxEhp = 0.0;
std::vector<Entry> entries; // HQ first, then ships, then stations std::vector<Entry> entries; // HQ first, then ships, then stations
// Remaining EHP as a whole-number percentage ("NN%"), or "n/a" when the
// team has no ships or stations (maxEhp == 0).
std::string ehpPercentText() const;
}; };
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
@@ -79,6 +94,7 @@ private:
BuildingId allocateBuildingId(); BuildingId allocateBuildingId();
void placeStructures(); void placeStructures();
void spawnShips(); void spawnShips();
void computeTeamMaxEhp();
void tick(); void tick();
void tickDeaths(); void tickDeaths();
void updateStatus(); void updateStatus();
@@ -112,6 +128,10 @@ private:
// Static accumulated threat per team, computed once from the configured roster. // Static accumulated threat per team, computed once from the configured roster.
double m_teamThreat[2] = {0.0, 0.0}; double m_teamThreat[2] = {0.0, 0.0};
// Full-HP baseline per team (ships + defence stations, HQ excluded), computed
// once after spawning; the EHP-percentage denominator.
double m_teamMaxEhp[2] = {0.0, 0.0};
std::vector<BeamFiredEvent> m_beamFiredEvents; std::vector<BeamFiredEvent> m_beamFiredEvents;
mutable std::mutex m_statusMutex; mutable std::mutex m_statusMutex;

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);
@@ -63,6 +66,8 @@ void ArenaWidget::buildLayout(const std::string& arenaName)
team1Layout->addWidget(m_team1Header); team1Layout->addWidget(m_team1Header);
m_team1Threat = new QLabel(this); m_team1Threat = new QLabel(this);
team1Layout->addWidget(m_team1Threat); team1Layout->addWidget(m_team1Threat);
m_team1Ehp = new QLabel(this);
team1Layout->addWidget(m_team1Ehp);
m_team1Content = new QLabel(this); m_team1Content = new QLabel(this);
team1Layout->addWidget(m_team1Content); team1Layout->addWidget(m_team1Content);
team1Layout->addStretch(); team1Layout->addStretch();
@@ -75,6 +80,8 @@ void ArenaWidget::buildLayout(const std::string& arenaName)
team2Layout->addWidget(m_team2Header); team2Layout->addWidget(m_team2Header);
m_team2Threat = new QLabel(this); m_team2Threat = new QLabel(this);
team2Layout->addWidget(m_team2Threat); team2Layout->addWidget(m_team2Threat);
m_team2Ehp = new QLabel(this);
team2Layout->addWidget(m_team2Ehp);
m_team2Content = new QLabel(this); m_team2Content = new QLabel(this);
team2Layout->addWidget(m_team2Content); team2Layout->addWidget(m_team2Content);
team2Layout->addStretch(); team2Layout->addStretch();
@@ -99,13 +106,29 @@ void ArenaWidget::resetToGrey()
setStyleSheet("ArenaWidget { border: 2px solid #999999; padding: 8px; }"); setStyleSheet("ArenaWidget { border: 2px solid #999999; padding: 8px; }");
} }
ArenaWidget::State ArenaWidget::getState() const
{
if (m_wasFinished)
{
return State::Ended;
}
if (m_running)
{
return State::Running;
}
return State::NotStarted;
}
void ArenaWidget::updateStatus(const ArenaStatus& status) void ArenaWidget::updateStatus(const ArenaStatus& status)
{ {
m_lastStatus = status;
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];
QLabel* header = (ti == 0) ? m_team1Header : m_team2Header; QLabel* header = (ti == 0) ? m_team1Header : m_team2Header;
QLabel* threat = (ti == 0) ? m_team1Threat : m_team2Threat; QLabel* threat = (ti == 0) ? m_team1Threat : m_team2Threat;
QLabel* ehp = (ti == 0) ? m_team1Ehp : m_team2Ehp;
QLabel* content = (ti == 0) ? m_team1Content : m_team2Content; QLabel* content = (ti == 0) ? m_team1Content : m_team2Content;
if (status.finished && status.winnerTeam == ti) if (status.finished && status.winnerTeam == ti)
@@ -118,6 +141,7 @@ void ArenaWidget::updateStatus(const ArenaStatus& status)
} }
threat->setText(tr("Threat: %1").arg(QString::number(team.threatLevel, 'f', 0))); threat->setText(tr("Threat: %1").arg(QString::number(team.threatLevel, 'f', 0)));
ehp->setText(tr("EHP: %1").arg(QString::fromStdString(team.ehpPercentText())));
QString lines; QString lines;
for (const ArenaStatus::Entry& entry : team.entries) for (const ArenaStatus::Entry& entry : team.entries)
@@ -126,15 +150,22 @@ void ArenaWidget::updateStatus(const ArenaStatus& status)
{ {
lines += "\n"; lines += "\n";
} }
lines += QString("%1/%2 %3 L%4") lines += QString("%1/%2 %3")
.arg(entry.surviving) .arg(entry.surviving)
.arg(entry.total) .arg(entry.total)
.arg(QString::fromStdString(entry.displayName)) .arg(QString::fromStdString(entry.displayName));
.arg(entry.level); if (entry.level.has_value())
{
lines += QString(" L%1").arg(entry.level.value());
}
} }
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

@@ -14,21 +14,35 @@ class ArenaWidget : public QFrame
Q_OBJECT Q_OBJECT
public: public:
enum class State
{
NotStarted,
Running,
Ended
};
ArenaWidget(int arenaIndex, const std::string& arenaName, QWidget* parent = nullptr); ArenaWidget(int arenaIndex, const std::string& arenaName, QWidget* parent = nullptr);
void updateStatus(const ArenaStatus& status); void updateStatus(const ArenaStatus& status);
void startSimulation(); void startSimulation();
void resetToGrey(); void resetToGrey();
const ArenaStatus& getLastStatus() const { return m_lastStatus; }
State getState() const;
private: private:
void buildLayout(const std::string& arenaName); void buildLayout(const std::string& arenaName);
int m_arenaIndex; int m_arenaIndex;
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;
QLabel* m_team2Threat; QLabel* m_team2Threat;
QLabel* m_team1Ehp;
QLabel* m_team2Ehp;
QLabel* m_team1Content; QLabel* m_team1Content;
QLabel* m_team2Content; QLabel* m_team2Content;
QPushButton* m_inspectButton; QPushButton* m_inspectButton;

View File

@@ -125,8 +125,6 @@ BalancingConfig loadBalancingConfig(const std::string& path)
ArenaShipEntry entry; ArenaShipEntry entry;
entry.schematicId = requireString((*shipTbl)["schematic"], entry.schematicId = requireString((*shipTbl)["schematic"],
sPrefix + ".schematic"); sPrefix + ".schematic");
entry.level = static_cast<int>(
requireInt((*shipTbl)["level"], sPrefix + ".level"));
entry.count = static_cast<int>( entry.count = static_cast<int>(
requireInt((*shipTbl)["count"], sPrefix + ".count")); requireInt((*shipTbl)["count"], sPrefix + ".count"));

View File

@@ -18,7 +18,6 @@ struct ArenaStationEntry
struct ArenaShipEntry struct ArenaShipEntry
{ {
std::string schematicId; std::string schematicId;
int level;
int count; int count;
std::optional<ShipLayoutConfig> layout; std::optional<ShipLayoutConfig> layout;
}; };

View File

@@ -1,13 +1,70 @@
#include "BalancingWindow.h" #include "BalancingWindow.h"
#include <algorithm>
#include <QFile>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QMessageBox> #include <QMessageBox>
#include <QTextStream>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "ConfigLoader.h" #include "ConfigLoader.h"
#include "InspectWindow.h" #include "InspectWindow.h"
#include "VisualsLoader.h" #include "VisualsLoader.h"
namespace
{
// Escapes characters that would break a markdown table cell.
QString escapeCell(const QString& text)
{
QString escaped = text;
escaped.replace("|", "\\|");
return escaped;
}
QString stateText(ArenaWidget::State state)
{
switch (state)
{
case ArenaWidget::State::NotStarted:
return QStringLiteral("not started");
case ArenaWidget::State::Running:
return QStringLiteral("running");
case ArenaWidget::State::Ended:
return QStringLiteral("ended");
}
return QString();
}
// Team column header: "[WON] Name — threat N", matching the arena widget.
QString teamHeaderCell(const ArenaStatus& status, int teamIndex)
{
const ArenaStatus::TeamStatus& team = status.teams[teamIndex];
QString header = QString::fromStdString(team.name);
if (status.finished && status.winnerTeam == teamIndex)
{
header = QStringLiteral("[WON] ") + header;
}
header += QStringLiteral(" - threat %1").arg(QString::number(team.threatLevel, 'f', 0));
header += QStringLiteral(" - EHP %1").arg(QString::fromStdString(team.ehpPercentText()));
return escapeCell(header);
}
// Single entry line: "surviving/total DisplayName [L<level>]", matching the arena widget.
QString entryCell(const ArenaStatus::Entry& entry)
{
QString cell = QString("%1/%2 %3")
.arg(entry.surviving)
.arg(entry.total)
.arg(QString::fromStdString(entry.displayName));
if (entry.level.has_value())
{
cell += QStringLiteral(" L%1").arg(entry.level.value());
}
return escapeCell(cell);
}
}
BalancingWindow::BalancingWindow(const BalancingConfig& balancingConfig, BalancingWindow::BalancingWindow(const BalancingConfig& balancingConfig,
GameConfig gameConfig, GameConfig gameConfig,
const std::string& configDir, const std::string& configDir,
@@ -31,13 +88,16 @@ BalancingWindow::BalancingWindow(const BalancingConfig& balancingConfig,
QHBoxLayout* buttonRow = new QHBoxLayout(); QHBoxLayout* buttonRow = new QHBoxLayout();
m_reloadButton = new QPushButton(tr("Reload Config"), this); m_reloadButton = new QPushButton(tr("Reload Config"), this);
m_startAllButton = new QPushButton(tr("Start All"), this); m_startAllButton = new QPushButton(tr("Start All"), this);
m_logButton = new QPushButton(tr("Log"), this);
buttonRow->addWidget(m_reloadButton); buttonRow->addWidget(m_reloadButton);
buttonRow->addWidget(m_startAllButton); buttonRow->addWidget(m_startAllButton);
buttonRow->addWidget(m_logButton);
buttonRow->addStretch(); buttonRow->addStretch();
mainLayout->addLayout(buttonRow); mainLayout->addLayout(buttonRow);
connect(m_reloadButton, &QPushButton::clicked, this, &BalancingWindow::reloadConfig); connect(m_reloadButton, &QPushButton::clicked, this, &BalancingWindow::reloadConfig);
connect(m_startAllButton, &QPushButton::clicked, this, &BalancingWindow::startAll); connect(m_startAllButton, &QPushButton::clicked, this, &BalancingWindow::startAll);
connect(m_logButton, &QPushButton::clicked, this, &BalancingWindow::writeLog);
m_scrollArea = new QScrollArea(this); m_scrollArea = new QScrollArea(this);
m_scrollArea->setWidgetResizable(true); m_scrollArea->setWidgetResizable(true);
@@ -255,6 +315,7 @@ void BalancingWindow::setMainControlsEnabled(bool enabled)
{ {
m_reloadButton->setEnabled(enabled); m_reloadButton->setEnabled(enabled);
m_startAllButton->setEnabled(enabled); m_startAllButton->setEnabled(enabled);
m_logButton->setEnabled(enabled);
for (ArenaEntry& entry : m_arenas) for (ArenaEntry& entry : m_arenas)
{ {
for (QPushButton* btn : entry.widget->findChildren<QPushButton*>()) for (QPushButton* btn : entry.widget->findChildren<QPushButton*>())
@@ -291,4 +352,58 @@ void BalancingWindow::updateButtons()
m_reloadButton->setEnabled(!anyRunning); m_reloadButton->setEnabled(!anyRunning);
m_startAllButton->setEnabled(!allRunning); m_startAllButton->setEnabled(!allRunning);
m_logButton->setEnabled(true);
}
void BalancingWindow::writeLog()
{
QFile file(QStringLiteral("balancing_log.md"));
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text))
{
QMessageBox::warning(this, tr("Log Failed"),
tr("Could not open balancing_log.md for writing."));
return;
}
QTextStream out(&file);
out.setCodec("UTF-8");
out << "# Balancing Log\n";
for (const ArenaEntry& entry : m_arenas)
{
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)
<< " (" << stateSuffix << ")\n\n";
out << "| " << teamHeaderCell(status, 0) << " | "
<< teamHeaderCell(status, 1) << " |\n";
out << "|---|---|\n";
const std::size_t rowCount = std::max(status.teams[0].entries.size(),
status.teams[1].entries.size());
for (std::size_t row = 0; row < rowCount; ++row)
{
QString leftCell;
if (row < status.teams[0].entries.size())
{
leftCell = entryCell(status.teams[0].entries[row]);
}
QString rightCell;
if (row < status.teams[1].entries.size())
{
rightCell = entryCell(status.teams[1].entries[row]);
}
out << "| " << leftCell << " | " << rightCell << " |\n";
}
}
} }

View File

@@ -46,6 +46,7 @@ private slots:
void pollStatuses(); void pollStatuses();
void reloadConfig(); void reloadConfig();
void startAll(); void startAll();
void writeLog();
private: private:
void startArena(int index); void startArena(int index);
@@ -72,6 +73,7 @@ private:
unsigned int m_nextSeed; unsigned int m_nextSeed;
QPushButton* m_reloadButton; QPushButton* m_reloadButton;
QPushButton* m_startAllButton; QPushButton* m_startAllButton;
QPushButton* m_logButton;
QScrollArea* m_scrollArea; QScrollArea* m_scrollArea;
QTimer* m_pollTimer; QTimer* m_pollTimer;

View File

@@ -14,6 +14,7 @@
#include "HealthComponent.h" #include "HealthComponent.h"
#include "InspectWindowClosedEvent.h" #include "InspectWindowClosedEvent.h"
#include "ModuleOwnerComponent.h" #include "ModuleOwnerComponent.h"
#include "SelectedBehaviorComponent.h"
#include "ShipIdentityComponent.h" #include "ShipIdentityComponent.h"
#include "ShipStatsCalculator.h" #include "ShipStatsCalculator.h"
#include "ShipStatsPanel.h" #include "ShipStatsPanel.h"
@@ -52,6 +53,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" };
@@ -93,6 +97,8 @@ InspectWindow::InspectWindow(ArenaSimulation* sim, const GameConfig* config,
team1Layout->addWidget(m_team1Header); team1Layout->addWidget(m_team1Header);
m_team1Threat = new QLabel(infoPanel); m_team1Threat = new QLabel(infoPanel);
team1Layout->addWidget(m_team1Threat); team1Layout->addWidget(m_team1Threat);
m_team1Ehp = new QLabel(infoPanel);
team1Layout->addWidget(m_team1Ehp);
m_team1Content = new QLabel(infoPanel); m_team1Content = new QLabel(infoPanel);
team1Layout->addWidget(m_team1Content); team1Layout->addWidget(m_team1Content);
team1Layout->addStretch(); team1Layout->addStretch();
@@ -104,6 +110,8 @@ InspectWindow::InspectWindow(ArenaSimulation* sim, const GameConfig* config,
team2Layout->addWidget(m_team2Header); team2Layout->addWidget(m_team2Header);
m_team2Threat = new QLabel(infoPanel); m_team2Threat = new QLabel(infoPanel);
team2Layout->addWidget(m_team2Threat); team2Layout->addWidget(m_team2Threat);
m_team2Ehp = new QLabel(infoPanel);
team2Layout->addWidget(m_team2Ehp);
m_team2Content = new QLabel(infoPanel); m_team2Content = new QLabel(infoPanel);
team2Layout->addWidget(m_team2Content); team2Layout->addWidget(m_team2Content);
team2Layout->addStretch(); team2Layout->addStretch();
@@ -198,11 +206,16 @@ 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];
QLabel* header = (ti == 0) ? m_team1Header : m_team2Header; QLabel* header = (ti == 0) ? m_team1Header : m_team2Header;
QLabel* threat = (ti == 0) ? m_team1Threat : m_team2Threat; QLabel* threat = (ti == 0) ? m_team1Threat : m_team2Threat;
QLabel* ehp = (ti == 0) ? m_team1Ehp : m_team2Ehp;
QLabel* content = (ti == 0) ? m_team1Content : m_team2Content; QLabel* content = (ti == 0) ? m_team1Content : m_team2Content;
if (status.finished && status.winnerTeam == ti) if (status.finished && status.winnerTeam == ti)
@@ -215,6 +228,7 @@ void InspectWindow::updateInfoPanel(const ArenaStatus& status)
} }
threat->setText(tr("Threat: %1").arg(QString::number(team.threatLevel, 'f', 0))); threat->setText(tr("Threat: %1").arg(QString::number(team.threatLevel, 'f', 0)));
ehp->setText(tr("EHP: %1").arg(QString::fromStdString(team.ehpPercentText())));
QString lines; QString lines;
for (const ArenaStatus::Entry& entry : team.entries) for (const ArenaStatus::Entry& entry : team.entries)
@@ -223,11 +237,14 @@ void InspectWindow::updateInfoPanel(const ArenaStatus& status)
{ {
lines += "\n"; lines += "\n";
} }
lines += QString("%1/%2 %3 L%4") lines += QString("%1/%2 %3")
.arg(entry.surviving) .arg(entry.surviving)
.arg(entry.total) .arg(entry.total)
.arg(QString::fromStdString(entry.displayName)) .arg(QString::fromStdString(entry.displayName));
.arg(entry.level); if (entry.level.has_value())
{
lines += QString(" L%1").arg(entry.level.value());
}
} }
content->setText(lines); content->setText(lines);
} }
@@ -256,13 +273,14 @@ void InspectWindow::handleEvent(std::shared_ptr<const EntitySelectedEvent> event
const ShipIdentityComponent& identity = admin.get<ShipIdentityComponent>(entity); const ShipIdentityComponent& identity = admin.get<ShipIdentityComponent>(entity);
const HealthComponent& health = admin.get<HealthComponent>(entity); const HealthComponent& health = admin.get<HealthComponent>(entity);
m_entityTitleLabel->setText(tr("Ship: %1 (Lv %2)") m_entityTitleLabel->setText(tr("Ship: %1")
.arg(QString::fromStdString(identity.schematicId)) .arg(QString::fromStdString(identity.schematicId)));
.arg(identity.level));
m_entityTitleLabel->show(); m_entityTitleLabel->show();
const ShipStats stats = buildShipStatsFromEntity(admin, entity); const ShipStats stats = buildShipStatsFromEntity(admin, entity);
m_entityStatsPanel->refreshFromLive(stats, health.hp); m_entityStatsPanel->refreshFromLive(stats, health.hp);
m_entityStatsPanel->setBehavior(
admin.get<SelectedBehaviorComponent>(entity).winner);
m_entityStatsPanel->show(); m_entityStatsPanel->show();
m_stationStatsLabel->hide(); m_stationStatsLabel->hide();
} }
@@ -340,6 +358,8 @@ void InspectWindow::refreshEntityStats()
{ {
const ShipStats stats = buildShipStatsFromEntity(admin, entity); const ShipStats stats = buildShipStatsFromEntity(admin, entity);
m_entityStatsPanel->refreshFromLive(stats, health.hp); m_entityStatsPanel->refreshFromLive(stats, health.hp);
m_entityStatsPanel->setBehavior(
admin.get<SelectedBehaviorComponent>(entity).winner);
} }
else if (admin.hasAll<StationBodyComponent>(entity)) else if (admin.hasAll<StationBodyComponent>(entity))
{ {

View File

@@ -54,10 +54,13 @@ 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;
QLabel* m_team2Threat; QLabel* m_team2Threat;
QLabel* m_team1Ehp;
QLabel* m_team2Ehp;
QLabel* m_team1Content; QLabel* m_team1Content;
QLabel* m_team2Content; QLabel* m_team2Content;
QTimer* m_pollTimer; QTimer* m_pollTimer;

View File

@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <optional>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -18,9 +19,30 @@ 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;
// Optional hover-tooltip text for the build button (REQ-UI-BUILD-TOOLTIP).
std::optional<std::string> tooltip;
}; };
struct BuildingsConfig struct BuildingsConfig
{ {
std::vector<BuildingDef> buildings; std::vector<BuildingDef> buildings;
// Returns the definition for the given building type, or nullptr if the
// type has no entry in buildings.toml.
const BuildingDef* findBuildingDef(BuildingType type) const
{
for (const BuildingDef& def : buildings)
{
if (def.type == type)
{
return &def;
}
}
return nullptr;
}
}; };

View File

@@ -4,6 +4,7 @@
#include <sstream> #include <sstream>
#include <stdexcept> #include <stdexcept>
#include <string> #include <string>
#include <unordered_set>
#include <utility> #include <utility>
#include <vector> #include <vector>
@@ -264,6 +265,7 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path)
cfg.refundPercentage = static_cast<int>(requireInt(tbl["world"]["refund_percentage"], file, "world.refund_percentage")); cfg.refundPercentage = static_cast<int>(requireInt(tbl["world"]["refund_percentage"], file, "world.refund_percentage"));
cfg.startingBuildingBlocks = static_cast<int>(requireInt(tbl["world"]["starting_building_blocks"], file, "world.starting_building_blocks")); cfg.startingBuildingBlocks = static_cast<int>(requireInt(tbl["world"]["starting_building_blocks"], file, "world.starting_building_blocks"));
cfg.scrapDespawnSeconds = requireDouble(tbl["world"]["scrap_despawn_seconds"], file, "world.scrap_despawn_seconds"); cfg.scrapDespawnSeconds = requireDouble(tbl["world"]["scrap_despawn_seconds"], file, "world.scrap_despawn_seconds");
cfg.scrapPerThreat = requireDouble(tbl["world"]["scrap_per_threat"], file, "world.scrap_per_threat");
cfg.tileSize_m = requireDouble(tbl["world"]["tile_size_m"], file, "world.tile_size_m"); cfg.tileSize_m = requireDouble(tbl["world"]["tile_size_m"], file, "world.tile_size_m");
cfg.beltSpeed_tps = requireDouble(tbl["world"]["belt_speed_mps"], file, "world.belt_speed_mps") / cfg.tileSize_m; cfg.beltSpeed_tps = requireDouble(tbl["world"]["belt_speed_mps"], file, "world.belt_speed_mps") / cfg.tileSize_m;
cfg.tunnelMaxDistance_tiles = static_cast<int>(requireInt(tbl["world"]["tunnel_max_distance_tiles"], file, "world.tunnel_max_distance_tiles")); cfg.tunnelMaxDistance_tiles = static_cast<int>(requireInt(tbl["world"]["tunnel_max_distance_tiles"], file, "world.tunnel_max_distance_tiles"));
@@ -271,19 +273,24 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path)
cfg.orbitFactor = requireDouble(tbl["world"]["orbit_factor"], file, "world.orbit_factor"); cfg.orbitFactor = requireDouble(tbl["world"]["orbit_factor"], file, "world.orbit_factor");
cfg.rallyOrbitRadius_tiles = requireDouble(tbl["world"]["rally_orbit_radius_tiles"], file, "world.rally_orbit_radius_tiles"); cfg.rallyOrbitRadius_tiles = requireDouble(tbl["world"]["rally_orbit_radius_tiles"], file, "world.rally_orbit_radius_tiles");
if (const std::optional<std::string> tip =
tbl["world"]["building_blocks_tooltip"].value<std::string>())
{
cfg.buildingBlocksTooltip = *tip;
}
cfg.regions.asteroidWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["asteroid_width_tiles"], file, "regions.asteroid_width_tiles")); cfg.regions.asteroidWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["asteroid_width_tiles"], file, "regions.asteroid_width_tiles"));
cfg.regions.playerBufferWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["player_buffer_width_tiles"], file, "regions.player_buffer_width_tiles")); cfg.regions.playerBufferWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["player_buffer_width_tiles"], file, "regions.player_buffer_width_tiles"));
cfg.regions.contestZoneWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["contest_zone_width_tiles"], file, "regions.contest_zone_width_tiles")); cfg.regions.contestZoneWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["contest_zone_width_tiles"], file, "regions.contest_zone_width_tiles"));
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");
cfg.waves.threatRateFormula = requireFormula(tbl["waves"]["threat_rate_formula"], file, "waves.threat_rate_formula"); cfg.waves.threatRateFormula = requireFormula(tbl["waves"]["threat_rate_formula"], file, "waves.threat_rate_formula");
cfg.waves.shipLevelFormula = requireFormula(tbl["waves"]["ship_level_formula"], file, "waves.ship_level_formula");
cfg.waves.gapMinSeconds = requireDouble(tbl["waves"]["gap_min_seconds"], file, "waves.gap_min_seconds"); cfg.waves.gapMinSeconds = requireDouble(tbl["waves"]["gap_min_seconds"], file, "waves.gap_min_seconds");
cfg.waves.gapMaxSeconds = requireDouble(tbl["waves"]["gap_max_seconds"], file, "waves.gap_max_seconds"); cfg.waves.gapMaxSeconds = requireDouble(tbl["waves"]["gap_max_seconds"], file, "waves.gap_max_seconds");
cfg.waves.spawnDurationSeconds = requireDouble(tbl["waves"]["spawn_duration_seconds"], file, "waves.spawn_duration_seconds"); cfg.waves.spawnDurationSeconds = requireDouble(tbl["waves"]["spawn_duration_seconds"], file, "waves.spawn_duration_seconds");
@@ -304,6 +311,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;
} }
@@ -332,6 +343,17 @@ 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"));
}
if (mt.contains("tooltip"))
{
def.tooltip = requireString(mt["tooltip"], file, elemPath + ".tooltip");
}
const std::optional<BuildingType> parsedType = parseBuildingType(def.id); const std::optional<BuildingType> parsedType = parseBuildingType(def.id);
if (!parsedType) if (!parsedType)
{ {
@@ -383,6 +405,11 @@ RecipesConfig ConfigLoader::loadRecipes(const std::string& path)
{ {
def.unlockAtStationLevel = static_cast<int>(*level); def.unlockAtStationLevel = static_cast<int>(*level);
} }
if (mt.contains("unlock_requires"))
{
def.unlockRequires = requireStringArray(mt["unlock_requires"], file,
elemPath + ".unlock_requires");
}
} }
// inputs may be omitted (e.g. miner recipes). An empty array is fine. // inputs may be omitted (e.g. miner recipes). An empty array is fine.
@@ -422,6 +449,10 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path)
ShipDef def; ShipDef def;
def.id = requireString(mt["id"], file, elemPath + ".id"); def.id = requireString(mt["id"], file, elemPath + ".id");
def.unlockAtStationLevel = static_cast<int>(requireInt(mt["unlock_at_station_level"], file, elemPath + ".unlock_at_station_level")); def.unlockAtStationLevel = static_cast<int>(requireInt(mt["unlock_at_station_level"], file, elemPath + ".unlock_at_station_level"));
if (mt.contains("unlock_requires"))
{
def.unlockRequires = requireStringArray(mt["unlock_requires"], file, elemPath + ".unlock_requires");
}
def.layout = requireStringArray(mt["layout"], file, elemPath + ".layout"); def.layout = requireStringArray(mt["layout"], file, elemPath + ".layout");
// Schematic // Schematic
@@ -432,8 +463,6 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path)
const toml::array& materials = requireArray(bpMt["materials"], file, bpPath + ".materials"); const toml::array& materials = requireArray(bpMt["materials"], file, bpPath + ".materials");
def.schematic.materials = parseIngredients(materials, file, bpPath + ".materials"); def.schematic.materials = parseIngredients(materials, file, bpPath + ".materials");
def.schematic.playerProductionLevel = static_cast<int>(requireInt(
bpMt["player_production_level"], file, bpPath + ".player_production_level"));
def.schematic.productionTimeSeconds = requireDouble( def.schematic.productionTimeSeconds = requireDouble(
bpMt["production_time_seconds"], file, bpPath + ".production_time_seconds"); bpMt["production_time_seconds"], file, bpPath + ".production_time_seconds");
} }
@@ -443,7 +472,7 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path)
const std::string hPath = elemPath + ".health"; const std::string hPath = elemPath + ".health";
const toml::table& hTable = requireTable(mt["health"], file, hPath); const toml::table& hTable = requireTable(mt["health"], file, hPath);
toml::table& hMt = const_cast<toml::table&>(hTable); toml::table& hMt = const_cast<toml::table&>(hTable);
def.health.hpFormula = requireFormula(hMt["hp_formula"], file, hPath + ".hp_formula"); def.health.hp = static_cast<float>(requireDouble(hMt["hp"], file, hPath + ".hp"));
} }
// Movement // Movement
@@ -451,11 +480,11 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path)
const std::string mPath = elemPath + ".movement"; const std::string mPath = elemPath + ".movement";
const toml::table& mTable = requireTable(mt["movement"], file, mPath); const toml::table& mTable = requireTable(mt["movement"], file, mPath);
toml::table& mMt = const_cast<toml::table&>(mTable); toml::table& mMt = const_cast<toml::table&>(mTable);
def.movement.speedFormula = requireFormula(mMt["speed_mps_formula"], file, mPath + ".speed_mps_formula"); def.movement.speed_mps = static_cast<float>(requireDouble(mMt["speed_mps"], file, mPath + ".speed_mps"));
def.movement.mainAccelerationFormula = requireFormula(mMt["main_acceleration_mpss_formula"], file, mPath + ".main_acceleration_mpss_formula"); def.movement.mainAcceleration_mpss = static_cast<float>(requireDouble(mMt["main_acceleration_mpss"], file, mPath + ".main_acceleration_mpss"));
def.movement.maneuveringAccelerationFormula = requireFormula(mMt["maneuvering_acceleration_mpss_formula"], file, mPath + ".maneuvering_acceleration_mpss_formula"); def.movement.maneuveringAcceleration_mpss = static_cast<float>(requireDouble(mMt["maneuvering_acceleration_mpss"], file, mPath + ".maneuvering_acceleration_mpss"));
def.movement.angularAccelerationFormula = requireFormula(mMt["angular_acceleration_radpss_formula"], file, mPath + ".angular_acceleration_radpss_formula"); def.movement.angularAcceleration_radpss = static_cast<float>(requireDouble(mMt["angular_acceleration_radpss"], file, mPath + ".angular_acceleration_radpss"));
def.movement.maxRotationSpeedFormula = requireFormula(mMt["max_rotation_speed_radps_formula"], file, mPath + ".max_rotation_speed_radps_formula"); def.movement.maxRotationSpeed_radps = static_cast<float>(requireDouble(mMt["max_rotation_speed_radps"], file, mPath + ".max_rotation_speed_radps"));
} }
// Sensor // Sensor
@@ -463,15 +492,7 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path)
const std::string snsPath = elemPath + ".sensor"; const std::string snsPath = elemPath + ".sensor";
const toml::table& snsTable = requireTable(mt["sensor"], file, snsPath); const toml::table& snsTable = requireTable(mt["sensor"], file, snsPath);
toml::table& snsMt = const_cast<toml::table&>(snsTable); toml::table& snsMt = const_cast<toml::table&>(snsTable);
def.sensor.sensorRangeFormula = requireFormula(snsMt["sensor_range_m_formula"], file, snsPath + ".sensor_range_m_formula"); def.sensor.sensorRange_m = static_cast<float>(requireDouble(snsMt["sensor_range_m"], file, snsPath + ".sensor_range_m"));
}
// Loot
{
const std::string lPath = elemPath + ".loot";
const toml::table& lTable = requireTable(mt["loot"], file, lPath);
toml::table& lMt = const_cast<toml::table&>(lTable);
def.loot.scrapDrop = static_cast<int>(requireInt(lMt["scrap_drop"], file, lPath + ".scrap_drop"));
} }
// Optional: default_modules (REQ-WAV-DEFAULT-MODULES) // Optional: default_modules (REQ-WAV-DEFAULT-MODULES)
@@ -583,14 +604,21 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
def.id = requireString(mt["id"], file, elemPath + ".id"); def.id = requireString(mt["id"], file, elemPath + ".id");
def.unlockAtStationLevel = static_cast<int>( def.unlockAtStationLevel = static_cast<int>(
mt["unlock_at_station_level"].value_or<int64_t>(-1)); mt["unlock_at_station_level"].value_or<int64_t>(-1));
if (mt.contains("unlock_requires"))
{
def.unlockRequires = requireStringArray(mt["unlock_requires"], file, elemPath + ".unlock_requires");
}
def.surfaceMask = requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask"); def.surfaceMask = requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask");
def.playerProductionLevel = static_cast<int>(requireInt(
mt["player_production_level"], file, elemPath + ".player_production_level"));
def.productionTimeSeconds = requireDouble( def.productionTimeSeconds = requireDouble(
mt["production_time_seconds"], file, elemPath + ".production_time_seconds"); mt["production_time_seconds"], file, elemPath + ".production_time_seconds");
def.fillColor = requireString(mt["fill_color"], file, elemPath + ".fill_color"); def.fillColor = requireString(mt["fill_color"], file, elemPath + ".fill_color");
def.glyph = requireString(mt["glyph"], file, elemPath + ".glyph"); def.glyph = requireString(mt["glyph"], file, elemPath + ".glyph");
if (mt.contains("tooltip"))
{
def.tooltip = requireString(mt["tooltip"], file, elemPath + ".tooltip");
}
// Materials // Materials
{ {
const toml::array& materials = requireArray(mt["materials"], file, elemPath + ".materials"); const toml::array& materials = requireArray(mt["materials"], file, elemPath + ".materials");
@@ -608,15 +636,15 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
elemPath + "." + se.category); elemPath + "." + se.category);
toml::table& catMt = const_cast<toml::table&>(catTable); toml::table& catMt = const_cast<toml::table&>(catTable);
const std::string addedKey = std::string("added_") + se.stat + se.addedKeySuffix + "_formula"; const std::string addedKey = std::string("added_") + se.stat + se.addedKeySuffix;
const std::string multipliedKey = std::string("multiplied_") + se.stat + se.addedKeySuffix + "_formula"; const std::string multipliedKey = std::string("multiplied_") + se.stat + se.addedKeySuffix;
if (catMt.contains(addedKey)) if (catMt.contains(addedKey))
{ {
ModuleStatModifier mod; ModuleStatModifier mod;
mod.stat = se.stat; mod.stat = se.stat;
mod.modifierType = "additive"; mod.modifierType = "additive";
mod.formula = requireFormula(catMt[addedKey], file, mod.value = requireDouble(catMt[addedKey], file,
elemPath + "." + se.category + "." + addedKey); elemPath + "." + se.category + "." + addedKey);
def.statModifiers.push_back(std::move(mod)); def.statModifiers.push_back(std::move(mod));
} }
@@ -626,7 +654,7 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
ModuleStatModifier mod; ModuleStatModifier mod;
mod.stat = se.stat; mod.stat = se.stat;
mod.modifierType = "multiplicative"; mod.modifierType = "multiplicative";
mod.formula = requireFormula(catMt[multipliedKey], file, mod.value = requireDouble(catMt[multipliedKey], file,
elemPath + "." + se.category + "." + multipliedKey); elemPath + "." + se.category + "." + multipliedKey);
def.statModifiers.push_back(std::move(mod)); def.statModifiers.push_back(std::move(mod));
} }
@@ -638,16 +666,16 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
const std::string wPath = elemPath + ".weapon"; const std::string wPath = elemPath + ".weapon";
const toml::table& wTable = requireTable(mt["weapon"], file, wPath); const toml::table& wTable = requireTable(mt["weapon"], file, wPath);
toml::table& wMt = const_cast<toml::table&>(wTable); toml::table& wMt = const_cast<toml::table&>(wTable);
if (wMt.contains("damage_formula") || wMt.contains("attack_range_m_formula") if (wMt.contains("damage") || wMt.contains("attack_range_m")
|| wMt.contains("attack_rate_hz_formula")) || wMt.contains("attack_rate_hz"))
{ {
ModuleWeaponCapability cap; ModuleWeaponCapability cap;
cap.damageFormula = requireFormula(wMt["damage_formula"], cap.damage = static_cast<float>(requireDouble(wMt["damage"],
file, wPath + ".damage_formula"); file, wPath + ".damage"));
cap.attackRangeFormula = requireFormula(wMt["attack_range_m_formula"], cap.attackRange_m = static_cast<float>(requireDouble(wMt["attack_range_m"],
file, wPath + ".attack_range_m_formula"); file, wPath + ".attack_range_m"));
cap.attackRateFormula = requireFormula(wMt["attack_rate_hz_formula"], cap.attackRate_hz = static_cast<float>(requireDouble(wMt["attack_rate_hz"],
file, wPath + ".attack_rate_hz_formula"); file, wPath + ".attack_rate_hz"));
def.weaponCapability = std::move(cap); def.weaponCapability = std::move(cap);
} }
} }
@@ -658,16 +686,16 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
const std::string sPath = elemPath + ".salvage"; const std::string sPath = elemPath + ".salvage";
const toml::table& sTable = requireTable(mt["salvage"], file, sPath); const toml::table& sTable = requireTable(mt["salvage"], file, sPath);
toml::table& sMt = const_cast<toml::table&>(sTable); toml::table& sMt = const_cast<toml::table&>(sTable);
if (sMt.contains("collection_range_m_formula") || sMt.contains("cargo_capacity_formula") if (sMt.contains("collection_range_m") || sMt.contains("cargo_capacity")
|| sMt.contains("collection_rate_hz_formula")) || sMt.contains("collection_rate_hz"))
{ {
ModuleSalvageCapability cap; ModuleSalvageCapability cap;
cap.collectionRangeFormula = requireFormula(sMt["collection_range_m_formula"], cap.collectionRange_m = static_cast<float>(requireDouble(sMt["collection_range_m"],
file, sPath + ".collection_range_m_formula"); file, sPath + ".collection_range_m"));
cap.cargoCapacityFormula = requireFormula(sMt["cargo_capacity_formula"], cap.cargoCapacity = static_cast<float>(requireDouble(sMt["cargo_capacity"],
file, sPath + ".cargo_capacity_formula"); file, sPath + ".cargo_capacity"));
cap.collectionRateFormula = requireFormula(sMt["collection_rate_hz_formula"], cap.collectionRate_hz = static_cast<float>(requireDouble(sMt["collection_rate_hz"],
file, sPath + ".collection_rate_hz_formula"); file, sPath + ".collection_rate_hz"));
def.salvageCapability = std::move(cap); def.salvageCapability = std::move(cap);
} }
} }
@@ -678,15 +706,15 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
const std::string rPath = elemPath + ".repair"; const std::string rPath = elemPath + ".repair";
const toml::table& rTable = requireTable(mt["repair"], file, rPath); const toml::table& rTable = requireTable(mt["repair"], file, rPath);
toml::table& rMt = const_cast<toml::table&>(rTable); toml::table& rMt = const_cast<toml::table&>(rTable);
if (rMt.contains("repair_rate_hz_formula") || rMt.contains("repair_range_m_formula")) if (rMt.contains("repair_rate_hz") || rMt.contains("repair_range_m"))
{ {
ModuleRepairCapability cap; ModuleRepairCapability cap;
cap.repairRateFormula = requireFormula(rMt["repair_rate_hz_formula"], cap.repairRate_hz = static_cast<float>(requireDouble(rMt["repair_rate_hz"],
file, rPath + ".repair_rate_hz_formula"); file, rPath + ".repair_rate_hz"));
cap.repairAmountHpFormula = requireFormula(rMt["repair_amount_hp_formula"], cap.repairAmountHp = static_cast<float>(requireDouble(rMt["repair_amount_hp"],
file, rPath + ".repair_amount_hp_formula"); file, rPath + ".repair_amount_hp"));
cap.repairRangeFormula = requireFormula(rMt["repair_range_m_formula"], cap.repairRange_m = static_cast<float>(requireDouble(rMt["repair_range_m"],
file, rPath + ".repair_range_m_formula"); file, rPath + ".repair_range_m"));
def.repairCapability = std::move(cap); def.repairCapability = std::move(cap);
} }
} }
@@ -697,6 +725,67 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
return cfg; return cfg;
} }
namespace
{
// Throws if any id in requiredIds is not a valid explicitly-unlockable schematic.
void checkUnlockRequires(const std::vector<std::string>& requiredIds,
const std::unordered_set<std::string>& schematicIds,
const std::string& file,
const std::string& path)
{
for (const std::string& requiredId : requiredIds)
{
if (schematicIds.count(requiredId) == 0)
{
throw makeError(file, path,
"references unknown schematic '" + requiredId + "'");
}
}
}
// Validates that every id listed in an `unlock_requires` (REQ-LOCK-PREREQ)
// resolves to an explicitly-unlockable schematic: a ship, a module, or an
// assembler recipe that carries `unlock_at_station_level` (a recipe schematic,
// per REQ-LOCK-EXPLICIT). An unresolved id is a config error surfaced at load.
void validateUnlockRequires(const GameConfig& cfg)
{
std::unordered_set<std::string> schematicIds;
for (const ShipDef& def : cfg.ships.ships)
{
schematicIds.insert(def.id);
}
for (const ModuleDef& def : cfg.modules.modules)
{
schematicIds.insert(def.id);
}
for (const RecipeDef& def : cfg.recipes.recipes)
{
if (def.building == BuildingType::Assembler && def.unlockAtStationLevel.has_value())
{
schematicIds.insert(def.id);
}
}
for (const ShipDef& def : cfg.ships.ships)
{
checkUnlockRequires(def.unlockRequires, schematicIds, "ships.toml",
"ship '" + def.id + "'.unlock_requires");
}
for (const ModuleDef& def : cfg.modules.modules)
{
checkUnlockRequires(def.unlockRequires, schematicIds, "modules.toml",
"module '" + def.id + "'.unlock_requires");
}
for (const RecipeDef& def : cfg.recipes.recipes)
{
checkUnlockRequires(def.unlockRequires, schematicIds, "recipes.toml",
"recipe '" + def.id + "'.unlock_requires");
}
}
} // namespace
GameConfig ConfigLoader::loadFromDirectory(const std::string& configDir) GameConfig ConfigLoader::loadFromDirectory(const std::string& configDir)
{ {
GameConfig cfg; GameConfig cfg;
@@ -706,6 +795,7 @@ GameConfig ConfigLoader::loadFromDirectory(const std::string& configDir)
cfg.ships = loadShips(configDir + "/ships.toml"); cfg.ships = loadShips(configDir + "/ships.toml");
cfg.stations = loadStations(configDir + "/stations.toml"); cfg.stations = loadStations(configDir + "/stations.toml");
cfg.modules = loadModules(configDir + "/modules.toml"); cfg.modules = loadModules(configDir + "/modules.toml");
validateUnlockRequires(cfg);
cfg.threatCosts = computeThreatCostTable(cfg); cfg.threatCosts = computeThreatCostTable(cfg);
return cfg; return cfg;
} }

View File

@@ -4,7 +4,6 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "Formula.h"
#include "RecipesConfig.h" #include "RecipesConfig.h"
// A single stat modifier contributed by a module instance. // A single stat modifier contributed by a module instance.
@@ -13,38 +12,40 @@ struct ModuleStatModifier
{ {
std::string stat; // e.g. "hp", "speed", "sensor_range" std::string stat; // e.g. "hp", "speed", "sensor_range"
std::string modifierType; // "additive" or "multiplicative" std::string modifierType; // "additive" or "multiplicative"
Formula formula; double value;
}; };
// Capability sections — present when the module grants that capability. // Capability sections — present when the module grants that capability.
struct ModuleWeaponCapability struct ModuleWeaponCapability
{ {
Formula damageFormula; float damage;
Formula attackRangeFormula; float attackRange_m;
Formula attackRateFormula; float attackRate_hz;
}; };
struct ModuleSalvageCapability struct ModuleSalvageCapability
{ {
Formula collectionRangeFormula; float collectionRange_m;
Formula cargoCapacityFormula; float cargoCapacity;
Formula collectionRateFormula; float collectionRate_hz;
}; };
struct ModuleRepairCapability struct ModuleRepairCapability
{ {
Formula repairRateFormula; // repair cycles per second float repairRate_hz; // repair cycles per second
Formula repairAmountHpFormula; // HP restored per cycle float repairAmountHp; // HP restored per cycle
Formula repairRangeFormula; float repairRange_m;
}; };
struct ModuleDef struct ModuleDef
{ {
std::string id; std::string id;
int unlockAtStationLevel; int unlockAtStationLevel;
// Prerequisite schematic ids that must be explicitly unlocked before this
// schematic can enter the drop pool (REQ-LOCK-PREREQ). Empty = none.
std::vector<std::string> unlockRequires;
std::vector<std::string> surfaceMask; std::vector<std::string> surfaceMask;
std::vector<RecipeIngredient> materials; std::vector<RecipeIngredient> materials;
int playerProductionLevel;
double productionTimeSeconds; double productionTimeSeconds;
std::string fillColor; std::string fillColor;
std::string glyph; std::string glyph;
@@ -53,6 +54,10 @@ struct ModuleDef
std::optional<ModuleWeaponCapability> weaponCapability; std::optional<ModuleWeaponCapability> weaponCapability;
std::optional<ModuleSalvageCapability> salvageCapability; std::optional<ModuleSalvageCapability> salvageCapability;
std::optional<ModuleRepairCapability> repairCapability; std::optional<ModuleRepairCapability> repairCapability;
// Optional hover-tooltip text for the module selection button
// (REQ-MOD-UI-MODULE-TOOLTIP).
std::optional<std::string> tooltip;
}; };
struct ModulesConfig struct ModulesConfig

View File

@@ -36,6 +36,10 @@ struct RecipeDef
// at game start. >= 0 = locked; schematic enters drop pool at that station // at game start. >= 0 = locked; schematic enters drop pool at that station
// level once the output item is implicitly unlocked (REQ-LOCK-EXPLICIT). // level once the output item is implicitly unlocked (REQ-LOCK-EXPLICIT).
std::optional<int> unlockAtStationLevel; std::optional<int> unlockAtStationLevel;
// Assembler recipe schematics only. Prerequisite schematic ids that must be
// explicitly unlocked before this schematic can enter the drop pool
// (REQ-LOCK-PREREQ). Empty = none.
std::vector<std::string> unlockRequires;
}; };
struct RecipesConfig struct RecipesConfig

View File

@@ -3,55 +3,48 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "Formula.h"
#include "RecipesConfig.h" // for RecipeIngredient #include "RecipesConfig.h" // for RecipeIngredient
#include "ShipLayout.h" // for PlacedModule #include "ShipLayout.h" // for PlacedModule
// Build materials and initial per-schematic production level // Build materials and base production time (REQ-BLD-SHIPYARD, REQ-DEF-SCHEMATIC-DROP).
// (REQ-BLD-SHIPYARD, REQ-DEF-SCHEMATIC-DROP).
struct ShipSchematic struct ShipSchematic
{ {
std::vector<RecipeIngredient> materials; std::vector<RecipeIngredient> materials;
int playerProductionLevel;
double productionTimeSeconds; double productionTimeSeconds;
}; };
struct ShipHealth struct ShipHealth
{ {
Formula hpFormula; // REQ-SHP-STATS float hp; // REQ-SHP-STATS
}; };
struct ShipMovement struct ShipMovement
{ {
Formula speedFormula; // max linear speed cap, tiles/s (REQ-SHP-STATS, REQ-SHP-MOVEMENT) float speed_mps; // max linear speed cap, m/s (REQ-SHP-STATS, REQ-SHP-MOVEMENT)
Formula mainAccelerationFormula; // forward acceleration, tiles/s² float mainAcceleration_mpss; // forward acceleration, m/s²
Formula maneuveringAccelerationFormula;// omnidirectional acceleration, tiles/s² float maneuveringAcceleration_mpss;// omnidirectional acceleration, m/s²
Formula angularAccelerationFormula; // angular acceleration, rad/s² float angularAcceleration_radpss; // angular acceleration, rad/s²
Formula maxRotationSpeedFormula; // angular velocity cap, rad/s float maxRotationSpeed_radps; // angular velocity cap, rad/s
}; };
struct ShipSensor struct ShipSensor
{ {
Formula sensorRangeFormula; // REQ-SHP-SENSOR, REQ-SHP-STATS float sensorRange_m; // REQ-SHP-SENSOR, REQ-SHP-STATS
};
// Scrap dropped on destruction (REQ-RES-SCRAP-DROP).
struct ShipLoot
{
int scrapDrop;
}; };
struct ShipDef struct ShipDef
{ {
std::string id; std::string id;
int unlockAtStationLevel; int unlockAtStationLevel;
// Prerequisite schematic ids that must be explicitly unlocked before this
// schematic can enter the drop pool (REQ-LOCK-PREREQ). Empty = none.
std::vector<std::string> unlockRequires;
std::vector<std::string> layout; std::vector<std::string> layout;
ShipSchematic schematic; ShipSchematic schematic;
ShipHealth health; ShipHealth health;
ShipMovement movement; ShipMovement movement;
ShipSensor sensor; ShipSensor sensor;
ShipLoot loot;
// Module layout used for enemy wave ships (REQ-WAV-DEFAULT-MODULES). // Module layout used for enemy wave ships (REQ-WAV-DEFAULT-MODULES).
std::vector<PlacedModule> defaultModules; std::vector<PlacedModule> defaultModules;

View File

@@ -1,5 +1,8 @@
#pragma once #pragma once
#include <optional>
#include <string>
#include "Formula.h" #include "Formula.h"
// Region widths are in tiles (REQ-GW-REGIONS). // Region widths are in tiles (REQ-GW-REGIONS).
@@ -14,8 +17,8 @@ struct WorldRegions
// Asteroid expansion (REQ-EXP-UNLOCK, REQ-EXP-COST). // Asteroid expansion (REQ-EXP-UNLOCK, REQ-EXP-COST).
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).
@@ -29,7 +32,6 @@ struct WorldPush
struct WorldWaves struct WorldWaves
{ {
Formula threatRateFormula; // threat/s as a function of boss wave counter x Formula threatRateFormula; // threat/s as a function of boss wave counter x
Formula shipLevelFormula; // enemy ship level as a function of boss wave counter x
double gapMinSeconds; double gapMinSeconds;
double gapMaxSeconds; double gapMaxSeconds;
double spawnDurationSeconds; double spawnDurationSeconds;
@@ -54,12 +56,21 @@ 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
int refundPercentage; // REQ-BLD-DEMOLISH int refundPercentage; // REQ-BLD-DEMOLISH
int startingBuildingBlocks; // REQ-HQ-STARTING-BLOCKS int startingBuildingBlocks; // REQ-HQ-STARTING-BLOCKS
double scrapDespawnSeconds; // REQ-RES-SCRAP-DROP double scrapDespawnSeconds; // REQ-RES-SCRAP-DROP
double scrapPerThreat; // REQ-RES-SCRAP-DROP, REQ-THREAT-SCRAP (scrap dropped per unit threat)
double tileSize_m; // metres per tile (REQ-GW-TILE-SIZE) double tileSize_m; // metres per tile (REQ-GW-TILE-SIZE)
double beltSpeed_tps; // REQ-GW-BELT-SPEED (tiles/s, converted from m/s in config) double beltSpeed_tps; // REQ-GW-BELT-SPEED (tiles/s, converted from m/s in config)
int tunnelMaxDistance_tiles; // REQ-BLD-TUNNEL-PAIR int tunnelMaxDistance_tiles; // REQ-BLD-TUNNEL-PAIR
@@ -67,10 +78,15 @@ struct WorldConfig
double orbitFactor; // REQ-SHP-ORBIT (multiplies tool range for orbit radius) double orbitFactor; // REQ-SHP-ORBIT (multiplies tool range for orbit radius)
double rallyOrbitRadius_tiles; // REQ-SHP-ORBIT (fixed orbit radius around the rally point) double rallyOrbitRadius_tiles; // REQ-SHP-ORBIT (fixed orbit radius around the rally point)
// Optional hover-tooltip for the header building blocks stock display
// (REQ-UI-BLOCKS-TOOLTIP). Presentation-only; the simulation ignores it.
std::optional<std::string> buildingBlocksTooltip;
WorldRegions regions; WorldRegions regions;
WorldExpansion expansion; WorldExpansion expansion;
WorldPush push; WorldPush push;
WorldWaves waves; WorldWaves waves;
WorldTargeting targeting; WorldTargeting targeting;
WorldArtifacts artifacts; WorldArtifacts artifacts;
WorldScroll scroll;
}; };

View File

@@ -42,7 +42,7 @@ entt::entity EntityAdmin::spawnShip(QVector2D position, float hp, float maxHp,
float maxSpeed_tpt, float mainAcceleration_tptt, float maxSpeed_tpt, float mainAcceleration_tptt,
float maneuveringAcceleration_tptt, float maxAngularAcceleration_rptt, float maneuveringAcceleration_tptt, float maxAngularAcceleration_rptt,
float maxRotationSpeed_rpt, float sensorRange_tiles, float maxRotationSpeed_rpt, float sensorRange_tiles,
int level, const std::string& schematicId, bool isEnemy) const std::string& schematicId, bool isEnemy)
{ {
entt::entity entity = createEntity(); entt::entity entity = createEntity();
add<PositionComponent>(entity, PositionComponent{position}); add<PositionComponent>(entity, PositionComponent{position});
@@ -61,7 +61,7 @@ entt::entity EntityAdmin::spawnShip(QVector2D position, float hp, float maxHp,
0.0f // angularAcceleration_rptt 0.0f // angularAcceleration_rptt
}); });
add<SensorRangeComponent>(entity, SensorRangeComponent{sensorRange_tiles}); add<SensorRangeComponent>(entity, SensorRangeComponent{sensorRange_tiles});
add<ShipIdentityComponent>(entity, ShipIdentityComponent{level, schematicId}); add<ShipIdentityComponent>(entity, ShipIdentityComponent{schematicId});
add<MovementIntentComponent>(entity, MovementIntentComponent{0, QVector2D(0.0f, 0.0f)}); add<MovementIntentComponent>(entity, MovementIntentComponent{0, QVector2D(0.0f, 0.0f)});
return entity; return entity;
} }

View File

@@ -56,7 +56,7 @@ public:
float maxSpeed_tpt, float mainAcceleration_tptt, float maxSpeed_tpt, float mainAcceleration_tptt,
float maneuveringAcceleration_tptt, float maxAngularAcceleration_rptt, float maneuveringAcceleration_tptt, float maxAngularAcceleration_rptt,
float maxRotationSpeed_rpt, float sensorRange_tiles, float maxRotationSpeed_rpt, float sensorRange_tiles,
int level, const std::string& schematicId, bool isEnemy); const std::string& schematicId, bool isEnemy);
entt::entity spawnStation(QPoint anchor, QSize footprint, entt::entity spawnStation(QPoint anchor, QSize footprint,
const std::vector<QPoint>& bodyCells, const std::vector<QPoint>& bodyCells,

View File

@@ -19,11 +19,9 @@ struct SchematicChoiceOption
std::string schematicId; std::string schematicId;
SchematicType type; SchematicType type;
std::string displayName; std::string displayName;
bool isNewUnlock;
int targetLevel;
// 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

@@ -4,6 +4,8 @@
struct ShipIdentityComponent struct ShipIdentityComponent
{ {
int level;
std::string schematicId; std::string schematicId;
// Scrap dropped on destruction, derived from the ship's as-built threat cost
// at spawn time (REQ-RES-SCRAP-DROP).
int scrapDrop = 0;
}; };

View File

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

View File

@@ -15,6 +15,7 @@ struct ScrapInfo
{ {
entt::entity entity; entt::entity entity;
QVector2D position; QVector2D position;
int amount;
}; };
class ScrapSystem class ScrapSystem

View File

@@ -1,6 +1,8 @@
#include "ShipSystem.h" #include "ShipSystem.h"
#include <algorithm>
#include <cassert> #include <cassert>
#include <cmath>
#include <map> #include <map>
#include <stdexcept> #include <stdexcept>
#include <utility> #include <utility>
@@ -26,7 +28,9 @@
#include "SalvagerComponent.h" #include "SalvagerComponent.h"
#include "SelectedBehaviorComponent.h" #include "SelectedBehaviorComponent.h"
#include "SensorRangeComponent.h" #include "SensorRangeComponent.h"
#include "ShipIdentityComponent.h"
#include "StandbyBehavior.h" #include "StandbyBehavior.h"
#include "ThreatCostCalculator.h"
#include "Tick.h" #include "Tick.h"
#include "tracing.h" #include "tracing.h"
#include "WeaponComponent.h" #include "WeaponComponent.h"
@@ -61,48 +65,46 @@ const ModuleDef* ShipSystem::findModuleDef(const std::string& id) const
return nullptr; return nullptr;
} }
entt::entity ShipSystem::spawn(const std::string& schematicId, int level, entt::entity ShipSystem::spawn(const std::string& schematicId,
QVector2D position, bool isEnemy, QVector2D position, bool isEnemy,
const std::optional<ShipLayoutConfig>& layout, const std::optional<ShipLayoutConfig>& layout)
const std::map<std::string, int>& moduleLevelOverrides)
{ {
const ShipDef* def = findShipDef(schematicId); const ShipDef* def = findShipDef(schematicId);
assert(def != nullptr); assert(def != nullptr);
const double x = static_cast<double>(level);
const float tickRate = static_cast<float>(kTickRateHz); const float tickRate = static_cast<float>(kTickRateHz);
const float tileSize = static_cast<float>(m_config.world.tileSize_m); const float tileSize = static_cast<float>(m_config.world.tileSize_m);
float hp = static_cast<float>(def->health.hpFormula.evaluate(x)); float hp = def->health.hp;
float maxHp = hp; float maxHp = hp;
float maxSpeed_tpt = static_cast<float>(def->movement.speedFormula.evaluate(x)) float maxSpeed_tpt = def->movement.speed_mps / tileSize / tickRate;
/ tileSize / tickRate; float mainAcceleration_tptt = def->movement.mainAcceleration_mpss / tileSize / tickRate;
float mainAcceleration_tptt = static_cast<float>( float maneuveringAcceleration_tptt = def->movement.maneuveringAcceleration_mpss
def->movement.mainAccelerationFormula.evaluate(x))
/ tileSize / tickRate;
float maneuveringAcceleration_tptt = static_cast<float>(
def->movement.maneuveringAccelerationFormula.evaluate(x))
/ tileSize / tickRate; / tileSize / tickRate;
float maxAngularAcceleration_rptt = static_cast<float>( float maxAngularAcceleration_rptt = def->movement.angularAcceleration_radpss / tickRate;
def->movement.angularAccelerationFormula.evaluate(x)) float maxRotationSpeed_rpt = def->movement.maxRotationSpeed_radps / tickRate;
/ tickRate; float sensorRange_tiles = def->sensor.sensorRange_m / tileSize;
float maxRotationSpeed_rpt = static_cast<float>(
def->movement.maxRotationSpeedFormula.evaluate(x))
/ tickRate;
float sensorRange_tiles = static_cast<float>(
def->sensor.sensorRangeFormula.evaluate(x))
/ tileSize;
entt::entity entity = m_admin.spawnShip( entt::entity entity = m_admin.spawnShip(
position, hp, maxHp, position, hp, maxHp,
maxSpeed_tpt, mainAcceleration_tptt, maneuveringAcceleration_tptt, maxSpeed_tpt, mainAcceleration_tptt, maneuveringAcceleration_tptt,
maxAngularAcceleration_rptt, maxRotationSpeed_rpt, sensorRange_tiles, maxAngularAcceleration_rptt, maxRotationSpeed_rpt, sensorRange_tiles,
level, schematicId, isEnemy); schematicId, isEnemy);
// Determine module list: configured layout takes precedence over default. // Determine module list: configured layout takes precedence over default.
const std::vector<PlacedModule>& modules = const std::vector<PlacedModule>& modules =
layout.has_value() ? layout->placedModules : def->defaultModules; layout.has_value() ? layout->placedModules : def->defaultModules;
// Derive the scrap dropped on destruction from the ship's as-built threat cost
// (REQ-RES-SCRAP-DROP): round(threat * scrap_per_threat), floored at 1 for any
// ship with threat > 0. Computed once here since threat is level-independent.
const double threatCost = calculateShipThreatCost(m_config.threatCosts, m_config,
schematicId, modules);
const int scrapDrop = threatCost > 0.0
? std::max(1, static_cast<int>(std::lround(threatCost * m_config.world.scrapPerThreat)))
: 0;
m_admin.get<ShipIdentityComponent>(entity).scrapDrop = scrapDrop;
// --- Pass 1: create capability child entities ---------------------------- // --- Pass 1: create capability child entities ----------------------------
std::vector<entt::entity> weaponChildren; std::vector<entt::entity> weaponChildren;
std::vector<entt::entity> salvageChildren; std::vector<entt::entity> salvageChildren;
@@ -117,19 +119,12 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
const ModuleDef* modDef = findModuleDef(pm.moduleId); const ModuleDef* modDef = findModuleDef(pm.moduleId);
if (!modDef) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); } if (!modDef) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); }
const auto overIt = moduleLevelOverrides.find(pm.moduleId);
const double mx = static_cast<double>(
overIt != moduleLevelOverrides.end() ? overIt->second : modDef->playerProductionLevel);
if (modDef->weaponCapability) if (modDef->weaponCapability)
{ {
WeaponComponent w; WeaponComponent w;
w.damage = static_cast<float>( w.damage = modDef->weaponCapability->damage;
modDef->weaponCapability->damageFormula.evaluate(mx)); w.range_tiles = modDef->weaponCapability->attackRange_m / tileSize;
w.range_tiles = static_cast<float>( w.fireRateHz = modDef->weaponCapability->attackRate_hz;
modDef->weaponCapability->attackRangeFormula.evaluate(mx)) / tileSize;
w.fireRateHz = static_cast<float>(
modDef->weaponCapability->attackRateFormula.evaluate(mx));
w.cooldownTicks = 0.0f; w.cooldownTicks = 0.0f;
w.currentTarget = std::nullopt; w.currentTarget = std::nullopt;
@@ -141,12 +136,11 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
if (modDef->salvageCapability) if (modDef->salvageCapability)
{ {
cargoCapacityBase += modDef->salvageCapability->cargoCapacityFormula.evaluate(mx); cargoCapacityBase += modDef->salvageCapability->cargoCapacity;
SalvagerComponent salvager; SalvagerComponent salvager;
salvager.collectionRange_tiles = static_cast<float>( salvager.collectionRange_tiles = modDef->salvageCapability->collectionRange_m / tileSize;
modDef->salvageCapability->collectionRangeFormula.evaluate(mx)) / tileSize; const double rate = modDef->salvageCapability->collectionRate_hz;
const double rate = modDef->salvageCapability->collectionRateFormula.evaluate(mx);
salvager.collectionIntervalTicks = (rate > 0.0) salvager.collectionIntervalTicks = (rate > 0.0)
? static_cast<int>(kTickRateHz / rate + 0.5) ? static_cast<int>(kTickRateHz / rate + 0.5)
: 0; : 0;
@@ -161,16 +155,13 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
if (modDef->repairCapability) if (modDef->repairCapability)
{ {
RepairToolComponent rt; RepairToolComponent rt;
const double repairRateHz = const double repairRateHz = modDef->repairCapability->repairRate_hz;
modDef->repairCapability->repairRateFormula.evaluate(mx);
rt.repairIntervalTicks = (repairRateHz > 0.0) rt.repairIntervalTicks = (repairRateHz > 0.0)
? static_cast<int>(kTickRateHz / repairRateHz + 0.5) ? static_cast<int>(kTickRateHz / repairRateHz + 0.5)
: 0; : 0;
rt.repairAmountHp = static_cast<float>( rt.repairAmountHp = modDef->repairCapability->repairAmountHp;
modDef->repairCapability->repairAmountHpFormula.evaluate(mx));
rt.cooldownTicksRemaining = 0; rt.cooldownTicksRemaining = 0;
rt.range_tiles = static_cast<float>( rt.range_tiles = modDef->repairCapability->repairRange_m / tileSize;
modDef->repairCapability->repairRangeFormula.evaluate(mx)) / tileSize;
rt.currentTarget = std::nullopt; rt.currentTarget = std::nullopt;
entt::entity child = m_admin.createModuleEntity(); entt::entity child = m_admin.createModuleEntity();
@@ -196,13 +187,9 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
const ModuleDef* modDef = findModuleDef(pm.moduleId); const ModuleDef* modDef = findModuleDef(pm.moduleId);
if (!modDef) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); } if (!modDef) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); }
const auto overIt2 = moduleLevelOverrides.find(pm.moduleId);
const double mx = static_cast<double>(
overIt2 != moduleLevelOverrides.end() ? overIt2->second : modDef->playerProductionLevel);
for (const ModuleStatModifier& sm : modDef->statModifiers) for (const ModuleStatModifier& sm : modDef->statModifiers)
{ {
const double val = sm.formula.evaluate(mx); const double val = sm.value;
// Route modifier to the correct accumulator by stat category. // Route modifier to the correct accumulator by stat category.
// weapon/salvage/repair stats go to the corresponding child map; // weapon/salvage/repair stats go to the corresponding child map;

View File

@@ -18,10 +18,9 @@ class ShipSystem
public: public:
ShipSystem(const GameConfig& config, EntityAdmin& admin); ShipSystem(const GameConfig& config, EntityAdmin& admin);
entt::entity spawn(const std::string& schematicId, int level, QVector2D position, entt::entity spawn(const std::string& schematicId, QVector2D position,
bool isEnemy = false, bool isEnemy = false,
const std::optional<ShipLayoutConfig>& layout = std::nullopt, const std::optional<ShipLayoutConfig>& layout = std::nullopt);
const std::map<std::string, int>& moduleLevelOverrides = {});
void despawn(entt::entity entity); void despawn(entt::entity entity);
// Reset all movement intents to inactive before behavior systems run. // Reset all movement intents to inactive before behavior systems run.

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

View File

@@ -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
{
};

45
src/lib/sim/BeltSlot.cpp Normal file
View File

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

30
src/lib/sim/BeltSlot.h Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@
#include <vector> #include <vector>
#include <QPoint> #include <QPoint>
#include <QPointF>
#include <QVector2D> #include <QVector2D>
#include "BeltSystem.h" #include "BeltSystem.h"
@@ -59,6 +60,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.
@@ -89,7 +95,10 @@ public:
void tickBeltPull(); void tickBeltPull();
void tickProduction(Tick currentTick); void tickProduction(Tick currentTick);
void tickShipyardProduction(Tick currentTick); void tickShipyardProduction(Tick currentTick);
void tickBeltPush(); // Advances each building's virtual output belts, hands finished items off onto
// the adjacent real belt, and feeds new buffered items into them
// (REQ-MAT-OUTPUT-EMERGE).
void tickOutputBelts();
// -- Queries ------------------------------------------------------------- // -- Queries -------------------------------------------------------------
struct BeltTileInfo struct BeltTileInfo
@@ -116,6 +125,19 @@ public:
std::vector<BeltTileInfo> allBeltTiles() const; std::vector<BeltTileInfo> allBeltTiles() const;
bool isTileOccupied(QPoint tile) const; bool isTileOccupied(QPoint tile) const;
// Visits every item currently emerging from a building output port on its
// virtual output belt (REQ-MAT-OUTPUT-EMERGE), passing the item type and its
// world-space centre (in tile units). Least-progressed first (drawn bottom) so
// callers can paint in visit order (REQ-GW-TILE-SIZE ordering).
void forEachEmergingItem(
const std::function<void(const ItemType&, QPointF)>& visit) const;
// Visits every item currently travelling inward on a building input port's
// virtual input belt (REQ-MAT-INPUT-INTAKE), passing the item type and its
// world-space centre (in tile units). Least-progressed first (drawn bottom).
void forEachIncomingItem(
const std::function<void(const ItemType&, QPointF)>& visit) const;
// Returns the entity id of the building or construction site whose footprint // Returns the entity id of the building or construction site whose footprint
// exactly coincides with the ghost (type, anchor, rot) and is of the same // exactly coincides with the ghost (type, anchor, rot) and is of the same
// building type. Returns nullopt otherwise. // building type. Returns nullopt otherwise.
@@ -159,12 +181,36 @@ public:
void appendChecksum(Hasher& hasher) const; void appendChecksum(Hasher& hasher) const;
private: private:
Building* findBuildingMutable(BuildingId id);
// True if the consumer would accept `type` at the given input port right now:
// it is a required input (or a building block for the HQ), the reservation-aware
// buffer has room, and the input belt entry is free (REQ-MAT-INPUT-INTAKE).
bool canAcceptInput(const Building& consumer,
std::size_t inputPortIndex,
const ItemType& type) const;
// Places an accepted item onto the consumer's input belt at progress 0.0,
// reserving a per-material buffer slot (REQ-MAT-INPUT-INTAKE).
void depositToInputBelt(Building& consumer,
std::size_t inputPortIndex,
const Item& item);
// Attempts to hand an emerging output item straight into a directly adjacent
// building whose input edge meets the producer's output port (REQ-MAT-DIRECT-COUPLE).
// Returns true if the item was accepted onto the consumer's input belt.
bool tryDirectCoupleDeposit(BuildingId producerId,
const Port& outputPort,
const Item& item);
const BuildingDef* findBuildingDef(BuildingType type) const; const BuildingDef* findBuildingDef(BuildingType type) const;
const RecipeDef* findRecipe(const std::string& id, BuildingType type) const; const RecipeDef* findRecipe(const std::string& id, BuildingType type) const;
const ShipDef* findShipDef(const std::string& id) const; const ShipDef* findShipDef(const std::string& id) const;
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;
// Buffers for an auto-recipe building (Smelter, Reprocessing Plant): input
// caps span the union of every recipe of the building's type; no player
// recipe is selected (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING).
void initAutoBuffers(Building& b) const;
void initShipyardBuffers(Building& b) const; void 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 +225,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

@@ -8,8 +8,10 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.h ${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.h
${CMAKE_CURRENT_SOURCE_DIR}/ReplayPlayer.h ${CMAKE_CURRENT_SOURCE_DIR}/ReplayPlayer.h
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.h ${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.h
${CMAKE_CURRENT_SOURCE_DIR}/BeltSlot.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
@@ -30,7 +32,9 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.cpp
${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}/BeltSlot.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

@@ -1,9 +1,11 @@
#include "EntityHitTest.h" #include "EntityHitTest.h"
#include <algorithm>
#include <cmath> #include <cmath>
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include "PositionComponent.h" #include "PositionComponent.h"
#include "ScrapDataComponent.h"
#include "StationBodyComponent.h" #include "StationBodyComponent.h"
#include "HealthComponent.h" #include "HealthComponent.h"
@@ -54,3 +56,48 @@ entt::entity entityAtWorldPos(EntityAdmin& admin, QVector2D worldPos)
return bestShip; return bestShip;
} }
entt::entity scrapAtWorldPos(EntityAdmin& admin, QVector2D worldPos)
{
// Slightly larger than the scrap's rendered radius (0.2 tiles) so small piles
// remain easy to click; tunable.
constexpr float kScrapHitRadiusSquared = 0.35f * 0.35f;
entt::entity bestScrap = entt::null;
float bestDistSquared = kScrapHitRadiusSquared;
admin.forEach<ScrapDataComponent, PositionComponent>(
[&](entt::entity entity, const ScrapDataComponent& /*sd*/, const PositionComponent& pos)
{
const float dx = pos.value.x() - worldPos.x();
const float dy = pos.value.y() - worldPos.y();
const float distSquared = dx * dx + dy * dy;
if (distSquared < bestDistSquared)
{
bestDistSquared = distSquared;
bestScrap = entity;
}
});
return bestScrap;
}
std::vector<entt::entity> scrapInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB)
{
const int minX = std::min(tileA.x(), tileB.x());
const int maxX = std::max(tileA.x(), tileB.x());
const int minY = std::min(tileA.y(), tileB.y());
const int maxY = std::max(tileA.y(), tileB.y());
std::vector<entt::entity> result;
admin.forEach<ScrapDataComponent, PositionComponent>(
[&](entt::entity entity, const ScrapDataComponent& /*sd*/, const PositionComponent& pos)
{
const int tileX = static_cast<int>(std::floor(pos.value.x()));
const int tileY = static_cast<int>(std::floor(pos.value.y()));
if (tileX >= minX && tileX <= maxX && tileY >= minY && tileY <= maxY)
{
result.push_back(entity);
}
});
return result;
}

View File

@@ -1,5 +1,8 @@
#pragma once #pragma once
#include <vector>
#include <QPoint>
#include <QVector2D> #include <QVector2D>
#include "entt/entity/entity.hpp" #include "entt/entity/entity.hpp"
@@ -7,3 +10,12 @@
class EntityAdmin; class EntityAdmin;
entt::entity entityAtWorldPos(EntityAdmin& admin, QVector2D worldPos); entt::entity entityAtWorldPos(EntityAdmin& admin, QVector2D worldPos);
// Returns the nearest scrap pile whose center is within the scrap pick radius of
// worldPos, or entt::null if none (REQ-UI-SCRAP-CLICK-SELECT). Scrap is picked only
// after actors: entityAtWorldPos never returns scrap (scrap has no HealthComponent).
entt::entity scrapAtWorldPos(EntityAdmin& admin, QVector2D worldPos);
// Returns every scrap pile whose position falls within the inclusive tile rectangle
// spanned by tileA and tileB, in any corner order (REQ-UI-SCRAP-MULTI-SELECT).
std::vector<entt::entity> scrapInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB);

View File

@@ -17,9 +17,7 @@
ShipStats calculateShipStats(const GameConfig& config, ShipStats calculateShipStats(const GameConfig& config,
const std::string& shipId, const std::string& shipId,
int level, const std::vector<PlacedModule>& modules)
const std::vector<PlacedModule>& modules,
const std::map<std::string, int>& moduleLevelOverrides)
{ {
ShipStats result{}; ShipStats result{};
@@ -39,24 +37,20 @@ ShipStats calculateShipStats(const GameConfig& config,
return nullptr; return nullptr;
}; };
const double x = static_cast<double>(level);
const double tileSize = config.world.tileSize_m; const double tileSize = config.world.tileSize_m;
// --- Base hull stats (convert from SI to display units) ------------------ // --- Base hull stats (convert from SI to display units) ------------------
result.hp = static_cast<float>( result.hp = shipDef->health.hp;
shipDef->health.hpFormula.evaluate(x));
result.maxSpeed_tps = static_cast<float>( result.maxSpeed_tps = static_cast<float>(
shipDef->movement.speedFormula.evaluate(x) / tileSize); shipDef->movement.speed_mps / tileSize);
result.sensorRange_tiles = static_cast<float>( result.sensorRange_tiles = static_cast<float>(
shipDef->sensor.sensorRangeFormula.evaluate(x) / tileSize); shipDef->sensor.sensorRange_m / tileSize);
result.mainAcceleration_tpss = static_cast<float>( result.mainAcceleration_tpss = static_cast<float>(
shipDef->movement.mainAccelerationFormula.evaluate(x) / tileSize); shipDef->movement.mainAcceleration_mpss / tileSize);
result.maneuveringAcceleration_tpss = static_cast<float>( result.maneuveringAcceleration_tpss = static_cast<float>(
shipDef->movement.maneuveringAccelerationFormula.evaluate(x) / tileSize); shipDef->movement.maneuveringAcceleration_mpss / tileSize);
result.angularAcceleration_radpss = static_cast<float>( result.angularAcceleration_radpss = shipDef->movement.angularAcceleration_radpss;
shipDef->movement.angularAccelerationFormula.evaluate(x)); result.maxRotationSpeed_radps = shipDef->movement.maxRotationSpeed_radps;
result.maxRotationSpeed_radps = static_cast<float>(
shipDef->movement.maxRotationSpeedFormula.evaluate(x));
// --- Pass 1: base capability stats per module instance ------------------- // --- Pass 1: base capability stats per module instance -------------------
struct WeaponInstance { float damage; float range_tiles; float rate_hz; }; struct WeaponInstance { float damage; float range_tiles; float rate_hz; };
@@ -76,33 +70,29 @@ ShipStats calculateShipStats(const GameConfig& config,
const ModuleDef* def = findModuleDef(pm.moduleId); const ModuleDef* def = findModuleDef(pm.moduleId);
if (!def) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); } if (!def) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); }
const auto overIt = moduleLevelOverrides.find(pm.moduleId);
const double mx = static_cast<double>(
overIt != moduleLevelOverrides.end() ? overIt->second : def->playerProductionLevel);
if (def->weaponCapability) if (def->weaponCapability)
{ {
WeaponInstance wi; WeaponInstance wi;
wi.damage = static_cast<float>(def->weaponCapability->damageFormula.evaluate(mx)); wi.damage = def->weaponCapability->damage;
wi.range_tiles = static_cast<float>(def->weaponCapability->attackRangeFormula.evaluate(mx) / tileSize); wi.range_tiles = static_cast<float>(def->weaponCapability->attackRange_m / tileSize);
wi.rate_hz = static_cast<float>(def->weaponCapability->attackRateFormula.evaluate(mx)); wi.rate_hz = def->weaponCapability->attackRate_hz;
weaponInstances.push_back(wi); weaponInstances.push_back(wi);
} }
if (def->salvageCapability) if (def->salvageCapability)
{ {
cargoCapacityBase += def->salvageCapability->cargoCapacityFormula.evaluate(mx); cargoCapacityBase += def->salvageCapability->cargoCapacity;
SalvageInstance si; SalvageInstance si;
si.range_tiles = static_cast<float>(def->salvageCapability->collectionRangeFormula.evaluate(mx) / tileSize); si.range_tiles = static_cast<float>(def->salvageCapability->collectionRange_m / tileSize);
si.rate = static_cast<float>(def->salvageCapability->collectionRateFormula.evaluate(mx)); si.rate = def->salvageCapability->collectionRate_hz;
salvageInstances.push_back(si); salvageInstances.push_back(si);
} }
if (def->repairCapability) if (def->repairCapability)
{ {
RepairInstance ri; RepairInstance ri;
ri.rate_hz = static_cast<float>(def->repairCapability->repairRateFormula.evaluate(mx)); ri.rate_hz = def->repairCapability->repairRate_hz;
ri.amount_hp = static_cast<float>(def->repairCapability->repairAmountHpFormula.evaluate(mx)); ri.amount_hp = def->repairCapability->repairAmountHp;
ri.range_tiles = static_cast<float>(def->repairCapability->repairRangeFormula.evaluate(mx) / tileSize); ri.range_tiles = static_cast<float>(def->repairCapability->repairRange_m / tileSize);
repairInstances.push_back(ri); repairInstances.push_back(ri);
} }
} }
@@ -120,13 +110,9 @@ ShipStats calculateShipStats(const GameConfig& config,
const ModuleDef* def = findModuleDef(pm.moduleId); const ModuleDef* def = findModuleDef(pm.moduleId);
if (!def) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); } if (!def) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); }
const auto overIt = moduleLevelOverrides.find(pm.moduleId);
const double mx = static_cast<double>(
overIt != moduleLevelOverrides.end() ? overIt->second : def->playerProductionLevel);
for (const ModuleStatModifier& sm : def->statModifiers) for (const ModuleStatModifier& sm : def->statModifiers)
{ {
const double val = sm.formula.evaluate(mx); const double val = sm.value;
const bool isWeaponStat = (sm.stat == "damage" const bool isWeaponStat = (sm.stat == "damage"
|| sm.stat == "attack_range" || sm.stat == "attack_range"

View File

@@ -51,9 +51,7 @@ struct ShipStats
ShipStats calculateShipStats(const GameConfig& config, ShipStats calculateShipStats(const GameConfig& config,
const std::string& shipId, const std::string& shipId,
int level, const std::vector<PlacedModule>& modules);
const std::vector<PlacedModule>& modules,
const std::map<std::string, int>& moduleLevelOverrides = {});
ShipStats buildShipStatsFromEntity(const EntityAdmin& admin, entt::entity shipEntity); ShipStats buildShipStatsFromEntity(const EntityAdmin& admin, entt::entity shipEntity);

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"
@@ -61,13 +62,7 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
{ {
return; return;
} }
std::map<std::string, int> moduleLevels; m_shipSystem->spawn(id, pos, /*isEnemy=*/false, layout);
for (const auto& [mId, mState] : m_moduleSchematicLevels)
{
moduleLevels[mId] = mState.level;
}
m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout,
moduleLevels);
}, },
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); }, [this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
m_rng); m_rng);
@@ -86,7 +81,6 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
{ {
SchematicState state; SchematicState state;
state.unlocked = (def.unlockAtStationLevel == -1); state.unlocked = (def.unlockAtStationLevel == -1);
state.level = (def.unlockAtStationLevel == -1) ? def.schematic.playerProductionLevel : 0;
m_schematicLevels[def.id] = state; m_schematicLevels[def.id] = state;
} }
@@ -95,7 +89,6 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
{ {
SchematicState state; SchematicState state;
state.unlocked = (def.unlockAtStationLevel == -1); state.unlocked = (def.unlockAtStationLevel == -1);
state.level = (def.unlockAtStationLevel == -1) ? def.playerProductionLevel : 0;
m_moduleSchematicLevels[def.id] = state; m_moduleSchematicLevels[def.id] = state;
} }
@@ -140,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;
@@ -167,13 +161,7 @@ void Simulation::reset(unsigned int seed)
{ {
return; return;
} }
std::map<std::string, int> moduleLevels; m_shipSystem->spawn(id, pos, /*isEnemy=*/false, layout);
for (const auto& [mId, mState] : m_moduleSchematicLevels)
{
moduleLevels[mId] = mState.level;
}
m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout,
moduleLevels);
}, },
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); }, [this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
m_rng); m_rng);
@@ -192,7 +180,6 @@ void Simulation::reset(unsigned int seed)
{ {
SchematicState state; SchematicState state;
state.unlocked = (def.unlockAtStationLevel == -1); state.unlocked = (def.unlockAtStationLevel == -1);
state.level = (def.unlockAtStationLevel == -1) ? def.schematic.playerProductionLevel : 0;
m_schematicLevels[def.id] = state; m_schematicLevels[def.id] = state;
} }
@@ -201,7 +188,6 @@ void Simulation::reset(unsigned int seed)
{ {
SchematicState state; SchematicState state;
state.unlocked = (def.unlockAtStationLevel == -1); state.unlocked = (def.unlockAtStationLevel == -1);
state.level = (def.unlockAtStationLevel == -1) ? def.playerProductionLevel : 0;
m_moduleSchematicLevels[def.id] = state; m_moduleSchematicLevels[def.id] = state;
} }
@@ -291,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);
@@ -326,7 +315,7 @@ void Simulation::tick()
m_buildingSystem->tickBeltPull(); // step 3 m_buildingSystem->tickBeltPull(); // step 3
m_buildingSystem->tickProduction(m_currentTick); // step 4 m_buildingSystem->tickProduction(m_currentTick); // step 4
m_buildingSystem->tickShipyardProduction(m_currentTick); // step 4b m_buildingSystem->tickShipyardProduction(m_currentTick); // step 4b
m_buildingSystem->tickBeltPush(); // step 5 m_buildingSystem->tickOutputBelts(); // step 5
m_beltSystem.tick(); // step 6 m_beltSystem.tick(); // step 6
// Step 7: ship behavior systems (movement arbitration via intent priority) // Step 7: ship behavior systems (movement arbitration via intent priority)
@@ -551,15 +540,11 @@ void Simulation::tickDeathsAndLoot()
{ {
const ShipIdentityComponent& si = m_admin.get<ShipIdentityComponent>(deadEntity); const ShipIdentityComponent& si = m_admin.get<ShipIdentityComponent>(deadEntity);
const PositionComponent& pos = m_admin.get<PositionComponent>(deadEntity); const PositionComponent& pos = m_admin.get<PositionComponent>(deadEntity);
for (const ShipDef& def : m_config.ships.ships) if (si.scrapDrop > 0)
{ {
if (def.id == si.schematicId && def.loot.scrapDrop > 0) const Tick despawnAt = m_currentTick
{ + secondsToTicks(m_config.world.scrapDespawnSeconds);
const Tick despawnAt = m_currentTick m_scrapSystem->spawn(pos.value, si.scrapDrop, despawnAt);
+ secondsToTicks(m_config.world.scrapDespawnSeconds);
m_scrapSystem->spawn(pos.value, def.loot.scrapDrop, despawnAt);
break;
}
} }
m_shipSystem->despawn(deadEntity); m_shipSystem->despawn(deadEntity);
} }
@@ -647,19 +632,23 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
struct PoolEntry { std::string id; DropType type; }; struct PoolEntry { std::string id; DropType type; };
std::vector<PoolEntry> pool; std::vector<PoolEntry> pool;
// Owned schematics leave the pool (REQ-DEF-SCHEMATIC-DROP): only offer
// ship/module schematics that are gated to a station level in range and not
// yet unlocked. Schematics with unlock_at_station_level -1 start unlocked, so
// they are always owned and never eligible.
for (const ShipDef& def : m_config.ships.ships) for (const ShipDef& def : m_config.ships.ships)
{ {
if (def.unlockAtStationLevel == -1 || def.unlockAtStationLevel <= destroyedStationLevel) if (def.unlockAtStationLevel < 0 || def.unlockAtStationLevel > destroyedStationLevel) { continue; }
{ if (m_schematicLevels.at(def.id).unlocked) { continue; }
pool.push_back({def.id, DropType::Ship}); if (!prerequisitesSatisfied(def.unlockRequires)) { continue; }
} pool.push_back({def.id, DropType::Ship});
} }
for (const ModuleDef& def : m_config.modules.modules) for (const ModuleDef& def : m_config.modules.modules)
{ {
if (def.unlockAtStationLevel == -1 || def.unlockAtStationLevel <= destroyedStationLevel) if (def.unlockAtStationLevel < 0 || def.unlockAtStationLevel > destroyedStationLevel) { continue; }
{ if (m_moduleSchematicLevels.at(def.id).unlocked) { continue; }
pool.push_back({def.id, DropType::Module}); if (!prerequisitesSatisfied(def.unlockRequires)) { continue; }
} pool.push_back({def.id, DropType::Module});
} }
for (const RecipeDef& def : m_config.recipes.recipes) for (const RecipeDef& def : m_config.recipes.recipes)
{ {
@@ -674,6 +663,7 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
if (m_unlockedItemIds.count(out.item) > 0) { outputUnlocked = true; break; } if (m_unlockedItemIds.count(out.item) > 0) { outputUnlocked = true; break; }
} }
if (!outputUnlocked) { continue; } if (!outputUnlocked) { continue; }
if (!prerequisitesSatisfied(def.unlockRequires)) { continue; }
pool.push_back({def.id, DropType::Recipe}); pool.push_back({def.id, DropType::Recipe});
} }
@@ -708,31 +698,16 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
{ {
option.type = SchematicType::Ship; option.type = SchematicType::Ship;
option.displayName = toDisplayName(entry.id); option.displayName = toDisplayName(entry.id);
const SchematicState& state = m_schematicLevels.at(entry.id);
option.isNewUnlock = !state.unlocked;
option.targetLevel = state.level + 1;
} }
else if (entry.type == DropType::Module) else if (entry.type == DropType::Module)
{ {
option.type = SchematicType::Module; option.type = SchematicType::Module;
option.displayName = toDisplayName(entry.id); option.displayName = toDisplayName(entry.id);
const SchematicState& state = m_moduleSchematicLevels.at(entry.id);
option.isNewUnlock = !state.unlocked;
option.targetLevel = state.level + 1;
} }
else else
{ {
option.type = SchematicType::Recipe; option.type = SchematicType::Recipe;
option.isNewUnlock = true; option.displayName = toDisplayName(entry.id);
option.targetLevel = 0;
for (const RecipeDef& def : m_config.recipes.recipes)
{
if (def.id == entry.id && !def.outputs.empty())
{
option.displayName = toDisplayName(def.outputs[0].item);
break;
}
}
} }
// REQ-DEF-SCHEMATIC-DROP: preview recipes newly implicitly unlocked by this option. // REQ-DEF-SCHEMATIC-DROP: preview recipes newly implicitly unlocked by this option.
@@ -740,11 +715,11 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
std::set<std::string> hypotheticalModuleIds = currentModuleIds; std::set<std::string> hypotheticalModuleIds = currentModuleIds;
std::set<std::string> hypotheticalRecipeSchematicIds = m_unlockedRecipeSchematicIds; std::set<std::string> hypotheticalRecipeSchematicIds = m_unlockedRecipeSchematicIds;
if (entry.type == DropType::Ship && option.isNewUnlock) if (entry.type == DropType::Ship)
{ {
hypotheticalShipIds.insert(entry.id); hypotheticalShipIds.insert(entry.id);
} }
else if (entry.type == DropType::Module && option.isNewUnlock) else if (entry.type == DropType::Module)
{ {
hypotheticalModuleIds.insert(entry.id); hypotheticalModuleIds.insert(entry.id);
} }
@@ -755,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);
} }
@@ -766,8 +741,6 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
artifactOption.schematicId = ""; artifactOption.schematicId = "";
artifactOption.type = SchematicType::Artifact; artifactOption.type = SchematicType::Artifact;
artifactOption.displayName = "Artifact"; artifactOption.displayName = "Artifact";
artifactOption.isNewUnlock = false;
artifactOption.targetLevel = 0;
m_pendingSchematicChoices.push_back(std::move(artifactOption)); m_pendingSchematicChoices.push_back(std::move(artifactOption));
} }
} }
@@ -798,7 +771,6 @@ void Simulation::applySchematicChoice(int choiceIndex)
? m_moduleSchematicLevels.at(chosen.schematicId) ? m_moduleSchematicLevels.at(chosen.schematicId)
: m_schematicLevels.at(chosen.schematicId); : m_schematicLevels.at(chosen.schematicId);
state.unlocked = true; state.unlocked = true;
state.level += 1;
} }
recomputeUnlocked(); recomputeUnlocked();
@@ -837,6 +809,32 @@ std::set<std::string> Simulation::getUnlockedModuleSchematicIds() const
return ids; return ids;
} }
bool Simulation::isSchematicIdExplicitlyUnlocked(const std::string& schematicId) const
{
// A prerequisite is satisfied only by an explicitly-unlocked schematic
// (REQ-LOCK-PREREQ): a ship or module schematic marked unlocked, or a recipe
// schematic in the explicit-unlock set. Cross-type ids are looked up with
// find/count so an id from one namespace never throws against another.
const std::map<std::string, SchematicState>::const_iterator shipIt =
m_schematicLevels.find(schematicId);
if (shipIt != m_schematicLevels.end() && shipIt->second.unlocked) { return true; }
const std::map<std::string, SchematicState>::const_iterator moduleIt =
m_moduleSchematicLevels.find(schematicId);
if (moduleIt != m_moduleSchematicLevels.end() && moduleIt->second.unlocked) { return true; }
return m_unlockedRecipeSchematicIds.count(schematicId) > 0;
}
bool Simulation::prerequisitesSatisfied(const std::vector<std::string>& unlockRequires) const
{
for (const std::string& requiredId : unlockRequires)
{
if (!isSchematicIdExplicitlyUnlocked(requiredId)) { return false; }
}
return true;
}
Simulation::UnlockedSets Simulation::computeUnlockedSets( Simulation::UnlockedSets Simulation::computeUnlockedSets(
const std::set<std::string>& unlockedShipSchematicIds, const std::set<std::string>& unlockedShipSchematicIds,
const std::set<std::string>& unlockedModuleSchematicIds, const std::set<std::string>& unlockedModuleSchematicIds,
@@ -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);
{
if (def.id != recipeId) { continue; }
for (const RecipeOutput& out : def.outputs)
{
itemNames.insert(toDisplayName(out.item));
}
break;
}
} }
return std::vector<std::string>(itemNames.begin(), itemNames.end()); std::sort(recipeIds.begin(), recipeIds.end(),
[](const std::string& lhs, const std::string& rhs)
{
return toDisplayName(lhs) < toDisplayName(rhs);
});
return recipeIds;
} }
bool Simulation::isRecipeUnlocked(const std::string& recipeId) const bool Simulation::isRecipeUnlocked(const std::string& recipeId) const
@@ -961,7 +956,6 @@ void Simulation::appendSchematicMap(Hasher& hasher,
{ {
hasher.append(entry.first); hasher.append(entry.first);
hasher.append(entry.second.unlocked); hasher.append(entry.second.unlocked);
hasher.append(entry.second.level);
} }
} }
@@ -994,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());
@@ -1054,7 +1049,6 @@ unsigned long long Simulation::computeStateChecksum() const
[&hasher](entt::entity entity, const ShipIdentityComponent& c) [&hasher](entt::entity entity, const ShipIdentityComponent& c)
{ {
hasher.append(static_cast<std::uint32_t>(entity)); hasher.append(static_cast<std::uint32_t>(entity));
hasher.append(c.level);
hasher.append(c.schematicId); hasher.append(c.schematicId);
}); });
@@ -1102,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;
@@ -1152,17 +1171,6 @@ Tick Simulation::normalGapRemainingTicks() const
return m_waveSystem->normalGapRemainingTicks(); return m_waveSystem->normalGapRemainingTicks();
} }
int Simulation::schematicLevel(const std::string& shipId) const
{
const std::map<std::string, SchematicState>::const_iterator it =
m_schematicLevels.find(shipId);
if (it == m_schematicLevels.end())
{
return 0;
}
return it->second.level;
}
bool Simulation::isSchematicUnlocked(const std::string& shipId) const bool Simulation::isSchematicUnlocked(const std::string& shipId) const
{ {
const std::map<std::string, SchematicState>::const_iterator it = const std::map<std::string, SchematicState>::const_iterator it =
@@ -1174,17 +1182,6 @@ bool Simulation::isSchematicUnlocked(const std::string& shipId) const
return it->second.unlocked; return it->second.unlocked;
} }
int Simulation::moduleSchematicLevel(const std::string& moduleId) const
{
const std::map<std::string, SchematicState>::const_iterator it =
m_moduleSchematicLevels.find(moduleId);
if (it == m_moduleSchematicLevels.end())
{
return 0;
}
return it->second.level;
}
bool Simulation::isModuleSchematicUnlocked(const std::string& moduleId) const bool Simulation::isModuleSchematicUnlocked(const std::string& moduleId) const
{ {
const std::map<std::string, SchematicState>::const_iterator it = const std::map<std::string, SchematicState>::const_iterator it =

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;
@@ -83,12 +89,10 @@ public:
Tick bossCountdownTicks() const; Tick bossCountdownTicks() const;
Tick normalGapRemainingTicks() const; Tick normalGapRemainingTicks() const;
// Ship schematic state queries. // Ship schematic state query.
int schematicLevel(const std::string& shipId) const;
bool isSchematicUnlocked(const std::string& shipId) const; bool isSchematicUnlocked(const std::string& shipId) const;
// Module schematic state queries. // Module schematic state query.
int moduleSchematicLevel(const std::string& moduleId) const;
bool isModuleSchematicUnlocked(const std::string& moduleId) const; bool isModuleSchematicUnlocked(const std::string& moduleId) const;
// Implicit recipe/item unlock queries (REQ-LOCK-IMPLICIT). // Implicit recipe/item unlock queries (REQ-LOCK-IMPLICIT).
@@ -138,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();
@@ -167,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;
@@ -182,7 +192,6 @@ private:
struct SchematicState struct SchematicState
{ {
bool unlocked; bool unlocked;
int level;
}; };
std::map<std::string, SchematicState> m_schematicLevels; std::map<std::string, SchematicState> m_schematicLevels;
std::map<std::string, SchematicState> m_moduleSchematicLevels; std::map<std::string, SchematicState> m_moduleSchematicLevels;
@@ -218,9 +227,15 @@ private:
std::set<std::string> getUnlockedShipSchematicIds() const; std::set<std::string> getUnlockedShipSchematicIds() const;
std::set<std::string> getUnlockedModuleSchematicIds() const; std::set<std::string> getUnlockedModuleSchematicIds() const;
// Display names (deduplicated, alphabetical) of output items of recipes in // True if schematicId names a currently explicitly-unlocked ship, module, or
// assembler recipe schematic (REQ-LOCK-PREREQ satisfaction basis).
bool isSchematicIdExplicitlyUnlocked(const std::string& schematicId) const;
// True if every prerequisite in unlockRequires is explicitly unlocked (REQ-LOCK-PREREQ).
bool prerequisitesSatisfied(const std::vector<std::string>& unlockRequires) const;
// Ids (sorted alphabetically by display name) of the recipes in
// 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

@@ -1,6 +1,5 @@
#include "ThreatCostCalculator.h" #include "ThreatCostCalculator.h"
#include <limits>
#include <set> #include <set>
#include "GameConfig.h" #include "GameConfig.h"
@@ -31,6 +30,7 @@ double computeMaterialThreat(const ThreatCostTable& table,
return total; return total;
} }
// Returns true if every input of the recipe has a resolved threat value.
bool allInputsResolved(const RecipeDef& recipe, bool allInputsResolved(const RecipeDef& recipe,
const std::map<std::string, double>& resolved) const std::map<std::string, double>& resolved)
{ {
@@ -44,15 +44,18 @@ bool allInputsResolved(const RecipeDef& recipe,
return true; return true;
} }
double computeRecipeThreat(const RecipeDef& recipe, // Computes the raw recipe threat (duration + sum of input threats × amounts),
const std::map<std::string, double>& resolved) // divided by the output amount to get the per-unit threat.
double computeRecipeThreatPerUnit(const RecipeDef& recipe,
int outputAmount,
const std::map<std::string, double>& resolved)
{ {
double threat = recipe.durationSeconds; double threat = recipe.durationSeconds;
for (const RecipeIngredient& input : recipe.inputs) for (const RecipeIngredient& input : recipe.inputs)
{ {
threat += resolved.at(input.item) * input.amount; threat += resolved.at(input.item) * input.amount;
} }
return threat; return threat / static_cast<double>(outputAmount);
} }
} // namespace } // namespace
@@ -62,138 +65,267 @@ ThreatCostTable computeThreatCostTable(const GameConfig& config)
{ {
ThreatCostTable table; ThreatCostTable table;
// Build lookup: output item → non-reprocessing recipes and reprocessing recipes. // Scrap threat (REQ-THREAT-SCRAP) is the constant inverse of the scrap-drop
std::map<std::string, std::vector<RecipeRef>> nonReprocessingRecipes; // conversion (REQ-RES-SCRAP-DROP): one scrap is worth 1 / scrap_per_threat.
// Set it up front so reprocessing-only item threats (below) can use it, and so
// it no longer depends on any ship's threat cost.
table.scrapThreat = config.world.scrapPerThreat > 0.0
? 1.0 / config.world.scrapPerThreat
: 0.0;
// -------------------------------------------------------------------------
// Build per-item recipe lookup tables.
// -------------------------------------------------------------------------
// Items that have at least one non-reprocessing recipe that does NOT consume
// scrap — these items' scrap-consuming recipes are excluded from threat
// computation (REQ-THREAT-ITEM: scrap-consuming recipes are a fallback only).
std::set<std::string> scrapFreeItems;
// nonReprocessingRecipes: item → all eligible non-reprocessing (recipe, output)
// pairs. Scrap-consuming recipes are collected here temporarily; they are
// filtered out per item after we know which items have a scrap-free producer.
struct EligiblePair
{
const RecipeDef* recipe;
int outputAmount;
bool consumesScrap;
};
std::map<std::string, std::vector<EligiblePair>> nonReprocessingCandidates;
// reprocessingRecipes: item → all reprocessing-recipe refs (probability
// values are raw from config; we normalize them per-recipe below).
std::map<std::string, std::vector<RecipeRef>> reprocessingRecipes; std::map<std::string, std::vector<RecipeRef>> reprocessingRecipes;
for (const RecipeDef& recipe : config.recipes.recipes) for (const RecipeDef& recipe : config.recipes.recipes)
{ {
if (recipe.building == BuildingType::ReprocessingPlant) if (recipe.building == BuildingType::ReprocessingPlant)
{ {
// Compute the total weight across all outputs of this reprocessing recipe
// so we can normalize each output's probability.
double totalWeight = 0.0;
for (const RecipeOutput& out : recipe.outputs)
{
totalWeight += out.probability.value_or(1.0);
}
if (totalWeight <= 0.0)
{
continue;
}
for (const RecipeOutput& out : recipe.outputs) for (const RecipeOutput& out : recipe.outputs)
{ {
RecipeRef ref; RecipeRef ref;
ref.recipe = &recipe; ref.recipe = &recipe;
ref.outputItem = out.item; ref.outputItem = out.item;
ref.outputAmount = out.amount; ref.outputAmount = out.amount;
ref.probability = out.probability.value_or(1.0); ref.probability = out.probability.value_or(1.0) / totalWeight;
reprocessingRecipes[out.item].push_back(ref); reprocessingRecipes[out.item].push_back(ref);
} }
} }
else else
{ {
// Check whether this non-reprocessing recipe consumes scrap.
bool consumesScrap = false;
for (const RecipeIngredient& input : recipe.inputs)
{
if (input.item == "scrap")
{
consumesScrap = true;
break;
}
}
for (const RecipeOutput& out : recipe.outputs) for (const RecipeOutput& out : recipe.outputs)
{ {
RecipeRef ref; if (!consumesScrap)
ref.recipe = &recipe; {
ref.outputItem = out.item; scrapFreeItems.insert(out.item);
ref.outputAmount = out.amount; }
ref.probability = 1.0;
nonReprocessingRecipes[out.item].push_back(ref); EligiblePair pair;
pair.recipe = &recipe;
pair.outputAmount = out.amount;
pair.consumesScrap = consumesScrap;
nonReprocessingCandidates[out.item].push_back(pair);
} }
} }
} }
// Collect all item names that need resolving. // Filter nonReprocessingCandidates: for items that have at least one
std::set<std::string> unresolved; // scrap-free producer, drop their scrap-consuming recipes.
for (const std::pair<const std::string, std::vector<RecipeRef>>& entry : nonReprocessingRecipes) // Build the final per-item list of (recipe, outputAmount) pairs eligible
// for the max-across-recipes rule (REQ-THREAT-ITEM).
std::map<std::string, std::vector<std::pair<const RecipeDef*, int>>> eligibleRecipes;
for (std::map<std::string, std::vector<EligiblePair>>::const_iterator it =
nonReprocessingCandidates.begin();
it != nonReprocessingCandidates.end();
++it)
{ {
unresolved.insert(entry.first); const std::string& item = it->first;
} const std::vector<EligiblePair>& candidates = it->second;
for (const std::pair<const std::string, std::vector<RecipeRef>>& entry : reprocessingRecipes) bool hasScrapFree = (scrapFreeItems.find(item) != scrapFreeItems.end());
{
unresolved.insert(entry.first); for (const EligiblePair& candidate : candidates)
{
if (candidate.consumesScrap && hasScrapFree)
{
// Scrap-consuming recipe excluded: item has a scrap-free producer.
continue;
}
eligibleRecipes[item].emplace_back(candidate.recipe, candidate.outputAmount);
}
} }
// Iteratively resolve non-reprocessing items. // -------------------------------------------------------------------------
bool progress = true; // Resolution: seed resolved map with scrap, then alternate the
while (progress) // non-reprocessing pass and the reprocessing pass to a fixpoint.
// Fix (8): iterate until neither pass makes progress, rather than running
// the reprocessing pass once at the end.
// -------------------------------------------------------------------------
std::map<std::string, double>& resolved = table.itemThreat;
resolved["scrap"] = table.scrapThreat;
// Non-reprocessing resolution pass.
// Fix (9): commit an item only when EVERY eligible recipe for it is
// computable, not just the first one that resolves. This ensures a shallow
// shortcut recipe cannot undercut a deeper base recipe by resolving earlier.
auto runNonReprocessingPass = [&](bool requireAllRecipes) -> bool
{ {
progress = false; bool progress = false;
std::set<std::string> newlyResolved; std::map<std::string, double> newValues;
for (const std::string& item : unresolved)
for (std::map<std::string, std::vector<std::pair<const RecipeDef*, int>>>::const_iterator
it = eligibleRecipes.begin();
it != eligibleRecipes.end();
++it)
{ {
std::map<std::string, std::vector<RecipeRef>>::const_iterator it = const std::string& item = it->first;
nonReprocessingRecipes.find(item); if (resolved.find(item) != resolved.end())
if (it == nonReprocessingRecipes.end())
{ {
continue; continue;
} }
const std::vector<std::pair<const RecipeDef*, int>>& pairs = it->second;
bool allComputable = true;
double maxThreat = -1.0; double maxThreat = -1.0;
for (const RecipeRef& ref : it->second) for (const std::pair<const RecipeDef*, int>& pair : pairs)
{ {
if (allInputsResolved(*ref.recipe, table.itemThreat)) if (!allInputsResolved(*pair.first, resolved))
{ {
double threat = computeRecipeThreat(*ref.recipe, table.itemThreat); allComputable = false;
if (threat > maxThreat) if (requireAllRecipes)
{ {
maxThreat = threat; break;
} }
// In fallback mode: skip this recipe but continue gathering
// the computable subset.
continue;
}
double threat = computeRecipeThreatPerUnit(*pair.first, pair.second, resolved);
if (threat > maxThreat)
{
maxThreat = threat;
} }
} }
if (requireAllRecipes && !allComputable)
{
continue;
}
if (maxThreat >= 0.0) if (maxThreat >= 0.0)
{ {
table.itemThreat[item] = maxThreat; newValues[item] = maxThreat;
newlyResolved.insert(item);
progress = true;
} }
} }
for (const std::string& item : newlyResolved)
for (std::map<std::string, double>::const_iterator it = newValues.begin();
it != newValues.end();
++it)
{ {
unresolved.erase(item); resolved[it->first] = it->second;
progress = true;
} }
} return progress;
};
// Compute scrap threat (REQ-THREAT-SCRAP): find the ship with the smallest // Reprocessing pass: resolve items produced exclusively by reprocessing.
// scrap_drop and use its threat cost. // Items that also have a non-reprocessing recipe are skipped here (they are
int minScrapDrop = std::numeric_limits<int>::max(); // covered by the non-reprocessing pass or do not need the reprocessing path).
const ShipDef* cheapestScrapShip = nullptr; auto runReprocessingPass = [&]() -> bool
for (const ShipDef& def : config.ships.ships)
{ {
if (def.loot.scrapDrop > 0 && def.loot.scrapDrop < minScrapDrop) bool progress = false;
for (std::map<std::string, std::vector<RecipeRef>>::const_iterator it =
reprocessingRecipes.begin();
it != reprocessingRecipes.end();
++it)
{ {
minScrapDrop = def.loot.scrapDrop; const std::string& item = it->first;
cheapestScrapShip = &def; if (resolved.find(item) != resolved.end())
}
}
if (cheapestScrapShip != nullptr)
{
double shipThreat = calculateShipThreatCost(table, config,
cheapestScrapShip->id, cheapestScrapShip->defaultModules);
table.scrapThreat = shipThreat / minScrapDrop;
}
// Resolve reprocessing-only items.
for (const std::string& item : unresolved)
{
std::map<std::string, std::vector<RecipeRef>>::const_iterator it =
reprocessingRecipes.find(item);
if (it == reprocessingRecipes.end())
{
continue;
}
for (const RecipeRef& ref : it->second)
{
int scrapPerCycle = 0;
for (const RecipeIngredient& input : ref.recipe->inputs)
{ {
scrapPerCycle += input.amount; continue;
}
// Reprocessing defines an item's threat only when nothing else
// produces it (REQ-THREAT-ITEM).
if (scrapFreeItems.find(item) != scrapFreeItems.end())
{
continue;
}
// Also skip items covered by eligible (non-reprocessing) recipes.
if (eligibleRecipes.find(item) != eligibleRecipes.end())
{
continue;
} }
double threat = (table.scrapThreat * scrapPerCycle for (const RecipeRef& ref : it->second)
+ ref.recipe->durationSeconds) / ref.probability;
std::map<std::string, double>::iterator existing = table.itemThreat.find(item);
if (existing == table.itemThreat.end() || threat > existing->second)
{ {
table.itemThreat[item] = threat; // Sum all scrap inputs for this reprocessing recipe.
int scrapPerCycle = 0;
for (const RecipeIngredient& input : ref.recipe->inputs)
{
scrapPerCycle += input.amount;
}
double threat = (table.scrapThreat * scrapPerCycle
+ ref.recipe->durationSeconds) / ref.probability;
std::map<std::string, double>::iterator existing = resolved.find(item);
if (existing == resolved.end() || threat > existing->second)
{
resolved[item] = threat;
progress = true;
}
} }
} }
return progress;
};
// Main fixpoint loop: alternate non-reprocessing and reprocessing passes
// until neither makes any progress (fix 8).
bool anyProgress = true;
while (anyProgress)
{
anyProgress = runNonReprocessingPass(true);
anyProgress = runReprocessingPass() || anyProgress;
} }
// Deadlock fallback: if any items remain unresolved due to recipe cycles,
// fall back to committing with the max over the currently computable subset
// of recipes (fix 9, deadlock guard — same approach as threat_report.py's
// require_all_recipes=False mode).
anyProgress = true;
while (anyProgress)
{
anyProgress = runNonReprocessingPass(false);
anyProgress = runReprocessingPass() || anyProgress;
}
// Remove the sentinel scrap entry — scrapThreat is already stored on the
// table struct; having it in itemThreat would confuse callers iterating items.
resolved.erase("scrap");
return table; return table;
} }

View File

@@ -55,7 +55,7 @@ void WaveSystem::tickWaveScheduler(Tick currentTick, ShipSystem& ships,
{ {
if (currentTick >= entry.spawnAt) if (currentTick >= entry.spawnAt)
{ {
ships.spawn(entry.schematicId, entry.level, entry.position, ships.spawn(entry.schematicId, entry.position,
/*isEnemy=*/true, entry.layout); /*isEnemy=*/true, entry.layout);
} }
else else
@@ -174,11 +174,7 @@ std::vector<WaveSystem::SpawnEntry> WaveSystem::selectWaveShips(double& budget,
Tick currentTick, Tick currentTick,
int worldHeightTiles) int worldHeightTiles)
{ {
const int shipLevel = std::max(1, static_cast<int>( // Build eligible ship list with their (level-independent) threat costs.
m_config.world.waves.shipLevelFormula.evaluate(
static_cast<double>(m_bossWaveCounter))));
// Build eligible ship list with their costs at the current level.
struct EligibleShip struct EligibleShip
{ {
std::string schematicId; std::string schematicId;
@@ -242,7 +238,6 @@ std::vector<WaveSystem::SpawnEntry> WaveSystem::selectWaveShips(double& budget,
SpawnEntry entry; SpawnEntry entry;
entry.schematicId = chosen.schematicId; entry.schematicId = chosen.schematicId;
entry.level = shipLevel;
entry.spawnAt = 0; // set below after all picks are done entry.spawnAt = 0; // set below after all picks are done
entry.position = QVector2D(xDist(m_rng), entry.position = QVector2D(xDist(m_rng),
static_cast<float>(yDist(m_rng)) + 0.5f); static_cast<float>(yDist(m_rng)) + 0.5f);

View File

@@ -59,7 +59,6 @@ private:
struct SpawnEntry struct SpawnEntry
{ {
std::string schematicId; std::string schematicId;
int level;
Tick spawnAt; Tick spawnAt;
QVector2D position; QVector2D position;
ShipLayoutConfig layout; ShipLayoutConfig layout;

View File

@@ -238,7 +238,7 @@ TEST_CASE("BehaviorSystem: clearMovementIntents resets all ships to inactive",
"[behavior]") "[behavior]")
{ {
Fixture f; Fixture f;
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity e = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
f.admin.get<MovementIntentComponent>(e) = MovementIntentComponent{true, QVector2D(10.0f, 0.0f)}; f.admin.get<MovementIntentComponent>(e) = MovementIntentComponent{true, QVector2D(10.0f, 0.0f)};
f.ships.clearMovementIntents(); f.ships.clearMovementIntents();
@@ -254,7 +254,7 @@ TEST_CASE("BehaviorSystem: tickMovement advances ship by maxSpeed_tpt toward tar
"[behavior]") "[behavior]")
{ {
Fixture f; Fixture f;
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity e = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
const float speed = f.admin.get<DynamicBodyComponent>(e).maxSpeed_tpt; const float speed = f.admin.get<DynamicBodyComponent>(e).maxSpeed_tpt;
f.admin.get<MovementIntentComponent>(e) = MovementIntentComponent{true, QVector2D(100.0f, 0.0f)}; f.admin.get<MovementIntentComponent>(e) = MovementIntentComponent{true, QVector2D(100.0f, 0.0f)};
@@ -269,7 +269,7 @@ TEST_CASE("BehaviorSystem: tickMovement stops exactly at target without overshoo
"[behavior]") "[behavior]")
{ {
Fixture f; Fixture f;
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity e = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
const float speed = f.admin.get<DynamicBodyComponent>(e).maxSpeed_tpt; const float speed = f.admin.get<DynamicBodyComponent>(e).maxSpeed_tpt;
const QVector2D target(speed * 0.5f, 0.0f); const QVector2D target(speed * 0.5f, 0.0f);
@@ -288,7 +288,7 @@ TEST_CASE("BehaviorSystem: tickMovement stops exactly at target without overshoo
TEST_CASE("BehaviorSystem: healthy player ship does not retreat", "[behavior]") TEST_CASE("BehaviorSystem: healthy player ship does not retreat", "[behavior]")
{ {
Fixture f; Fixture f;
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity e = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
f.admin.get<HealthComponent>(e).hp = f.admin.get<HealthComponent>(e).maxHp; // full HP f.admin.get<HealthComponent>(e).hp = f.admin.get<HealthComponent>(e).maxHp; // full HP
f.decide(); f.decide();
@@ -301,7 +301,7 @@ TEST_CASE("BehaviorSystem: low-HP player ship retreats toward the rally point",
Fixture f; Fixture f;
const QVector2D rallyPoint(-50.0f, 0.0f); const QVector2D rallyPoint(-50.0f, 0.0f);
f.ships.setRallyPoint(rallyPoint); f.ships.setRallyPoint(rallyPoint);
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity e = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
f.admin.get<HealthComponent>(e).hp = f.admin.get<HealthComponent>(e).maxHp * 0.2f; // below threshold f.admin.get<HealthComponent>(e).hp = f.admin.get<HealthComponent>(e).maxHp * 0.2f; // below threshold
f.decide(); f.decide();
@@ -316,8 +316,8 @@ TEST_CASE("BehaviorSystem: low-HP retreat outranks attacking a nearby enemy", "[
Fixture f; Fixture f;
const QVector2D rallyPoint(-50.0f, 0.0f); const QVector2D rallyPoint(-50.0f, 0.0f);
f.ships.setRallyPoint(rallyPoint); f.ships.setRallyPoint(rallyPoint);
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f), /*isEnemy=*/true); f.ships.spawn("interceptor", QVector2D(5.0f, 0.0f), /*isEnemy=*/true);
f.admin.get<HealthComponent>(player).hp = f.admin.get<HealthComponent>(player).maxHp * 0.1f; f.admin.get<HealthComponent>(player).hp = f.admin.get<HealthComponent>(player).maxHp * 0.1f;
f.decide(); f.decide();
@@ -329,7 +329,7 @@ TEST_CASE("BehaviorSystem: low-HP retreat outranks attacking a nearby enemy", "[
TEST_CASE("BehaviorSystem: enemy ships never retreat even at low HP", "[behavior]") TEST_CASE("BehaviorSystem: enemy ships never retreat even at low HP", "[behavior]")
{ {
Fixture f; Fixture f;
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f), const entt::entity enemy = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f),
/*isEnemy=*/true); /*isEnemy=*/true);
f.admin.get<HealthComponent>(enemy).hp = f.admin.get<HealthComponent>(enemy).maxHp * 0.05f; f.admin.get<HealthComponent>(enemy).hp = f.admin.get<HealthComponent>(enemy).maxHp * 0.05f;
@@ -347,8 +347,8 @@ TEST_CASE("BehaviorSystem: player combat ship acquires nearest enemy ship in ran
"[behavior]") "[behavior]")
{ {
Fixture f; Fixture f;
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f), const entt::entity enemy = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f),
/*isEnemy=*/true); /*isEnemy=*/true);
f.decide(); f.decide();
@@ -364,8 +364,8 @@ TEST_CASE("BehaviorSystem: player combat ship does not target friendly ships",
"[behavior]") "[behavior]")
{ {
Fixture f; Fixture f;
const entt::entity e1 = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity e1 = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f)); // also player f.ships.spawn("interceptor", QVector2D(5.0f, 0.0f)); // also player
f.decide(); f.decide();
@@ -378,8 +378,8 @@ TEST_CASE("BehaviorSystem: player combat ship ignores enemy beyond engagement ra
"[behavior]") "[behavior]")
{ {
Fixture f; Fixture f;
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
f.ships.spawn("interceptor", 1, QVector2D(500.0f, 0.0f), /*isEnemy=*/true); f.ships.spawn("interceptor", QVector2D(500.0f, 0.0f), /*isEnemy=*/true);
f.decide(); f.decide();
@@ -398,10 +398,10 @@ TEST_CASE("BehaviorSystem: overclaim penalty steers a ship off a claimed target"
SECTION("no claim: the nearer enemy is chosen") SECTION("no claim: the nearer enemy is chosen")
{ {
Fixture f; Fixture f;
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
const entt::entity nearEnemy = f.ships.spawn("interceptor", 1, QVector2D(8.0f, 0.0f), const entt::entity nearEnemy = f.ships.spawn("interceptor", QVector2D(8.0f, 0.0f),
/*isEnemy=*/true); /*isEnemy=*/true);
f.ships.spawn("interceptor", 1, QVector2D(0.0f, 12.0f), /*isEnemy=*/true); f.ships.spawn("interceptor", QVector2D(0.0f, 12.0f), /*isEnemy=*/true);
f.decide(); f.decide();
@@ -412,15 +412,15 @@ TEST_CASE("BehaviorSystem: overclaim penalty steers a ship off a claimed target"
{ {
const float d = 10.0f; const float d = 10.0f;
Fixture f; Fixture f;
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
const entt::entity enemyA = f.ships.spawn("interceptor", 1, QVector2D(d, 0.0f), const entt::entity enemyA = f.ships.spawn("interceptor", QVector2D(d, 0.0f),
/*isEnemy=*/true); /*isEnemy=*/true);
const entt::entity enemyB = f.ships.spawn("interceptor", 1, QVector2D(0.0f, d), const entt::entity enemyB = f.ships.spawn("interceptor", QVector2D(0.0f, d),
/*isEnemy=*/true); /*isEnemy=*/true);
// A second player ship already commits to enemyA, registering a claim, so // A second player ship already commits to enemyA, registering a claim, so
// the penalised score of enemyA falls below the unclaimed equidistant enemyB. // the penalised score of enemyA falls below the unclaimed equidistant enemyB.
const entt::entity claimant = f.ships.spawn("interceptor", 1, QVector2D(d, 1.0f)); const entt::entity claimant = f.ships.spawn("interceptor", QVector2D(d, 1.0f));
f.admin.get<AttackBehavior>(claimant).currentTarget = enemyA; f.admin.get<AttackBehavior>(claimant).currentTarget = enemyA;
f.decide(); f.decide();
@@ -437,10 +437,10 @@ TEST_CASE("BehaviorSystem: hysteresis keeps a ship on its own claimed target",
{ {
const float d = 10.0f; const float d = 10.0f;
Fixture f; Fixture f;
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
const entt::entity enemyA = f.ships.spawn("interceptor", 1, QVector2D(d, 0.0f), const entt::entity enemyA = f.ships.spawn("interceptor", QVector2D(d, 0.0f),
/*isEnemy=*/true); /*isEnemy=*/true);
f.ships.spawn("interceptor", 1, QVector2D(0.0f, d), /*isEnemy=*/true); f.ships.spawn("interceptor", QVector2D(0.0f, d), /*isEnemy=*/true);
// The ship already holds enemyA; enemyB is equidistant. Without self-exclusion // The ship already holds enemyA; enemyB is equidistant. Without self-exclusion
// the ship's own claim would penalise enemyA and flip the choice. // the ship's own claim would penalise enemyA and flip the choice.
@@ -459,10 +459,10 @@ TEST_CASE("BehaviorSystem: a ship switches off a heavily overclaimed target",
{ {
const float d = 10.0f; const float d = 10.0f;
Fixture f; Fixture f;
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
const entt::entity enemyA = f.ships.spawn("interceptor", 1, QVector2D(d, 0.0f), const entt::entity enemyA = f.ships.spawn("interceptor", QVector2D(d, 0.0f),
/*isEnemy=*/true); /*isEnemy=*/true);
const entt::entity enemyB = f.ships.spawn("interceptor", 1, QVector2D(0.0f, d), const entt::entity enemyB = f.ships.spawn("interceptor", QVector2D(0.0f, d),
/*isEnemy=*/true); /*isEnemy=*/true);
f.admin.get<AttackBehavior>(player).currentTarget = enemyA; f.admin.get<AttackBehavior>(player).currentTarget = enemyA;
@@ -471,7 +471,7 @@ TEST_CASE("BehaviorSystem: a ship switches off a heavily overclaimed target",
for (int i = 0; i < 5; ++i) for (int i = 0; i < 5; ++i)
{ {
const entt::entity other = const entt::entity other =
f.ships.spawn("interceptor", 1, QVector2D(d, static_cast<float>(2 + i))); f.ships.spawn("interceptor", QVector2D(d, static_cast<float>(2 + i)));
f.admin.get<AttackBehavior>(other).currentTarget = enemyA; f.admin.get<AttackBehavior>(other).currentTarget = enemyA;
} }
@@ -488,8 +488,8 @@ TEST_CASE("BehaviorSystem: enemy ship acquires nearest player ship in range",
"[behavior]") "[behavior]")
{ {
Fixture f; Fixture f;
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f), const entt::entity enemy = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f),
/*isEnemy=*/true); /*isEnemy=*/true);
f.decide(); f.decide();
@@ -505,7 +505,7 @@ TEST_CASE("BehaviorSystem: enemy ship with no target advances leftward",
"[behavior]") "[behavior]")
{ {
Fixture f; Fixture f;
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(100.0f, 0.0f), const entt::entity enemy = f.ships.spawn("interceptor", QVector2D(100.0f, 0.0f),
/*isEnemy=*/true); /*isEnemy=*/true);
f.decide(); f.decide();
@@ -526,7 +526,7 @@ TEST_CASE("BehaviorSystem: advancing ship targets center between enemy defence s
f.admin.spawnStation(QPoint(1000, 10), QSize(1, 1), body, 100.0f, 100.0f, /*isEnemy=*/true); f.admin.spawnStation(QPoint(1000, 10), QSize(1, 1), body, 100.0f, 100.0f, /*isEnemy=*/true);
f.admin.spawnStation(QPoint(1000, 30), QSize(1, 1), body, 100.0f, 100.0f, /*isEnemy=*/true); f.admin.spawnStation(QPoint(1000, 30), QSize(1, 1), body, 100.0f, 100.0f, /*isEnemy=*/true);
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f), const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f),
/*isEnemy=*/false); /*isEnemy=*/false);
// Player ships rally until departure; drop Rally so Advance is the fallback. // Player ships rally until departure; drop Rally so Advance is the fallback.
f.ships.triggerRallyDeparture(); f.ships.triggerRallyDeparture();
@@ -549,7 +549,7 @@ TEST_CASE("BehaviorSystem: advancing ship falls back to enemy HQ, then off-world
const QVector2D hqPos(5.0f, 7.0f); const QVector2D hqPos(5.0f, 7.0f);
const entt::entity hq = f.admin.spawnHqProxy(hqPos, 100.0f, 100.0f); const entt::entity hq = f.admin.spawnHqProxy(hqPos, 100.0f, 100.0f);
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(1000.0f, 0.0f), const entt::entity enemy = f.ships.spawn("interceptor", QVector2D(1000.0f, 0.0f),
/*isEnemy=*/true); /*isEnemy=*/true);
f.decide(); f.decide();
@@ -576,9 +576,9 @@ TEST_CASE("BehaviorSystem: repair ship orbits damaged friendly ship",
{ {
Fixture f; Fixture f;
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
false, repairLayout); false, repairLayout);
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f)); const entt::entity friendly = f.ships.spawn("interceptor", QVector2D(5.0f, 0.0f));
f.admin.get<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f; f.admin.get<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
@@ -605,10 +605,10 @@ TEST_CASE("BehaviorSystem: idle repair ship stands by with the fleet instead of
{ {
Fixture f; Fixture f;
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
false, repairLayout); false, repairLayout);
// A healthy ally (nothing to repair) and a far enemy station (no threat in range). // A healthy ally (nothing to repair) and a far enemy station (no threat in range).
const entt::entity ally = f.ships.spawn("interceptor", 1, QVector2D(50.0f, 0.0f)); const entt::entity ally = f.ships.spawn("interceptor", QVector2D(50.0f, 0.0f));
const std::vector<QPoint> body{QPoint(0, 0)}; const std::vector<QPoint> body{QPoint(0, 0)};
f.admin.spawnStation(QPoint(1000, 0), QSize(1, 1), body, 100.0f, 100.0f, /*isEnemy=*/true); f.admin.spawnStation(QPoint(1000, 0), QSize(1, 1), body, 100.0f, 100.0f, /*isEnemy=*/true);
@@ -628,8 +628,8 @@ TEST_CASE("BehaviorSystem: repair ship heals damaged ally within repair range",
{ {
Fixture f; Fixture f;
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, repairLayout); f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f), false, repairLayout);
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f)); const entt::entity friendly = f.ships.spawn("interceptor", QVector2D(1.0f, 0.0f));
const float initialHp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f; const float initialHp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
f.admin.get<HealthComponent>(friendly).hp = initialHp; f.admin.get<HealthComponent>(friendly).hp = initialHp;
@@ -644,8 +644,8 @@ TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]")
{ {
Fixture f; Fixture f;
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, repairLayout); f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f), false, repairLayout);
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f)); const entt::entity friendly = f.ships.spawn("interceptor", QVector2D(1.0f, 0.0f));
f.admin.get<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(friendly).maxHp - 0.001f; f.admin.get<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(friendly).maxHp - 0.001f;
@@ -666,9 +666,9 @@ TEST_CASE("RepairSystem: tool heals the in-range damaged target chosen by the ex
{ {
Fixture f; Fixture f;
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
false, repairLayout); false, repairLayout);
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f)); const entt::entity friendly = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f));
const float initHp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f; const float initHp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
f.admin.get<HealthComponent>(friendly).hp = initHp; f.admin.get<HealthComponent>(friendly).hp = initHp;
@@ -688,12 +688,12 @@ TEST_CASE("RepairSystem: tool falls back to in-range target when its target is o
{ {
Fixture f; Fixture f;
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
false, repairLayout); false, repairLayout);
// out of repair range (80) but in sensor range (200) // out of repair range (80) but in sensor range (200)
const entt::entity outOfRange = f.ships.spawn("interceptor", 1, QVector2D(90.0f, 0.0f)); const entt::entity outOfRange = f.ships.spawn("interceptor", QVector2D(90.0f, 0.0f));
// within repair range // within repair range
const entt::entity fallback = f.ships.spawn("interceptor", 1, QVector2D(20.0f, 0.0f)); const entt::entity fallback = f.ships.spawn("interceptor", QVector2D(20.0f, 0.0f));
const float outInitHp = f.admin.get<HealthComponent>(outOfRange).maxHp * 0.5f; const float outInitHp = f.admin.get<HealthComponent>(outOfRange).maxHp * 0.5f;
const float fallbackInitHp = f.admin.get<HealthComponent>(fallback).maxHp * 0.5f; const float fallbackInitHp = f.admin.get<HealthComponent>(fallback).maxHp * 0.5f;
@@ -717,10 +717,10 @@ TEST_CASE("RepairSystem: tool falls back when its target is fully healed",
{ {
Fixture f; Fixture f;
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
false, repairLayout); false, repairLayout);
const entt::entity healed = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f)); const entt::entity healed = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f));
const entt::entity fallback = f.ships.spawn("interceptor", 1, QVector2D(15.0f, 0.0f)); const entt::entity fallback = f.ships.spawn("interceptor", QVector2D(15.0f, 0.0f));
f.admin.get<HealthComponent>(healed).hp = f.admin.get<HealthComponent>(healed).maxHp; f.admin.get<HealthComponent>(healed).hp = f.admin.get<HealthComponent>(healed).maxHp;
const float fallbackInitHp = f.admin.get<HealthComponent>(fallback).maxHp * 0.5f; const float fallbackInitHp = f.admin.get<HealthComponent>(fallback).maxHp * 0.5f;
@@ -740,10 +740,10 @@ TEST_CASE("RepairSystem: tool falls back when its target is destroyed",
{ {
Fixture f; Fixture f;
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
false, repairLayout); false, repairLayout);
const entt::entity gone = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f)); const entt::entity gone = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f));
const entt::entity fallback = f.ships.spawn("interceptor", 1, QVector2D(15.0f, 0.0f)); const entt::entity fallback = f.ships.spawn("interceptor", QVector2D(15.0f, 0.0f));
const float fallbackInitHp = f.admin.get<HealthComponent>(fallback).maxHp * 0.5f; const float fallbackInitHp = f.admin.get<HealthComponent>(fallback).maxHp * 0.5f;
f.admin.get<HealthComponent>(fallback).hp = fallbackInitHp; f.admin.get<HealthComponent>(fallback).hp = fallbackInitHp;
@@ -763,10 +763,10 @@ TEST_CASE("RepairSystem: tool target is cleared when no repairable target is in
{ {
Fixture f; Fixture f;
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
false, repairLayout); false, repairLayout);
// damaged but beyond repair range (80) // damaged but beyond repair range (80)
const entt::entity outOfRange = f.ships.spawn("interceptor", 1, QVector2D(150.0f, 0.0f)); const entt::entity outOfRange = f.ships.spawn("interceptor", QVector2D(150.0f, 0.0f));
const float initHp = f.admin.get<HealthComponent>(outOfRange).maxHp * 0.5f; const float initHp = f.admin.get<HealthComponent>(outOfRange).maxHp * 0.5f;
f.admin.get<HealthComponent>(outOfRange).hp = initHp; f.admin.get<HealthComponent>(outOfRange).hp = initHp;
@@ -785,9 +785,9 @@ TEST_CASE("RepairSystem: two repair modules both heal the chosen target additive
{ {
Fixture f; Fixture f;
const ShipLayoutConfig repairLayout = makeTwoModuleLayout("repair_tool"); const ShipLayoutConfig repairLayout = makeTwoModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
false, repairLayout); false, repairLayout);
const entt::entity targetA = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f)); const entt::entity targetA = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f));
const float initHp = f.admin.get<HealthComponent>(targetA).maxHp * 0.5f; const float initHp = f.admin.get<HealthComponent>(targetA).maxHp * 0.5f;
f.admin.get<HealthComponent>(targetA).hp = initHp; f.admin.get<HealthComponent>(targetA).hp = initHp;
@@ -813,10 +813,10 @@ TEST_CASE("RepairSystem: two modules both fall back and heal the same target",
{ {
Fixture f; Fixture f;
const ShipLayoutConfig repairLayout = makeTwoModuleLayout("repair_tool"); const ShipLayoutConfig repairLayout = makeTwoModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
false, repairLayout); false, repairLayout);
const entt::entity healed = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f)); const entt::entity healed = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f));
const entt::entity targetB = f.ships.spawn("interceptor", 1, QVector2D(20.0f, 0.0f)); const entt::entity targetB = f.ships.spawn("interceptor", QVector2D(20.0f, 0.0f));
f.admin.get<HealthComponent>(healed).hp = f.admin.get<HealthComponent>(healed).maxHp; f.admin.get<HealthComponent>(healed).hp = f.admin.get<HealthComponent>(healed).maxHp;
const float initHp = f.admin.get<HealthComponent>(targetB).maxHp * 0.5f; const float initHp = f.admin.get<HealthComponent>(targetB).maxHp * 0.5f;
@@ -847,7 +847,7 @@ TEST_CASE("RepairSystem: does not crash when a tool's owner is not a repair ship
Fixture f; Fixture f;
// Bare child entity: RepairToolComponent + ModuleOwnerComponent, owner is a combat ship. // Bare child entity: RepairToolComponent + ModuleOwnerComponent, owner is a combat ship.
const entt::entity ownerShip = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity ownerShip = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
const entt::entity moduleEntity = f.admin.createModuleEntity(); const entt::entity moduleEntity = f.admin.createModuleEntity();
RepairToolComponent rt; RepairToolComponent rt;
rt.repairAmountHp = 1.0f; rt.repairAmountHp = 1.0f;
@@ -868,7 +868,7 @@ TEST_CASE("RepairSystem: repair tool does not repair an HQ", "[behavior]")
{ {
Fixture f; Fixture f;
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
false, repairLayout); false, repairLayout);
// A damaged, same-faction HQ in repair range — spawned as a station and tagged // A damaged, same-faction HQ in repair range — spawned as a station and tagged
@@ -890,7 +890,7 @@ TEST_CASE("RepairSystem: repair tool still repairs a damaged defence station", "
{ {
Fixture f; Fixture f;
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
false, repairLayout); false, repairLayout);
// A damaged, same-faction defence station (no HQ tag) in repair range. // A damaged, same-faction defence station (no HQ tag) in repair range.
@@ -914,7 +914,7 @@ TEST_CASE("BehaviorSystem: salvage ship orbits nearest scrap", "[behavior]")
{ {
Fixture f; Fixture f;
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager"); const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f),
false, salvageLayout); false, salvageLayout);
const QVector2D scrapPos(100.0f, 0.0f); const QVector2D scrapPos(100.0f, 0.0f);
@@ -937,7 +937,7 @@ TEST_CASE("BehaviorSystem: salvage ship collects scrap on arrival", "[behavior]"
{ {
Fixture f; Fixture f;
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager"); const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f),
false, salvageLayout); false, salvageLayout);
const entt::entity scrapEntity = f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000); const entt::entity scrapEntity = f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
@@ -967,7 +967,7 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b
REQUIRE(f.buildings.findBuilding(bayId) != nullptr); REQUIRE(f.buildings.findBuilding(bayId) != nullptr);
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager"); const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(5.0f, 0.0f), const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(5.0f, 0.0f),
false, salvageLayout); false, salvageLayout);
{ {
REQUIRE(f.admin.isValid(firstSalvageChild(f.admin, ship))); REQUIRE(f.admin.isValid(firstSalvageChild(f.admin, ship)));
@@ -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)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1001,7 +1040,7 @@ TEST_CASE("SalvagerSystem: module does not collect scrap beyond its collection r
// collection_range_m_formula = "50"; scrap at distance 55 must not be collected. // collection_range_m_formula = "50"; scrap at distance 55 must not be collected.
Fixture f; Fixture f;
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager"); const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f),
false, salvageLayout); false, salvageLayout);
f.scraps.spawn(QVector2D(55.0f, 0.0f), 1, 100000); f.scraps.spawn(QVector2D(55.0f, 0.0f), 1, 100000);
@@ -1016,7 +1055,7 @@ TEST_CASE("SalvagerSystem: module collects scrap within its collection range",
// collection_range_m_formula = "50"; scrap at distance 45 must be collected. // collection_range_m_formula = "50"; scrap at distance 45 must be collected.
Fixture f; Fixture f;
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager"); const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f),
false, salvageLayout); false, salvageLayout);
f.scraps.spawn(QVector2D(45.0f, 0.0f), 1, 100000); f.scraps.spawn(QVector2D(45.0f, 0.0f), 1, 100000);
@@ -1033,7 +1072,7 @@ TEST_CASE("SalvagerSystem: collection sets cooldown on module", "[behavior]")
{ {
Fixture f; Fixture f;
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager"); const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f),
false, salvageLayout); false, salvageLayout);
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000); f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
@@ -1051,7 +1090,7 @@ TEST_CASE("SalvagerSystem: module on cooldown does not collect scrap", "[behavio
{ {
Fixture f; Fixture f;
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager"); const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f),
false, salvageLayout); false, salvageLayout);
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000); f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
@@ -1066,7 +1105,7 @@ TEST_CASE("SalvagerSystem: module collects again after cooldown expires", "[beha
{ {
Fixture f; Fixture f;
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager"); const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f),
false, salvageLayout); false, salvageLayout);
const entt::entity sc = firstSalvageChild(f.admin, ship); const entt::entity sc = firstSalvageChild(f.admin, ship);
@@ -1093,7 +1132,7 @@ TEST_CASE("SalvagerSystem: two salvage modules collect independently in same tic
{ {
Fixture f; Fixture f;
const ShipLayoutConfig salvageLayout = makeTwoModuleLayout("salvager"); const ShipLayoutConfig salvageLayout = makeTwoModuleLayout("salvager");
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f),
false, salvageLayout); false, salvageLayout);
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000); f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
@@ -1110,7 +1149,7 @@ TEST_CASE("SalvagerSystem: second salvage module does not collect when first is
// One module on cooldown, one ready: only the ready module collects. // One module on cooldown, one ready: only the ready module collects.
Fixture f; Fixture f;
const ShipLayoutConfig salvageLayout = makeTwoModuleLayout("salvager"); const ShipLayoutConfig salvageLayout = makeTwoModuleLayout("salvager");
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f),
false, salvageLayout); false, salvageLayout);
// Put the first salvage child on cooldown. // Put the first salvage child on cooldown.
@@ -1141,7 +1180,7 @@ TEST_CASE("SalvagerSystem: second salvage module does not collect when first is
TEST_CASE("SensorRange: sensorRange is populated from config formula at spawn", "[sensor]") TEST_CASE("SensorRange: sensorRange is populated from config formula at spawn", "[sensor]")
{ {
Fixture f; Fixture f;
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity e = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
REQUIRE(f.admin.get<SensorRangeComponent>(e).value_tiles == Approx(200.0f)); REQUIRE(f.admin.get<SensorRangeComponent>(e).value_tiles == Approx(200.0f));
} }
@@ -1152,8 +1191,8 @@ TEST_CASE("SensorRange: sensorRange is populated from config formula at spawn",
TEST_CASE("SensorRange: player combat ship acquires enemy just inside sensor range", "[sensor]") TEST_CASE("SensorRange: player combat ship acquires enemy just inside sensor range", "[sensor]")
{ {
Fixture f; Fixture f;
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(190.0f, 0.0f), const entt::entity enemy = f.ships.spawn("interceptor", QVector2D(190.0f, 0.0f),
/*isEnemy=*/true); /*isEnemy=*/true);
f.decide(); f.decide();
@@ -1164,8 +1203,8 @@ TEST_CASE("SensorRange: player combat ship acquires enemy just inside sensor ran
TEST_CASE("SensorRange: player combat ship ignores enemy just outside sensor range", "[sensor]") TEST_CASE("SensorRange: player combat ship ignores enemy just outside sensor range", "[sensor]")
{ {
Fixture f; Fixture f;
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f), /*isEnemy=*/true); f.ships.spawn("interceptor", QVector2D(210.0f, 0.0f), /*isEnemy=*/true);
f.decide(); f.decide();
@@ -1175,8 +1214,8 @@ TEST_CASE("SensorRange: player combat ship ignores enemy just outside sensor ran
TEST_CASE("SensorRange: enemy ship ignores player just outside sensor range", "[sensor]") TEST_CASE("SensorRange: enemy ship ignores player just outside sensor range", "[sensor]")
{ {
Fixture f; Fixture f;
f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f), const entt::entity enemy = f.ships.spawn("interceptor", QVector2D(210.0f, 0.0f),
/*isEnemy=*/true); /*isEnemy=*/true);
f.decide(); f.decide();
@@ -1194,9 +1233,9 @@ TEST_CASE("SensorRange: repair ship retreats from enemy within sensor range", "[
const QVector2D rallyPoint(-100.0f, 0.0f); const QVector2D rallyPoint(-100.0f, 0.0f);
f.ships.setRallyPoint(rallyPoint); f.ships.setRallyPoint(rallyPoint);
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
false, repairLayout); false, repairLayout);
f.ships.spawn("interceptor", 1, QVector2D(200.0f, 0.0f), /*isEnemy=*/true); f.ships.spawn("interceptor", QVector2D(200.0f, 0.0f), /*isEnemy=*/true);
f.decide(); f.decide();
@@ -1208,9 +1247,9 @@ TEST_CASE("SensorRange: repair ship does not retreat from enemy beyond sensor ra
{ {
Fixture f; Fixture f;
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
false, repairLayout); false, repairLayout);
f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f), /*isEnemy=*/true); f.ships.spawn("interceptor", QVector2D(300.0f, 0.0f), /*isEnemy=*/true);
f.decide(); f.decide();
@@ -1225,9 +1264,9 @@ TEST_CASE("SensorRange: repair ship does not acquire damaged ally beyond sensor
{ {
Fixture f; Fixture f;
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f),
false, repairLayout); false, repairLayout);
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f)); const entt::entity friendly = f.ships.spawn("interceptor", QVector2D(300.0f, 0.0f));
f.admin.get<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f; f.admin.get<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
f.decide(); f.decide();
@@ -1243,7 +1282,7 @@ TEST_CASE("SensorRange: salvage ship ignores scrap beyond sensor range", "[senso
{ {
Fixture f; Fixture f;
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager"); const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f),
false, salvageLayout); false, salvageLayout);
f.scraps.spawn(QVector2D(300.0f, 0.0f), 1, 100000); f.scraps.spawn(QVector2D(300.0f, 0.0f), 1, 100000);
@@ -1261,8 +1300,8 @@ TEST_CASE("Orbit: combat ship's intent carries the target center and orbit radiu
"[orbit]") "[orbit]")
{ {
Fixture f; Fixture f;
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f), const entt::entity enemy = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f),
/*isEnemy=*/true); /*isEnemy=*/true);
f.decide(); f.decide();
@@ -1282,7 +1321,7 @@ TEST_CASE("Orbit: rally ship orbits the rally point at the configured rally radi
Fixture f; Fixture f;
const QVector2D rallyPoint(-50.0f, 0.0f); const QVector2D rallyPoint(-50.0f, 0.0f);
f.ships.setRallyPoint(rallyPoint); f.ships.setRallyPoint(rallyPoint);
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
f.decide(); f.decide();
@@ -1298,8 +1337,8 @@ TEST_CASE("Orbit: combat ship settles near the orbit radius and circles a statio
{ {
Fixture f; Fixture f;
const QVector2D enemyPos(80.0f, 0.0f); const QVector2D enemyPos(80.0f, 0.0f);
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
const entt::entity enemy = f.ships.spawn("interceptor", 1, enemyPos, /*isEnemy=*/true); const entt::entity enemy = f.ships.spawn("interceptor", enemyPos, /*isEnemy=*/true);
const float orbitRadius = f.admin.get<AttackBehavior>(player).orbitRadius_tiles; const float orbitRadius = f.admin.get<AttackBehavior>(player).orbitRadius_tiles;
REQUIRE(orbitRadius > 0.0f); REQUIRE(orbitRadius > 0.0f);

View File

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

View File

@@ -8,6 +8,7 @@
#include "Blueprint.h" #include "Blueprint.h"
#include "Building.h" #include "Building.h"
#include "BuildingConfig.h"
#include "BuildingsConfig.h" #include "BuildingsConfig.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "BuildingType.h" #include "BuildingType.h"
@@ -696,6 +697,92 @@ TEST_CASE("Blueprint placement: recipe transfers to building after construction
REQUIRE(b->recipeId == "mine_copper_ore"); REQUIRE(b->recipeId == "mine_copper_ore");
} }
// ---------------------------------------------------------------------------
// Blueprint capture from construction sites (REQ-UI-BLUEPRINT-CREATE,
// REQ-UI-BLUEPRINT-STORAGE): a still-under-construction building is a valid
// blueprint source, captured identically to an operational building.
// ---------------------------------------------------------------------------
TEST_CASE("Blueprint creation: a construction site is captured", "[blueprint]")
{
Simulation sim(loadConfig());
// Freshly placed → a ConstructionSite (not ticked to completion). A 1x1 belt keeps
// the body-cell bounding-box centered on the anchor, so a single site → zero offset.
const BuildingId id =
SimulationTestAccess::place(sim, BuildingType::Belt, QPoint(-2, 0), Rotation::East);
REQUIRE(id != kInvalidBuildingId);
REQUIRE(sim.buildings().findSite(id) != nullptr);
REQUIRE(sim.buildings().findBuilding(id) == nullptr);
const Blueprint bp = captureBlueprintFromSelection(sim, { id });
REQUIRE(bp.buildings.size() == 1);
REQUIRE(bp.buildings[0].type == BuildingType::Belt);
REQUIRE(bp.buildings[0].offset == QPoint(0, 0)); // single 1x1 building → zero offset
}
TEST_CASE("Blueprint creation: a construction site's recipe is captured", "[blueprint]")
{
Simulation sim(loadConfig());
const BuildingId id =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
REQUIRE(id != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore");
const Blueprint bp = captureBlueprintFromSelection(sim, { id });
REQUIRE(bp.buildings.size() == 1);
REQUIRE(bp.buildings[0].recipeId == "mine_iron_ore");
}
TEST_CASE("Blueprint creation: mixed operational building and construction site are both captured",
"[blueprint]")
{
Simulation sim(loadConfig());
// Building A: place, configure, and tick to completion so it is operational.
const BuildingId idA =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
REQUIRE(idA != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(idA, "mine_iron_ore");
for (int i = 0; i <= static_cast<int>(secondsToTicks(10.0)); ++i) { sim.tick(); }
REQUIRE(sim.buildings().findBuilding(idA) != nullptr);
// Building B: place and configure, but leave as a construction site.
const BuildingId idB =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-6, 0), Rotation::East);
REQUIRE(idB != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(idB, "mine_copper_ore");
REQUIRE(sim.buildings().findSite(idB) != nullptr);
const Blueprint bp = captureBlueprintFromSelection(sim, { idA, idB });
REQUIRE(bp.buildings.size() == 2);
REQUIRE(bp.buildings[0].type == BuildingType::Miner);
REQUIRE(bp.buildings[1].type == BuildingType::Miner);
// Both the operational building's and the site's configs come through.
std::vector<std::string> recipes = { bp.buildings[0].recipeId, bp.buildings[1].recipeId };
std::sort(recipes.begin(), recipes.end());
REQUIRE(recipes == std::vector<std::string>{ "mine_copper_ore", "mine_iron_ore" });
// Distinct anchors → distinct offsets.
REQUIRE(bp.buildings[0].offset != bp.buildings[1].offset);
}
TEST_CASE("Blueprint creation: selectionHasPlaceableBuilding sees a construction site", "[blueprint]")
{
Simulation sim(loadConfig());
REQUIRE_FALSE(selectionHasPlaceableBuilding(sim, {}));
const BuildingId id =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
REQUIRE(id != kInvalidBuildingId);
REQUIRE(sim.buildings().findSite(id) != nullptr);
REQUIRE(selectionHasPlaceableBuilding(sim, { id }));
}
TEST_CASE("Blueprint placement: interceptor schematic is unlocked at game start", "[blueprint]") TEST_CASE("Blueprint placement: interceptor schematic is unlocked at game start", "[blueprint]")
{ {
// "interceptor" has unlock_at_station_level = -1 in the test config. // "interceptor" has unlock_at_station_level = -1 in the test config.

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

@@ -58,12 +58,28 @@ static void runTicks(BuildingSystem& bs, BeltSystem& belts, int n, Tick& tick)
bs.tickConstruction(tick); bs.tickConstruction(tick);
bs.tickBeltPull(); bs.tickBeltPull();
bs.tickProduction(tick); bs.tickProduction(tick);
bs.tickBeltPush(); bs.tickOutputBelts();
belts.tick(); belts.tick();
++tick; ++tick;
} }
} }
// All items currently on a building's output side: buffered plus still-emerging on
// the virtual output belts (REQ-MAT-OUTPUT-EMERGE). A produced item leaves the
// output buffer the moment it starts emerging, so tests count both.
static std::vector<Item> outputSideItems(const Building& b)
{
std::vector<Item> items = b.outputBuffer.items;
for (const std::vector<BeltItemSlot>& lane : b.emergingItems)
{
for (const BeltItemSlot& slot : lane)
{
items.push_back(slot.item);
}
}
return items;
}
// Owns a BuildingSystem and its dependencies for placement-bounds tests. // Owns a BuildingSystem and its dependencies for placement-bounds tests.
struct PlacementFixture struct PlacementFixture
{ {
@@ -214,13 +230,13 @@ TEST_CASE("BuildingSystem: placing a belt registers it with BeltSystem after con
bs.place(BuildingType::Belt, QPoint(5, 5), Rotation::East, 0); bs.place(BuildingType::Belt, QPoint(5, 5), Rotation::East, 0);
// Belt is queued — not yet in BeltSystem. // Belt is queued — not yet in BeltSystem.
REQUIRE_FALSE(belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore"))); REQUIRE_FALSE(belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore"), Rotation::East));
// Complete construction (1 s). // Complete construction (1 s).
Tick tick = 0; Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick); runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
REQUIRE(belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore"))); REQUIRE(belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore"), Rotation::East));
REQUIRE(bs.allBuildings().size() == 1); REQUIRE(bs.allBuildings().size() == 1);
REQUIRE(bs.allBuildings()[0].type == BuildingType::Belt); REQUIRE(bs.allBuildings()[0].type == BuildingType::Belt);
REQUIRE(bs.allBuildings()[0].anchor == QPoint(5, 5)); REQUIRE(bs.allBuildings()[0].anchor == QPoint(5, 5));
@@ -402,8 +418,11 @@ TEST_CASE("BuildingSystem: miner produces iron_ore after recipe duration", "[bui
const Building* b = bs.findBuilding(id); const Building* b = bs.findBuilding(id);
REQUIRE(b != nullptr); REQUIRE(b != nullptr);
REQUIRE_FALSE(b->outputBuffer.items.empty()); // No belt at the output port, so the produced item emerges and stays on the
REQUIRE(b->outputBuffer.items.front().type.id == "iron_ore"); // building's virtual output belt (REQ-MAT-OUTPUT-EMERGE).
const std::vector<Item> out = outputSideItems(*b);
REQUIRE(out.size() == 1);
REQUIRE(out.front().type.id == "iron_ore");
} }
TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]") TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
@@ -436,7 +455,9 @@ TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
const Building* b = bs.findBuilding(id); const Building* b = bs.findBuilding(id);
REQUIRE(b != nullptr); REQUIRE(b != nullptr);
REQUIRE(static_cast<int>(b->outputBuffer.items.size()) == 2); // Both produced items are held on the output side (buffer + emerging lane),
// which is what the capacity rule counts (REQ-MAT-OUTPUT-EMERGE).
REQUIRE(b->outputItemCount() == 2);
REQUIRE_FALSE(b->production.has_value()); REQUIRE_FALSE(b->production.has_value());
} }
@@ -474,7 +495,8 @@ TEST_CASE("BuildingSystem: productionBuildingCount excludes construction sites",
runTicks(bs, belts, static_cast<int>(secondsToTicks(15.0)), tick); runTicks(bs, belts, static_cast<int>(secondsToTicks(15.0)), tick);
REQUIRE(bs.productionBuildingCount() == 2); REQUIRE(bs.productionBuildingCount() == 2);
// Neither has a recipe selected, so neither has an active cycle. // Neither is producing yet: the miner has no recipe selected, and the
// smelter (auto-recipe, REQ-BLD-SMELTER) has no input feeding it.
REQUIRE(bs.activeProductionBuildingCount() == 0); REQUIRE(bs.activeProductionBuildingCount() == 0);
bs.setRecipe(minerId, "mine_iron_ore"); bs.setRecipe(minerId, "mine_iron_ore");
@@ -514,7 +536,7 @@ TEST_CASE("BuildingSystem: activeProductionBuildingCount tracks production cycle
const Building* b = bs.findBuilding(id); const Building* b = bs.findBuilding(id);
REQUIRE(b != nullptr); REQUIRE(b != nullptr);
REQUIRE(static_cast<int>(b->outputBuffer.items.size()) == 2); REQUIRE(b->outputItemCount() == 2);
REQUIRE_FALSE(b->production.has_value()); REQUIRE_FALSE(b->production.has_value());
REQUIRE(bs.activeProductionBuildingCount() == 0); REQUIRE(bs.activeProductionBuildingCount() == 0);
} }
@@ -542,7 +564,8 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing
// Smelter mask ["AA ","AA>"] → body (0,0),(1,0),(0,1),(1,1). // Smelter mask ["AA ","AA>"] → body (0,0),(1,0),(0,1),(1,1).
// Output port (2,1) East. Input port example: (2,0) West. // Output port (2,1) East. Input port example: (2,0) West.
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0); const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(sid, "iron_ingot"); // Smelters have no recipe selection (REQ-BLD-SMELTER); they auto-accept any
// ore/scrap that is an input to a smelter recipe.
// Complete construction (15s → tick 450+1 = 451 ticks). // Complete construction (15s → tick 450+1 = 451 ticks).
Tick tick = 0; Tick tick = 0;
@@ -557,10 +580,192 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing
const Building* b = bs.findBuilding(sid); const Building* b = bs.findBuilding(sid);
REQUIRE(b != nullptr); REQUIRE(b != nullptr);
const std::map<ItemType, int>::const_iterator it = // The item was accepted; it may still be travelling inward on the input belt,
// so count buffered + in-transit (REQ-MAT-INPUT-INTAKE).
REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) >= 1);
}
// An accepted input item travels inward on its input belt before it becomes usable
// stock: it is reserved (counts against the cap) on entry and only enters the
// buffer on reaching the tile centre (REQ-MAT-INPUT-INTAKE).
TEST_CASE("BuildingSystem: accepted input travels inward before entering the buffer",
"[building]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(static_cast<double>(kTickRateHz)); // fast belt: 1 tile/tick
int stock = 0;
std::mt19937 rng(0);
BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts,
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
belts.placeBelt(QPoint(2, 0), Rotation::West);
belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
belts.tick();
bs.tickBeltPull(); // accepts the item onto the input belt at progress 0.0
const Building* b = bs.findBuilding(sid);
REQUIRE(b != nullptr);
// Reserved but not yet consumable: nothing in the buffer, but it counts against
// the cap via pendingInputCount.
const std::map<ItemType, int>::const_iterator it0 =
b->inputBuffer.counts.find(ItemType{"iron_ore"}); b->inputBuffer.counts.find(ItemType{"iron_ore"});
REQUIRE(it != b->inputBuffer.counts.end()); REQUIRE((it0 == b->inputBuffer.counts.end() || it0->second == 0));
REQUIRE(it->second >= 1); REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) == 1);
// One more pull tick advances the input belt to the centre; the item arrives.
bs.tickBeltPull();
REQUIRE(b->inputBuffer.counts.at(ItemType{"iron_ore"}) == 1);
REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) == 1);
}
// The acceptance test counts in-transit items, so buffered + reserved never exceeds
// the per-material cap; excess items stay on the belt (REQ-MAT-INPUT-INTAKE).
TEST_CASE("BuildingSystem: input reservation caps buffered plus in-transit at the cap",
"[building]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(static_cast<double>(kTickRateHz)); // fast belt
int stock = 0;
std::mt19937 rng(0);
BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts,
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0);
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(25.0)) + 1, tick);
// Feed scrap via an input belt without ever running production (only pull), so
// the buffer fills and stays full. Try to over-fill it well past the cap.
belts.placeBelt(QPoint(-1, 0), Rotation::East);
for (int i = 0; i < 20; ++i)
{
belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East);
belts.tick();
bs.tickBeltPull();
}
const Building* b = bs.findBuilding(id);
REQUIRE(b != nullptr);
const int cap = b->inputBuffer.caps.at(ItemType{"scrap"});
REQUIRE(cap > 0);
// buffered + in-transit is capped; the plant never over-pulls.
REQUIRE(b->pendingInputCount(ItemType{"scrap"}) == cap);
// Excess scrap is left stuck on the feeding belt rather than silently dropped.
REQUIRE(belts.peekItem(eastPort(QPoint(-1, 0))).has_value());
}
// A smelter auto-selects the matching recipe for whatever it is fed, with no
// player recipe selection (REQ-BLD-SMELTER).
TEST_CASE("BuildingSystem: smelter auto-smelts ore without a recipe selection",
"[building]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(static_cast<double>(kTickRateHz));
int stock = 0;
std::mt19937 rng(0);
BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts,
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
// Feed 2 iron_ore (the test-config iron_ingot recipe needs 2) via a
// west-flowing belt at input port (2,0).
belts.placeBelt(QPoint(2, 0), Rotation::West);
for (int i = 0; i < 2; ++i)
{
belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
belts.tick();
bs.tickBeltPull();
}
// iron_ingot recipe cycle is 2s; run to completion.
runTicks(bs, belts, static_cast<int>(secondsToTicks(2.0)) + 2, tick);
const Building* b = bs.findBuilding(sid);
REQUIRE(b != nullptr);
bool hasIronIngot = false;
for (const Item& item : outputSideItems(*b))
{
if (item.type.id == "iron_ingot") { hasIronIngot = true; }
}
REQUIRE(hasIronIngot);
}
// With mixed inputs, the smelter runs whichever recipe is currently satisfiable
// and leaves an incomplete batch of another input waiting (see the union-of-
// inputs caps in initAutoBuffers).
TEST_CASE("BuildingSystem: smelter runs a satisfiable recipe while an incomplete batch waits",
"[building]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(static_cast<double>(kTickRateHz));
int stock = 0;
std::mt19937 rng(0);
BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts,
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
// Feed 1 iron_ore (iron_ingot needs 2 — incomplete) then 2 copper_ore
// (copper_ingot needs 2 — satisfiable) via the west-flowing input belt.
belts.placeBelt(QPoint(2, 0), Rotation::West);
const char* fed[] = { "iron_ore", "copper_ore", "copper_ore" };
for (const char* id : fed)
{
belts.tryPutItem(QPoint(2, 0), makeItem(id));
belts.tick();
bs.tickBeltPull();
}
// copper_ingot cycle is 2.5s; run to completion.
runTicks(bs, belts, static_cast<int>(secondsToTicks(2.5)) + 2, tick);
const Building* b = bs.findBuilding(sid);
REQUIRE(b != nullptr);
// Copper was smelted; the lone iron_ore still waits for a second unit.
bool hasCopperIngot = false;
for (const Item& item : outputSideItems(*b))
{
if (item.type.id == "copper_ingot") { hasCopperIngot = true; }
}
REQUIRE(hasCopperIngot);
const std::map<ItemType, int>::const_iterator ironIt =
b->inputBuffer.counts.find(ItemType{"iron_ore"});
REQUIRE(ironIt != b->inputBuffer.counts.end());
REQUIRE(ironIt->second == 1);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -602,6 +807,84 @@ TEST_CASE("BuildingSystem: miner output buffer drains onto adjacent belt", "[bui
REQUIRE(item->type.id == "iron_ore"); REQUIRE(item->type.id == "iron_ore");
} }
// Two directly adjacent buildings whose ports meet transfer items with no belt in
// between: a miner's iron_ore output feeds straight into a smelter, which smelts it
// (REQ-MAT-DIRECT-COUPLE).
TEST_CASE("BuildingSystem: output port couples directly into an adjacent input port",
"[building]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0;
std::mt19937 rng(0);
BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts,
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
// Miner at (0,0): body (0,0),(1,0),(0,1); output port tile (1,1) flowing East.
const BuildingId minerId = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(minerId, "mine_iron_ore");
// Smelter anchored at (1,1): body (1,1),(2,1),(1,2),(2,2). Its body cell (1,1) is
// the miner's output-port tile, and its west input edge there faces East, so the
// two ports meet — no belt placed anywhere.
const BuildingId smelterId = bs.place(BuildingType::Smelter, QPoint(1, 1), Rotation::East, 0);
Tick tick = 0;
// Smelter build (15s) + margin for coupling and a smelt cycle.
runTicks(bs, belts, static_cast<int>(secondsToTicks(30.0)), tick);
const Building* smelter = bs.findBuilding(smelterId);
REQUIRE(smelter != nullptr);
// iron_ore reached the smelter over the direct coupling and was smelted.
bool hasIronIngot = false;
for (const Item& produced : outputSideItems(*smelter))
{
if (produced.type.id == "iron_ingot") { hasIronIngot = true; }
}
REQUIRE(hasIronIngot);
}
// A producer coupled to a building that cannot accept its item delivers nothing; the
// item stays stuck at the producer's output port (REQ-MAT-DIRECT-COUPLE acceptance).
TEST_CASE("BuildingSystem: direct coupling to a non-consumer leaves the item stuck",
"[building]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0;
std::mt19937 rng(0);
BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts,
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
// Producing miner at (0,0), output port (1,1) East.
const BuildingId minerId = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(minerId, "mine_iron_ore");
// A second, idle miner anchored at (1,1) occupies the output-port tile but takes
// no inputs, so it cannot accept the iron_ore.
const BuildingId sinkId = bs.place(BuildingType::Miner, QPoint(1, 1), Rotation::East, 0);
Tick tick = 0;
// Both miners build sequentially (10s each), then the producer runs and jams.
runTicks(bs, belts, static_cast<int>(secondsToTicks(25.0)), tick);
const Building* miner = bs.findBuilding(minerId);
const Building* sink = bs.findBuilding(sinkId);
REQUIRE(miner != nullptr);
REQUIRE(sink != nullptr);
// Nothing was delivered, and the producer's output side has backed up to its cap.
REQUIRE(sink->pendingInputCount(ItemType{"iron_ore"}) == 0);
REQUIRE(miner->outputItemCount() == miner->outputBuffer.capacity);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// setRecipe clears buffers // setRecipe clears buffers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -633,13 +916,15 @@ TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production"
{ {
const Building* b = bs.findBuilding(id); const Building* b = bs.findBuilding(id);
REQUIRE(b != nullptr); REQUIRE(b != nullptr);
REQUIRE_FALSE(b->outputBuffer.items.empty()); REQUIRE(b->outputItemCount() > 0);
} }
bs.setRecipe(id, "mine_copper_ore"); bs.setRecipe(id, "mine_copper_ore");
const Building* b = bs.findBuilding(id); const Building* b = bs.findBuilding(id);
REQUIRE(b->outputBuffer.items.empty()); // Clearing the output buffer on a recipe change also discards emerging items
// (REQ-MAT-OUTPUT-EMERGE).
REQUIRE(b->outputItemCount() == 0);
REQUIRE_FALSE(b->production.has_value()); REQUIRE_FALSE(b->production.has_value());
} }
@@ -664,7 +949,8 @@ TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max
const BuildingId id = bs.place(BuildingType::ReprocessingPlant, const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0); QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(id, "reprocessing_cycle"); // Reprocessing plants have no recipe selection (REQ-BLD-REPROCESSING); the
// single reprocessing recipe is applied automatically on completion.
// Complete construction (25s). // Complete construction (25s).
Tick tick = 0; Tick tick = 0;
@@ -695,7 +981,8 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta
const BuildingId id = bs.place(BuildingType::ReprocessingPlant, const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0); QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(id, "reprocessing_cycle"); // Reprocessing plants have no recipe selection (REQ-BLD-REPROCESSING); the
// single reprocessing recipe is applied automatically on completion.
// Complete construction (25s). // Complete construction (25s).
Tick tick = 0; Tick tick = 0;
@@ -707,19 +994,17 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta
belts.placeBelt(QPoint(-1, 0), Rotation::East); belts.placeBelt(QPoint(-1, 0), Rotation::East);
for (int i = 0; i < 5; ++i) for (int i = 0; i < 5; ++i)
{ {
belts.tryPutItem(QPoint(-1, 0), makeItem("scrap")); belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East);
belts.tick(); belts.tick();
bs.tickBeltPull(); bs.tickBeltPull();
} }
// Verify scrap is in input buffer. // Verify all five scrap were accepted; some may still be travelling inward on
// the input belt (REQ-MAT-INPUT-INTAKE), so count buffered + in-transit.
{ {
const Building* b = bs.findBuilding(id); const Building* b = bs.findBuilding(id);
REQUIRE(b != nullptr); REQUIRE(b != nullptr);
const std::map<ItemType, int>::const_iterator it = REQUIRE(b->pendingInputCount(ItemType{"scrap"}) == 5);
b->inputBuffer.counts.find(ItemType{"scrap"});
REQUIRE(it != b->inputBuffer.counts.end());
REQUIRE(it->second == 5);
} }
// Run production cycle (3s = 90 ticks + 1 for the completion tick). // Run production cycle (3s = 90 ticks + 1 for the completion tick).

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
@@ -21,6 +22,7 @@ add_files(
ShipModuleTest.cpp ShipModuleTest.cpp
ThreatCostCalculatorTest.cpp ThreatCostCalculatorTest.cpp
RecipeSchematicTest.cpp RecipeSchematicTest.cpp
UnlockPrereqTest.cpp
ArtifactWinConditionTest.cpp ArtifactWinConditionTest.cpp
DeterminismTest.cpp DeterminismTest.cpp
CommandTest.cpp CommandTest.cpp

View File

@@ -14,6 +14,7 @@
#include "HealthComponent.h" #include "HealthComponent.h"
#include "HqProxyComponent.h" #include "HqProxyComponent.h"
#include "ModuleOwnerComponent.h" #include "ModuleOwnerComponent.h"
#include "ScrapDataComponent.h"
#include "ScrapSystem.h" #include "ScrapSystem.h"
#include "ShipSystem.h" #include "ShipSystem.h"
#include "Simulation.h" #include "Simulation.h"
@@ -106,8 +107,8 @@ TEST_CASE("CombatSystem: ship fires when cooldown=0 and target in range", "[comb
const ShipDef* combatDef = findCombatShip(f.cfg); const ShipDef* combatDef = findCombatShip(f.cfg);
REQUIRE(combatDef != nullptr); REQUIRE(combatDef != nullptr);
const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true); const entt::entity enemy = f.ships.spawn(combatDef->id, QVector2D(5.0f, 5.0f), true);
const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false); const entt::entity player = f.ships.spawn(combatDef->id, QVector2D(4.0f, 5.0f), false);
f.wireEnemyTarget(enemy, player); f.wireEnemyTarget(enemy, player);
const float hpBefore = f.admin.get<HealthComponent>(player).hp; const float hpBefore = f.admin.get<HealthComponent>(player).hp;
@@ -126,8 +127,8 @@ TEST_CASE("CombatSystem: cooldown prevents firing before it expires", "[combat]"
const ShipDef* combatDef = findCombatShip(f.cfg); const ShipDef* combatDef = findCombatShip(f.cfg);
REQUIRE(combatDef != nullptr); REQUIRE(combatDef != nullptr);
const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true); const entt::entity enemy = f.ships.spawn(combatDef->id, QVector2D(5.0f, 5.0f), true);
const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false); const entt::entity player = f.ships.spawn(combatDef->id, QVector2D(4.0f, 5.0f), false);
f.wireEnemyTarget(enemy, player); f.wireEnemyTarget(enemy, player);
{ {
@@ -162,8 +163,8 @@ TEST_CASE("CombatSystem: no fire when target is out of range", "[combat]")
const ShipDef* combatDef = findCombatShip(f.cfg); const ShipDef* combatDef = findCombatShip(f.cfg);
REQUIRE(combatDef != nullptr); REQUIRE(combatDef != nullptr);
const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(0.0f, 0.0f), true); const entt::entity enemy = f.ships.spawn(combatDef->id, QVector2D(0.0f, 0.0f), true);
const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(500.0f, 0.0f), false); const entt::entity player = f.ships.spawn(combatDef->id, QVector2D(500.0f, 0.0f), false);
f.wireEnemyTarget(enemy, player); f.wireEnemyTarget(enemy, player);
std::vector<BeamFiredEvent> events; std::vector<BeamFiredEvent> events;
@@ -199,7 +200,7 @@ TEST_CASE("CombatSystem: player station fires at enemy ship in range", "[combat]
REQUIRE(combatDef != nullptr); REQUIRE(combatDef != nullptr);
const entt::entity enemyShip = sim.ships().spawn( const entt::entity enemyShip = sim.ships().spawn(
combatDef->id, 1, combatDef->id,
QVector2D(stationCenter.x() + 1.0f, stationCenter.y()), QVector2D(stationCenter.x() + 1.0f, stationCenter.y()),
/*isEnemy=*/true); /*isEnemy=*/true);
@@ -237,7 +238,7 @@ TEST_CASE("CombatSystem: enemy station fires at player ship in range", "[combat]
REQUIRE(combatDef != nullptr); REQUIRE(combatDef != nullptr);
sim.ships().spawn( sim.ships().spawn(
combatDef->id, 1, combatDef->id,
QVector2D(stationCenter.x() - 1.0f, stationCenter.y()), QVector2D(stationCenter.x() - 1.0f, stationCenter.y()),
/*isEnemy=*/false); /*isEnemy=*/false);
@@ -275,7 +276,7 @@ TEST_CASE("CombatSystem: player ship fires at enemy station in range", "[combat]
REQUIRE(combatDef != nullptr); REQUIRE(combatDef != nullptr);
const entt::entity playerShip = sim.ships().spawn( const entt::entity playerShip = sim.ships().spawn(
combatDef->id, 1, combatDef->id,
QVector2D(stationCenter.x() - 1.0f, stationCenter.y()), QVector2D(stationCenter.x() - 1.0f, stationCenter.y()),
/*isEnemy=*/false); /*isEnemy=*/false);
@@ -303,8 +304,8 @@ TEST_CASE("CombatSystem: damage not applied before impact tick", "[combat]")
const ShipDef* combatDef = findCombatShip(f.cfg); const ShipDef* combatDef = findCombatShip(f.cfg);
REQUIRE(combatDef != nullptr); REQUIRE(combatDef != nullptr);
const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true); const entt::entity enemy = f.ships.spawn(combatDef->id, QVector2D(5.0f, 5.0f), true);
const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false); const entt::entity player = f.ships.spawn(combatDef->id, QVector2D(4.0f, 5.0f), false);
f.wireEnemyTarget(enemy, player); f.wireEnemyTarget(enemy, player);
const float hpBefore = f.admin.get<HealthComponent>(player).hp; const float hpBefore = f.admin.get<HealthComponent>(player).hp;
@@ -325,8 +326,8 @@ TEST_CASE("CombatSystem: damage applied exactly at impact tick", "[combat]")
const ShipDef* combatDef = findCombatShip(f.cfg); const ShipDef* combatDef = findCombatShip(f.cfg);
REQUIRE(combatDef != nullptr); REQUIRE(combatDef != nullptr);
const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true); const entt::entity enemy = f.ships.spawn(combatDef->id, QVector2D(5.0f, 5.0f), true);
const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false); const entt::entity player = f.ships.spawn(combatDef->id, QVector2D(4.0f, 5.0f), false);
f.wireEnemyTarget(enemy, player); f.wireEnemyTarget(enemy, player);
const float hpBefore = f.admin.get<HealthComponent>(player).hp; const float hpBefore = f.admin.get<HealthComponent>(player).hp;
@@ -344,8 +345,8 @@ TEST_CASE("CombatSystem: damage silently dropped if target already dead", "[comb
const ShipDef* combatDef = findCombatShip(f.cfg); const ShipDef* combatDef = findCombatShip(f.cfg);
REQUIRE(combatDef != nullptr); REQUIRE(combatDef != nullptr);
const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true); const entt::entity enemy = f.ships.spawn(combatDef->id, QVector2D(5.0f, 5.0f), true);
const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false); const entt::entity player = f.ships.spawn(combatDef->id, QVector2D(4.0f, 5.0f), false);
f.wireEnemyTarget(enemy, player); f.wireEnemyTarget(enemy, player);
std::vector<BeamFiredEvent> events; std::vector<BeamFiredEvent> events;
@@ -365,8 +366,8 @@ TEST_CASE("CombatSystem: damage still applied if shooter already dead", "[combat
const ShipDef* combatDef = findCombatShip(f.cfg); const ShipDef* combatDef = findCombatShip(f.cfg);
REQUIRE(combatDef != nullptr); REQUIRE(combatDef != nullptr);
const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true); const entt::entity enemy = f.ships.spawn(combatDef->id, QVector2D(5.0f, 5.0f), true);
const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false); const entt::entity player = f.ships.spawn(combatDef->id, QVector2D(4.0f, 5.0f), false);
f.wireEnemyTarget(enemy, player); f.wireEnemyTarget(enemy, player);
const float hpBefore = f.admin.get<HealthComponent>(player).hp; const float hpBefore = f.admin.get<HealthComponent>(player).hp;
@@ -392,7 +393,7 @@ TEST_CASE("CombatSystem: dead ship is removed after tick step 9", "[combat]")
const ShipDef* combatDef = findCombatShip(sim.config()); const ShipDef* combatDef = findCombatShip(sim.config());
REQUIRE(combatDef != nullptr); REQUIRE(combatDef != nullptr);
const entt::entity ship = sim.ships().spawn(combatDef->id, 1, const entt::entity ship = sim.ships().spawn(combatDef->id,
QVector2D(10.0f, 10.0f)); QVector2D(10.0f, 10.0f));
sim.admin().get<HealthComponent>(ship).hp = -1.0f; sim.admin().get<HealthComponent>(ship).hp = -1.0f;
@@ -406,24 +407,19 @@ TEST_CASE("CombatSystem: scrap is spawned on ship death", "[combat]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadConfig(), 42);
const ShipDef* droppingDef = nullptr; // Scrap dropped on death is derived from the ship's as-built threat cost
for (const ShipDef& def : sim.config().ships.ships) // (REQ-RES-SCRAP-DROP): round(threat * scrap_per_threat). The interceptor's
{ // threat is 59.0 and the test config sets scrap_per_threat = 1.0, so it drops
if (def.loot.scrapDrop > 0) // round(59.0 * 1.0) = 59 scrap.
{ const entt::entity ship = sim.ships().spawn("interceptor",
droppingDef = &def;
break;
}
}
REQUIRE(droppingDef != nullptr);
const entt::entity ship = sim.ships().spawn(droppingDef->id, 1,
QVector2D(10.0f, 10.0f)); QVector2D(10.0f, 10.0f));
sim.admin().get<HealthComponent>(ship).hp = -1.0f; sim.admin().get<HealthComponent>(ship).hp = -1.0f;
sim.tick(); sim.tick();
REQUIRE(!sim.scraps().allScrapInfo().empty()); const std::vector<ScrapInfo> scraps = sim.scraps().allScrapInfo();
REQUIRE(scraps.size() == 1);
CHECK(sim.admin().get<ScrapDataComponent>(scraps[0].entity).amount == 59);
} }
TEST_CASE("CombatSystem: HQ death sets game over", "[combat]") TEST_CASE("CombatSystem: HQ death sets game over", "[combat]")

View File

@@ -6,6 +6,7 @@
#include <stdexcept> #include <stdexcept>
#include <string> #include <string>
#include <system_error> #include <system_error>
#include <vector>
#include "BuildingType.h" #include "BuildingType.h"
#include "ConfigLoader.h" #include "ConfigLoader.h"
@@ -75,9 +76,17 @@ 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));
REQUIRE(cfg.world.scrapPerThreat == Approx(1.0));
// Optional header building blocks tooltip (REQ-UI-BLOCKS-TOOLTIP).
REQUIRE(cfg.world.buildingBlocksTooltip.has_value());
REQUIRE(*cfg.world.buildingBlocksTooltip ==
"Spend building blocks to build; deliver them to the HQ to gain more.");
// Spot-check that a config-derived formula computes as expected. // Spot-check that a config-derived formula computes as expected.
// threat_rate_formula = "x": evaluates to the input value. // threat_rate_formula = "x": evaluates to the input value.
@@ -99,6 +108,21 @@ 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);
// Optional per-building tooltip (REQ-UI-BUILD-TOOLTIP): the Salvage Bay
// defines one; the Miner leaves it unset.
REQUIRE(salvageBayIt->tooltip.has_value());
REQUIRE(*salvageBayIt->tooltip == "Drop-off point for salvage ships.");
REQUIRE_FALSE(minerIt->tooltip.has_value());
// recipes.toml — reprocessing cycle has three weighted outputs. // recipes.toml — reprocessing cycle has three weighted outputs.
const auto reproIt = std::find_if( const auto reproIt = std::find_if(
@@ -131,6 +155,21 @@ TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[c
REQUIRE(salvageShipIt != cfg.ships.ships.end()); REQUIRE(salvageShipIt != cfg.ships.ships.end());
REQUIRE(salvageShipIt->defaultModules.empty()); REQUIRE(salvageShipIt->defaultModules.empty());
// modules.toml — optional per-module tooltip (REQ-MOD-UI-MODULE-TOOLTIP):
// armor_plate defines one; salvager leaves it unset.
const auto armorPlateIt = std::find_if(
cfg.modules.modules.begin(), cfg.modules.modules.end(),
[](const ModuleDef& m) { return m.id == "armor_plate"; });
REQUIRE(armorPlateIt != cfg.modules.modules.end());
REQUIRE(armorPlateIt->tooltip.has_value());
REQUIRE(*armorPlateIt->tooltip == "Adds a large flat bonus to hit points.");
const auto salvagerModuleIt = std::find_if(
cfg.modules.modules.begin(), cfg.modules.modules.end(),
[](const ModuleDef& m) { return m.id == "salvager"; });
REQUIRE(salvagerModuleIt != cfg.modules.modules.end());
REQUIRE_FALSE(salvagerModuleIt->tooltip.has_value());
// stations.toml // stations.toml
REQUIRE(cfg.stations.playerStation.level == 5); REQUIRE(cfg.stations.playerStation.level == 5);
REQUIRE(cfg.stations.playerStation.hpFormula.evaluate(5.0) == Approx(500.0)); // 300 + 40*5 REQUIRE(cfg.stations.playerStation.hpFormula.evaluate(5.0) == Approx(500.0)); // 300 + 40*5
@@ -167,6 +206,7 @@ TEST_CASE("Missing field in world.toml is rejected with the field path", "[confi
height_tiles = 60 height_tiles = 60
refund_percentage = 75 refund_percentage = 75
scrap_despawn_seconds = 30 scrap_despawn_seconds = 30
scrap_per_threat = 0.01
tile_size_m = 10 tile_size_m = 10
belt_speed_mps = 20 belt_speed_mps = 20
starting_building_blocks = 100 starting_building_blocks = 100
@@ -183,7 +223,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
@@ -217,6 +257,7 @@ TEST_CASE("Malformed formula in world.toml is rejected with field identification
height_tiles = 60 height_tiles = 60
refund_percentage = 75 refund_percentage = 75
scrap_despawn_seconds = 30 scrap_despawn_seconds = 30
scrap_per_threat = 0.01
tile_size_m = 10 tile_size_m = 10
belt_speed_mps = 20 belt_speed_mps = 20
starting_building_blocks = 100 starting_building_blocks = 100
@@ -233,7 +274,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
@@ -268,6 +309,7 @@ TEST_CASE("Inverted wave gap range is rejected", "[config]")
height_tiles = 60 height_tiles = 60
refund_percentage = 75 refund_percentage = 75
scrap_despawn_seconds = 30 scrap_despawn_seconds = 30
scrap_per_threat = 0.01
tile_size_m = 10 tile_size_m = 10
belt_speed_mps = 20 belt_speed_mps = 20
@@ -279,7 +321,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
@@ -339,3 +381,151 @@ duration_seconds = 1.0
std::runtime_error); std::runtime_error);
} }
// --- unlock_requires (REQ-LOCK-PREREQ) ------------------------------------
namespace
{
// Copies every regular file from CONFIG_DIR into dir, giving tests a full,
// valid config set they can then perturb before calling loadFromDirectory.
void copyConfigInto(const std::filesystem::path& dir)
{
for (const std::filesystem::directory_entry& entry :
std::filesystem::directory_iterator(std::string(CONFIG_DIR)))
{
if (entry.is_regular_file())
{
std::filesystem::copy_file(entry.path(), dir / entry.path().filename());
}
}
}
} // namespace
TEST_CASE("unlock_requires parses into a ship def", "[config]")
{
TempConfigDir dir;
writeFile(dir.path() / "ships.toml", R"(
[[ship]]
id = "alpha"
unlock_at_station_level = 1
unlock_requires = ["beta", "gamma"]
layout = ["O"]
[ship.schematic]
materials = []
production_time_seconds = 5
[ship.health]
hp = 10
[ship.movement]
speed_mps = 1
main_acceleration_mpss = 1
maneuvering_acceleration_mpss = 1
angular_acceleration_radpss = 1
max_rotation_speed_radps = 1
[ship.sensor]
sensor_range_m = 10
)");
const ShipsConfig cfg = ConfigLoader::loadShips((dir.path() / "ships.toml").string());
REQUIRE(cfg.ships.size() == 1);
CHECK(cfg.ships[0].unlockRequires == std::vector<std::string>{"beta", "gamma"});
}
TEST_CASE("unlock_requires parses into a module def", "[config]")
{
TempConfigDir dir;
writeFile(dir.path() / "modules.toml", R"(
[[module]]
id = "m1"
unlock_at_station_level = 0
unlock_requires = ["dep"]
surface_mask = ["O"]
materials = [{item = "iron_ingot", amount = 1}]
production_time_seconds = 1
fill_color = "#ffffff"
glyph = "M"
)");
const ModulesConfig cfg = ConfigLoader::loadModules((dir.path() / "modules.toml").string());
REQUIRE(cfg.modules.size() == 1);
CHECK(cfg.modules[0].unlockRequires == std::vector<std::string>{"dep"});
}
TEST_CASE("unlock_requires parses only for assembler recipes", "[config]")
{
TempConfigDir dir;
writeFile(dir.path() / "recipes.toml", R"(
[[recipe]]
id = "gated_asm"
building = "assembler"
unlock_at_station_level = 1
unlock_requires = ["prereq_recipe"]
inputs = []
outputs = [{item = "foo", amount = 1}]
duration_seconds = 1.0
[[recipe]]
id = "a_miner"
building = "miner"
unlock_requires = ["ignored"]
inputs = []
outputs = [{item = "ore", amount = 1}]
duration_seconds = 1.0
)");
const RecipesConfig cfg = ConfigLoader::loadRecipes((dir.path() / "recipes.toml").string());
REQUIRE(cfg.recipes.size() == 2);
CHECK(cfg.recipes[0].unlockRequires == std::vector<std::string>{"prereq_recipe"});
// The field is only consumed for assembler recipe schematics; a miner recipe
// leaves it unparsed (empty).
CHECK(cfg.recipes[1].unlockRequires.empty());
}
TEST_CASE("unlock_requires referencing an unknown schematic is rejected at load", "[config]")
{
TempConfigDir dir;
copyConfigInto(dir.path());
std::ofstream out((dir.path() / "recipes.toml").string(), std::ios::app);
out << "\n[[recipe]]\n"
"id = \"gated_bogus\"\n"
"building = \"assembler\"\n"
"unlock_at_station_level = 1\n"
"unlock_requires = [\"___does_not_exist___\"]\n"
"inputs = []\n"
"outputs = [{item = \"circuit_board\", amount = 1}]\n"
"duration_seconds = 1.0\n";
out.close();
try
{
ConfigLoader::loadFromDirectory(dir.path().string());
FAIL("Expected exception");
}
catch (const std::runtime_error& e)
{
const std::string msg = e.what();
REQUIRE(msg.find("___does_not_exist___") != std::string::npos);
}
}
TEST_CASE("unlock_requires referencing a real schematic loads without error", "[config]")
{
TempConfigDir dir;
copyConfigInto(dir.path());
// "interceptor" is a ship schematic in the test config — a valid prerequisite.
std::ofstream out((dir.path() / "recipes.toml").string(), std::ios::app);
out << "\n[[recipe]]\n"
"id = \"gated_ok\"\n"
"building = \"assembler\"\n"
"unlock_at_station_level = 1\n"
"unlock_requires = [\"interceptor\"]\n"
"inputs = []\n"
"outputs = [{item = \"circuit_board\", amount = 1}]\n"
"duration_seconds = 1.0\n";
out.close();
REQUIRE_NOTHROW(ConfigLoader::loadFromDirectory(dir.path().string()));
}

View File

@@ -38,14 +38,13 @@ TEST_CASE("ConfigLoader: loadModules parses modules.toml", "[config][modules]")
CHECK(armor.materials.size() == 1); CHECK(armor.materials.size() == 1);
CHECK(armor.materials[0].item == "iron_ingot"); CHECK(armor.materials[0].item == "iron_ingot");
CHECK(armor.materials[0].amount == 2); CHECK(armor.materials[0].amount == 2);
CHECK(armor.playerProductionLevel == 1);
CHECK(armor.productionTimeSeconds == Approx(3.0)); CHECK(armor.productionTimeSeconds == Approx(3.0));
CHECK(armor.fillColor == "#808080"); CHECK(armor.fillColor == "#808080");
CHECK(armor.glyph == "A"); CHECK(armor.glyph == "A");
REQUIRE(armor.statModifiers.size() == 1); REQUIRE(armor.statModifiers.size() == 1);
CHECK(armor.statModifiers[0].stat == "hp"); CHECK(armor.statModifiers[0].stat == "hp");
CHECK(armor.statModifiers[0].modifierType == "multiplicative"); CHECK(armor.statModifiers[0].modifierType == "multiplicative");
CHECK(armor.statModifiers[0].formula.evaluate(1.0) == Approx(1.5)); CHECK(armor.statModifiers[0].value == Approx(1.5));
} }
TEST_CASE("ConfigLoader: loadModules parses additive modifiers", "[config][modules]") TEST_CASE("ConfigLoader: loadModules parses additive modifiers", "[config][modules]")
@@ -58,7 +57,7 @@ TEST_CASE("ConfigLoader: loadModules parses additive modifiers", "[config][modul
REQUIRE(sensor.statModifiers.size() == 1); REQUIRE(sensor.statModifiers.size() == 1);
CHECK(sensor.statModifiers[0].stat == "sensor_range"); CHECK(sensor.statModifiers[0].stat == "sensor_range");
CHECK(sensor.statModifiers[0].modifierType == "additive"); CHECK(sensor.statModifiers[0].modifierType == "additive");
CHECK(sensor.statModifiers[0].formula.evaluate(1.0) == Approx(100.0)); CHECK(sensor.statModifiers[0].value == Approx(100.0));
} }
TEST_CASE("ConfigLoader: multiplicative modifier with unit suffix is parsed (weapon_primer)", "[config][modules]") TEST_CASE("ConfigLoader: multiplicative modifier with unit suffix is parsed (weapon_primer)", "[config][modules]")
@@ -70,7 +69,7 @@ TEST_CASE("ConfigLoader: multiplicative modifier with unit suffix is parsed (wea
const ModuleStatModifier* sm = findModifier(*primer, "attack_rate"); const ModuleStatModifier* sm = findModifier(*primer, "attack_rate");
REQUIRE(sm != nullptr); REQUIRE(sm != nullptr);
CHECK(sm->modifierType == "multiplicative"); CHECK(sm->modifierType == "multiplicative");
CHECK(sm->formula.evaluate(1.0) == Approx(1.2)); CHECK(sm->value == Approx(1.2));
} }
TEST_CASE("ConfigLoader: weapon_stabilizer parses two multiplicative weapon modifiers", "[config][modules]") TEST_CASE("ConfigLoader: weapon_stabilizer parses two multiplicative weapon modifiers", "[config][modules]")
@@ -83,12 +82,12 @@ TEST_CASE("ConfigLoader: weapon_stabilizer parses two multiplicative weapon modi
const ModuleStatModifier* rangeMod = findModifier(*stab, "attack_range"); const ModuleStatModifier* rangeMod = findModifier(*stab, "attack_range");
REQUIRE(rangeMod != nullptr); REQUIRE(rangeMod != nullptr);
CHECK(rangeMod->modifierType == "multiplicative"); CHECK(rangeMod->modifierType == "multiplicative");
CHECK(rangeMod->formula.evaluate(1.0) == Approx(1.5)); CHECK(rangeMod->value == Approx(1.5));
const ModuleStatModifier* rateMod = findModifier(*stab, "attack_rate"); const ModuleStatModifier* rateMod = findModifier(*stab, "attack_rate");
REQUIRE(rateMod != nullptr); REQUIRE(rateMod != nullptr);
CHECK(rateMod->modifierType == "multiplicative"); CHECK(rateMod->modifierType == "multiplicative");
CHECK(rateMod->formula.evaluate(1.0) == Approx(0.8)); CHECK(rateMod->value == Approx(0.8));
} }
TEST_CASE("ConfigLoader: afterburner parses multiplicative speed and additive main_acceleration", "[config][modules]") TEST_CASE("ConfigLoader: afterburner parses multiplicative speed and additive main_acceleration", "[config][modules]")
@@ -101,12 +100,12 @@ TEST_CASE("ConfigLoader: afterburner parses multiplicative speed and additive ma
const ModuleStatModifier* speedMod = findModifier(*ab, "speed"); const ModuleStatModifier* speedMod = findModifier(*ab, "speed");
REQUIRE(speedMod != nullptr); REQUIRE(speedMod != nullptr);
CHECK(speedMod->modifierType == "multiplicative"); CHECK(speedMod->modifierType == "multiplicative");
CHECK(speedMod->formula.evaluate(1.0) == Approx(1.6)); CHECK(speedMod->value == Approx(1.6));
const ModuleStatModifier* accelMod = findModifier(*ab, "main_acceleration"); const ModuleStatModifier* accelMod = findModifier(*ab, "main_acceleration");
REQUIRE(accelMod != nullptr); REQUIRE(accelMod != nullptr);
CHECK(accelMod->modifierType == "additive"); CHECK(accelMod->modifierType == "additive");
CHECK(accelMod->formula.evaluate(1.0) == Approx(60.0)); CHECK(accelMod->value == Approx(60.0));
} }
TEST_CASE("ConfigLoader: maneuvering_thrusters parses multiplicative speed and additive maneuvering_acceleration", "[config][modules]") TEST_CASE("ConfigLoader: maneuvering_thrusters parses multiplicative speed and additive maneuvering_acceleration", "[config][modules]")
@@ -119,12 +118,12 @@ TEST_CASE("ConfigLoader: maneuvering_thrusters parses multiplicative speed and a
const ModuleStatModifier* speedMod = findModifier(*mt, "speed"); const ModuleStatModifier* speedMod = findModifier(*mt, "speed");
REQUIRE(speedMod != nullptr); REQUIRE(speedMod != nullptr);
CHECK(speedMod->modifierType == "multiplicative"); CHECK(speedMod->modifierType == "multiplicative");
CHECK(speedMod->formula.evaluate(1.0) == Approx(1.2)); CHECK(speedMod->value == Approx(1.2));
const ModuleStatModifier* accelMod = findModifier(*mt, "maneuvering_acceleration"); const ModuleStatModifier* accelMod = findModifier(*mt, "maneuvering_acceleration");
REQUIRE(accelMod != nullptr); REQUIRE(accelMod != nullptr);
CHECK(accelMod->modifierType == "additive"); CHECK(accelMod->modifierType == "additive");
CHECK(accelMod->formula.evaluate(1.0) == Approx(10.0)); CHECK(accelMod->value == Approx(10.0));
} }
TEST_CASE("ConfigLoader: loadShips parses layout field", "[config][ships]") TEST_CASE("ConfigLoader: loadShips parses layout field", "[config][ships]")

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,17 +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]));
// A level-up doesn't change the explicit unlock state, so the
// implicit unlock set - and thus this preview - must be empty.
if (!opt.isNewUnlock)
{
CHECK(opt.newlyUnlockedItemNames.empty());
} }
} }
@@ -313,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());
@@ -343,21 +337,73 @@ 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);
{
expectedNames.insert(toDisplayName(out.item));
}
} }
} }
const std::vector<std::string> expected(expectedNames.begin(), expectedNames.end()); std::sort(expected.begin(), expected.end(),
[](const std::string& lhs, const std::string& rhs)
{
return toDisplayName(lhs) < toDisplayName(rhs);
});
REQUIRE(choice.newlyUnlockedItemNames == expected); REQUIRE(choice.newlyUnlockedRecipeIds == expected);
} }
} }
// ---------------------------------------------------------------------------
// Owned ship/module schematics leave the drop pool (REQ-DEF-SCHEMATIC-DROP)
// ---------------------------------------------------------------------------
TEST_CASE("SchematicDrop: an owned ship schematic is never offered again",
"[recipe_schematic]")
{
// repair_ship has unlock_at_station_level = 0 in the test config, so it is
// locked at start and becomes eligible once a station set is destroyed.
Simulation sim(loadConfig());
REQUIRE_FALSE(sim.isSchematicUnlocked("repair_ship"));
bool wasUnlocked = false;
for (int i = 0; i < 200; ++i)
{
killEnemyStations(sim);
if (!sim.hasSchematicChoicesPending()) { continue; }
const std::vector<SchematicChoiceOption>& choices =
sim.getPendingSchematicChoices();
// Locate the repair_ship option, if present in this drop.
int repairShipIndex = -1;
for (int j = 0; j < static_cast<int>(choices.size()); ++j)
{
if (choices[j].schematicId == "repair_ship") { repairShipIndex = j; }
}
// Once owned, it must never appear in the pool again.
if (sim.isSchematicUnlocked("repair_ship"))
{
CHECK(repairShipIndex == -1);
}
// Prefer picking repair_ship the first time it shows up so we exercise
// the transition from unowned to owned; otherwise just take choice 0.
int pick = 0;
if (repairShipIndex >= 0 && !sim.isSchematicUnlocked("repair_ship"))
{
pick = repairShipIndex;
wasUnlocked = true;
}
SimulationTestAccess::applySchematicChoice(sim, pick);
}
// Sanity: we actually unlocked it at some point, so the exclusion CHECK above
// was meaningfully exercised.
CHECK(wasUnlocked);
CHECK(sim.isSchematicUnlocked("repair_ship"));
}

View File

@@ -2,11 +2,22 @@
#include <QVector2D> #include <QVector2D>
#include <algorithm>
#include "DespawnAtComponent.h" #include "DespawnAtComponent.h"
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include "EntityHitTest.h"
#include "ScrapDataComponent.h" #include "ScrapDataComponent.h"
#include "ScrapSystem.h" #include "ScrapSystem.h"
namespace
{
bool contains(const std::vector<entt::entity>& v, entt::entity e)
{
return std::find(v.begin(), v.end(), e) != v.end();
}
} // namespace
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Spawn // Spawn
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -140,3 +151,78 @@ TEST_CASE("ScrapSystem: allScrapInfo returns all spawned scrap", "[scrap]")
const std::vector<ScrapInfo> info = ss.allScrapInfo(); const std::vector<ScrapInfo> info = ss.allScrapInfo();
REQUIRE(info.size() == 2); REQUIRE(info.size() == 2);
} }
TEST_CASE("ScrapSystem: allScrapInfo reports each pile's remaining amount", "[scrap]")
{
EntityAdmin admin;
ScrapSystem ss(admin);
const entt::entity a = ss.spawn(QVector2D(1.0f, 2.0f), 3, 100);
const entt::entity b = ss.spawn(QVector2D(4.0f, 5.0f), 6, 200);
const std::vector<ScrapInfo> info = ss.allScrapInfo();
REQUIRE(info.size() == 2);
for (const ScrapInfo& i : info)
{
if (i.entity == a) { REQUIRE(i.amount == 3); }
else if (i.entity == b) { REQUIRE(i.amount == 6); }
else { FAIL("unexpected scrap entity"); }
}
}
// ---------------------------------------------------------------------------
// Selection hit-testing (REQ-UI-SCRAP-CLICK-SELECT, REQ-UI-SCRAP-MULTI-SELECT)
// ---------------------------------------------------------------------------
TEST_CASE("scrapAtWorldPos returns the pile near a point and null when far", "[scrap]")
{
EntityAdmin admin;
ScrapSystem ss(admin);
const entt::entity e = ss.spawn(QVector2D(3.0f, 4.0f), 5, 100);
// Extra parens keep Catch from decomposing the comparison, which is ambiguous
// between Catch's expression templates and entt's entity operator==.
REQUIRE((scrapAtWorldPos(admin, QVector2D(3.1f, 4.0f)) == e));
REQUIRE((scrapAtWorldPos(admin, QVector2D(10.0f, 10.0f)) == entt::null));
}
TEST_CASE("scrapAtWorldPos returns the nearest of several piles", "[scrap]")
{
EntityAdmin admin;
ScrapSystem ss(admin);
const entt::entity near = ss.spawn(QVector2D(2.0f, 2.0f), 1, 100);
ss.spawn(QVector2D(2.4f, 2.0f), 1, 100);
REQUIRE((scrapAtWorldPos(admin, QVector2D(2.05f, 2.0f)) == near));
}
TEST_CASE("entityAtWorldPos never returns a scrap pile", "[scrap]")
{
EntityAdmin admin;
ScrapSystem ss(admin);
ss.spawn(QVector2D(3.0f, 4.0f), 5, 100);
// Scrap has no HealthComponent, so the actor hit-test ignores it entirely.
REQUIRE((entityAtWorldPos(admin, QVector2D(3.0f, 4.0f)) == entt::null));
}
TEST_CASE("scrapInBox returns exactly the piles inside the tile rectangle", "[scrap]")
{
EntityAdmin admin;
ScrapSystem ss(admin);
const entt::entity inA = ss.spawn(QVector2D(1.2f, 2.7f), 1, 100); // tile (1,2)
const entt::entity inB = ss.spawn(QVector2D(4.9f, 5.1f), 1, 100); // tile (4,5)
const entt::entity outX = ss.spawn(QVector2D(10.0f, 10.0f), 1, 100);
// Box given in reversed corner order to confirm normalization.
const std::vector<entt::entity> hit = scrapInBox(admin, QPoint(5, 5), QPoint(0, 0));
REQUIRE(hit.size() == 2);
REQUIRE(contains(hit, inA));
REQUIRE(contains(hit, inB));
REQUIRE_FALSE(contains(hit, outX));
}

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"
@@ -110,11 +112,9 @@ TEST_CASE("Ship spawn: no modules leaves base stats unchanged", "[modules]")
const ShipDef* def = findSchematic(sim.config(), "interceptor"); const ShipDef* def = findSchematic(sim.config(), "interceptor");
REQUIRE(def != nullptr); REQUIRE(def != nullptr);
const double x = static_cast<double>(def->schematic.playerProductionLevel); const float expectedHp = static_cast<float>(def->health.hp);
const float expectedHp = static_cast<float>(def->health.hpFormula.evaluate(x));
const entt::entity e = sim.ships().spawn("interceptor", const entt::entity e = sim.ships().spawn("interceptor",
def->schematic.playerProductionLevel,
QVector2D(5.0f, 5.0f), false, std::nullopt); QVector2D(5.0f, 5.0f), false, std::nullopt);
REQUIRE(sim.admin().isValid(e)); REQUIRE(sim.admin().isValid(e));
@@ -127,8 +127,7 @@ TEST_CASE("Ship spawn: multiplicative HP module applies correctly", "[modules]")
const ShipDef* def = findSchematic(sim.config(), "interceptor"); const ShipDef* def = findSchematic(sim.config(), "interceptor");
REQUIRE(def != nullptr); REQUIRE(def != nullptr);
const double x = static_cast<double>(def->schematic.playerProductionLevel); const float baseHp = static_cast<float>(def->health.hp);
const float baseHp = static_cast<float>(def->health.hpFormula.evaluate(x));
ShipLayoutConfig layout; ShipLayoutConfig layout;
PlacedModule pm; PlacedModule pm;
@@ -138,7 +137,6 @@ TEST_CASE("Ship spawn: multiplicative HP module applies correctly", "[modules]")
layout.placedModules.push_back(pm); layout.placedModules.push_back(pm);
const entt::entity e = sim.ships().spawn("interceptor", const entt::entity e = sim.ships().spawn("interceptor",
def->schematic.playerProductionLevel,
QVector2D(5.0f, 5.0f), false, layout); QVector2D(5.0f, 5.0f), false, layout);
REQUIRE(sim.admin().isValid(e)); REQUIRE(sim.admin().isValid(e));
@@ -154,9 +152,8 @@ TEST_CASE("Ship spawn: additive sensor module applies correctly", "[modules]")
const ShipDef* def = findSchematic(sim.config(), "interceptor"); const ShipDef* def = findSchematic(sim.config(), "interceptor");
REQUIRE(def != nullptr); REQUIRE(def != nullptr);
const double x = static_cast<double>(def->schematic.playerProductionLevel);
const float tileSize = static_cast<float>(sim.config().world.tileSize_m); const float tileSize = static_cast<float>(sim.config().world.tileSize_m);
const float baseRange_tiles = static_cast<float>(def->sensor.sensorRangeFormula.evaluate(x)) / tileSize; const float baseRange_tiles = static_cast<float>(def->sensor.sensorRange_m) / tileSize;
ShipLayoutConfig layout; ShipLayoutConfig layout;
PlacedModule pm; PlacedModule pm;
@@ -166,7 +163,6 @@ TEST_CASE("Ship spawn: additive sensor module applies correctly", "[modules]")
layout.placedModules.push_back(pm); layout.placedModules.push_back(pm);
const entt::entity e = sim.ships().spawn("interceptor", const entt::entity e = sim.ships().spawn("interceptor",
def->schematic.playerProductionLevel,
QVector2D(5.0f, 5.0f), false, layout); QVector2D(5.0f, 5.0f), false, layout);
REQUIRE(sim.admin().isValid(e)); REQUIRE(sim.admin().isValid(e));
@@ -181,8 +177,7 @@ TEST_CASE("Ship spawn: multiple modules stack correctly", "[modules]")
const ShipDef* def = findSchematic(sim.config(), "interceptor"); const ShipDef* def = findSchematic(sim.config(), "interceptor");
REQUIRE(def != nullptr); REQUIRE(def != nullptr);
const double x = static_cast<double>(def->schematic.playerProductionLevel); const float baseHp = static_cast<float>(def->health.hp);
const float baseHp = static_cast<float>(def->health.hpFormula.evaluate(x));
ShipLayoutConfig layout; ShipLayoutConfig layout;
for (int i = 0; i < 2; ++i) for (int i = 0; i < 2; ++i)
@@ -195,7 +190,6 @@ TEST_CASE("Ship spawn: multiple modules stack correctly", "[modules]")
} }
const entt::entity e = sim.ships().spawn("interceptor", const entt::entity e = sim.ships().spawn("interceptor",
def->schematic.playerProductionLevel,
QVector2D(5.0f, 5.0f), false, layout); QVector2D(5.0f, 5.0f), false, layout);
REQUIRE(sim.admin().isValid(e)); REQUIRE(sim.admin().isValid(e));
@@ -272,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);
@@ -300,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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -321,7 +391,6 @@ TEST_CASE("Ship spawn: weapon_primer multiplies attack rate in simulation", "[mo
} }
const entt::entity ship = sim.ships().spawn("interceptor", const entt::entity ship = sim.ships().spawn("interceptor",
def->schematic.playerProductionLevel,
QVector2D(5.0f, 5.0f), false, layout); QVector2D(5.0f, 5.0f), false, layout);
const entt::entity weapon = findFirstWeaponChild(sim.admin(), ship); const entt::entity weapon = findFirstWeaponChild(sim.admin(), ship);
@@ -349,7 +418,6 @@ TEST_CASE("Ship spawn: weapon_stabilizer multiplies attack range in simulation",
} }
const entt::entity ship = sim.ships().spawn("interceptor", const entt::entity ship = sim.ships().spawn("interceptor",
def->schematic.playerProductionLevel,
QVector2D(5.0f, 5.0f), false, layout); QVector2D(5.0f, 5.0f), false, layout);
const entt::entity weapon = findFirstWeaponChild(sim.admin(), ship); const entt::entity weapon = findFirstWeaponChild(sim.admin(), ship);
@@ -364,10 +432,9 @@ TEST_CASE("Ship spawn: afterburner additive main_acceleration is converted m/s²
const ShipDef* def = findSchematic(sim.config(), "interceptor"); const ShipDef* def = findSchematic(sim.config(), "interceptor");
REQUIRE(def != nullptr); REQUIRE(def != nullptr);
const double x = static_cast<double>(def->schematic.playerProductionLevel);
const float tileSize = static_cast<float>(sim.config().world.tileSize_m); const float tileSize = static_cast<float>(sim.config().world.tileSize_m);
const float tickRate = static_cast<float>(kTickRateHz); const float tickRate = static_cast<float>(kTickRateHz);
const float base_mpss = static_cast<float>(def->movement.mainAccelerationFormula.evaluate(x)); const float base_mpss = static_cast<float>(def->movement.mainAcceleration_mpss);
ShipLayoutConfig layout; ShipLayoutConfig layout;
PlacedModule pm; PlacedModule pm;
@@ -377,7 +444,6 @@ TEST_CASE("Ship spawn: afterburner additive main_acceleration is converted m/s²
layout.placedModules.push_back(pm); layout.placedModules.push_back(pm);
const entt::entity e = sim.ships().spawn("interceptor", const entt::entity e = sim.ships().spawn("interceptor",
def->schematic.playerProductionLevel,
QVector2D(5.0f, 5.0f), false, layout); QVector2D(5.0f, 5.0f), false, layout);
// added_main_acceleration_mpss = 60; same conversion as base: / tileSize / tickRate // added_main_acceleration_mpss = 60; same conversion as base: / tileSize / tickRate
@@ -391,10 +457,9 @@ TEST_CASE("Ship spawn: maneuvering_thrusters additive maneuvering_acceleration i
const ShipDef* def = findSchematic(sim.config(), "interceptor"); const ShipDef* def = findSchematic(sim.config(), "interceptor");
REQUIRE(def != nullptr); REQUIRE(def != nullptr);
const double x = static_cast<double>(def->schematic.playerProductionLevel);
const float tileSize = static_cast<float>(sim.config().world.tileSize_m); const float tileSize = static_cast<float>(sim.config().world.tileSize_m);
const float tickRate = static_cast<float>(kTickRateHz); const float tickRate = static_cast<float>(kTickRateHz);
const float base_mpss = static_cast<float>(def->movement.maneuveringAccelerationFormula.evaluate(x)); const float base_mpss = static_cast<float>(def->movement.maneuveringAcceleration_mpss);
ShipLayoutConfig layout; ShipLayoutConfig layout;
PlacedModule pm; PlacedModule pm;
@@ -404,7 +469,6 @@ TEST_CASE("Ship spawn: maneuvering_thrusters additive maneuvering_acceleration i
layout.placedModules.push_back(pm); layout.placedModules.push_back(pm);
const entt::entity e = sim.ships().spawn("interceptor", const entt::entity e = sim.ships().spawn("interceptor",
def->schematic.playerProductionLevel,
QVector2D(5.0f, 5.0f), false, layout); QVector2D(5.0f, 5.0f), false, layout);
// added_maneuvering_acceleration_mpss = 10; same conversion as base: / tileSize / tickRate // added_maneuvering_acceleration_mpss = 10; same conversion as base: / tileSize / tickRate
@@ -433,7 +497,7 @@ TEST_CASE("calculateShipStats: weapon_primer multiplies attack rate in stats vie
} }
const ShipStats stats = calculateShipStats(cfg, "interceptor", const ShipStats stats = calculateShipStats(cfg, "interceptor",
def->schematic.playerProductionLevel, modules); modules);
REQUIRE(stats.weapons.has_value()); REQUIRE(stats.weapons.has_value());
// base: damage = 2, rate = 2.0 hz; weapon_primer multiplies rate by 1.2 → DPS = 2 * 2.4 = 4.8 // base: damage = 2, rate = 2.0 hz; weapon_primer multiplies rate by 1.2 → DPS = 2 * 2.4 = 4.8
@@ -459,7 +523,7 @@ TEST_CASE("calculateShipStats: weapon_stabilizer multiplies attack range in stat
} }
const ShipStats stats = calculateShipStats(cfg, "interceptor", const ShipStats stats = calculateShipStats(cfg, "interceptor",
def->schematic.playerProductionLevel, modules); modules);
REQUIRE(stats.weapons.has_value()); REQUIRE(stats.weapons.has_value());
// base range = 50 m / tileSize; weapon_stabilizer multiplier = 1.5 // base range = 50 m / tileSize; weapon_stabilizer multiplier = 1.5
@@ -472,9 +536,8 @@ TEST_CASE("calculateShipStats: afterburner additive main_acceleration is convert
const ShipDef* def = findSchematic(cfg, "interceptor"); const ShipDef* def = findSchematic(cfg, "interceptor");
REQUIRE(def != nullptr); REQUIRE(def != nullptr);
const double x = static_cast<double>(def->schematic.playerProductionLevel);
const float tileSize = static_cast<float>(cfg.world.tileSize_m); const float tileSize = static_cast<float>(cfg.world.tileSize_m);
const float base_mpss = static_cast<float>(def->movement.mainAccelerationFormula.evaluate(x)); const float base_mpss = static_cast<float>(def->movement.mainAcceleration_mpss);
std::vector<PlacedModule> modules; std::vector<PlacedModule> modules;
PlacedModule pm; PlacedModule pm;
@@ -484,7 +547,7 @@ TEST_CASE("calculateShipStats: afterburner additive main_acceleration is convert
modules.push_back(pm); modules.push_back(pm);
const ShipStats stats = calculateShipStats(cfg, "interceptor", const ShipStats stats = calculateShipStats(cfg, "interceptor",
def->schematic.playerProductionLevel, modules); modules);
// added_main_acceleration_mpss = 60; converted to tiles/s²: / tileSize // added_main_acceleration_mpss = 60; converted to tiles/s²: / tileSize
const float expected = (base_mpss + 60.0f) / tileSize; const float expected = (base_mpss + 60.0f) / tileSize;
@@ -497,9 +560,8 @@ TEST_CASE("calculateShipStats: maneuvering_thrusters additive maneuvering_accele
const ShipDef* def = findSchematic(cfg, "interceptor"); const ShipDef* def = findSchematic(cfg, "interceptor");
REQUIRE(def != nullptr); REQUIRE(def != nullptr);
const double x = static_cast<double>(def->schematic.playerProductionLevel);
const float tileSize = static_cast<float>(cfg.world.tileSize_m); const float tileSize = static_cast<float>(cfg.world.tileSize_m);
const float base_mpss = static_cast<float>(def->movement.maneuveringAccelerationFormula.evaluate(x)); const float base_mpss = static_cast<float>(def->movement.maneuveringAcceleration_mpss);
std::vector<PlacedModule> modules; std::vector<PlacedModule> modules;
PlacedModule pm; PlacedModule pm;
@@ -509,7 +571,7 @@ TEST_CASE("calculateShipStats: maneuvering_thrusters additive maneuvering_accele
modules.push_back(pm); modules.push_back(pm);
const ShipStats stats = calculateShipStats(cfg, "interceptor", const ShipStats stats = calculateShipStats(cfg, "interceptor",
def->schematic.playerProductionLevel, modules); modules);
// added_maneuvering_acceleration_mpss = 10; converted to tiles/s²: / tileSize // added_maneuvering_acceleration_mpss = 10; converted to tiles/s²: / tileSize
const float expected = (base_mpss + 10.0f) / tileSize; const float expected = (base_mpss + 10.0f) / tileSize;

View File

@@ -94,7 +94,7 @@ TEST_CASE("ShipSystem: interceptor spawn has weapon child and attack behavior, n
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadConfig();
ShipSystem ss(cfg, admin); ShipSystem ss(cfg, admin);
const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity e = ss.spawn("interceptor", QVector2D(0.0f, 0.0f));
REQUIRE(admin.isValid(e)); REQUIRE(admin.isValid(e));
REQUIRE(admin.isValid(firstWeaponChild(admin, e))); REQUIRE(admin.isValid(firstWeaponChild(admin, e)));
@@ -118,7 +118,7 @@ TEST_CASE("ShipSystem: enemy combat ship has no rally or retreat behavior", "[sh
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadConfig();
ShipSystem ss(cfg, admin); ShipSystem ss(cfg, admin);
const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f), /*isEnemy=*/true); const entt::entity e = ss.spawn("interceptor", QVector2D(0.0f, 0.0f), /*isEnemy=*/true);
REQUIRE(admin.hasAll<AttackBehavior>(e)); REQUIRE(admin.hasAll<AttackBehavior>(e));
REQUIRE(admin.hasAll<AdvanceBehavior>(e)); REQUIRE(admin.hasAll<AdvanceBehavior>(e));
@@ -133,7 +133,7 @@ TEST_CASE("ShipSystem: setRetreatEnabled(false) suppresses player retreat behavi
ShipSystem ss(cfg, admin); ShipSystem ss(cfg, admin);
ss.setRetreatEnabled(false); ss.setRetreatEnabled(false);
const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity e = ss.spawn("interceptor", QVector2D(0.0f, 0.0f));
// Other player behaviors are unaffected; only retreat is suppressed. // Other player behaviors are unaffected; only retreat is suppressed.
REQUIRE(admin.hasAll<AttackBehavior>(e)); REQUIRE(admin.hasAll<AttackBehavior>(e));
@@ -147,7 +147,7 @@ TEST_CASE("ShipSystem: interceptor level 1 stats match config formulas", "[ship]
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadConfig();
ShipSystem ss(cfg, admin); ShipSystem ss(cfg, admin);
const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity e = ss.spawn("interceptor", QVector2D(0.0f, 0.0f));
// hp_formula = "40 + 5*x" at x=1 → 45 // hp_formula = "40 + 5*x" at x=1 → 45
REQUIRE(admin.get<HealthComponent>(e).maxHp == Approx(45.0f)); REQUIRE(admin.get<HealthComponent>(e).maxHp == Approx(45.0f));
@@ -163,28 +163,28 @@ TEST_CASE("ShipSystem: interceptor level 1 stats match config formulas", "[ship]
REQUIRE(admin.get<WeaponComponent>(wc).cooldownTicks == Approx(0.0f)); REQUIRE(admin.get<WeaponComponent>(wc).cooldownTicks == Approx(0.0f));
} }
TEST_CASE("ShipSystem: interceptor level 5 hp matches formula", "[ship]") TEST_CASE("ShipSystem: interceptor hp matches config value", "[ship]")
{ {
EntityAdmin admin; EntityAdmin admin;
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadConfig();
ShipSystem ss(cfg, admin); ShipSystem ss(cfg, admin);
const entt::entity e = ss.spawn("interceptor", 5, QVector2D(0.0f, 0.0f)); const entt::entity e = ss.spawn("interceptor", QVector2D(0.0f, 0.0f));
// hp_formula = "40 + 5*x" at x=5 → 65 // interceptor hp = 45 (plain value) in the test config
REQUIRE(admin.get<HealthComponent>(e).maxHp == Approx(65.0f)); REQUIRE(admin.get<HealthComponent>(e).maxHp == Approx(45.0f));
} }
TEST_CASE("ShipSystem: interceptor level 0 maxSpeed_tpt matches formula / tileSize / kTickRateHz", "[ship]") TEST_CASE("ShipSystem: interceptor maxSpeed_tpt matches config value / tileSize / kTickRateHz", "[ship]")
{ {
EntityAdmin admin; EntityAdmin admin;
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadConfig();
ShipSystem ss(cfg, admin); ShipSystem ss(cfg, admin);
const entt::entity e = ss.spawn("interceptor", 0, QVector2D(0.0f, 0.0f)); const entt::entity e = ss.spawn("interceptor", QVector2D(0.0f, 0.0f));
// speed_mps_formula = "2000 + 50*x" m/s at x=0 → 2000 m/s; maxSpeed_tpt = 2000/(10*30) // speed_mps = 2050 m/s in the test config; maxSpeed_tpt = 2050/(10*30)
const float expected = 2000.0f / 10.0f / static_cast<float>(kTickRateHz); const float expected = 2050.0f / 10.0f / static_cast<float>(kTickRateHz);
REQUIRE(admin.get<DynamicBodyComponent>(e).maxSpeed_tpt == Approx(expected)); REQUIRE(admin.get<DynamicBodyComponent>(e).maxSpeed_tpt == Approx(expected));
} }
@@ -200,7 +200,7 @@ TEST_CASE("ShipSystem: salvage_ship spawn with salvage module has cargo child an
ShipSystem ss(cfg, admin); ShipSystem ss(cfg, admin);
const ShipLayoutConfig layout = makeSingleModuleLayout("salvager"); const ShipLayoutConfig layout = makeSingleModuleLayout("salvager");
const entt::entity e = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), false, layout); const entt::entity e = ss.spawn("salvage_ship", QVector2D(0.0f, 0.0f), false, layout);
REQUIRE(admin.isValid(firstSalvageChild(admin, e))); REQUIRE(admin.isValid(firstSalvageChild(admin, e)));
REQUIRE(admin.hasAll<SalvageScrapBehavior>(e)); REQUIRE(admin.hasAll<SalvageScrapBehavior>(e));
@@ -216,7 +216,7 @@ TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]")
ShipSystem ss(cfg, admin); ShipSystem ss(cfg, admin);
const ShipLayoutConfig layout = makeSingleModuleLayout("salvager"); const ShipLayoutConfig layout = makeSingleModuleLayout("salvager");
const entt::entity e = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), false, layout); const entt::entity e = ss.spawn("salvage_ship", QVector2D(0.0f, 0.0f), false, layout);
// salvager: cargo_capacity_formula = "10", collection_range_m_formula = "500" m → 500/10 = 50 tiles // salvager: cargo_capacity_formula = "10", collection_range_m_formula = "500" m → 500/10 = 50 tiles
const entt::entity sc = firstSalvageChild(admin, e); const entt::entity sc = firstSalvageChild(admin, e);
@@ -241,7 +241,7 @@ TEST_CASE("ShipSystem: repair_ship spawn with repair module has repair child and
ShipSystem ss(cfg, admin); ShipSystem ss(cfg, admin);
const ShipLayoutConfig layout = makeSingleModuleLayout("repair_tool"); const ShipLayoutConfig layout = makeSingleModuleLayout("repair_tool");
const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, layout); const entt::entity e = ss.spawn("repair_ship", QVector2D(0.0f, 0.0f), false, layout);
REQUIRE(admin.isValid(firstRepairChild(admin, e))); REQUIRE(admin.isValid(firstRepairChild(admin, e)));
REQUIRE(admin.hasAll<RepairBehavior>(e)); REQUIRE(admin.hasAll<RepairBehavior>(e));
@@ -256,7 +256,7 @@ TEST_CASE("ShipSystem: repair_ship level 1 repair stats match config formulas",
ShipSystem ss(cfg, admin); ShipSystem ss(cfg, admin);
const ShipLayoutConfig layout = makeSingleModuleLayout("repair_tool"); const ShipLayoutConfig layout = makeSingleModuleLayout("repair_tool");
const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, layout); const entt::entity e = ss.spawn("repair_ship", QVector2D(0.0f, 0.0f), false, layout);
const entt::entity rc = firstRepairChild(admin, e); const entt::entity rc = firstRepairChild(admin, e);
REQUIRE(admin.isValid(rc)); REQUIRE(admin.isValid(rc));
@@ -280,9 +280,9 @@ TEST_CASE("ShipSystem: spawned ships are valid entities", "[ship]")
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadConfig();
ShipSystem ss(cfg, admin); ShipSystem ss(cfg, admin);
const entt::entity e1 = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity e1 = ss.spawn("interceptor", QVector2D(0.0f, 0.0f));
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager"); const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
const entt::entity e2 = ss.spawn("salvage_ship", 1, QVector2D(1.0f, 0.0f), false, salvageLayout); const entt::entity e2 = ss.spawn("salvage_ship", QVector2D(1.0f, 0.0f), false, salvageLayout);
REQUIRE(admin.isValid(e1)); REQUIRE(admin.isValid(e1));
REQUIRE(admin.isValid(e2)); REQUIRE(admin.isValid(e2));
@@ -295,7 +295,7 @@ TEST_CASE("ShipSystem: despawn removes the ship and its weapon children", "[ship
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadConfig();
ShipSystem ss(cfg, admin); ShipSystem ss(cfg, admin);
const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity e = ss.spawn("interceptor", QVector2D(0.0f, 0.0f));
const entt::entity wc = firstWeaponChild(admin, e); const entt::entity wc = firstWeaponChild(admin, e);
REQUIRE(admin.isValid(e)); REQUIRE(admin.isValid(e));
REQUIRE(admin.isValid(wc)); REQUIRE(admin.isValid(wc));

View File

@@ -41,16 +41,15 @@ TEST_CASE("ThreatCostCalculator: assembler takes max across recipes", "[threat]"
CHECK(table.itemThreat.at("circuit_board") == Approx(28.0)); CHECK(table.itemThreat.at("circuit_board") == Approx(28.0));
} }
TEST_CASE("ThreatCostCalculator: scrap threat from cheapest ship", "[threat]") TEST_CASE("ThreatCostCalculator: scrap threat is 1 / scrap_per_threat", "[threat]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadConfig();
const ThreatCostTable& table = cfg.threatCosts; const ThreatCostTable& table = cfg.threatCosts;
// Cheapest ship by scrap_drop is interceptor (scrap_drop=2). // REQ-THREAT-SCRAP: scrap threat is the constant 1 / world.scrap_per_threat.
// Interceptor threat: 10 + iron_ingot(4)*3 + circuit_board(28)*1 // The test config sets scrap_per_threat = 1.0, so scrapThreat = 1.0.
// + laser_cannon(5 + iron_ingot(4)*1) = 10 + 12 + 28 + 9 = 59.0 CHECK(table.scrapThreat == Approx(1.0 / cfg.world.scrapPerThreat));
// scrapThreat = 59.0 / 2 = 29.5 CHECK(table.scrapThreat == Approx(1.0));
CHECK(table.scrapThreat == Approx(29.5));
} }
TEST_CASE("ThreatCostCalculator: reprocessing-only item threat", "[threat]") TEST_CASE("ThreatCostCalculator: reprocessing-only item threat", "[threat]")
@@ -59,8 +58,8 @@ TEST_CASE("ThreatCostCalculator: reprocessing-only item threat", "[threat]")
const ThreatCostTable& table = cfg.threatCosts; const ThreatCostTable& table = cfg.threatCosts;
// advanced_alloy: reprocessing recipe with scrap*5, duration 3.0, probability 0.1 // advanced_alloy: reprocessing recipe with scrap*5, duration 3.0, probability 0.1
// (29.5 * 5 + 3.0) / 0.1 = 1505.0 // scrapThreat = 1.0 (= 1 / scrap_per_threat), so (1.0 * 5 + 3.0) / 0.1 = 80.0
CHECK(table.itemThreat.at("advanced_alloy") == Approx(1505.0)); CHECK(table.itemThreat.at("advanced_alloy") == Approx(80.0));
} }
TEST_CASE("ThreatCostCalculator: ship threat with default modules", "[threat]") TEST_CASE("ThreatCostCalculator: ship threat with default modules", "[threat]")
@@ -108,3 +107,62 @@ TEST_CASE("ThreatCostCalculator: unknown ship returns zero", "[threat]")
double threat = calculateShipThreatCost(table, cfg, "nonexistent_ship", {}); double threat = calculateShipThreatCost(table, cfg, "nonexistent_ship", {});
CHECK(threat == Approx(0.0)); CHECK(threat == Approx(0.0));
} }
// Fix 6: scrap-consuming recipes are a fallback only.
// iron_ingot has a scrap-free smelter recipe, so the scrap_iron recipe must
// be excluded. iron_ingot threat must not be inflated by the scrap path.
TEST_CASE("ThreatCostCalculator: scrap-consuming recipe excluded when scrap-free recipe exists", "[threat]")
{
const GameConfig cfg = loadConfig();
const ThreatCostTable& table = cfg.threatCosts;
// scrap_iron recipe: duration=1.0, 1 scrap (threat=1.0) -> 1 iron_ingot.
// That would give 1.0 + 1.0*1 = 2.0 per unit — but it must be excluded
// because the scrap-free iron_ingot smelter recipe (threat=4.0) exists.
// iron_ingot threat stays at 4.0.
CHECK(table.itemThreat.at("iron_ingot") == Approx(4.0));
// The pure reprocessing-only item (advanced_alloy) must still be resolved
// via the reprocessing path.
CHECK(table.itemThreat.count("advanced_alloy") == 1u);
}
// Fix 7: per-unit item threat divides by output amount.
// dual_wire: assembler, 1 iron_ore -> 2 dual_wire, duration 3.0.
// Per-unit threat = (3.0 + iron_ore(1.0)*1) / 2 = 4.0 / 2 = 2.0.
TEST_CASE("ThreatCostCalculator: per-unit division by output amount", "[threat]")
{
const GameConfig cfg = loadConfig();
const ThreatCostTable& table = cfg.threatCosts;
CHECK(table.itemThreat.at("dual_wire") == Approx(2.0));
}
// Fix 8: fixpoint resolution — items downstream of reprocessing-only items
// must be resolved after the reprocessing pass re-enables the non-reprocessing
// pass.
// downstream_product: assembler, 1 advanced_alloy -> 1, duration 2.0.
// advanced_alloy = 80.0 (reprocessing-only).
// downstream_product = 2.0 + 80.0*1 = 82.0.
TEST_CASE("ThreatCostCalculator: downstream-of-reprocessing item resolves via fixpoint", "[threat]")
{
const GameConfig cfg = loadConfig();
const ThreatCostTable& table = cfg.threatCosts;
CHECK(table.itemThreat.at("advanced_alloy") == Approx(80.0));
CHECK(table.itemThreat.at("downstream_product") == Approx(82.0));
}
// Fix 9: max rule across staggered recipes — item is committed only once
// every eligible recipe for it is computable.
// staggered_item has two recipes:
// cheap: 1 iron_ore (1.0) + 1.0 s = 2.0 (resolves early)
// expensive: 1 circuit_board (28.0) + 1.0 s = 29.0 (resolves later)
// expected threat = max(2.0, 29.0) = 29.0, not 2.0.
TEST_CASE("ThreatCostCalculator: staggered recipes committed only when all computable", "[threat]")
{
const GameConfig cfg = loadConfig();
const ThreatCostTable& table = cfg.threatCosts;
CHECK(table.itemThreat.at("staggered_item") == Approx(29.0));
}

View File

@@ -0,0 +1,174 @@
// Drop-pool behaviour for unlock_requires schematic prerequisites
// (REQ-LOCK-PREREQ). A schematic enters the drop pool only when every id in its
// unlock_requires is already explicitly unlocked, on top of the station-level
// (and, for recipes, output-implicitly-unlocked) checks.
//
// Configs are built in memory (load the test config, then mutate) so each case
// is isolated and does not perturb the shared test config used by other suites.
#include <algorithm>
#include <string>
#include <utility>
#include <vector>
#include "catch.hpp"
#include "ConfigLoader.h"
#include "FactionComponent.h"
#include "GameConfig.h"
#include "HealthComponent.h"
#include "ModulesConfig.h"
#include "RecipesConfig.h"
#include "SchematicChoiceOption.h"
#include "ShipsConfig.h"
#include "Simulation.h"
#include "SimulationTestAccess.h"
#include "StationBodyComponent.h"
namespace
{
GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
ShipDef& findShip(GameConfig& cfg, const std::string& id)
{
for (ShipDef& def : cfg.ships.ships)
{
if (def.id == id) { return def; }
}
FAIL("ship not found: " + id);
return cfg.ships.ships.front();
}
ModuleDef& findModule(GameConfig& cfg, const std::string& id)
{
for (ModuleDef& def : cfg.modules.modules)
{
if (def.id == id) { return def; }
}
FAIL("module not found: " + id);
return cfg.modules.modules.front();
}
RecipeDef& findRecipe(GameConfig& cfg, const std::string& id)
{
for (RecipeDef& def : cfg.recipes.recipes)
{
if (def.id == id) { return def; }
}
FAIL("recipe not found: " + id);
return cfg.recipes.recipes.front();
}
// Zeros the HP of both enemy defence stations and advances one tick so that
// tickDeathsAndLoot fires, triggering the push and schematic choices.
void killEnemyStations(Simulation& sim)
{
sim.admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity, StationBodyComponent&, FactionComponent& faction, HealthComponent& health)
{
if (faction.isEnemy)
{
health.hp = 0.0f;
}
});
sim.tick();
}
void killEnemyStationsAndApply(Simulation& sim)
{
killEnemyStations(sim);
if (sim.hasSchematicChoicesPending())
{
SimulationTestAccess::applySchematicChoice(sim, 0);
}
}
bool optionOffered(const Simulation& sim, const std::string& id)
{
const std::vector<SchematicChoiceOption>& options = sim.getPendingSchematicChoices();
return std::any_of(options.begin(), options.end(),
[&](const SchematicChoiceOption& o) { return o.schematicId == id; });
}
} // namespace
TEST_CASE("UnlockPrereq: a recipe gated behind a locked ship is withheld from the pool",
"[unlock_prereq]")
{
// quick_circuit (assembler recipe, level 0, output circuit_board is implicitly
// unlocked) would normally be eligible at the first station destruction. Gate
// it behind repair_ship (ship, level 0, locked at game start).
GameConfig cfg = loadConfig();
findRecipe(cfg, "quick_circuit").unlockRequires = {"repair_ship"};
Simulation sim(std::move(cfg), 123);
REQUIRE_FALSE(sim.isSchematicUnlocked("repair_ship"));
killEnemyStations(sim); // destroyed set level 0
REQUIRE(sim.hasSchematicChoicesPending());
// Prerequisite unmet -> the gated recipe must not be offered, while the
// prerequisite schematic itself (the only other eligible pick) is.
CHECK_FALSE(optionOffered(sim, "quick_circuit"));
CHECK(optionOffered(sim, "repair_ship"));
}
TEST_CASE("UnlockPrereq: the gated recipe becomes eligible once its prerequisite is unlocked",
"[unlock_prereq]")
{
GameConfig cfg = loadConfig();
findRecipe(cfg, "quick_circuit").unlockRequires = {"repair_ship"};
Simulation sim(std::move(cfg), 123);
// Applying choice 0 each drop unlocks repair_ship first (the only eligible
// pick at level 0), which then opens quick_circuit for a later drop.
bool unlocked = false;
for (int i = 0; i < 150 && !unlocked; ++i)
{
killEnemyStationsAndApply(sim);
unlocked = sim.isRecipeUnlocked("quick_circuit");
}
CHECK(sim.isSchematicUnlocked("repair_ship")); // prerequisite got unlocked along the way
CHECK(unlocked);
}
TEST_CASE("UnlockPrereq: a module gated behind another module is withheld until it unlocks",
"[unlock_prereq]")
{
// Mirror the app-config demo (laser_cannon_m -> laser_cannon_l) using two
// test-config modules made lockable at the same station level.
GameConfig cfg = loadConfig();
findModule(cfg, "laser_cannon").unlockAtStationLevel = 0;
ModuleDef& armor = findModule(cfg, "armor_plate");
armor.unlockAtStationLevel = 0;
armor.unlockRequires = {"laser_cannon"};
Simulation sim(std::move(cfg), 7);
REQUIRE_FALSE(sim.isModuleSchematicUnlocked("laser_cannon"));
REQUIRE_FALSE(sim.isModuleSchematicUnlocked("armor_plate"));
killEnemyStations(sim); // level 0
REQUIRE(sim.hasSchematicChoicesPending());
// armor_plate is gated behind the still-locked laser_cannon module.
CHECK_FALSE(optionOffered(sim, "armor_plate"));
// Drive the drops until the gated module unlocks; it can only do so after its
// prerequisite has itself been unlocked.
bool armorUnlocked = false;
for (int i = 0; i < 300 && !armorUnlocked; ++i)
{
killEnemyStationsAndApply(sim);
armorUnlocked = sim.isModuleSchematicUnlocked("armor_plate");
if (armorUnlocked)
{
CHECK(sim.isModuleSchematicUnlocked("laser_cannon"));
}
}
CHECK(armorUnlocked);
}

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();
} }
@@ -153,67 +146,10 @@ void BlueprintPanel::onBlueprintButtonClicked(int index)
Blueprint BlueprintPanel::createBlueprintFromSelection() const Blueprint BlueprintPanel::createBlueprintFromSelection() const
{ {
struct Entry // Capture is shared, testable logic in lib/sim: it resolves each selected id as an
{ // operational building or a construction site alike (REQ-UI-BLUEPRINT-CREATE,
const Building* building; // REQ-UI-BLUEPRINT-STORAGE).
}; return captureBlueprintFromSelection(*m_sim, m_selectedBuildingIds);
std::vector<Entry> entries;
entries.reserve(m_selectedBuildingIds.size());
for (const BuildingId id : m_selectedBuildingIds)
{
const Building* b = m_sim->buildings().findBuilding(id);
if (!b) { continue; }
const bool placeable = [&]() {
for (const BuildingDef& def : m_config->buildings.buildings)
{
if (def.type == b->type) { return def.playerPlaceable; }
}
return false;
}();
if (placeable) { entries.push_back({ b }); }
}
if (entries.empty()) { return Blueprint{}; }
int minX = INT_MAX, maxX = INT_MIN;
int minY = INT_MAX, maxY = INT_MIN;
for (const Entry& e : entries)
{
for (const QPoint& cell : e.building->bodyCells)
{
minX = std::min(minX, cell.x());
maxX = std::max(maxX, cell.x());
minY = std::min(minY, cell.y());
maxY = std::max(maxY, cell.y());
}
}
const QPoint center((minX + maxX) / 2, (minY + maxY) / 2);
Blueprint bp;
bp.buildings.reserve(entries.size());
for (const Entry& e : entries)
{
BlueprintBuilding bb;
bb.type = e.building->type;
bb.rotation = e.building->rotation;
bb.offset = e.building->anchor - center;
bb.recipeId = e.building->recipeId;
bb.shipLayout = e.building->shipLayout;
if (e.building->type == BuildingType::Splitter)
{
const std::optional<BeltSystem::SplitterInfo> info =
m_sim->belts().getSplitterInfo(e.building->anchor);
if (info.has_value())
{
bb.splitterFilterA = info->filterA;
bb.splitterFilterB = info->filterB;
}
}
bp.buildings.push_back(bb);
}
return bp;
} }
int BlueprintPanel::computeBlueprintCost(const Blueprint& bp) const int BlueprintPanel::computeBlueprintCost(const Blueprint& bp) const
@@ -221,14 +157,8 @@ int BlueprintPanel::computeBlueprintCost(const Blueprint& bp) const
int total = 0; int total = 0;
for (const BlueprintBuilding& bb : bp.buildings) for (const BlueprintBuilding& bb : bp.buildings)
{ {
for (const BuildingDef& def : m_config->buildings.buildings) const BuildingDef* def = m_config->buildings.findBuildingDef(bb.type);
{ if (def) { total += def->cost; }
if (def.type == bb.type)
{
total += def.cost;
break;
}
}
} }
return total; return total;
} }
@@ -247,7 +177,7 @@ void BlueprintPanel::rebuildButtons()
{ {
const Blueprint& bp = m_blueprints[static_cast<std::size_t>(i)]; const Blueprint& bp = m_blueprints[static_cast<std::size_t>(i)];
const int cost = computeBlueprintCost(bp); const int cost = computeBlueprintCost(bp);
const QString label = bp.name + "\n" + tr("%1 Blocks").arg(cost); const QString label = bp.name + "\n" + tr("%1 Building Blocks").arg(cost);
QWidget* row = new QWidget(m_buttonsContainer); QWidget* row = new QWidget(m_buttonsContainer);
QHBoxLayout* rowLayout = new QHBoxLayout(row); QHBoxLayout* rowLayout = new QHBoxLayout(row);
@@ -281,83 +211,42 @@ 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();
} }
} }
void BlueprintPanel::refreshButtonStates() void BlueprintPanel::refreshButtonStates()
{ {
const bool anyPlaceable = [&]() { // A construction site counts the same as an operational building (REQ-UI-BLUEPRINT-CREATE).
for (const BuildingId id : m_selectedBuildingIds) m_createBtn->setEnabled(selectionHasPlaceableBuilding(*m_sim, m_selectedBuildingIds));
{
const Building* b = m_sim->buildings().findBuilding(id);
if (!b) { continue; }
for (const BuildingDef& def : m_config->buildings.buildings)
{
if (def.type == b->type) { return def.playerPlaceable; }
}
}
return false;
}();
m_createBtn->setEnabled(anyPlaceable);
for (int i = 0; i < static_cast<int>(m_blueprintButtons.size()); ++i) for (int i = 0; i < static_cast<int>(m_blueprintButtons.size()); ++i)
{ {
@@ -377,3 +266,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

@@ -38,10 +38,14 @@ BuildButtonGrid::BuildButtonGrid(const GameConfig* config, QWidget* parent)
m_costs[def.type] = def.cost; m_costs[def.type] = def.cost;
const QString label = QString::fromStdString(toDisplayName(def.id)) const QString label = QString::fromStdString(toDisplayName(def.id))
+ "\n" + tr("%1 Blocks").arg(def.cost); + "\n" + tr("%1 Building Blocks").arg(def.cost);
QPushButton* btn = new QPushButton(label, this); QPushButton* btn = new QPushButton(label, this);
btn->setCheckable(true); btn->setCheckable(true);
btn->setFixedHeight(48); btn->setFixedHeight(48);
if (def.tooltip)
{
btn->setToolTip(QString::fromStdString(*def.tooltip));
}
layout->addWidget(btn, row, col); layout->addWidget(btn, row, col);
const int idx = static_cast<int>(m_buttons.size()); const int idx = static_cast<int>(m_buttons.size());

View File

@@ -3,6 +3,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/VisualsConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/VisualsConfig.h
${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.h ${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.h
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.h ${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.h
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.h
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h ${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h
@@ -13,6 +14,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
) )
@@ -20,6 +22,7 @@ SET(SRCS
${SRCS} ${SRCS}
${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.cpp ${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.cpp
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.cpp ${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.cpp
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.cpp ${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.cpp
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp
@@ -30,5 +33,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
) )

File diff suppressed because it is too large Load Diff

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"
@@ -114,11 +115,16 @@ private:
bool canAfford(BuildingType type) const; bool canAfford(BuildingType type) const;
void drawTiles(QPainter& painter); void drawTiles(QPainter& painter);
void drawPortItems(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);
@@ -129,20 +135,32 @@ private:
float tilePx() const; float tilePx() const;
float viewportWidthTiles() const; float viewportWidthTiles() const;
// World-X (tiles) at the left edge of the viewport. m_scrollXTiles stores the
// view center; this derives the left edge the world<->widget conversions need.
float viewLeftTiles() const;
QPointF worldToWidget(QVector2D worldPos) const; QPointF worldToWidget(QVector2D worldPos) const;
QPointF tileToWidget(QPoint tile) const; QPointF tileToWidget(QPoint tile) const;
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,
@@ -154,9 +172,22 @@ private:
void placeBlueprintAtTile(QPoint center); void placeBlueprintAtTile(QPoint center);
std::optional<QVector2D> entityPosition(entt::entity entity) const; std::optional<QVector2D> entityPosition(entt::entity entity) const;
// Clears the scrap selection, emitting an empty ScrapSelectionChangedEvent when
// it was non-empty (REQ-UI-SCRAP-CLICK-SELECT). Used when another selection
// category takes over.
void clearScrapSelection();
// Drops despawned or fully-collected piles from the scrap selection and re-emits
// when it changed (REQ-UI-SCRAP-CLICK-SELECT). Called each frame from onFrame().
void pruneDespawnedScrap();
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 +204,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;
@@ -192,6 +222,7 @@ private:
std::mt19937 m_rng; std::mt19937 m_rng;
double m_gameSpeedMultiplier; double m_gameSpeedMultiplier;
double m_prevNonZeroSpeed; double m_prevNonZeroSpeed;
// World-X (tiles) at the center of the viewport (see viewLeftTiles()).
float m_scrollXTiles; float m_scrollXTiles;
QTimer* m_renderTimer; QTimer* m_renderTimer;
@@ -208,12 +239,29 @@ 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;
std::vector<BuildingId> m_selectedBuildingIds; std::vector<BuildingId> m_selectedBuildingIds;
std::optional<entt::entity> m_selectedEntity; std::optional<entt::entity> m_selectedEntity;
std::vector<entt::entity> m_selectedScrap;
bool m_boxSelecting; bool m_boxSelecting;
QPoint m_boxStartTile; QPoint m_boxStartTile;
QPoint m_boxCurrentTile; QPoint m_boxCurrentTile;
@@ -226,6 +274,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"
@@ -15,7 +17,7 @@
const double HeaderBar::kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 }; const double HeaderBar::kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 };
const int HeaderBar::kSpeedCount = 5; const int HeaderBar::kSpeedCount = 5;
HeaderBar::HeaderBar(QWidget* parent) HeaderBar::HeaderBar(const GameConfig* config, QWidget* parent)
: QWidget(parent) : QWidget(parent)
{ {
QHBoxLayout* layout = new QHBoxLayout(this); QHBoxLayout* layout = new QHBoxLayout(this);
@@ -23,7 +25,12 @@ HeaderBar::HeaderBar(QWidget* parent)
layout->setSpacing(8); layout->setSpacing(8);
m_timeLabel = new QLabel("00:00", this); m_timeLabel = new QLabel("00:00", this);
m_blocksLabel = new QLabel(tr("Blocks: 0"), this); m_blocksLabel = new QLabel(tr("Building Blocks: 0"), this);
if (config->world.buildingBlocksTooltip)
{
m_blocksLabel->setToolTip(
QString::fromStdString(*config->world.buildingBlocksTooltip));
}
m_artifactsLabel = new QLabel(tr("Artifacts: 0/?"), this); m_artifactsLabel = new QLabel(tr("Artifacts: 0/?"), this);
m_bossLabel = new QLabel(tr("Boss Wave #1 Next boss: 5:00"), this); m_bossLabel = new QLabel(tr("Boss Wave #1 Next boss: 5:00"), this);
layout->addWidget(m_timeLabel); layout->addWidget(m_timeLabel);
@@ -32,6 +39,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 +85,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_blocksLabel->setText(tr("Blocks: %1").arg(event->blocks)); m_blocks = event->blocks;
m_blocksLabel->setText(tr("Building 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 Building 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,8 @@
#include "BossWaveUpdatedEvent.h" #include "BossWaveUpdatedEvent.h"
#include "BuildingBlocksChangedEvent.h" #include "BuildingBlocksChangedEvent.h"
#include "EventHandler.h" #include "EventHandler.h"
#include "ExpansionCostChangedEvent.h"
#include "GameConfig.h"
#include "GameSpeedChangedEvent.h" #include "GameSpeedChangedEvent.h"
#include "Tick.h" #include "Tick.h"
#include "TickAdvancedEvent.h" #include "TickAdvancedEvent.h"
@@ -18,6 +20,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>
@@ -25,7 +28,7 @@ class HeaderBar : public QWidget,
Q_OBJECT Q_OBJECT
public: public:
explicit HeaderBar(QWidget* parent = nullptr); explicit HeaderBar(const GameConfig* config, QWidget* parent = nullptr);
~HeaderBar() override; ~HeaderBar() override;
private slots: private slots:
@@ -34,16 +37,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

@@ -42,15 +42,15 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
setWindowTitle(tr("Dota Factory")); setWindowTitle(tr("Dota Factory"));
resize(1280, 768); resize(1280, 768);
m_headerBar = new HeaderBar(this); m_headerBar = new HeaderBar(&sim->config(), this);
m_gameWorldView = new GameWorldView(sim, &sim->config(), &m_visuals, m_configDir, m_gameWorldView = new GameWorldView(sim, &sim->config(), &m_visuals, m_configDir,
m_replay.get(), this); m_replay.get(), this);
m_sidePanel = new QWidget(this); m_sidePanel = new QWidget(this);
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel); QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel);
sideLayout->setContentsMargins(0, 0, 0, 0); sideLayout->setContentsMargins(1, 1, 1, 1);
sideLayout->setSpacing(0); sideLayout->setSpacing(1);
m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->config(), m_sidePanel); m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->config(), m_sidePanel);
m_buildButtonGrid = new BuildButtonGrid(&sim->config(), m_sidePanel); m_buildButtonGrid = new BuildButtonGrid(&sim->config(), m_sidePanel);
@@ -60,6 +60,24 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
sideLayout->addWidget(m_buildButtonGrid, 1); sideLayout->addWidget(m_buildButtonGrid, 1);
sideLayout->addWidget(m_blueprintPanel, 1); sideLayout->addWidget(m_blueprintPanel, 1);
// Draw a thin border around each of the three side-panel sections. The class
// scoped selectors keep the border on the panels themselves rather than
// cascading onto their child widgets; WA_StyledBackground lets the plain
// QWidget subclasses honor the stylesheet box (border/background).
for (QWidget* panel : { static_cast<QWidget*>(m_selectedBuildingPanel),
static_cast<QWidget*>(m_buildButtonGrid),
static_cast<QWidget*>(m_blueprintPanel) })
{
panel->setAttribute(Qt::WA_StyledBackground, true);
}
m_sidePanel->setStyleSheet(QStringLiteral(
"SelectedBuildingPanel, BuildButtonGrid, BlueprintPanel {"
" border: 1px solid palette(mid); }"));
// Created last so it stacks above the other children; covers the whole window and
// dims the game behind modal dialogs/menus (REQ-UI-MODAL-DIM).
m_dimOverlay = new ModalDimOverlay(m_visuals.overlays.modalDim, this);
m_gameWorldView->setFocus(); m_gameWorldView->setFocus();
connect(qApp, &QApplication::focusChanged, this, [this](QWidget*, QWidget* newWidget) { connect(qApp, &QApplication::focusChanged, this, [this](QWidget*, QWidget* newWidget) {
@@ -130,6 +148,7 @@ void MainWindow::layoutPanels()
m_headerBar->setGeometry(0, 0, mainW, headerH); m_headerBar->setGeometry(0, 0, mainW, headerH);
m_gameWorldView->setGeometry(0, headerH, mainW, totalH - headerH); m_gameWorldView->setGeometry(0, headerH, mainW, totalH - headerH);
m_sidePanel->setGeometry(mainW, 0, sideW, totalH); m_sidePanel->setGeometry(mainW, 0, sideW, totalH);
m_dimOverlay->setGeometry(0, 0, totalW, totalH);
} }
void MainWindow::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) void MainWindow::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event)
@@ -142,7 +161,8 @@ 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); ModalDimScope dim(*m_dimOverlay);
SchematicChoiceDialog dialog(event->choices, m_sim->config().recipes, this);
dialog.exec(); dialog.exec();
std::shared_ptr<ApplySchematicChoiceCommand> command = std::shared_ptr<ApplySchematicChoiceCommand> command =
@@ -160,6 +180,7 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
const double prevSpeed = m_gameWorldView->gameSpeed(); const double prevSpeed = m_gameWorldView->gameSpeed();
m_gameWorldView->setGameSpeed(0.0); m_gameWorldView->setGameSpeed(0.0);
ModalDimScope dim(*m_dimOverlay);
QMessageBox box(this); QMessageBox box(this);
box.setWindowTitle(tr("Paused")); box.setWindowTitle(tr("Paused"));
QPushButton* continueBtn = box.addButton(tr("Continue"), QMessageBox::AcceptRole); QPushButton* continueBtn = box.addButton(tr("Continue"), QMessageBox::AcceptRole);
@@ -178,6 +199,7 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
ConfigLoader::loadFromDirectory(m_configDir)); ConfigLoader::loadFromDirectory(m_configDir));
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml"); VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
m_visuals = std::move(newVisuals); m_visuals = std::move(newVisuals);
m_dimOverlay->setDimColor(m_visuals.overlays.modalDim);
} }
catch (const std::exception& e) catch (const std::exception& e)
{ {
@@ -206,11 +228,44 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
} }
} }
void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event) void MainWindow::openShipLayoutDialog(BuildingId shipyardId,
const std::string& schematicId,
const ShipLayoutConfig& currentLayout)
{ {
const double prevSpeed = m_gameWorldView->gameSpeed(); const double prevSpeed = m_gameWorldView->gameSpeed();
m_gameWorldView->setGameSpeed(0.0); m_gameWorldView->setGameSpeed(0.0);
std::set<std::string> unlockedModuleIds;
for (const ModuleDef& def : m_sim->config().modules.modules)
{
if (m_sim->isModuleSchematicUnlocked(def.id))
{
unlockedModuleIds.insert(def.id);
}
}
ModalDimScope dim(*m_dimOverlay);
ShipLayoutDialog dialog(&m_sim->config(), schematicId, currentLayout,
m_layoutBlueprints,
std::move(unlockedModuleIds),
m_gameWorldView->isDebugDrawEnabled(),
this);
if (dialog.exec() == QDialog::Accepted && dialog.result().has_value())
{
std::shared_ptr<SetShipLayoutCommand> command =
std::make_shared<SetShipLayoutCommand>();
command->id = shipyardId;
command->layout = *dialog.result();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
}
void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event)
{
// A construction site has no Building yet; fall back to its site record so // A construction site has no Building yet; fall back to its site record so
// the shipyard layout can be configured before it is built (REQ-BLD-SITE-CONFIG). // the shipyard layout can be configured before it is built (REQ-BLD-SITE-CONFIG).
const Building* b = m_sim->buildings().findBuilding(event->shipyardId); const Building* b = m_sim->buildings().findBuilding(event->shipyardId);
@@ -218,8 +273,6 @@ void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> e
b ? nullptr : m_sim->buildings().findSite(event->shipyardId); b ? nullptr : m_sim->buildings().findSite(event->shipyardId);
if (!b && !s) if (!b && !s)
{ {
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
return; return;
} }
@@ -233,35 +286,7 @@ void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> e
currentLayout = *layoutOpt; currentLayout = *layoutOpt;
} }
std::set<std::string> unlockedModuleIds; openShipLayoutDialog(event->shipyardId, schematicId, currentLayout);
std::map<std::string, int> moduleLevels;
for (const ModuleDef& def : m_sim->config().modules.modules)
{
if (m_sim->isModuleSchematicUnlocked(def.id))
{
unlockedModuleIds.insert(def.id);
}
moduleLevels[def.id] = m_sim->moduleSchematicLevel(def.id);
}
ShipLayoutDialog dialog(&m_sim->config(), schematicId, currentLayout,
m_layoutBlueprints,
std::move(unlockedModuleIds),
std::move(moduleLevels),
m_gameWorldView->isDebugDrawEnabled(),
this);
if (dialog.exec() == QDialog::Accepted && dialog.result().has_value())
{
std::shared_ptr<SetShipLayoutCommand> command =
std::make_shared<SetShipLayoutCommand>();
command->id = event->shipyardId;
command->layout = *dialog.result();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
} }
void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event) void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event)
@@ -281,13 +306,23 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
return; return;
} }
// Held across both the selection dialog and any auto-opened layout dialog so the
// dim stays continuously visible through that sequence (REQ-UI-MODAL-DIM).
ModalDimScope dim(*m_dimOverlay);
const BuildingType type = b ? b->type : s->type; const BuildingType type = b ? b->type : s->type;
// Captured as a copy: a queued command may drain during the modal dialog's
// event loop and reallocate the building vectors, so b/s must not be
// dereferenced after dialog.exec() returns.
const std::string oldSchematic = b ? b->recipeId : s->recipeId;
const std::vector<RecipeSelectionOption> options = const std::vector<RecipeSelectionOption> options =
buildRecipeSelectionOptions(type, *m_sim, m_sim->config()); buildRecipeSelectionOptions(type, *m_sim, m_sim->config());
const QString title = (type == BuildingType::Shipyard) const QString title = (type == BuildingType::Shipyard)
? tr("Select Schematic") ? tr("Select Schematic")
: tr("Select Recipe"); : tr("Select Recipe");
bool autoOpenLayout = false;
std::string chosenSchematic;
RecipeSelectionDialog dialog(options, title, this); RecipeSelectionDialog dialog(options, title, this);
if (dialog.exec() == QDialog::Accepted && dialog.getChosenId().has_value()) if (dialog.exec() == QDialog::Accepted && dialog.getChosenId().has_value())
{ {
@@ -296,10 +331,27 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
command->recipeId = *dialog.getChosenId(); command->recipeId = *dialog.getChosenId();
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command)); std::make_shared<CommandRequestedEvent>(command));
// REQ-MOD-UI-AUTO-DIALOG: picking a new schematic for a shipyard opens the
// layout configuration dialog immediately. Only on an actual change.
if (type == BuildingType::Shipyard && *dialog.getChosenId() != oldSchematic)
{
autoOpenLayout = true;
chosenSchematic = *dialog.getChosenId();
}
} }
m_gameWorldView->setGameSpeed(prevSpeed); m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer(); m_gameWorldView->resetFrameTimer();
// The SetRecipeCommand above is queued (drains on a later frame) and clears
// the shipyard's layout, so open the dialog with the chosen schematic and an
// empty layout rather than reading the not-yet-updated building state. Speed
// is already restored so the helper snapshots the real speed to restore.
if (autoOpenLayout)
{
openShipLayoutDialog(event->buildingId, chosenSchematic, ShipLayoutConfig{});
}
} }
void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/) void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
@@ -309,6 +361,7 @@ void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
const int minutes = totalSeconds / 60; const int minutes = totalSeconds / 60;
const int seconds = totalSeconds % 60; const int seconds = totalSeconds % 60;
ModalDimScope dim(*m_dimOverlay);
QMessageBox box(this); QMessageBox box(this);
box.setWindowTitle(tr("Game Over")); box.setWindowTitle(tr("Game Over"));
box.setText(tr("HQ destroyed!\nSurvival time: %1:%2") box.setText(tr("HQ destroyed!\nSurvival time: %1:%2")
@@ -327,6 +380,7 @@ void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
ConfigLoader::loadFromDirectory(m_configDir)); ConfigLoader::loadFromDirectory(m_configDir));
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml"); VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
m_visuals = std::move(newVisuals); m_visuals = std::move(newVisuals);
m_dimOverlay->setDimColor(m_visuals.overlays.modalDim);
} }
catch (const std::exception& e) catch (const std::exception& e)
{ {
@@ -354,6 +408,7 @@ void MainWindow::handleEvent(std::shared_ptr<const WinEvent> /*event*/)
const int minutes = totalSeconds / 60; const int minutes = totalSeconds / 60;
const int seconds = totalSeconds % 60; const int seconds = totalSeconds % 60;
ModalDimScope dim(*m_dimOverlay);
QMessageBox box(this); QMessageBox box(this);
box.setWindowTitle(tr("Won!")); box.setWindowTitle(tr("Won!"));
box.setText(tr("You collected all artifacts!\nSurvival time: %1:%2") box.setText(tr("You collected all artifacts!\nSurvival time: %1:%2")
@@ -370,6 +425,7 @@ void MainWindow::handleEvent(std::shared_ptr<const WinEvent> /*event*/)
GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir); GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir);
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml"); VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
m_visuals = std::move(newVisuals); m_visuals = std::move(newVisuals);
m_dimOverlay->setDimColor(m_visuals.overlays.modalDim);
m_sim->reset(std::move(newConfig)); m_sim->reset(std::move(newConfig));
} }
catch (const std::exception& e) catch (const std::exception& e)

View File

@@ -12,9 +12,11 @@
#include "EventHandler.h" #include "EventHandler.h"
#include "GameOverEvent.h" #include "GameOverEvent.h"
#include "LayoutDialogRequestedEvent.h" #include "LayoutDialogRequestedEvent.h"
#include "ModalDimOverlay.h"
#include "WinEvent.h" #include "WinEvent.h"
#include "RecipeSelectionRequestedEvent.h" #include "RecipeSelectionRequestedEvent.h"
#include "SchematicChoicesAvailableEvent.h" #include "SchematicChoicesAvailableEvent.h"
#include "ShipLayout.h"
#include "ShipLayoutBlueprint.h" #include "ShipLayoutBlueprint.h"
#include "Tick.h" #include "Tick.h"
#include "VisualsConfig.h" #include "VisualsConfig.h"
@@ -57,6 +59,12 @@ private:
void handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> event) override; void handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event) override; void handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event) override; void handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event) override;
// Opens the shipyard layout configuration dialog for the given schematic and
// current layout, applying the result via SetShipLayoutCommand (REQ-MOD-UI-DIALOG).
void openShipLayoutDialog(BuildingId shipyardId,
const std::string& schematicId,
const ShipLayoutConfig& currentLayout);
void layoutPanels(); void layoutPanels();
private: private:
@@ -69,6 +77,7 @@ private:
BuildButtonGrid* m_buildButtonGrid; BuildButtonGrid* m_buildButtonGrid;
BlueprintPanel* m_blueprintPanel; BlueprintPanel* m_blueprintPanel;
QWidget* m_sidePanel; QWidget* m_sidePanel;
ModalDimOverlay* m_dimOverlay = nullptr;
std::vector<ShipLayoutBlueprint> m_layoutBlueprints; std::vector<ShipLayoutBlueprint> m_layoutBlueprints;
std::shared_ptr<ParsedReplay> m_replay; // non-null => view-only playback std::shared_ptr<ParsedReplay> m_replay; // non-null => view-only playback

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