Compare commits
43 Commits
e5017ab3c5
...
0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 271855781c | |||
| 5f6ecbf6c8 | |||
| cd966daba9 | |||
| 88bc4f2170 | |||
| 4986c1bac8 | |||
| e110e7e413 | |||
| 6274b5ce60 | |||
| 16ed6a5695 | |||
| 13dacab274 | |||
| 2ddf13238c | |||
| cc38bf95fa | |||
| b58bcfe272 | |||
| de1ebb8a5f | |||
| 24c18f8ac6 | |||
| 2cedd5d433 | |||
| f11db0c072 | |||
| 808e0c6a7b | |||
| e4ea4ca4b4 | |||
| fef22b9f86 | |||
| cdf89ce0dd | |||
| e8786c3922 | |||
| fc622670d2 | |||
| e32d384c99 | |||
| 24e0999d8a | |||
| fcaee000fa | |||
| 751ef27a7b | |||
| 3c1376828c | |||
| bd0675db66 | |||
| 5b86b15c71 | |||
| c6db4bf24a | |||
| 6ea0655eaf | |||
| 58d4586d00 | |||
| 18e732ae99 | |||
| 81b1c7a66b | |||
| 5bc581bcb8 | |||
| 0a7e9a34ef | |||
| d74ba5bfad | |||
| cf68ac2862 | |||
| b301db2008 | |||
| 31a8915b0f | |||
| f818c90af0 | |||
| 577927ef70 | |||
| d271d65678 |
@@ -4,10 +4,20 @@ message(STATUS "Using CMake ${CMAKE_VERSION}")
|
||||
|
||||
include(cmake/add_files.cmake)
|
||||
include(cmake/create_source_groups.cmake)
|
||||
include(cmake/version.cmake)
|
||||
|
||||
# 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")
|
||||
|
||||
|
||||
@@ -81,7 +81,8 @@ id = "salvage_bay"
|
||||
cost = 25
|
||||
player_placeable = true
|
||||
construction_time_seconds = 1
|
||||
output_buffer_capacity = 20
|
||||
surface_mask = [
|
||||
"SAA",
|
||||
"SAA>",
|
||||
"<AAS",
|
||||
" AAS",
|
||||
]
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
# modules.toml
|
||||
#
|
||||
# First real-content iteration: module ids and surface masks are the designed
|
||||
# content; stats, materials, and threat costs are placeholders until the
|
||||
# recipe and balancing passes.
|
||||
# Production tree v2: all weapons are railguns for now — the implementation
|
||||
# (instant damage, no projectile, no ammunition) stays as-is and the beam
|
||||
# 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
|
||||
# module, purely through geometry (see ships.toml for the matching hull
|
||||
# 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,
|
||||
# armor_plates frigate and up
|
||||
# 1x3 afterburner frigate and up (eats most of a frigate)
|
||||
# L-shape weapon_stabilizer, weapon_primer,
|
||||
# weapon_upgrade frigate and up
|
||||
# 2x2 laser_cannon_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)
|
||||
# 2x2 railgun_m, drone_bay cruiser and up (no 2x2 area on s hulls)
|
||||
# 3x3 railgun_l battleship and up (no 3x3 area on m hulls)
|
||||
# 2x6 drone_hangar carrier only
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -23,56 +31,54 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
[[module]]
|
||||
id = "laser_cannon_s"
|
||||
id = "railgun_s"
|
||||
unlock_at_station_level = -1
|
||||
surface_mask = ["O"]
|
||||
materials = [{item = "laser_cannon_s_module", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 0.5
|
||||
materials = [{item = "railgun_s_module", amount = 1}]
|
||||
production_time_seconds = 1
|
||||
fill_color = "#FF8040"
|
||||
glyph = "Ls"
|
||||
glyph = "Rs"
|
||||
|
||||
[module.weapon]
|
||||
damage_formula = "2"
|
||||
attack_range_m_formula = "50"
|
||||
attack_rate_hz_formula = "2.0"
|
||||
damage = 2
|
||||
attack_range_m = 50
|
||||
attack_rate_hz = 2.0
|
||||
|
||||
|
||||
[[module]]
|
||||
id = "laser_cannon_m"
|
||||
unlock_at_station_level = -1
|
||||
id = "railgun_m"
|
||||
unlock_at_station_level = 2
|
||||
surface_mask = [
|
||||
"OO",
|
||||
"OO"]
|
||||
materials = [{item = "laser_cannon_m_module", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 2
|
||||
materials = [{item = "railgun_m_module", amount = 1}]
|
||||
production_time_seconds = 3
|
||||
fill_color = "#FF8040"
|
||||
glyph = "Lm"
|
||||
glyph = "Rm"
|
||||
|
||||
[module.weapon]
|
||||
damage_formula = "10"
|
||||
attack_range_m_formula = "70"
|
||||
attack_rate_hz_formula = "1.5"
|
||||
damage = 14
|
||||
attack_range_m = 70
|
||||
attack_rate_hz = 1.5
|
||||
|
||||
|
||||
[[module]]
|
||||
id = "laser_cannon_l"
|
||||
unlock_at_station_level = -1
|
||||
id = "railgun_l"
|
||||
unlock_at_station_level = 6
|
||||
unlock_requires = ["railgun_m"]
|
||||
surface_mask = [
|
||||
"OOO",
|
||||
"OOO",
|
||||
"OOO"]
|
||||
materials = [{item = "laser_cannon_l_module", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 8
|
||||
materials = [{item = "railgun_l_module", amount = 1}]
|
||||
production_time_seconds = 4
|
||||
fill_color = "#FF8040"
|
||||
glyph = "Ll"
|
||||
glyph = "Rl"
|
||||
|
||||
[module.weapon]
|
||||
damage_formula = "40"
|
||||
attack_range_m_formula = "100"
|
||||
attack_rate_hz_formula = "0.8"
|
||||
damage = 52
|
||||
attack_range_m = 100
|
||||
attack_rate_hz = 0.8
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Utility tools
|
||||
@@ -83,31 +89,29 @@ id = "salvager"
|
||||
unlock_at_station_level = -1
|
||||
surface_mask = ["O"]
|
||||
materials = [{item = "salvager_module", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 2
|
||||
production_time_seconds = 1
|
||||
fill_color = "#AACC44"
|
||||
glyph = "Sv"
|
||||
|
||||
[module.salvage]
|
||||
collection_range_m_formula = "500"
|
||||
cargo_capacity_formula = "10"
|
||||
collection_rate_hz_formula = "0.5"
|
||||
collection_range_m = 60
|
||||
cargo_capacity = 20
|
||||
collection_rate_hz = 0.5
|
||||
|
||||
|
||||
[[module]]
|
||||
id = "repair_tool"
|
||||
unlock_at_station_level = -1
|
||||
unlock_at_station_level = 0
|
||||
surface_mask = ["O"]
|
||||
materials = [{item = "repair_tool_module", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 2
|
||||
production_time_seconds = 1
|
||||
fill_color = "#66CCFF"
|
||||
glyph = "Rp"
|
||||
|
||||
[module.repair]
|
||||
repair_rate_hz_formula = "1"
|
||||
repair_amount_hp_formula = "5 + x"
|
||||
repair_range_m_formula = "80"
|
||||
repair_rate_hz = 1
|
||||
repair_amount_hp = 4
|
||||
repair_range_m = 80
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Propulsion
|
||||
@@ -115,32 +119,30 @@ repair_range_m_formula = "80"
|
||||
|
||||
[[module]]
|
||||
id = "afterburner"
|
||||
unlock_at_station_level = -1
|
||||
unlock_at_station_level = 2
|
||||
surface_mask = ["OOO"]
|
||||
materials = [{item = "afterburner_module", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 2
|
||||
production_time_seconds = 1
|
||||
fill_color = "#40A0FF"
|
||||
glyph = "Ab"
|
||||
|
||||
[module.movement]
|
||||
multiplied_speed_mps_formula = "1.6"
|
||||
added_main_acceleration_mpss_formula = "60"
|
||||
multiplied_speed_mps = 1.6
|
||||
added_main_acceleration_mpss = 60
|
||||
|
||||
|
||||
[[module]]
|
||||
id = "maneuvering_thrusters"
|
||||
unlock_at_station_level = -1
|
||||
unlock_at_station_level = 1
|
||||
surface_mask = ["OO"]
|
||||
materials = [{item = "maneuvering_thrusters_module", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 2
|
||||
production_time_seconds = 1
|
||||
fill_color = "#40A0FF"
|
||||
glyph = "Mt"
|
||||
|
||||
[module.movement]
|
||||
multiplied_speed_mps_formula = "1.2"
|
||||
added_maneuvering_acceleration_mpss_formula = "10"
|
||||
multiplied_speed_mps = 1.2
|
||||
added_maneuvering_acceleration_mpss = 10
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Defense & sensors
|
||||
@@ -148,30 +150,28 @@ added_maneuvering_acceleration_mpss_formula = "10"
|
||||
|
||||
[[module]]
|
||||
id = "armor_plates"
|
||||
unlock_at_station_level = -1
|
||||
unlock_at_station_level = 0
|
||||
surface_mask = ["OO"]
|
||||
materials = [{item = "armor_plates_module", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 3
|
||||
production_time_seconds = 1
|
||||
fill_color = "#808080"
|
||||
glyph = "A"
|
||||
|
||||
[module.health]
|
||||
added_hp_formula = "40"
|
||||
added_hp = 1200
|
||||
|
||||
|
||||
[[module]]
|
||||
id = "sensor_booster"
|
||||
unlock_at_station_level = -1
|
||||
unlock_at_station_level = 1
|
||||
surface_mask = ["OO"]
|
||||
materials = [{item = "sensor_booster_module", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 2
|
||||
production_time_seconds = 1
|
||||
fill_color = "#40A0FF"
|
||||
glyph = "S"
|
||||
|
||||
[module.sensor]
|
||||
added_sensor_range_m_formula = "50"
|
||||
added_sensor_range_m = 50
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Weapon modifiers
|
||||
@@ -179,54 +179,51 @@ added_sensor_range_m_formula = "50"
|
||||
|
||||
[[module]]
|
||||
id = "weapon_upgrade"
|
||||
unlock_at_station_level = -1
|
||||
unlock_at_station_level = 4
|
||||
surface_mask = [
|
||||
"OO",
|
||||
"OX",
|
||||
]
|
||||
materials = [{item = "weapon_upgrade_module", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 4
|
||||
production_time_seconds = 2
|
||||
fill_color = "#FF4040"
|
||||
glyph = "Wu"
|
||||
|
||||
[module.weapon]
|
||||
multiplied_damage_formula = "1.2"
|
||||
multiplied_damage = 1.2
|
||||
|
||||
|
||||
[[module]]
|
||||
id = "weapon_primer"
|
||||
unlock_at_station_level = -1
|
||||
unlock_at_station_level = 4
|
||||
surface_mask = [
|
||||
"OO",
|
||||
"OX",
|
||||
]
|
||||
materials = [{item = "weapon_primer_module", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 4
|
||||
production_time_seconds = 2
|
||||
fill_color = "#FF4040"
|
||||
glyph = "Wp"
|
||||
|
||||
[module.weapon]
|
||||
multiplied_attack_rate_hz_formula = "1.2"
|
||||
multiplied_attack_rate_hz = 1.2
|
||||
|
||||
|
||||
[[module]]
|
||||
id = "weapon_stabilizer"
|
||||
unlock_at_station_level = -1
|
||||
unlock_at_station_level = 3
|
||||
surface_mask = [
|
||||
"OO",
|
||||
"OX",
|
||||
]
|
||||
materials = [{item = "weapon_stabilizer_module", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 4
|
||||
production_time_seconds = 1
|
||||
fill_color = "#FF4040"
|
||||
glyph = "Ws"
|
||||
|
||||
[module.weapon]
|
||||
multiplied_attack_range_m_formula = "1.5"
|
||||
multiplied_attack_rate_hz_formula = "0.8"
|
||||
multiplied_attack_range_m = 1.3
|
||||
multiplied_attack_rate_hz = 0.8
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Drone modules
|
||||
@@ -237,25 +234,23 @@ multiplied_attack_rate_hz_formula = "0.8"
|
||||
|
||||
[[module]]
|
||||
id = "drone_bay"
|
||||
unlock_at_station_level = -1
|
||||
unlock_at_station_level = 5
|
||||
surface_mask = [
|
||||
"OO",
|
||||
"OO"]
|
||||
materials = [{item = "drone_bay_module", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 5
|
||||
production_time_seconds = 3
|
||||
fill_color = "#CC66FF"
|
||||
glyph = "Db"
|
||||
|
||||
|
||||
[[module]]
|
||||
id = "drone_hangar"
|
||||
unlock_at_station_level = -1
|
||||
unlock_at_station_level = 9
|
||||
surface_mask = [
|
||||
"OOOOOO",
|
||||
"OOOOOO"]
|
||||
materials = [{item = "drone_hangar_module", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 20
|
||||
production_time_seconds = 6
|
||||
fill_color = "#9933CC"
|
||||
glyph = "Dh"
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
# recipes.toml
|
||||
#
|
||||
# First real-content iteration of the production tree. Quantities and
|
||||
# durations are a first guess; the balancing pass will tune them and assign
|
||||
# real unlock_at_station_level values (everything is unlocked for now so the
|
||||
# full tree is testable).
|
||||
# Production tree v2 (structure in docs/content_design.md, numbers with
|
||||
# derivations in docs/balancing/derived.md). Quantities and durations are tuned so that every
|
||||
# fitted ship lands on the threat-cost ladder and the ratio curve is
|
||||
# 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,
|
||||
# circuit_board
|
||||
# mid + titanium_ore -> titanium_frame; assembler-made
|
||||
# mechanical_parts, targeting_unit,
|
||||
# drive_unit
|
||||
# late + advanced_alloy -> reinforced_plating, capital_core.
|
||||
# advanced_alloy CANNOT be mined; it only
|
||||
# comes from reprocessing salvaged scrap,
|
||||
# so capital production requires combat.
|
||||
# early iron_ore + copper_ore minable on every asteroid tile (the
|
||||
# asteroid is an M-type body — its bulk
|
||||
# rock IS ore)
|
||||
# mid + quartz geode deposits in expansion territory
|
||||
# (deposit gating pending — see action
|
||||
# item 5 in docs/balancing/README.md;
|
||||
# until then quartz mines anywhere)
|
||||
# late + voidsteel battle-forged: ONLY from reprocessing
|
||||
# salvaged scrap, so capital production
|
||||
# requires combat
|
||||
#
|
||||
# Run tools/verify_recipes.py after editing to check that every consumed
|
||||
# item has a producer and every item has a visuals.toml entry.
|
||||
# Run tools/verify_recipes.py and tools/threat_report.py after editing.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Mining (tier 0)
|
||||
@@ -36,404 +38,409 @@ id = "mine_copper_ore"
|
||||
building = "miner"
|
||||
inputs = []
|
||||
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]]
|
||||
id = "mine_titanium_ore"
|
||||
id = "mine_quartz"
|
||||
building = "miner"
|
||||
inputs = []
|
||||
outputs = [{item = "titanium_ore", amount = 1}]
|
||||
duration_seconds = 3.0
|
||||
outputs = [{item = "quartz", amount = 1}]
|
||||
duration_seconds = 2.0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Smelting (tier 1)
|
||||
# Smelting (tier 1) — one recipe per input item; ratios are 1:1 with miners.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
[[recipe]]
|
||||
id = "iron_ingot"
|
||||
building = "smelter"
|
||||
inputs = [{item = "iron_ore", amount = 2}]
|
||||
inputs = [{item = "iron_ore", amount = 1}]
|
||||
outputs = [{item = "iron_ingot", amount = 1}]
|
||||
duration_seconds = 2.0
|
||||
duration_seconds = 1.0
|
||||
|
||||
[[recipe]]
|
||||
id = "copper_ingot"
|
||||
building = "smelter"
|
||||
inputs = [{item = "copper_ore", amount = 2}]
|
||||
inputs = [{item = "copper_ore", amount = 1}]
|
||||
outputs = [{item = "copper_ingot", amount = 1}]
|
||||
duration_seconds = 2.5
|
||||
duration_seconds = 1.0
|
||||
|
||||
[[recipe]]
|
||||
id = "titanium_ingot"
|
||||
id = "silicon"
|
||||
building = "smelter"
|
||||
inputs = [{item = "titanium_ore", amount = 3}]
|
||||
outputs = [{item = "titanium_ingot", amount = 1}]
|
||||
duration_seconds = 4.0
|
||||
inputs = [{item = "quartz", amount = 1}]
|
||||
outputs = [{item = "silicon", amount = 1}]
|
||||
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
|
||||
#
|
||||
# The only source of advanced_alloy: salvaged scrap from destroyed ships.
|
||||
# Reprocessing — the only source of voidsteel (battle-forged; formed when
|
||||
# weapon plasma anneals hull metal in the violence of ship destruction).
|
||||
# Weights are authored for the fully unlocked pool state; the pool
|
||||
# renormalizes over implicitly unlocked items early game.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
[[recipe]]
|
||||
id = "reprocessing_cycle"
|
||||
building = "reprocessing_plant"
|
||||
inputs = [{item = "scrap", amount = 5}]
|
||||
duration_seconds = 3.0
|
||||
inputs = [{item = "scrap", amount = 4}]
|
||||
duration_seconds = 4.0
|
||||
|
||||
[[recipe.outputs]]
|
||||
item = "iron_ingot"
|
||||
amount = 2
|
||||
probability = 0.45
|
||||
amount = 1
|
||||
probability = 0.3
|
||||
|
||||
[[recipe.outputs]]
|
||||
item = "copper_ingot"
|
||||
amount = 1
|
||||
probability = 0.25
|
||||
probability = 0.3
|
||||
|
||||
[[recipe.outputs]]
|
||||
item = "titanium_ingot"
|
||||
item = "silicon"
|
||||
amount = 1
|
||||
probability = 0.15
|
||||
probability = 0.2
|
||||
|
||||
[[recipe.outputs]]
|
||||
item = "advanced_alloy"
|
||||
item = "voidsteel"
|
||||
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]]
|
||||
id = "steel_plate"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [{item = "iron_ingot", amount = 2}]
|
||||
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
|
||||
|
||||
[[recipe]]
|
||||
id = "circuit_board"
|
||||
unlock_at_station_level = -1
|
||||
id = "shortcut_control_chip"
|
||||
unlock_at_station_level = 2
|
||||
building = "assembler"
|
||||
inputs = [{item = "iron_ingot", amount = 1}, {item = "copper_wire", amount = 2}]
|
||||
outputs = [{item = "circuit_board", amount = 1}]
|
||||
duration_seconds = 2.0
|
||||
inputs = [{item = "quartz", amount = 2}]
|
||||
outputs = [{item = "control_chip", amount = 1}]
|
||||
duration_seconds = 4.0
|
||||
|
||||
[[recipe]]
|
||||
id = "building_blocks"
|
||||
unlock_at_station_level = -1
|
||||
id = "shortcut_hardened_steel"
|
||||
unlock_at_station_level = 2
|
||||
building = "assembler"
|
||||
inputs = [{item = "iron_ingot", amount = 4}]
|
||||
outputs = [{item = "building_block", amount = 10}]
|
||||
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}]
|
||||
outputs = [{item = "hardened_steel", amount = 1}]
|
||||
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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
[[recipe]]
|
||||
id = "drone_hull"
|
||||
unlock_at_station_level = -1
|
||||
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}]
|
||||
duration_seconds = 4.0
|
||||
duration_seconds = 1.0
|
||||
|
||||
[[recipe]]
|
||||
id = "frigate_hull"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "steel_plate", amount = 3},
|
||||
{item = "mechanical_parts", amount = 1},
|
||||
{item = "circuit_board", amount = 1},
|
||||
]
|
||||
inputs = [{item = "steel_plate", amount = 2}, {item = "copper_wire", amount = 1}]
|
||||
outputs = [{item = "frigate_hull", amount = 1}]
|
||||
duration_seconds = 8.0
|
||||
duration_seconds = 2.0
|
||||
|
||||
[[recipe]]
|
||||
id = "destroyer_hull"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "steel_plate", amount = 5},
|
||||
{item = "mechanical_parts", amount = 2},
|
||||
{item = "circuit_board", amount = 1},
|
||||
]
|
||||
inputs = [{item = "steel_plate", amount = 3}, {item = "copper_coil", amount = 2}]
|
||||
outputs = [{item = "destroyer_hull", amount = 1}]
|
||||
duration_seconds = 10.0
|
||||
duration_seconds = 4.0
|
||||
|
||||
[[recipe]]
|
||||
id = "cruiser_hull"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "titanium_frame", amount = 2},
|
||||
{item = "steel_plate", amount = 4},
|
||||
{item = "drive_unit", amount = 1},
|
||||
]
|
||||
inputs = [{item = "hardened_steel", amount = 2}, {item = "control_chip", amount = 2}]
|
||||
outputs = [{item = "cruiser_hull", amount = 1}]
|
||||
duration_seconds = 15.0
|
||||
duration_seconds = 6.0
|
||||
|
||||
[[recipe]]
|
||||
id = "battlecruiser_hull"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "titanium_frame", amount = 3},
|
||||
{item = "steel_plate", amount = 6},
|
||||
{item = "hardened_steel", amount = 3},
|
||||
{item = "control_chip", amount = 2},
|
||||
{item = "drive_unit", amount = 1},
|
||||
{item = "targeting_unit", amount = 1},
|
||||
]
|
||||
outputs = [{item = "battlecruiser_hull", amount = 1}]
|
||||
duration_seconds = 20.0
|
||||
duration_seconds = 8.0
|
||||
|
||||
[[recipe]]
|
||||
id = "battleship_hull"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "titanium_frame", amount = 4},
|
||||
{item = "reinforced_plating", amount = 2},
|
||||
{item = "drive_unit", amount = 2},
|
||||
{item = "voidsteel_plate", amount = 3},
|
||||
{item = "drive_unit", amount = 1},
|
||||
{item = "control_chip", amount = 2},
|
||||
]
|
||||
outputs = [{item = "battleship_hull", amount = 1}]
|
||||
duration_seconds = 30.0
|
||||
duration_seconds = 10.0
|
||||
|
||||
[[recipe]]
|
||||
id = "dreadnought_hull"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "voidsteel_plate", amount = 5},
|
||||
{item = "capital_core", amount = 1},
|
||||
{item = "titanium_frame", amount = 6},
|
||||
{item = "reinforced_plating", amount = 4},
|
||||
{item = "drive_unit", amount = 2},
|
||||
]
|
||||
outputs = [{item = "dreadnought_hull", amount = 1}]
|
||||
duration_seconds = 60.0
|
||||
duration_seconds = 12.0
|
||||
|
||||
[[recipe]]
|
||||
id = "carrier_hull"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "voidsteel_plate", amount = 5},
|
||||
{item = "capital_core", amount = 1},
|
||||
{item = "titanium_frame", amount = 5},
|
||||
{item = "reinforced_plating", amount = 3},
|
||||
{item = "drive_unit", amount = 2},
|
||||
]
|
||||
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
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
# content; stats, materials, and production times are placeholders until the
|
||||
# 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:
|
||||
# xs drone 1 cell — exactly one 1x1 module
|
||||
# s frigate, destroyer no 2x2 area anywhere: only 1x1/1x2/1x3/L modules fit
|
||||
@@ -19,28 +24,24 @@
|
||||
id = "drone"
|
||||
unlock_at_station_level = -1
|
||||
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]
|
||||
materials = [{item = "iron_ore", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 5
|
||||
materials = [{item = "drone_hull", amount = 1}]
|
||||
production_time_seconds = 1
|
||||
|
||||
[ship.health]
|
||||
hp_formula = "3"
|
||||
hp = 60
|
||||
|
||||
[ship.movement]
|
||||
speed_mps_formula = "40"
|
||||
main_acceleration_mpss_formula = "2"
|
||||
maneuvering_acceleration_mpss_formula = "1"
|
||||
angular_acceleration_radpss_formula = "12.56"
|
||||
max_rotation_speed_radps_formula = "6.28"
|
||||
speed_mps = 45
|
||||
main_acceleration_mpss = 60
|
||||
maneuvering_acceleration_mpss = 30
|
||||
angular_acceleration_radpss = 12
|
||||
max_rotation_speed_radps = 6
|
||||
|
||||
[ship.sensor]
|
||||
sensor_range_m_formula = "150"
|
||||
|
||||
[ship.loot]
|
||||
scrap_drop = 2
|
||||
sensor_range_m = 150
|
||||
|
||||
|
||||
# Frigate — 5 cells in a plus shape. Holds a couple of small guns plus at
|
||||
@@ -54,27 +55,28 @@ layout = [
|
||||
"OOO",
|
||||
"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]
|
||||
materials = [{item = "frigate_hull", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 10
|
||||
production_time_seconds = 2
|
||||
|
||||
[ship.health]
|
||||
hp_formula = "30"
|
||||
hp = 300
|
||||
|
||||
[ship.movement]
|
||||
speed_mps_formula = "30"
|
||||
main_acceleration_mpss_formula = "50"
|
||||
maneuvering_acceleration_mpss_formula = "25"
|
||||
angular_acceleration_radpss_formula = "8"
|
||||
max_rotation_speed_radps_formula = "4"
|
||||
speed_mps = 35
|
||||
main_acceleration_mpss = 45
|
||||
maneuvering_acceleration_mpss = 22
|
||||
angular_acceleration_radpss = 8
|
||||
max_rotation_speed_radps = 4
|
||||
|
||||
[ship.sensor]
|
||||
sensor_range_m_formula = "200"
|
||||
|
||||
[ship.loot]
|
||||
scrap_drop = 5
|
||||
sensor_range_m = 200
|
||||
|
||||
|
||||
# Destroyer — 8 cells: a long gun deck with three turret bumps on top.
|
||||
@@ -82,32 +84,35 @@ scrap_drop = 5
|
||||
# mount medium hardware.
|
||||
[[ship]]
|
||||
id = "destroyer"
|
||||
unlock_at_station_level = -1
|
||||
unlock_at_station_level = 0
|
||||
layout = [
|
||||
"OXOXO",
|
||||
"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]
|
||||
materials = [{item = "destroyer_hull", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 15
|
||||
production_time_seconds = 3
|
||||
|
||||
[ship.health]
|
||||
hp_formula = "50"
|
||||
hp = 550
|
||||
|
||||
[ship.movement]
|
||||
speed_mps_formula = "25"
|
||||
main_acceleration_mpss_formula = "40"
|
||||
maneuvering_acceleration_mpss_formula = "20"
|
||||
angular_acceleration_radpss_formula = "6"
|
||||
max_rotation_speed_radps_formula = "3"
|
||||
speed_mps = 30
|
||||
main_acceleration_mpss = 35
|
||||
maneuvering_acceleration_mpss = 18
|
||||
angular_acceleration_radpss = 6
|
||||
max_rotation_speed_radps = 3
|
||||
|
||||
[ship.sensor]
|
||||
sensor_range_m_formula = "220"
|
||||
|
||||
[ship.loot]
|
||||
scrap_drop = 8
|
||||
sensor_range_m = 220
|
||||
|
||||
|
||||
# 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.
|
||||
[[ship]]
|
||||
id = "cruiser"
|
||||
unlock_at_station_level = -1
|
||||
unlock_at_station_level = 2
|
||||
layout = [
|
||||
"XOOX",
|
||||
"OOOO",
|
||||
"OOOO",
|
||||
"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]
|
||||
materials = [{item = "cruiser_hull", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 25
|
||||
production_time_seconds = 4
|
||||
|
||||
[ship.health]
|
||||
hp_formula = "120"
|
||||
hp = 1500
|
||||
|
||||
[ship.movement]
|
||||
speed_mps_formula = "20"
|
||||
main_acceleration_mpss_formula = "30"
|
||||
maneuvering_acceleration_mpss_formula = "15"
|
||||
angular_acceleration_radpss_formula = "4"
|
||||
max_rotation_speed_radps_formula = "2"
|
||||
speed_mps = 24
|
||||
main_acceleration_mpss = 25
|
||||
maneuvering_acceleration_mpss = 12
|
||||
angular_acceleration_radpss = 4
|
||||
max_rotation_speed_radps = 2
|
||||
|
||||
[ship.sensor]
|
||||
sensor_range_m_formula = "250"
|
||||
|
||||
[ship.loot]
|
||||
scrap_drop = 15
|
||||
sensor_range_m = 250
|
||||
|
||||
|
||||
# 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.
|
||||
[[ship]]
|
||||
id = "battlecruiser"
|
||||
unlock_at_station_level = -1
|
||||
unlock_at_station_level = 4
|
||||
unlock_requires = ["cruiser"]
|
||||
layout = [
|
||||
"OOXXOO",
|
||||
"OOOOOO",
|
||||
"XOOOOX",
|
||||
"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]
|
||||
materials = [{item = "battlecruiser_hull", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 35
|
||||
production_time_seconds = 5
|
||||
|
||||
[ship.health]
|
||||
hp_formula = "180"
|
||||
hp = 2400
|
||||
|
||||
[ship.movement]
|
||||
speed_mps_formula = "18"
|
||||
main_acceleration_mpss_formula = "25"
|
||||
maneuvering_acceleration_mpss_formula = "12"
|
||||
angular_acceleration_radpss_formula = "3"
|
||||
max_rotation_speed_radps_formula = "1.5"
|
||||
speed_mps = 20
|
||||
main_acceleration_mpss = 20
|
||||
maneuvering_acceleration_mpss = 10
|
||||
angular_acceleration_radpss = 3
|
||||
max_rotation_speed_radps = 1.5
|
||||
|
||||
[ship.sensor]
|
||||
sensor_range_m_formula = "260"
|
||||
|
||||
[ship.loot]
|
||||
scrap_drop = 20
|
||||
sensor_range_m = 260
|
||||
|
||||
|
||||
# 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.
|
||||
[[ship]]
|
||||
id = "battleship"
|
||||
unlock_at_station_level = -1
|
||||
unlock_at_station_level = 6
|
||||
unlock_requires = ["battlecruiser"]
|
||||
layout = [
|
||||
"XOOOOX",
|
||||
"OOOOOO",
|
||||
@@ -197,27 +210,31 @@ layout = [
|
||||
"OOOOOO",
|
||||
"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]
|
||||
materials = [{item = "battleship_hull", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 60
|
||||
production_time_seconds = 6
|
||||
|
||||
[ship.health]
|
||||
hp_formula = "350"
|
||||
hp = 6300
|
||||
|
||||
[ship.movement]
|
||||
speed_mps_formula = "14"
|
||||
main_acceleration_mpss_formula = "18"
|
||||
maneuvering_acceleration_mpss_formula = "8"
|
||||
angular_acceleration_radpss_formula = "2"
|
||||
max_rotation_speed_radps_formula = "1"
|
||||
speed_mps = 15
|
||||
main_acceleration_mpss = 14
|
||||
maneuvering_acceleration_mpss = 7
|
||||
angular_acceleration_radpss = 2
|
||||
max_rotation_speed_radps = 1
|
||||
|
||||
[ship.sensor]
|
||||
sensor_range_m_formula = "280"
|
||||
|
||||
[ship.loot]
|
||||
scrap_drop = 35
|
||||
sensor_range_m = 280
|
||||
|
||||
|
||||
# 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.
|
||||
[[ship]]
|
||||
id = "dreadnought"
|
||||
unlock_at_station_level = -1
|
||||
unlock_at_station_level = 8
|
||||
unlock_requires = ["battleship"]
|
||||
layout = [
|
||||
"XXXOOOOOXXX",
|
||||
"OOOXOOOXOOO",
|
||||
@@ -235,27 +253,33 @@ layout = [
|
||||
"OOOXOOOXOOO",
|
||||
"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]
|
||||
materials = [{item = "dreadnought_hull", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 120
|
||||
production_time_seconds = 8
|
||||
|
||||
[ship.health]
|
||||
hp_formula = "800"
|
||||
hp = 24000
|
||||
|
||||
[ship.movement]
|
||||
speed_mps_formula = "8"
|
||||
main_acceleration_mpss_formula = "10"
|
||||
maneuvering_acceleration_mpss_formula = "5"
|
||||
angular_acceleration_radpss_formula = "1"
|
||||
max_rotation_speed_radps_formula = "0.5"
|
||||
speed_mps = 10
|
||||
main_acceleration_mpss = 8
|
||||
maneuvering_acceleration_mpss = 4
|
||||
angular_acceleration_radpss = 1
|
||||
max_rotation_speed_radps = 0.5
|
||||
|
||||
[ship.sensor]
|
||||
sensor_range_m_formula = "300"
|
||||
|
||||
[ship.loot]
|
||||
scrap_drop = 60
|
||||
sensor_range_m = 300
|
||||
|
||||
|
||||
# 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.
|
||||
[[ship]]
|
||||
id = "carrier"
|
||||
unlock_at_station_level = -1
|
||||
unlock_at_station_level = 9
|
||||
unlock_requires = ["battleship"]
|
||||
layout = [
|
||||
"XOOOOOOOOX",
|
||||
"OOOOOOOOOO",
|
||||
@@ -272,24 +297,28 @@ layout = [
|
||||
"XOOOOOOOOX",
|
||||
"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]
|
||||
materials = [{item = "carrier_hull", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 120
|
||||
production_time_seconds = 8
|
||||
|
||||
[ship.health]
|
||||
hp_formula = "700"
|
||||
hp = 24000
|
||||
|
||||
[ship.movement]
|
||||
speed_mps_formula = "9"
|
||||
main_acceleration_mpss_formula = "10"
|
||||
maneuvering_acceleration_mpss_formula = "5"
|
||||
angular_acceleration_radpss_formula = "1"
|
||||
max_rotation_speed_radps_formula = "0.5"
|
||||
speed_mps = 10
|
||||
main_acceleration_mpss = 8
|
||||
maneuvering_acceleration_mpss = 4
|
||||
angular_acceleration_radpss = 1
|
||||
max_rotation_speed_radps = 0.5
|
||||
|
||||
[ship.sensor]
|
||||
sensor_range_m_formula = "350"
|
||||
|
||||
[ship.loot]
|
||||
scrap_drop = 60
|
||||
sensor_range_m = 350
|
||||
|
||||
@@ -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]
|
||||
surface_mask = [
|
||||
"AAA",
|
||||
"AAA",
|
||||
"AAA",
|
||||
]
|
||||
hp_formula = "1000"
|
||||
hp_formula = "5000"
|
||||
|
||||
[player_station]
|
||||
surface_mask = [
|
||||
@@ -12,19 +20,19 @@ surface_mask = [
|
||||
"SS",
|
||||
]
|
||||
level = 1
|
||||
hp_formula = "300"
|
||||
damage_formula = "5"
|
||||
range_m_formula = "200"
|
||||
hp_formula = "3000"
|
||||
damage_formula = "25"
|
||||
range_m_formula = "120"
|
||||
fire_rate_hz_formula = "1"
|
||||
scrap_drop_formula = "10"
|
||||
scrap_drop_formula = "40"
|
||||
|
||||
[enemy_station]
|
||||
surface_mask = [
|
||||
"SS",
|
||||
"SS",
|
||||
]
|
||||
hp_formula = "300 + 150*x"
|
||||
damage_formula = "2 + 1*x"
|
||||
range_m_formula = "200"
|
||||
fire_rate_hz_formula = "1.0 + 0.2*x"
|
||||
scrap_drop_formula = "10 + 5*x"
|
||||
hp_formula = "3000 + 1500*x"
|
||||
damage_formula = "25 + 12*x"
|
||||
range_m_formula = "120"
|
||||
fire_rate_hz_formula = "1.0 + 0.1*x"
|
||||
scrap_drop_formula = "40 + 30*x"
|
||||
|
||||
@@ -116,11 +116,11 @@ outline = "#201010"
|
||||
fill = "#c47a3a"
|
||||
outline = "#3a1a0a"
|
||||
|
||||
[items.titanium_ore]
|
||||
fill = "#9aa3ad"
|
||||
outline = "#2a2e33"
|
||||
[items.quartz]
|
||||
fill = "#e0d4f0"
|
||||
outline = "#40345a"
|
||||
|
||||
# --- ingots ---
|
||||
# --- smelted basics ---
|
||||
|
||||
[items.iron_ingot]
|
||||
fill = "#b0b0b8"
|
||||
@@ -130,9 +130,9 @@ outline = "#202028"
|
||||
fill = "#d48a4a"
|
||||
outline = "#402010"
|
||||
|
||||
[items.titanium_ingot]
|
||||
fill = "#c8d2dc"
|
||||
outline = "#3a4048"
|
||||
[items.silicon]
|
||||
fill = "#33415e"
|
||||
outline = "#0e1420"
|
||||
|
||||
# --- salvage loop ---
|
||||
|
||||
@@ -140,9 +140,9 @@ outline = "#3a4048"
|
||||
fill = "#7a7268"
|
||||
outline = "#201a14"
|
||||
|
||||
[items.advanced_alloy]
|
||||
fill = "#a06acc"
|
||||
outline = "#201030"
|
||||
[items.voidsteel]
|
||||
fill = "#4a3a6a"
|
||||
outline = "#151020"
|
||||
|
||||
# --- basic components ---
|
||||
|
||||
@@ -154,9 +154,9 @@ outline = "#3a2008"
|
||||
fill = "#8a92a0"
|
||||
outline = "#22262c"
|
||||
|
||||
[items.circuit_board]
|
||||
fill = "#2ea35a"
|
||||
outline = "#0a2a14"
|
||||
[items.copper_coil]
|
||||
fill = "#d07030"
|
||||
outline = "#381808"
|
||||
|
||||
[items.building_block]
|
||||
fill = "#c8b070"
|
||||
@@ -164,26 +164,30 @@ outline = "#302810"
|
||||
|
||||
# --- advanced components ---
|
||||
|
||||
[items.mechanical_parts]
|
||||
fill = "#6f7a66"
|
||||
outline = "#1c2018"
|
||||
[items.control_chip]
|
||||
fill = "#2ea35a"
|
||||
outline = "#0a2a14"
|
||||
|
||||
[items.targeting_unit]
|
||||
fill = "#3a9e8c"
|
||||
outline = "#0c2824"
|
||||
[items.capacitor_bank]
|
||||
fill = "#d0a030"
|
||||
outline = "#302408"
|
||||
|
||||
[items.hardened_steel]
|
||||
fill = "#6a7280"
|
||||
outline = "#181c22"
|
||||
|
||||
[items.ceramic_plate]
|
||||
fill = "#e0d8c8"
|
||||
outline = "#3a3428"
|
||||
|
||||
[items.drive_unit]
|
||||
fill = "#4a6ad0"
|
||||
outline = "#101a38"
|
||||
|
||||
[items.titanium_frame]
|
||||
fill = "#b8c4d4"
|
||||
outline = "#343c48"
|
||||
|
||||
# --- capital components ---
|
||||
|
||||
[items.reinforced_plating]
|
||||
fill = "#8a6ad0"
|
||||
[items.voidsteel_plate]
|
||||
fill = "#7a5aaa"
|
||||
outline = "#1c1038"
|
||||
|
||||
[items.capital_core]
|
||||
@@ -192,15 +196,15 @@ outline = "#280c30"
|
||||
|
||||
# --- module items ---
|
||||
|
||||
[items.laser_cannon_s_module]
|
||||
[items.railgun_s_module]
|
||||
fill = "#691313"
|
||||
outline = "#f3ff4f"
|
||||
|
||||
[items.laser_cannon_m_module]
|
||||
[items.railgun_m_module]
|
||||
fill = "#892020"
|
||||
outline = "#f3ff4f"
|
||||
|
||||
[items.laser_cannon_l_module]
|
||||
[items.railgun_l_module]
|
||||
fill = "#a92d2d"
|
||||
outline = "#f3ff4f"
|
||||
|
||||
@@ -344,6 +348,7 @@ demolish_tint = "#ff000033" # demolish-mode hover tint
|
||||
selection_rect = "#00ff00" # box-drag selection rectangle (REQ-UI-MULTI-SELECT)
|
||||
tile_highlight = "#ffffff22" # tile under cursor
|
||||
selected_outline = "#ffff00" # outline drawn around currently-selected building(s)
|
||||
copy_config = "#33ccff66" # copy-settings eligible-target tint + copy/paste flash (REQ-BLD-COPY-CONFIG-FEEDBACK)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Schematic-drop toasts (REQ-UI-SCHEMATIC-TOAST)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
[world]
|
||||
height_tiles = 40
|
||||
refund_percentage = 75
|
||||
starting_building_blocks = 1000
|
||||
scrap_despawn_seconds = 30
|
||||
refund_percentage = 100
|
||||
starting_building_blocks = 200
|
||||
scrap_despawn_seconds = 120
|
||||
scrap_per_threat = 0.25
|
||||
tile_size_m = 10
|
||||
belt_speed_mps = 20
|
||||
tunnel_max_distance_tiles = 10
|
||||
@@ -16,9 +17,18 @@ player_buffer_width_tiles = 20
|
||||
contest_zone_width_tiles = 60
|
||||
enemy_buffer_width_tiles = 20
|
||||
|
||||
[scroll]
|
||||
# View pan speed (REQ-UI-SCROLL-SPEED): slow near the asteroid, fast across the
|
||||
# contest zone, with a linear ramp of the given width straddling each boundary.
|
||||
pan_speed_slow_tiles_per_second = 8.0
|
||||
pan_speed_fast_tiles_per_second = 24.0
|
||||
pan_ramp_band_width_tiles = 16
|
||||
|
||||
[expansion]
|
||||
columns_per_expansion_tiles = 10
|
||||
cost_building_blocks = 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_expand_columns_tiles = 10
|
||||
@@ -29,9 +39,15 @@ target_score_formula = "1 / (1 + x)" # x = distance / max weapon r
|
||||
overclaim_penalty_formula = "max(0.5, 1 - 0.1*x)" # x = competing claim count; multiplies score, clamped to [0,1]
|
||||
target_hysteresis = 0.40 # keep current target unless a challenger beats it by >10%
|
||||
|
||||
[artifacts]
|
||||
artifact_chance_formula = "0.05 * x" # 5% chance per station level
|
||||
artifact_win_count = 5
|
||||
|
||||
[waves]
|
||||
threat_rate_formula = "x"
|
||||
ship_level_formula = "1"
|
||||
# Tuned against the factory-size curve (docs/balancing/targets.md, balancing
|
||||
# targets): stays below the player's achievable military output early,
|
||||
# crosses it around the late boundary (~cycle 15), overwhelms by ~24.
|
||||
threat_rate_formula = "2*x + 0.15*x*x"
|
||||
gap_min_seconds = 15
|
||||
gap_max_seconds = 45
|
||||
spawn_duration_seconds = 10
|
||||
|
||||
@@ -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]]
|
||||
name = "Fighters vs Sniper"
|
||||
height_tiles = 20
|
||||
name = "Mirror: drones 20v20"
|
||||
height_tiles = 10
|
||||
player_buffer_width_tiles = 10
|
||||
contest_zone_width_tiles = 60
|
||||
contest_zone_width_tiles = 50
|
||||
enemy_buffer_width_tiles = 10
|
||||
|
||||
[[arena.team]]
|
||||
name = "Alpha"
|
||||
[[arena.team.ship]]
|
||||
schematic = "drone"
|
||||
level = 1
|
||||
count = 5
|
||||
count = 20
|
||||
modules = [
|
||||
{type = "laser_cannon_s", x = 1, y = 1, rotation = "east"},
|
||||
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena.team]]
|
||||
name = "Beta"
|
||||
[[arena.team.ship]]
|
||||
schematic = "drone"
|
||||
level = 1
|
||||
count = 2
|
||||
count = 20
|
||||
modules = [
|
||||
{type = "laser_cannon_s", x = 1, y = 1, 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"},
|
||||
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena]]
|
||||
name = "Fighters vs Supported"
|
||||
height_tiles = 20
|
||||
name = "Mirror: cruisers 6v6"
|
||||
height_tiles = 10
|
||||
player_buffer_width_tiles = 10
|
||||
contest_zone_width_tiles = 60
|
||||
contest_zone_width_tiles = 50
|
||||
enemy_buffer_width_tiles = 10
|
||||
|
||||
[[arena.team]]
|
||||
name = "Fighters"
|
||||
name = "Alpha"
|
||||
[[arena.team.ship]]
|
||||
schematic = "drone"
|
||||
level = 1
|
||||
count = 5
|
||||
schematic = "cruiser"
|
||||
count = 6
|
||||
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]]
|
||||
name = "Supported"
|
||||
name = "Beta"
|
||||
[[arena.team.ship]]
|
||||
schematic = "drone"
|
||||
level = 1
|
||||
count = 3
|
||||
schematic = "cruiser"
|
||||
count = 6
|
||||
modules = [
|
||||
{type = "laser_cannon_s", x = 1, y = 1, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena.team.ship]]
|
||||
schematic = "drone"
|
||||
level = 1
|
||||
count = 2
|
||||
modules = [
|
||||
{type = "repair_tool", 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]]
|
||||
name = "Stations and Ships"
|
||||
height_tiles = 60
|
||||
name = "Mirror: battleships 2v2"
|
||||
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
|
||||
contest_zone_width_tiles = 40
|
||||
enemy_buffer_width_tiles = 15
|
||||
@@ -76,10 +400,9 @@ enemy_buffer_width_tiles = 15
|
||||
name = "Fortified"
|
||||
[[arena.team.ship]]
|
||||
schematic = "drone"
|
||||
level = 1
|
||||
count = 3
|
||||
count = 10
|
||||
modules = [
|
||||
{type = "laser_cannon_s", x = 1, y = 1, rotation = "east"},
|
||||
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
|
||||
]
|
||||
[[arena.team.station]]
|
||||
type = "player_station"
|
||||
@@ -96,8 +419,7 @@ enemy_buffer_width_tiles = 15
|
||||
name = "Swarm"
|
||||
[[arena.team.ship]]
|
||||
schematic = "drone"
|
||||
level = 1
|
||||
count = 8
|
||||
count = 30
|
||||
modules = [
|
||||
{type = "laser_cannon_s", x = 1, y = 1, rotation = "east"},
|
||||
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
|
||||
]
|
||||
|
||||
@@ -83,6 +83,7 @@ id = "salvage_bay"
|
||||
cost = 25
|
||||
player_placeable = true
|
||||
construction_time_seconds = 15
|
||||
output_buffer_capacity = 20
|
||||
surface_mask = [
|
||||
"SAA",
|
||||
"SAA>",
|
||||
|
||||
@@ -3,136 +3,126 @@ id = "armor_plate"
|
||||
unlock_at_station_level = -1
|
||||
surface_mask = ["OO"]
|
||||
materials = [{item = "iron_ingot", amount = 2}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 3
|
||||
fill_color = "#808080"
|
||||
glyph = "A"
|
||||
|
||||
[module.health]
|
||||
multiplied_hp_formula = "1.5"
|
||||
multiplied_hp = 1.5
|
||||
|
||||
[[module]]
|
||||
id = "sensor_booster"
|
||||
unlock_at_station_level = -1
|
||||
surface_mask = ["O"]
|
||||
materials = [{item = "circuit_board", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 2
|
||||
fill_color = "#40A0FF"
|
||||
glyph = "S"
|
||||
|
||||
[module.sensor]
|
||||
added_sensor_range_m_formula = "100"
|
||||
added_sensor_range_m = 100
|
||||
|
||||
[[module]]
|
||||
id = "weapon_upgrade"
|
||||
unlock_at_station_level = -1
|
||||
surface_mask = ["O"]
|
||||
materials = [{item = "iron_ingot", amount = 1}, {item = "circuit_board", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 4
|
||||
fill_color = "#FF4040"
|
||||
glyph = "W"
|
||||
|
||||
[module.weapon]
|
||||
multiplied_damage_formula = "1.2"
|
||||
multiplied_damage = 1.2
|
||||
|
||||
[[module]]
|
||||
id = "laser_cannon"
|
||||
unlock_at_station_level = -1
|
||||
surface_mask = ["O"]
|
||||
materials = [{item = "iron_ingot", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 5
|
||||
fill_color = "#FF8040"
|
||||
glyph = "L"
|
||||
|
||||
[module.weapon]
|
||||
damage_formula = "2"
|
||||
attack_range_m_formula = "50"
|
||||
attack_rate_hz_formula = "2.0"
|
||||
damage = 2
|
||||
attack_range_m = 50
|
||||
attack_rate_hz = 2.0
|
||||
|
||||
[[module]]
|
||||
id = "salvager"
|
||||
unlock_at_station_level = -1
|
||||
surface_mask = ["OO"]
|
||||
materials = [{item = "iron_ingot", amount = 2}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 5
|
||||
fill_color = "#AACC44"
|
||||
glyph = "Sv"
|
||||
|
||||
[module.salvage]
|
||||
collection_range_m_formula = "500"
|
||||
cargo_capacity_formula = "10"
|
||||
collection_rate_hz_formula = "0.5"
|
||||
collection_range_m = 500
|
||||
cargo_capacity = 10
|
||||
collection_rate_hz = 0.5
|
||||
|
||||
[[module]]
|
||||
id = "repair_tool"
|
||||
unlock_at_station_level = -1
|
||||
surface_mask = ["O"]
|
||||
materials = [{item = "circuit_board", amount = 2}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 5
|
||||
fill_color = "#66CCFF"
|
||||
glyph = "Rp"
|
||||
|
||||
[module.repair]
|
||||
repair_rate_hz_formula = "1"
|
||||
repair_amount_hp_formula = "5 + x"
|
||||
repair_range_m_formula = "800"
|
||||
repair_rate_hz = 1
|
||||
repair_amount_hp = 6
|
||||
repair_range_m = 800
|
||||
|
||||
[[module]]
|
||||
id = "weapon_primer"
|
||||
unlock_at_station_level = -1
|
||||
surface_mask = ["O"]
|
||||
materials = [{item = "iron_ingot", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 4
|
||||
fill_color = "#FF4040"
|
||||
glyph = "Wp"
|
||||
|
||||
[module.weapon]
|
||||
multiplied_attack_rate_hz_formula = "1.2"
|
||||
multiplied_attack_rate_hz = 1.2
|
||||
|
||||
[[module]]
|
||||
id = "weapon_stabilizer"
|
||||
unlock_at_station_level = -1
|
||||
surface_mask = ["O"]
|
||||
materials = [{item = "iron_ingot", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 4
|
||||
fill_color = "#FF4040"
|
||||
glyph = "Ws"
|
||||
|
||||
[module.weapon]
|
||||
multiplied_attack_range_m_formula = "1.5"
|
||||
multiplied_attack_rate_hz_formula = "0.8"
|
||||
multiplied_attack_range_m = 1.5
|
||||
multiplied_attack_rate_hz = 0.8
|
||||
|
||||
[[module]]
|
||||
id = "afterburner"
|
||||
unlock_at_station_level = -1
|
||||
surface_mask = ["O"]
|
||||
materials = [{item = "iron_ingot", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 2
|
||||
fill_color = "#40A0FF"
|
||||
glyph = "Ab"
|
||||
|
||||
[module.movement]
|
||||
multiplied_speed_mps_formula = "1.6"
|
||||
added_main_acceleration_mpss_formula = "60"
|
||||
multiplied_speed_mps = 1.6
|
||||
added_main_acceleration_mpss = 60
|
||||
|
||||
[[module]]
|
||||
id = "maneuvering_thrusters"
|
||||
unlock_at_station_level = -1
|
||||
surface_mask = ["O"]
|
||||
materials = [{item = "iron_ingot", amount = 1}]
|
||||
player_production_level = 1
|
||||
production_time_seconds = 2
|
||||
fill_color = "#40A0FF"
|
||||
glyph = "Mt"
|
||||
|
||||
[module.movement]
|
||||
multiplied_speed_mps_formula = "1.2"
|
||||
added_maneuvering_acceleration_mpss_formula = "10"
|
||||
multiplied_speed_mps = 1.2
|
||||
added_maneuvering_acceleration_mpss = 10
|
||||
|
||||
@@ -92,3 +92,60 @@ duration_seconds = 3.0
|
||||
item = "advanced_alloy"
|
||||
amount = 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
|
||||
|
||||
@@ -6,24 +6,20 @@ default_modules = [{type = "laser_cannon", x = 1, y = 1, rotation = "east"}]
|
||||
|
||||
[ship.schematic]
|
||||
materials = [{item = "iron_ingot", amount = 3}, {item = "circuit_board", amount = 1}]
|
||||
player_production_level = 3
|
||||
production_time_seconds = 10
|
||||
|
||||
[ship.health]
|
||||
hp_formula = "40 + 5*x"
|
||||
hp = 45
|
||||
|
||||
[ship.movement]
|
||||
speed_mps_formula = "2000 + 50*x"
|
||||
main_acceleration_mpss_formula = "1000000"
|
||||
maneuvering_acceleration_mpss_formula = "1000000"
|
||||
angular_acceleration_radpss_formula = "100000"
|
||||
max_rotation_speed_radps_formula = "100000"
|
||||
speed_mps = 2050
|
||||
main_acceleration_mpss = 1000000
|
||||
maneuvering_acceleration_mpss = 1000000
|
||||
angular_acceleration_radpss = 100000
|
||||
max_rotation_speed_radps = 100000
|
||||
|
||||
[ship.sensor]
|
||||
sensor_range_m_formula = "2000"
|
||||
|
||||
[ship.loot]
|
||||
scrap_drop = 2
|
||||
sensor_range_m = 2000
|
||||
|
||||
|
||||
[[ship]]
|
||||
@@ -34,24 +30,20 @@ default_modules = [{type = "laser_cannon", x = 1, y = 1, rotation = "east"}]
|
||||
|
||||
[ship.schematic]
|
||||
materials = [{item = "iron_ingot", amount = 5}, {item = "circuit_board", amount = 2}]
|
||||
player_production_level = 5
|
||||
production_time_seconds = 20
|
||||
|
||||
[ship.health]
|
||||
hp_formula = "120 + 15*x"
|
||||
hp = 135
|
||||
|
||||
[ship.movement]
|
||||
speed_mps_formula = "1200"
|
||||
main_acceleration_mpss_formula = "1000000"
|
||||
maneuvering_acceleration_mpss_formula = "1000000"
|
||||
angular_acceleration_radpss_formula = "100000"
|
||||
max_rotation_speed_radps_formula = "100000"
|
||||
speed_mps = 1200
|
||||
main_acceleration_mpss = 1000000
|
||||
maneuvering_acceleration_mpss = 1000000
|
||||
angular_acceleration_radpss = 100000
|
||||
max_rotation_speed_radps = 100000
|
||||
|
||||
[ship.sensor]
|
||||
sensor_range_m_formula = "3000"
|
||||
|
||||
[ship.loot]
|
||||
scrap_drop = 4
|
||||
sensor_range_m = 3000
|
||||
|
||||
|
||||
[[ship]]
|
||||
@@ -61,24 +53,20 @@ layout = ["OOO", "OOO"]
|
||||
|
||||
[ship.schematic]
|
||||
materials = [{item = "iron_ingot", amount = 4}]
|
||||
player_production_level = 3
|
||||
production_time_seconds = 10
|
||||
|
||||
[ship.health]
|
||||
hp_formula = "40 + 4*x"
|
||||
hp = 44
|
||||
|
||||
[ship.movement]
|
||||
speed_mps_formula = "1100"
|
||||
main_acceleration_mpss_formula = "1000000"
|
||||
maneuvering_acceleration_mpss_formula = "1000000"
|
||||
angular_acceleration_radpss_formula = "100000"
|
||||
max_rotation_speed_radps_formula = "100000"
|
||||
speed_mps = 1100
|
||||
main_acceleration_mpss = 1000000
|
||||
maneuvering_acceleration_mpss = 1000000
|
||||
angular_acceleration_radpss = 100000
|
||||
max_rotation_speed_radps = 100000
|
||||
|
||||
[ship.sensor]
|
||||
sensor_range_m_formula = "2500"
|
||||
|
||||
[ship.loot]
|
||||
scrap_drop = 2
|
||||
sensor_range_m = 2500
|
||||
|
||||
|
||||
[[ship]]
|
||||
@@ -88,21 +76,17 @@ layout = ["XOX", "OOO", "XOX"]
|
||||
|
||||
[ship.schematic]
|
||||
materials = [{item = "iron_ingot", amount = 4}, {item = "circuit_board", amount = 2}]
|
||||
player_production_level = 3
|
||||
production_time_seconds = 15
|
||||
|
||||
[ship.health]
|
||||
hp_formula = "60 + 5*x"
|
||||
hp = 65
|
||||
|
||||
[ship.movement]
|
||||
speed_mps_formula = "1300"
|
||||
main_acceleration_mpss_formula = "1000000"
|
||||
maneuvering_acceleration_mpss_formula = "1000000"
|
||||
angular_acceleration_radpss_formula = "100000"
|
||||
max_rotation_speed_radps_formula = "100000"
|
||||
speed_mps = 1300
|
||||
main_acceleration_mpss = 1000000
|
||||
maneuvering_acceleration_mpss = 1000000
|
||||
angular_acceleration_radpss = 100000
|
||||
max_rotation_speed_radps = 100000
|
||||
|
||||
[ship.sensor]
|
||||
sensor_range_m_formula = "2500"
|
||||
|
||||
[ship.loot]
|
||||
scrap_drop = 2
|
||||
sensor_range_m = 2500
|
||||
|
||||
@@ -3,6 +3,7 @@ height_tiles = 60
|
||||
refund_percentage = 75
|
||||
starting_building_blocks = 100
|
||||
scrap_despawn_seconds = 30
|
||||
scrap_per_threat = 1.0
|
||||
tile_size_m = 10
|
||||
belt_speed_mps = 20
|
||||
tunnel_max_distance_tiles = 10
|
||||
@@ -16,9 +17,14 @@ player_buffer_width_tiles = 10
|
||||
contest_zone_width_tiles = 30
|
||||
enemy_buffer_width_tiles = 15
|
||||
|
||||
[scroll]
|
||||
pan_speed_slow_tiles_per_second = 8.0
|
||||
pan_speed_fast_tiles_per_second = 24.0
|
||||
pan_ramp_band_width_tiles = 16
|
||||
|
||||
[expansion]
|
||||
columns_per_expansion_tiles = 10
|
||||
cost_building_blocks = 200
|
||||
cost_building_blocks_formula = "400 * 2^x"
|
||||
|
||||
[push]
|
||||
push_expand_columns_tiles = 20
|
||||
@@ -29,9 +35,12 @@ target_score_formula = "1 / (1 + x)" # x = distance / max weapon r
|
||||
overclaim_penalty_formula = "max(0.5, 1 - 0.1*x)" # x = competing claim count; multiplies score, clamped to [0,1]
|
||||
target_hysteresis = 0.10 # keep current target unless a challenger beats it by >10%
|
||||
|
||||
[artifacts]
|
||||
artifact_chance_formula = "0.05 * x" # 5% chance per station level
|
||||
artifact_win_count = 3
|
||||
|
||||
[waves]
|
||||
threat_rate_formula = "x"
|
||||
ship_level_formula = "1 + x / 10"
|
||||
gap_min_seconds = 15
|
||||
gap_max_seconds = 45
|
||||
spawn_duration_seconds = 10
|
||||
|
||||
60
cmake/version.cmake
Normal file
60
cmake/version.cmake
Normal 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
35
cmake/version.rc.in
Normal 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
56
docs/balancing/README.md
Normal 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
164
docs/balancing/derived.md
Normal 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 1–5)
|
||||
|
||||
**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 2–3 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
96
docs/balancing/history.md
Normal 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 1–5/6–14/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 96–124% 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 1–5
|
||||
|
||||
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 8–9 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, 2–3 cycles apart late.
|
||||
- **First full balancing round complete.** Next: full-game playtests
|
||||
against the run-shape targets.
|
||||
98
docs/balancing/process.md
Normal file
98
docs/balancing/process.md
Normal 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 1–5)
|
||||
|
||||
- **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
333
docs/balancing/rules.md
Normal 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
104
docs/balancing/targets.md
Normal 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 20–24. Losing runs end earlier.
|
||||
2. **Phase boundaries** — early = cycles 1–5 (iron/copper, small hulls),
|
||||
mid = cycles 6–14 (quartz, medium hulls), late = cycles 15+
|
||||
(voidsteel, capitals). Push cadence: first station set around cycle
|
||||
2–3, 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 ~10–20% 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 2–3
|
||||
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 30–60 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 (~15–25%) 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 ~15–18 pushes (~7 cumulative artifact offers at
|
||||
the current chance formula), the player must choose the artifact over
|
||||
a schematic about five times.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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 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:
|
||||
- **Time scaling** — enemy strength increases gradually over elapsed time.
|
||||
- **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.
|
||||
- A tougher **boss wave** spawns periodically on its own countdown, on top of normal waves.
|
||||
- Enemy strength increases gradually over time and with each boss wave that occurs.
|
||||
|
||||
## Push Mechanic
|
||||
|
||||
- The player is not forced to push; purely defensive play is valid.
|
||||
- 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.
|
||||
- Destroyed enemy defence stations drop ship schematics.
|
||||
- The player must push — destroying enemy defence stations is the only way to earn artifacts, which are required to win.
|
||||
- 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 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
|
||||
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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
|
||||
module surface masks; pass 2 defined the production tree (recipes). Stats and
|
||||
threat costs in the config files are still placeholders for the balancing
|
||||
pass.
|
||||
The designed game content: hull layout grids, module footprints and the
|
||||
gating between them, and the production tree (items, chains, fiction).
|
||||
All numbers — quantities, durations, threat values, stats, unlock levels
|
||||
— 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
|
||||
|
||||
@@ -17,12 +18,12 @@ and makes them trivially moddable through the config files alone.
|
||||
|
||||
| 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 |
|
||||
| 1x3 | afterburner | frigate (eats most of it) |
|
||||
| L-shape (3 cells) | weapon_stabilizer, weapon_primer, weapon_upgrade | frigate |
|
||||
| 2x2 | laser_cannon_m, drone_bay | cruiser |
|
||||
| 3x3 | laser_cannon_l | battleship |
|
||||
| 2x2 | railgun_m, drone_bay | cruiser |
|
||||
| 3x3 | railgun_l | battleship |
|
||||
| 2x6 | drone_hangar | carrier (only) |
|
||||
|
||||
### Hull grids
|
||||
@@ -131,64 +132,163 @@ drone hangar — carrier 1.
|
||||
|
||||
## Production tree
|
||||
|
||||
Design principle: each game phase adds exactly one new base input chain, so
|
||||
factory complexity ramps alongside ship size.
|
||||
Designed against the rules in `docs/balancing/rules.md` (ratio curve,
|
||||
cost ladder, cost archetypes, refactorability). Quantities, durations,
|
||||
and threat values live in `docs/balancing/derived.md`.
|
||||
|
||||
| Phase | New input | How acquired | Unlocks |
|
||||
|-------|-----------|--------------|---------|
|
||||
| early | iron_ore, copper_ore | mined | drone, frigate, destroyer; small guns and basic supports |
|
||||
| mid | titanium_ore | mined (3x slower than iron) | cruiser, battlecruiser; m guns, drone bay, weapon modifiers |
|
||||
| late | advanced_alloy | ONLY from reprocessing salvaged scrap | battleship, dreadnought, carrier; l guns, drone hangar |
|
||||
### Base inputs (4) and fiction
|
||||
|
||||
The advanced_alloy gate is the core loop hook: capital ship production
|
||||
requires fighting (salvaging scrap from kills and reprocessing it), not just
|
||||
mining. The reprocessing plant turns 5 scrap into iron/copper/titanium ingots
|
||||
or advanced_alloy probabilistically.
|
||||
- **iron_ore, copper_ore** — from the start, minable on every asteroid
|
||||
tile. Fiction: the asteroid is an M-type (metal) body — its bulk rock
|
||||
*is* ore, which is why the shipyard operation was built here at all.
|
||||
- **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 + wire), building_block (iron).
|
||||
- **Tier 3 (mid):** mechanical_parts (steel + iron), targeting_unit (circuits
|
||||
+ wire), drive_unit (steel + mechanical_parts + circuit), titanium_frame
|
||||
(titanium + steel).
|
||||
- **Tier 4 (late):** reinforced_plating (steel + advanced_alloy),
|
||||
capital_core (targeting_unit + drive_unit + 2 advanced_alloy).
|
||||
- **iron/steel** — structure.
|
||||
- **copper** — conduction and heat: wiring, coils, heat sinks.
|
||||
- **silicon family** (all derived from quartz): silicon (logic,
|
||||
sensors), ceramics (heat shielding, insulators); glass/optics are cut
|
||||
from v1 — their only consumers would be lasers, which are deferred.
|
||||
- **voidsteel** — capital-tier structure and exotics.
|
||||
- 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
|
||||
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.*]`.
|
||||
### Weapons
|
||||
|
||||
Consistency is checked by `tools/verify_recipes.py` — re-run it after editing
|
||||
recipes, ship/module materials, or visuals:
|
||||
- All v1 weapons are **railguns** (`railgun_s/m/l`, renamed from the
|
||||
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
|
||||
|
||||
It verifies every consumed item has a producer, every item has a visuals
|
||||
entry, flags orphaned items, and prints which items are reprocessing-only
|
||||
(currently exactly advanced_alloy).
|
||||
|
||||
## 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.
|
||||
entry, flags orphaned items, and prints which items are
|
||||
reprocessing-only (currently exactly voidsteel).
|
||||
|
||||
482
docs/replay_design.md
Normal file
482
docs/replay_design.md
Normal file
@@ -0,0 +1,482 @@
|
||||
# Replay — Design
|
||||
|
||||
This document captures the design for the replay record/playback feature. It records the
|
||||
decisions made during design discussion; it is a complement to `architecture.md`. No
|
||||
implementation exists yet — this is the agreed design to implement against.
|
||||
|
||||
## Goal
|
||||
|
||||
Record every play session and allow it to be played back later. Playback is **view-only**
|
||||
(no interaction) with **manual game-speed selection** (including pause). Playback is launched
|
||||
via a command-line argument to the executable.
|
||||
|
||||
## Approach: deterministic command-replay (re-simulation)
|
||||
|
||||
We record **player intent** (commands) plus the inputs needed to reproduce the run, and on
|
||||
playback we **re-run the real simulation**, injecting the recorded commands at their recorded
|
||||
ticks. We do **not** record per-tick state snapshots.
|
||||
|
||||
This is viable because the simulation is already built for it (see `architecture.md`:
|
||||
"determinism, replayability ... fall out for free"):
|
||||
|
||||
- Fixed 30 Hz tick-based simulation, decoupled from render rate via `TickDriver`.
|
||||
- Game speed (0/0.5/1/2/4×) and pause are tick-rate multipliers — they change *how many*
|
||||
ticks run per frame, never the *outcome* of a tick. So speed, pause, camera scroll, and
|
||||
selection are pure presentation and are **not recorded**.
|
||||
- A single deterministic RNG stream: `Simulation::m_rng` (one `std::mt19937`) is passed by
|
||||
reference into `WaveSystem` and `BuildingSystem`, the only two consumers. ECS combat/AI/
|
||||
movement/scrap systems use no RNG. The `utility::getRandom*` global is not used by the sim.
|
||||
- Config is immutable after load; a replay is pinned to the config it was recorded with.
|
||||
|
||||
A replay run is therefore a pure function of `(seed, config, ordered commands)`.
|
||||
|
||||
### What we do NOT do (now)
|
||||
|
||||
- No per-tick / keyframe state snapshots.
|
||||
- No backward seek / scrubbing (would require snapshots).
|
||||
- No save/load. (See "Future direction".)
|
||||
- No interactive playback (no taking over a replay mid-run).
|
||||
|
||||
## Replay commands
|
||||
|
||||
A *replay command* is the resolved, serializable **intent** behind a player action — the
|
||||
data, not the UI gesture. Example: placing a miner records
|
||||
`PlaceBuilding{type=Miner, anchor=(3,5), rotation=East}`, not the mouse pixel that produced it.
|
||||
|
||||
- Commands are at **intent level, resolved to tile coordinates / domain ids** — independent
|
||||
of window size, camera scroll, and DPI.
|
||||
- Command payloads reference **stable, deterministic domain ids** (`BuildingId`, tile
|
||||
coordinates, choice indices) — never raw `entt::entity` handles. These ids are sim-allocated
|
||||
deterministically, so a recorded command resolves to the same entity on replay.
|
||||
- Camera scroll, selection, game speed, and pause are **not** commands.
|
||||
|
||||
### Command vocabulary
|
||||
|
||||
One command per sim-mutating operation (the complete mutation surface):
|
||||
|
||||
- `PlaceBuilding`
|
||||
- `Demolish`
|
||||
- `RotateInPlace`
|
||||
- `SetRecipe`
|
||||
- `SetShipLayout`
|
||||
- `SetSplitterFilters` (building-site and belt variants)
|
||||
- `ClearBeltTiles`
|
||||
- `ApplySchematicChoice`
|
||||
- `Reset` / restart — see "Restart is a boundary".
|
||||
|
||||
### Command representation
|
||||
|
||||
Commands use a **base class + derived classes** (mirroring the existing `Event` hierarchy
|
||||
idiom, so it is native to this codebase). They are routed through a dedicated command path,
|
||||
**not** through `EventManager` (see next section).
|
||||
|
||||
> **Implementation refinement (Phase 1).** `PlaceBuilding` is **atomic**: it carries the
|
||||
> optional recipe / ship-layout / splitter-filters to configure the new building in the same
|
||||
> command. This is forced by the deferred-drain timing — commands apply at a later tick
|
||||
> boundary, so the caller never sees the new `BuildingId` and therefore cannot issue a
|
||||
> follow-up `SetRecipe`/`SetShipLayout` against it. The standalone `SetRecipe`,
|
||||
> `SetShipLayout`, and the two `SetSplitterFilters` commands remain for the dialog-driven
|
||||
> edits on *existing* buildings (which reference a known id). `Reset` carries the (move-only)
|
||||
> `GameConfig` via `shared_ptr` and is moved into the sim on apply; a null config means "keep
|
||||
> current config".
|
||||
|
||||
## Command system: reuse the *pattern*, not the EventManager singleton
|
||||
|
||||
We reuse the **pattern** of the existing event system (a polymorphic base + small derived
|
||||
types), but the sim-mutating command path is a **dedicated, ordered queue**, not the
|
||||
`EventManager` pub/sub bus. Reasons:
|
||||
|
||||
1. **Determinism / ordering.** Sim mutations must apply in a strict, tick-pinned, recorded
|
||||
order. `architecture.md` deliberately keeps the sim free of `EventManager` for exactly this
|
||||
reason (determinism, tick-order fidelity, headless testability — why `BeamFiredEvent` uses a
|
||||
plain vector). Routing commands into the sim via the singleton would break that.
|
||||
2. **Single consumer.** A command has exactly one recipient (the `Simulation`); pub/sub
|
||||
N-handler fan-out is the wrong shape.
|
||||
3. **Recording chokepoint.** One place must see every command, stamp its tick, append it to the
|
||||
file, and apply it. A direct queue gives that; a multi-handler bus does not.
|
||||
4. **Headless tests.** Tests link only `lib` and build a `Simulation` directly; the command
|
||||
type and apply path live in `lib` and must work with no UI and no singleton.
|
||||
|
||||
### Structure
|
||||
|
||||
- **In `lib`:** a `Command` base class + derived command types, plus a `CommandManager`
|
||||
(ordered queue) and a single `Simulation::apply(command)` chokepoint.
|
||||
- **UI fan-in still uses `EventManager`:** widgets emit a UI event as today; a single
|
||||
dispatcher/recorder catches it, builds the `lib` command, and hands it to the
|
||||
`CommandManager`. This keeps widgets decoupled (consistent with current architecture).
|
||||
- **Replay** skips the UI half and feeds commands straight into the same `CommandManager` /
|
||||
`Simulation::apply` chokepoint.
|
||||
|
||||
### The completeness invariant (enforced structurally)
|
||||
|
||||
**Every** sim mutation must flow through the single `CommandManager → Simulation::apply`
|
||||
chokepoint. Any path that mutates the sim directly would not be recorded and would silently
|
||||
desync the replay.
|
||||
|
||||
This is enforced **structurally**: the `Simulation` player-action mutators are **private**, so
|
||||
the only way production code can reach them is `apply(command)`.
|
||||
|
||||
> **Implementation decision (Phase 1, revised post-Phase 4).** Structural enforcement was
|
||||
> initially deferred in favour of convention, because the test suite legitimately drives the
|
||||
> same mutators directly and relies on their return values (notably the `BuildingId` from
|
||||
> placement, which `apply()` cannot hand back to a caller). It was later restored once a key
|
||||
> observation made the change cheap: **the UI's only handle to a mutable subsystem is through
|
||||
> `Simulation`** — no production code in `ui`/`app`/`balancing` holds a `BuildingSystem`/
|
||||
> `BeltSystem` directly, and every production `buildings()`/`belts()` call is a const query.
|
||||
> So:
|
||||
>
|
||||
> - `Simulation::tryPlaceBuilding`, `demolish`, and `applySchematicChoice` are **private**.
|
||||
> - The mutable subsystem accessors are private and renamed `buildingsMutable()` /
|
||||
> `beltsMutable()`; only `const BuildingSystem& buildings() const` / `belts() const` are
|
||||
> public (queries). UI query sites bind to the const overload unchanged.
|
||||
> - `Simulation::apply` still mutates through the private members directly, so the chokepoint
|
||||
> itself is unaffected.
|
||||
> - Tests reach the private mutators through `SimulationTestAccess` (src/test, a `friend struct`
|
||||
> of `Simulation`), so they keep calling the real mutators **and keep getting return values**
|
||||
> — no id-by-position recovery needed. This header is not on the lib/ui/app include path, so
|
||||
> only test translation units can use it.
|
||||
>
|
||||
> The `BuildingSystem` subsystem mutators (`place`, `setRecipe`, `placeImmediate`,
|
||||
> `forEachBuilding`, …) stay **public**: `BuildingTest` unit-tests a bare `BuildingSystem` with
|
||||
> no `Simulation`/command layer, and that surface is unreachable from production anyway (you
|
||||
> cannot obtain a mutable subsystem without the private accessor). A `[command]` Catch2 suite
|
||||
> still asserts `apply(...)` produces byte-identical state to the direct mutator path, guarding
|
||||
> the equivalence the replay relies on. Tests are not gameplay (they never record), so direct
|
||||
> mutator use there does not affect replay correctness.
|
||||
|
||||
Recording happens **at the apply chokepoint**, not at the UI gesture — so only commands that
|
||||
actually reached the sim are recorded, and they replay through the identical apply path.
|
||||
UI-side validation (placement validity, affordability) remains a pre-filter that simply does
|
||||
not produce a command unless the action reaches the sim.
|
||||
|
||||
## Command timing: drain once per frame, before the tick batch
|
||||
|
||||
- During live play, input pushes commands onto the `CommandManager` queue (not applied
|
||||
synchronously).
|
||||
- The queue is drained at **one defined point: once per frame, before stepping the tick
|
||||
batch.** The whole queue is drained in FIFO order (not one-per-tick), so bursts (e.g. laying
|
||||
many belts quickly) apply immediately instead of dribbling across ticks, and it matches the
|
||||
lockstep model wanted later.
|
||||
- Each drained command is **tagged with the current completed-tick count**, recorded at drain
|
||||
time (so record-order == apply-order canonically), and applied.
|
||||
|
||||
### Build-while-paused is preserved
|
||||
|
||||
The drain runs every frame including at 0× (the tick batch is simply empty when paused). So a
|
||||
player can place buildings while paused and **see the construction sites immediately**. This is
|
||||
still fully deterministic: replay applies each command at its recorded tick regardless of the
|
||||
frame cadence that produced it.
|
||||
|
||||
On replay, there is no input; the player applies each pre-filled command at its recorded tick
|
||||
through the same drain path, preserving order.
|
||||
|
||||
The Qt single-threaded event loop guarantees input events and the `onFrame` tick-batch never
|
||||
interleave, so the completed-tick count at drain time is unambiguous. (If the sim is ever moved
|
||||
to a worker thread, this needs a lock at the sim boundary.)
|
||||
|
||||
## Determinism: checksums and verification
|
||||
|
||||
We do not verify EnTT iteration order statically. EnTT view iteration is a pure function of the
|
||||
sequence of spawn/destroy/add/remove operations, so on a fixed binary it contributes zero
|
||||
run-to-run nondeterminism. Instead we verify **end-to-end determinism** with a state checksum,
|
||||
and any divergence (EnTT order, float, container ordering, etc.) surfaces loudly.
|
||||
|
||||
### What is checksummed (now)
|
||||
|
||||
- **RNG state only**, for now. The `mt19937` state is fingerprinted into a 64-bit value.
|
||||
- The hash can be extended later (entity positions/HP, belt items, building buffers, scalars)
|
||||
without changing the format.
|
||||
|
||||
### Cadence
|
||||
|
||||
- **In the replay file:** every **30 ticks**, **and** after **every command** is applied. The
|
||||
per-command checksum pins any divergence to the action that triggered it; the periodic one
|
||||
localizes drift to a ~1 s window. On playback the recomputed checksum is compared; a mismatch
|
||||
reports "desync at tick N".
|
||||
- **In tests:** the Catch2 **double-run determinism test** hashes **full sim state every tick**
|
||||
(not just RNG). It runs a scripted command sequence twice from the same seed and asserts
|
||||
per-tick checksums match. This keeps the file lean while still catching non-RNG determinism
|
||||
bugs during development.
|
||||
|
||||
### Known limitation of the RNG-only file checksum (accepted)
|
||||
|
||||
An RNG-only checksum only catches divergences that change **how much randomness is consumed**
|
||||
(wave composition, recipe rolls, scrap). Float or iteration drift that does **not** alter RNG
|
||||
draw counts passes the checksum undetected. This is acceptable for same-binary Windows replay
|
||||
(no float drift expected on an identical binary; the checksum's real job there is catching
|
||||
determinism *bugs*). When cross-platform replay becomes a goal, the **file** hash must be
|
||||
expanded to include entity state.
|
||||
|
||||
## Cross-platform: Windows-first, portable by construction
|
||||
|
||||
The replay file is platform-neutral data; `std::mt19937` is bit-identical across platforms, so
|
||||
RNG is not a cross-platform problem. The only real cross-platform issue is **floating-point
|
||||
reproducibility** — the sim does heavy `QVector2D` float math, and a 1-ULP difference (compiler
|
||||
/ CPU / SIMD / FMA contraction) can flip an in-range comparison and cascade into different ship
|
||||
behavior (the classic lockstep-RTS problem).
|
||||
|
||||
Decision: **Windows-only first**, but make the later swap cheap and bounded by, from day one:
|
||||
|
||||
- a **per-period state checksum** in the file (above), and
|
||||
- a **build/version + config-hash identity tag** in the header.
|
||||
|
||||
Then cross-platform later is a contained float-hardening pass (`/fp:strict`, no FMA contraction,
|
||||
possibly fixed-point positions) guided by the checksums — **not** a redesign of the
|
||||
command-replay architecture.
|
||||
|
||||
Note: even a new Windows *build* of the game can desync old replays for the same float reasons,
|
||||
so the version tag + "warn on mismatch" is needed regardless of cross-platform ambitions.
|
||||
|
||||
## Seed and config
|
||||
|
||||
- **Seed:** a **random** seed is generated at the start of each run, **outside** the sim (e.g.
|
||||
`std::random_device` in `main`/reset), so the `Simulation` stays a pure function of
|
||||
`(seed, config, commands)`. The seed is written to the replay header.
|
||||
- **Config:** the header stores a **config hash** (not a full config snapshot). On playback the
|
||||
current config is hashed and compared; a mismatch warns/refuses. The hash is taken over the
|
||||
actually-loaded config (so editing config files and restarting yields a new, consistent
|
||||
replay).
|
||||
|
||||
## File format: line-oriented append-friendly text
|
||||
|
||||
Non-binary, chosen for readability and crash-safety. Size is a non-issue: the command log is
|
||||
sparse (only ticks with a player action), so even a multi-hour game is tens of KB in any text
|
||||
format.
|
||||
|
||||
- A small keyed/header section: seed, config hash, build/version, start timestamp.
|
||||
- One line per command, e.g. `1234 place miner 3 5 E`.
|
||||
- Periodic checksum lines interleaved, e.g. `# checksum 9000 a1b2c3...`.
|
||||
|
||||
Why line-oriented text:
|
||||
|
||||
- **Append-friendly** — the recorder stream-appends as the game runs, so a crash does not lose
|
||||
the replay (a crash is exactly when you would want it). A format that must be rewritten/closed
|
||||
as a whole is rejected for this reason.
|
||||
- **No new dependency** — the project has no JSON lib; toml++ is parse-oriented and clunky for a
|
||||
long event stream (fine for the header, awkward as an array-of-tables of thousands of
|
||||
entries).
|
||||
- Greppable, diffable, tiny.
|
||||
- Aligns with the project's existing text-serialization idiom (`BlueprintSerializer`,
|
||||
`ShipLayoutBlueprintSerializer`).
|
||||
|
||||
## Recording lifecycle
|
||||
|
||||
- **Record every run.** A new replay file is created at `Simulation` construction and at each
|
||||
`reset()`.
|
||||
- **Restart is a boundary.** Restart (escape menu → restart, which reloads config and resets)
|
||||
closes the current file and opens a new one with a fresh seed and header. One replay file =
|
||||
one contiguous run from tick 0 to game-over/quit.
|
||||
- **Retention: keep everything.** Files live in the existing `data/` directory, named by
|
||||
timestamp + seed. (No automatic pruning for now.)
|
||||
|
||||
## Playback
|
||||
|
||||
Launched via a command-line argument, e.g. `DotaFactory.exe --replay <file>`.
|
||||
|
||||
`main` for the `--replay` path:
|
||||
|
||||
1. Read the header → validate config hash and build/version (warn on mismatch).
|
||||
2. Construct the `Simulation` from the recorded seed + config.
|
||||
3. Construct the `CommandManager` in **Replay mode**, **pre-filled** with the whole command list
|
||||
from the file. (Pre-fill memory is trivial; streaming-read is a later optimization if files
|
||||
ever get huge — not needed now.)
|
||||
4. Run the driver in replay mode: each frame, drain commands due at the reached tick (same drain
|
||||
path as live), step ticks, compare checksums.
|
||||
|
||||
### Replay mode rules
|
||||
|
||||
Reframe: the schematic-choice modal is **an input source** (the device that produces an
|
||||
`ApplySchematicChoice` command in live play), exactly like the mouse. Replay's single rule is
|
||||
"**disable live input sources**", which the modal falls under.
|
||||
|
||||
- **`CommandManager` in replay mode:** `addCommand` is a no-op; the queue is pre-filled from the
|
||||
file. Live input therefore produces nothing.
|
||||
- **Only two reactions need explicit gating** — the sim-state *polls* in `onFrame` that emit
|
||||
`SchematicChoicesAvailableEvent` and `GameOverEvent`. In replay these polls do not run, so no
|
||||
modal opens, no auto-pause occurs, and there is no deadlock against the recorded command.
|
||||
- **Everything else falls away for free** because it is click-driven, not sim-state-driven: the
|
||||
recipe dialog (`RecipeSelectionRequestedEvent`), ship-layout dialog
|
||||
(`LayoutDialogRequestedEvent`), and escape menu are all triggered by player input, which is
|
||||
disabled — so they never open and need no special handling.
|
||||
- **Schematic choice still resolves with no UI:** the sim regenerates identical choices
|
||||
deterministically (same seed + prior commands), and the pre-filled `ApplySchematicChoice`
|
||||
applies itself at its recorded tick through the normal drain path. The tick-tag invariant
|
||||
places it correctly relative to when the choices became pending, in both record and replay.
|
||||
- **Game-over is replaced, not just suppressed:** instead of the live restart/quit dialog,
|
||||
playback detects the end condition (command stream exhausted / recorded game-over reached) and
|
||||
stops, showing a passive "replay ended" state.
|
||||
- **Kept in replay:** the renderer/view and **manual game-speed selection** (including pause /
|
||||
0× and fast-forward via high speed). Playback only ever moves forward.
|
||||
|
||||
## Future direction (informs the design, not built now)
|
||||
|
||||
Save/load and (deterministic lockstep) multiplayer are wanted later. The command bus is the
|
||||
shared foundation; two cheap shaping decisions now keep that path open:
|
||||
|
||||
1. **Each command carries a source/player id** (always "player 0" in single-player). Lockstep
|
||||
multiplayer is just commands from multiple sources merged into one ordered stream.
|
||||
2. **Commands are applied at a defined tick boundary** (already required for replay). Multiplayer
|
||||
schedules them a few ticks in the future to hide latency; single-player uses the next drain.
|
||||
|
||||
Implications to note:
|
||||
|
||||
- Multiplayer makes cross-platform float determinism mandatory and promotes the checksum to
|
||||
load-bearing desync-detection (rather than a test aid) — reinforcing doing the checksum now.
|
||||
- **Save/load** is the one feature that needs a *different* mechanism: either "replay to current
|
||||
tick" on load (reuses 100% of replay machinery; load time grows with game length, though
|
||||
fast-forward usually replays hours in seconds), or a full **state-snapshot serializer**
|
||||
(EnTT registry + belts + buildings + scalars). The snapshot serializer is also what
|
||||
backward-seek/scrubbing would need. Building the command bus now does not block adding it
|
||||
later; it is explicitly out of scope here.
|
||||
|
||||
## Summary of decisions
|
||||
|
||||
- Approach: **A — deterministic command-replay** (re-simulation), no snapshots.
|
||||
- Scope: **view-only** playback + **manual speed selection**; launched via CLI argument.
|
||||
- Commands: **base class + derived types**, routed through a dedicated `CommandManager` queue
|
||||
and a single `Simulation::apply` chokepoint; sim mutators made non-public to **enforce** the
|
||||
chokepoint. UI fan-in still uses `EventManager`.
|
||||
- Timing: queue **drained once per frame before the tick batch**, whole queue FIFO, each command
|
||||
tick-tagged; **build-while-paused preserved**.
|
||||
- Determinism: **RNG-state checksum** in the file every **30 ticks + after each command**;
|
||||
**full-state per-tick hashing** in the Catch2 double-run test. Known RNG-only blind spot
|
||||
accepted for now.
|
||||
- Platform: **Windows-first**; file format + version/config-hash make a later cross-platform
|
||||
pass contained.
|
||||
- Seed: **random**, generated outside the sim, written to the header.
|
||||
- Config: **config hash** in the header, validated on playback.
|
||||
- File: **line-oriented append-friendly text**, kept in `data/`, **one file per run**,
|
||||
**retain everything**.
|
||||
- Restart: **a boundary** — new file, new seed.
|
||||
- Replay mode: `CommandManager` `addCommand` is a no-op + pre-filled; gate the two sim-state
|
||||
polls (schematic choices, game-over); passive "replay ended" instead of the game-over dialog;
|
||||
keep view + speed.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
Ordered to de-risk: prove determinism first, then build the command path, then recording, then
|
||||
playback. Each phase is independently testable and leaves the game in a working state. Phases
|
||||
0 → 1 → 2 → 3 are strictly sequential; Phase 4 tests can start as soon as their subject exists.
|
||||
|
||||
### Phase 0 — Determinism foundation & verification (no replay yet)
|
||||
|
||||
The whole feature rests on a deterministic sim, so prove that before building on it.
|
||||
|
||||
- Add a `mt19937` state **fingerprint** (fold its serialized state into a 64-bit value).
|
||||
- Add a **full-state checksum** path (positions, HP, velocities, belt items, building buffers,
|
||||
scalars), used by tests; each subsystem contributes via its own `appendChecksum(Hasher&)` so
|
||||
no state knowledge is duplicated.
|
||||
- Add a Catch2 **double-run determinism test**: run a scripted sequence twice from the same
|
||||
seed, assert per-tick **full-state** checksums match.
|
||||
- **Files:** new `lib/sim` checksum helper; small additions to `Simulation`, `BeltSystem`,
|
||||
`BuildingSystem`, ECS state; new test.
|
||||
- **Exit criteria:** the double-run test passes. If it fails, fix the nondeterminism here before
|
||||
proceeding.
|
||||
|
||||
### Phase 1 — Command model + chokepoint (no recording yet) — DONE
|
||||
|
||||
Reshape mutations to flow through one path; behaviour unchanged.
|
||||
|
||||
- Defined `Command` base + derived types (`PlaceBuilding`, `Demolish`, `RotateInPlace`,
|
||||
`SetRecipe`, `SetShipLayout`, `SetSiteSplitterFilters`, `SetSplitterFilters`,
|
||||
`ClearBeltTiles`, `ApplySchematicChoice`, `Reset`) in `lib`, each with a `playerId` (always 0
|
||||
now). `PlaceBuilding` is atomic (carries optional config — see the refinement note above).
|
||||
- Added `CommandManager` (FIFO queue, `enqueue`/`drain`) in `lib`, holding a `Simulation&`.
|
||||
- Added `Simulation::apply(const Command&)` dispatching by `CommandKind` to the underlying
|
||||
mutators — the single chokepoint. The `Simulation` player-action mutators are **private**
|
||||
(compile-time enforced; tests reach them via the `SimulationTestAccess` friend) — see the
|
||||
decision note above.
|
||||
- Wired the drain: `GameWorldView::onFrame` calls `CommandManager::drain()` once per frame,
|
||||
before the tick batch (runs even at 0× → build-while-paused preserved). A drained `Reset`
|
||||
triggers the view reset.
|
||||
- Refactored every UI mutation site: `GameWorldView` owns the `CommandManager` and enqueues
|
||||
directly; `MainWindow` and `SelectedBuildingPanel` emit `CommandRequestedEvent` (carrying a
|
||||
`shared_ptr<const Command>`) which `GameWorldView` subscribes to and enqueues.
|
||||
- **Files:** new `lib/sim/Command.h`, `CommandManager.{h,cpp}`; `CommandRequestedEvent.h`;
|
||||
`Simulation.{h,cpp}` (`apply`); `GameWorldView.{h,cpp}`, `MainWindow.cpp`,
|
||||
`SelectedBuildingPanel.cpp`; new `CommandTest.cpp`.
|
||||
- **Exit criteria:** game plays identically (including build-while-paused); determinism test
|
||||
still passes; `[command]` equivalence tests pass; no production call site can mutate the sim
|
||||
directly (compile-enforced: the `Simulation` mutators are private, tests excepted via
|
||||
`SimulationTestAccess`).
|
||||
|
||||
### Phase 2 — Recording — DONE
|
||||
|
||||
- `ReplayRecorder` (lib) writes the **line-oriented append file**: header (`version`, `build`,
|
||||
`seed`, `config_hash`, `timestamp`) then `---`, then one tick-tagged line per command
|
||||
interleaved with `# checksum <tick> <hex>` lines. Each line is flushed, so a crash mid-run
|
||||
leaves a valid partial file. `CommandSerializer` produces the per-command text (length-prefixed
|
||||
variable parts; `ShipLayoutConfig`/filters serialized inline). The build tag is
|
||||
`__DATE__ " " __TIME__`; the config hash is a 64-bit FNV over the `*.toml` files in the config
|
||||
dir (re-hashed on playback to detect mismatch).
|
||||
- **Random seed** generated in `main` (and on each restart in `MainWindow`) via
|
||||
`std::random_device`; `Simulation` retains it (`getSeed()`) for the header.
|
||||
- **Recorder hooked at the chokepoint:** `CommandManager` owns an optional `ReplayRecorder`;
|
||||
`drain()` records each applied command (tick-tagged) + a post-apply RNG checksum, and
|
||||
`recordTickCheckpoint()` (called per tick from the `onFrame` loop) writes a checksum every 30
|
||||
ticks. A drained `Reset` rolls the recorder to a new file (restart = boundary).
|
||||
- **Lifecycle:** `GameWorldView` attaches the recorder at construction (opens the first file with
|
||||
the initial seed + a tick-0 checksum); files live in `<data>/replays`, named
|
||||
`<timestamp>_<seed>.replay`; everything is retained.
|
||||
- **Files:** new `lib/sim/ReplayRecorder.{h,cpp}`, `CommandSerializer.{h,cpp}`; `Simulation`
|
||||
(`getSeed`); `CommandManager` (recorder + tick checkpoint); `main.cpp` (seed);
|
||||
`MainWindow.cpp` / `GameWorldView.{h,cpp}` (wiring); new `ReplayRecorderTest.cpp`.
|
||||
- **Exit criteria met:** recorder + serializer + drain-integration tests pass; the format is
|
||||
well-formed and flushed per line. (Live GUI recording is wired but not auto-tested here.)
|
||||
|
||||
### Phase 3 — Playback — DONE
|
||||
|
||||
- `ReplayReader` (lib) parses the file into `{ header, entries }`, where each entry is a command
|
||||
(with its tick) or a checksum (with its tick), kept in **file order**. `CommandSerializer`
|
||||
gained the inverse `parseCommand` (round-tripping every verb).
|
||||
- `--replay <file>` CLI path in `main`: reads the file, **warns** on version / config-hash
|
||||
mismatch (proceeds anyway), constructs the `Simulation` from the header seed, and threads the
|
||||
parsed replay through `MainWindow` to `GameWorldView`.
|
||||
- `ReplayPlayer` (lib) is the playback driver. Rather than reproduce frame batching, it applies
|
||||
each command at its **exact recorded tick** and verifies checksums **in file order**:
|
||||
`start()` processes the tick-0 entries, then after every `sim.tick()` `advanceTo(tick)`
|
||||
consumes that tick's entries (periodic checksum first, then command + its checksum — the order
|
||||
the file already has). This makes playback independent of replay-time speed/pause.
|
||||
- `GameWorldView` runs the player in `onFrame` when in replay mode (manual speed/pause kept,
|
||||
forward-only); `CommandManager` is put in **replay mode** so live input is a no-op. The two
|
||||
sim-state polls (schematic-choices, game-over) are **gated off**; dialog/escape paths are
|
||||
input-driven and fall away. A **"REPLAY"** tag plus a passive **"Replay ended"** /
|
||||
**"Desync at tick N"** overlay replaces the restart dialog.
|
||||
- **Files:** new `lib/sim/ReplayReader.{h,cpp}`, `ReplayPlayer.{h,cpp}`; `CommandSerializer`
|
||||
(`parseCommand`); `ReplayRecorder` (shared `computeReplayConfigHash`); `CommandManager`
|
||||
(replay mode); `main.cpp`; `MainWindow.{h,cpp}`; `GameWorldView.{h,cpp}`; new
|
||||
`ReplayPlaybackTest.cpp`.
|
||||
- **Exit criteria met:** the headless `ReplayPlaybackTest` records a scripted run, reads it back,
|
||||
replays it, and asserts **no desync** and a **byte-identical final state checksum** — including
|
||||
the periodic-checksum-then-command ordering at a shared tick. (Live GUI playback is wired but
|
||||
not auto-tested here.)
|
||||
|
||||
### Phase 4 — Closing tests & polish — DONE
|
||||
|
||||
- **Round-trip:** every command verb serializes → parses → re-serializes identically;
|
||||
malformed input is rejected (`parseCommand` returns nullptr).
|
||||
- **Replay-equivalence (headless):** a short scripted run and a **long ~2400-tick run through
|
||||
waves/combat** each record → read → replay with **no desync** and a **byte-identical final
|
||||
state checksum**.
|
||||
- **Desync detection:** corrupting one recorded checksum makes the player report the exact
|
||||
desync tick.
|
||||
- **Reset boundary:** a `Reset` drained through `CommandManager` rolls the recorder to a new
|
||||
file (named by the new seed).
|
||||
- **Polish:** the end-of-replay / desync overlay dims the world behind the message for
|
||||
readability; config/version mismatch is warned to the log on launch (its visible consequence,
|
||||
a desync, is already surfaced by the overlay).
|
||||
|
||||
### Status
|
||||
|
||||
Record + playback is functionally complete and covered by headless tests. Still deferred (per
|
||||
this design): snapshots, save/load, backward-seek, cross-platform float hardening, expanding the
|
||||
file checksum beyond RNG. Known minor rough edge: in replay mode the recipe/layout dialogs and
|
||||
escape→restart can still open but do nothing (their commands hit the no-op enqueue); fully
|
||||
disabling that input UI is polish, not correctness.
|
||||
|
||||
### Notes
|
||||
|
||||
- Phase 1 is the largest (the mutation-site refactor); Phase 0 is the riskiest (it may surface
|
||||
latent nondeterminism that must be fixed first).
|
||||
- Still deferred (per this design): snapshots, save/load, backward-seek, cross-platform float
|
||||
hardening, expanding the file checksum beyond RNG.
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
Config files use the TOML format. The following config files drive game parameters:
|
||||
|
||||
- **world.toml** — world dimensions, region widths, expansion amounts, building refund percentage, wave timing, boss wave timing, enemy ship level formula, belt speed, starting building blocks, departure interval, ship orbit factor, rally orbit radius, and combat target-selection parameters (target score formula, overclaim penalty formula, target hysteresis).
|
||||
- **world.toml** — world dimensions, region widths, expansion amounts, building refund percentage, wave timing, boss wave timing, belt speed, starting building blocks, departure interval, ship orbit factor, rally orbit radius, scrap-per-threat conversion, combat target-selection parameters (target score formula, overclaim penalty formula, target hysteresis), artifact chance formula, artifact win count, and view pan speeds (slow and fast horizontal pan speed and pan ramp band width).
|
||||
- **buildings.toml** — building block cost and construction time per building type.
|
||||
- **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).
|
||||
- **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).
|
||||
- **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).
|
||||
- **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities. Assembler recipe entries may optionally define `unlock_at_station_level` (integer): -1 means the recipe is explicitly unlocked at game start; a value ≥ 0 means the recipe starts locked and a schematic for it can be awarded via defence station destruction (see REQ-LOCK-EXPLICIT, REQ-DEF-SCHEMATIC-DROP). An assembler recipe schematic entry may also define an optional `unlock_requires` list of prerequisite schematic ids (REQ-LOCK-PREREQ).
|
||||
- **ships.toml** — per schematic: a human-readable display name (used in the UI), hull stats (HP, max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, max rotation speed) as plain values, required build materials, the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the schematic already unlocked), an optional `unlock_requires` prerequisite list (REQ-LOCK-PREREQ), a layout grid defining the ship's module slots, and a `default_modules` list used for enemy wave ships (see REQ-WAV-DEFAULT-MODULES).
|
||||
- **modules.toml** — per module type: id, surface mask, materials list, production time, fill color, glyph, the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the module schematic already unlocked), an optional `unlock_requires` prerequisite list (REQ-LOCK-PREREQ), and an optional capability section and/or stat modifier formulas. A module with a capability section (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat formulas is a **capability module** that grants the ship a weapon, salvage bay, or repair tool per instance (see REQ-MOD-CONFIG for the full list of formulas per capability type). A module with only `added_*`/`multiplied_*` formulas is a **passive module** that modifies stats on the ship or on capability module instances (see REQ-MOD-STAT-CALC).
|
||||
- **stations.toml** — HP, damage, range, fire rate, and scrap drop for player and enemy defence stations, defined as formulas of station level.
|
||||
- **visuals.toml** — rendering-only config (not game parameters): fill and outline colors and glyphs for every building type, item type, ship schematic, and station type; a distinct beam color per tool type (weapon, repair, salvage) and beam width; overlay and toast colors. Loaded by the UI at startup; the simulation does not read it.
|
||||
- **ship_layouts.toml** — named layout blueprints per ship type; written and read by the application to persist the layout blueprint panel (REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD). Not a game parameter file; the simulation does not read it.
|
||||
@@ -90,20 +90,34 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
- REQ-HQ-GAME-OVER: If the HQ is destroyed, the game ends. A game-over screen shows the final survival time and offers "Restart" and "Quit" buttons.
|
||||
- REQ-HQ-INVULNERABLE: Factory buildings (other than the HQ) are never targeted or destroyed by enemies.
|
||||
|
||||
## Win Condition
|
||||
|
||||
- REQ-WIN-ARTIFACT-COUNT: The player has an artifact count, starting at 0 at game start and resetting to 0 on Restart (REQ-CFG-RELOAD). When the player selects an artifact option in the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP), the artifact count increments by 1. When the artifact count reaches `world.toml [world].artifact_win_count`, the player wins and the win screen is shown (REQ-WIN-SCREEN).
|
||||
- REQ-WIN-SCREEN: When the player wins (REQ-WIN-ARTIFACT-COUNT), the simulation stops and a win screen is shown. The win screen functions identically to the game-over screen (REQ-HQ-GAME-OVER) — it displays the final survival time and offers "Restart" and "Quit" buttons — but with the caption "Won!" instead of the game-over caption.
|
||||
|
||||
## Building Placement & Management
|
||||
|
||||
- REQ-BLD-COST: The player places buildings from a build menu. Placement costs building blocks from the global stock. The cost per building type is read from `buildings.toml [[building]].cost`.
|
||||
- REQ-BLD-QUEUE: Placed buildings enter a construction queue and are built one at a time. Each building takes a duration defined in `buildings.toml [[building]].construction_time_seconds` to construct.
|
||||
- REQ-BLD-ASTEROID-ONLY: Buildings can only be placed on asteroid tiles (per surface_mask; tiles marked `S` may extend into space).
|
||||
- REQ-BLD-BUILDER-MODE: Clicking a build button activates builder mode for that building type. Builder mode is exited by right-clicking in the game world or clicking the same build button again.
|
||||
- REQ-BLD-GHOST: While in builder mode, a ghost of the building is rendered at the tile under the cursor, showing where it would be placed.
|
||||
- REQ-BLD-ROTATE: While in builder mode, pressing E rotates the ghost 90° clockwise and Q rotates it 90° counter-clockwise. Rotation affects the direction of the output port.
|
||||
- REQ-BLD-GHOST: While in builder mode, a ghost of the building is rendered at the tile under the cursor, showing where it would be placed. The ghost is drawn semi-transparently in the building type's own visuals — its `fill` and `outline` colors and `glyph` from `visuals.toml` — so that different building types are visually distinguishable in builder mode rather than all looking alike. When the current cursor position is invalid, the ghost instead uses the distinct "invalid" color (REQ-BLD-PLACE-VALID), which overrides the per-building coloring.
|
||||
- REQ-BLD-ROTATE: While in builder mode, pressing Shift+R rotates the ghost 90° clockwise and R rotates it 90° counter-clockwise. Rotation affects the direction of the output port.
|
||||
- REQ-BLD-PLACE: Clicking a valid tile in builder mode places a construction site and adds it to the build queue, consuming building blocks from the global stock.
|
||||
- REQ-BLD-PLACE-VALID: A placement position is valid only if (a) every footprint cell in the rotated `surface_mask` is satisfied by the underlying terrain — `A` cells coincide with asteroid tiles, `S` cells coincide with space tiles — (b) no footprint cell overlaps an existing placed building or construction site, except as allowed by REQ-BLD-ROTATE-IN-PLACE, and (c) the player has enough building blocks to afford the building. The ghost (REQ-BLD-GHOST) is rendered in a distinct "invalid" color when the current cursor position fails any of these conditions.
|
||||
- REQ-BLD-PLACE-VALID: A placement position is valid only if (a) every footprint cell in the rotated `surface_mask` is satisfied by the underlying terrain — `A` cells coincide with asteroid tiles, `S` cells coincide with space tiles — (b) no footprint cell overlaps an existing placed building or construction site, except as allowed by REQ-BLD-ROTATE-IN-PLACE, and (c) the player has enough building blocks to afford the building. The ghost (REQ-BLD-GHOST) is rendered in a distinct "invalid" color — overriding its per-building coloring (REQ-BLD-GHOST) — when the current cursor position fails any of these conditions.
|
||||
- REQ-BLD-ROTATE-IN-PLACE: If the ghost's footprint exactly coincides with the footprint of an existing placed building or construction site of the same building type, clicking places no new construction site and consumes no building blocks. Instead, the existing building or site is rotated to match the ghost's rotation. If the target is a construction site, its construction progress is preserved. This applies in both normal builder mode and blueprint placement mode; in blueprint placement mode it is evaluated per building in the blueprint independently — buildings in the blueprint whose footprint coincides with an existing same-type building or site are rotated in place, while the remaining buildings in the blueprint are placed as normal construction sites (subject to the usual validity checks and total cost).
|
||||
- REQ-BLD-BELT-DRAG: For belts, the player can click and drag across multiple tiles to place a construction site on each tile in one gesture.
|
||||
- REQ-BLD-TUNNEL-AUTO-SWITCH: After the player successfully places a Tunnel Entry construction site, builder mode automatically switches to Tunnel Exit (and vice versa), preserving the current ghost rotation. This makes it easy to immediately place the paired end without manually selecting the complementary type.
|
||||
- REQ-BLD-DEMOLISH: The player can demolish a placed factory building. Demolition returns `world.toml [world].refund_percentage` percent of the original building block cost (default 75%) to the global stock. Exception: if the building is still in the construction queue (not yet fully built, including the one currently being constructed), it is removed from the queue and the **full** building block cost is refunded. The HQ and player defence stations cannot be demolished.
|
||||
- REQ-BLD-DEMOLISH-CLICK: While in demolish mode (REQ-UI-HOTKEYS, REQ-UI-DEMOLISH-BUTTON), left-clicking a placed factory building or construction site in the game world demolishes it, following the refund rules of REQ-BLD-DEMOLISH — the partial refund for built buildings and the full refund for still-queued construction sites. Clicking a building that cannot be demolished (the HQ or a player defence station, per REQ-BLD-DEMOLISH), or clicking empty world space, has no effect. Demolish mode stays active after a demolition so the player can demolish further buildings without re-entering the mode; it is exited via the Q toggle (REQ-UI-HOTKEYS) or the Demolish button (REQ-UI-DEMOLISH-BUTTON).
|
||||
- REQ-BLD-DEMOLISH-BOX: While in demolish mode (REQ-UI-HOTKEYS, REQ-UI-DEMOLISH-BUTTON), the player can click and drag a selection box in the game world. A selection rectangle is drawn while dragging, using the same box-drag gesture and coverage semantics as the multi-select box (REQ-UI-MULTI-SELECT). On mouse up, every placed factory building and construction site covered by the box is demolished, each following the refund rules of REQ-BLD-DEMOLISH — the partial refund for built buildings and the full refund for still-queued construction sites. Buildings that cannot be demolished (the HQ and player defence stations, per REQ-BLD-DEMOLISH) are excluded from the box demolition; ships and defence stations are never affected.
|
||||
- REQ-BLD-SITE-CONFIG: A construction site — a building that has been placed but is still queued or under construction (REQ-BLD-QUEUE) — can be selected and configured exactly like the equivalent operational building, before it finishes building. Whatever configuration the building type supports is available on the site: the recipe for a Miner or Assembler (REQ-UI-SELECT-BUTTON), the produced-ship schematic and its module layout for a Shipyard (REQ-UI-SELECT-BUTTON, REQ-MOD-UI-PREVIEW, REQ-MOD-UI-DIALOG), and the output filters for a Splitter (REQ-BLD-SPLITTER) — all set through the same Selected Building Panel controls (REQ-UI-CONFIG-INLINE). Only currently unlocked recipes and schematics are offered, exactly as for operational buildings (REQ-LOCK-UI-RECIPE, REQ-LOCK-UI-SCHEMATIC, REQ-LOCK-UI-SPLITTER). The configuration is stored on the construction site and carries over unchanged when construction completes, so the building becomes operational already configured. A construction site has no input/output buffers and runs no production cycle, so the buffer and production-progress portions of the panel (REQ-UI-SINGLE-SELECTION, REQ-UI-PRODUCTION-PROGRESS) are not shown for it; only its construction progress (REQ-UI-CONSTRUCTION-PROGRESS) and its configuration controls appear. (Blueprint placement already applies a stored recipe or schematic to a construction site on placement per REQ-UI-BLUEPRINT-PLACE; this requirement additionally lets the player set or change that configuration directly on an existing site.)
|
||||
- REQ-BLD-COPY-CONFIG: **Copy building settings (hold Shift).** While the Shift key is held, the player can copy one building's settings onto other buildings of the same type, so several identical machines can be set up without opening each one's panel. This gesture is available only in the default selection mode; while a builder, blueprint placement, or demolish mode is active it is disabled, so it never clashes with placement or demolition clicks.
|
||||
- **Shift + right-click** a building copies its current settings into a temporary cache, along with the building's type. The settings copied are whatever that building type supports: the selected recipe (Miner, Assembler), the selected schematic together with its module layout (Shipyard), or the two output filters (Splitter, REQ-BLD-SPLITTER). Copying succeeds only when there is something to copy — a Miner or Assembler with a recipe selected, a Shipyard with a schematic selected, or any Splitter (whose output filters, even when empty/accept-all, always constitute valid settings). Shift + right-clicking a configurable building with nothing yet selected, a building type that has no settings at all (Smelter, Reprocessing Plant, Salvage Bay, belt/tunnel tiles, the HQ), or empty world space, has no effect and leaves any existing cache unchanged.
|
||||
- **Shift + left-click** a building of the **same type** as the cached one applies the cached settings to it, exactly as if the player had made that selection through the selected building panel — with the same effects as a normal selection change (buffer clearing per REQ-MAT-INPUT-BUFFER and REQ-MAT-OUTPUT-BUFFER, and, for a Shipyard, in-progress cycle cancellation per REQ-BLD-SHIPYARD). This can be repeated on any number of same-type buildings while Shift stays held. Shift + left-clicking a building of a different type than the cached one, any building while the cache is empty, or empty world space, has no effect.
|
||||
- Both operational buildings and construction sites take part as source and target (REQ-BLD-SITE-CONFIG); settings applied to a construction site carry over unchanged when it finishes building.
|
||||
- **Releasing Shift clears the temporary cache.** It is never persisted and does not survive Shift being released; the next copy starts fresh.
|
||||
- Because the cached settings were already valid on a same-type source building, they remain valid and available on the target (a selected recipe/schematic stays unlocked per REQ-LOCK-UI-RECIPE and REQ-LOCK-UI-SCHEMATIC; splitter filter item types stay unlocked per REQ-LOCK-UI-SPLITTER).
|
||||
|
||||
## Building Types
|
||||
|
||||
@@ -111,8 +125,8 @@ 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-ASSEMBLER: **Assembler** (3×3): The player selects a recipe from the config-defined crafting tree. Produces the selected output item at the rate defined in the corresponding `recipes.toml [[recipe]]` entry with `building = "assembler"`. Only implicitly unlocked recipes are available for selection (REQ-LOCK-UI-RECIPE).
|
||||
- REQ-BLD-REPROCESSING: **Reprocessing Plant** (3×3): Consumes scrap per cycle and produces exactly one higher-level intermediate product per cycle via weighted random pick. The input quantity, possible output items, per-output weights, and amounts are defined in `recipes.toml [[recipe]]` entries with `building = "reprocessing_plant"` (`inputs`, `outputs[].item`, `outputs[].amount`, `outputs[].weight`). Weights are normalized at load time; their sum does not need to equal 1. The output is rolled at cycle start (see REQ-MAT-CYCLE); the pool of eligible outputs is restricted to implicitly unlocked item types (REQ-LOCK-REPROCESSING-POOL). The output buffer holds at most one cycle's output — see REQ-MAT-OUTPUT-BUFFER-REPROCESSING.
|
||||
- REQ-BLD-SHIPYARD: **Shipyard** (4×2): The player selects a schematic. When all required materials — the ship's base materials (`[ship.schematic].materials`) plus the materials of all modules in the configured layout (REQ-MOD-MATERIALS) — are present in its input buffer, the shipyard consumes them and begins a production cycle lasting the ship's base `[ship.schematic].production_time_seconds` plus the sum of production times contributed by all module instances in the configured layout (REQ-MOD-PRODUCTION-TIME). One ship of that type is spawned 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-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-SHIPYARD: **Shipyard** (4×2): The player selects a schematic. When all required materials — the ship's base materials (`[ship.schematic].materials`) plus the materials of all modules in the configured layout (REQ-MOD-MATERIALS) — are present in its input buffer, the shipyard consumes them and begins a production cycle lasting the ship's base `[ship.schematic].production_time_seconds` plus the sum of production times contributed by all module instances in the configured layout (REQ-MOD-PRODUCTION-TIME). One ship of that type is spawned with the configured modules when the cycle completes. The shipyard cannot start a new cycle while one is in progress. If the player confirms a layout change (REQ-MOD-UI-DIALOG) while a production cycle is in progress, the current cycle is cancelled and all consumed materials are discarded; the shipyard returns to idle with the new layout configuration.
|
||||
- REQ-BLD-SALVAGE-BAY: **Salvage Bay** (3×2): A dedicated drop-off point for salvage ships. It has an output buffer whose holding capacity is defined by the `output_buffer_capacity` field of the `salvage_bay` entry in `buildings.toml` (rather than by a production cycle, since the Salvage Bay has no recipe). A ship at the bay hands over one unit of scrap per tick while the buffer has free space; a full buffer blocks further drop-off until space frees up (consistent with the buffer-full semantics of REQ-MAT-OUTPUT-BUFFER). Held scrap is pushed onto connected output belts.
|
||||
- REQ-BLD-BELT: **Belt** (1×1): Transports items. A belt tile has one direction (N, S, E, W) set at placement (modified by rotation). Curved belts are auto-derived: when a belt tile's outgoing direction leads into another belt whose direction is orthogonal, the downstream belt is rendered and behaves as a curve. Belt speed is defined in `world.toml [world].belt_speed_tiles_per_second` (REQ-GW-BELT-SPEED).
|
||||
- REQ-BLD-SPLITTER: **Splitter** (1×1): Distributes incoming items between two output directions. Each output can optionally have a filter (a list of item types), configurable via the selected building panel; only implicitly unlocked item types are available as filter options (REQ-LOCK-UI-SPLITTER). Routing rules:
|
||||
- An item matching only one output's filter is routed to that output.
|
||||
@@ -120,8 +134,8 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
- An item matching neither output's filter is routed to the unfiltered output. If both outputs have a filter and the item matches neither, the splitter stalls and moves no items until the situation is resolved.
|
||||
- If neither output has a filter, items are distributed by strict alternation.
|
||||
- In all alternation cases, if one output is blocked the item goes to the other output until it unblocks.
|
||||
- REQ-BLD-TUNNEL-ENTRY: **Tunnel Entry** (1×1): The sending end of a tunnel pair. The player sets a direction (N, S, E, W) at placement, rotatable with Q/E. Items arriving from an adjacent belt tile whose direction points into the entry are forwarded through the tunnel to the paired Tunnel Exit (see REQ-BLD-TUNNEL-PAIR, REQ-BLD-TUNNEL-TRANSIT). If the entry is unpaired, or if the paired exit's output is blocked, the entry blocks like a full belt tile.
|
||||
- REQ-BLD-TUNNEL-EXIT: **Tunnel Exit** (1×1): The receiving end of a tunnel pair. The player sets a direction at placement, rotatable with Q/E. 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-ENTRY: **Tunnel Entry** (1×1): The sending end of a tunnel pair. The player sets a direction (N, S, E, W) at placement, rotatable with R/Shift+R. Items arriving from an adjacent belt tile whose direction points into the entry are forwarded through the tunnel to the paired Tunnel Exit (see REQ-BLD-TUNNEL-PAIR, REQ-BLD-TUNNEL-TRANSIT). If the entry is unpaired, or if the paired exit's output is blocked, the entry blocks like a full belt tile.
|
||||
- REQ-BLD-TUNNEL-EXIT: **Tunnel Exit** (1×1): The receiving end of a tunnel pair. The player sets a direction at placement, rotatable with R/Shift+R. Items received from the paired Tunnel Entry emerge from the output side of the exit tile — the tile adjacent in the exit's facing direction — continuing in that direction. If the exit is unpaired or its output is blocked, it holds received items until they can advance.
|
||||
- REQ-BLD-TUNNEL-PAIR: **Tunnel pairing rules.** Pairing is re-evaluated for all Tunnel Entries whenever any Tunnel Entry or Tunnel Exit is placed or demolished.
|
||||
- A Tunnel Entry searches tile-by-tile in its facing direction for a partner. Any tunnel building (entry or exit) that faces a *different* direction is ignored and skipped. The search stops at the first tunnel building that faces the *same* direction as the searching entry.
|
||||
- If that first same-direction tunnel building is a Tunnel Exit, is within `tunnel_max_distance` tiles of the entry, and is not already paired with a closer entry, the two form a pair.
|
||||
@@ -143,16 +157,16 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
|
||||
## 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.
|
||||
|
||||
## Ships
|
||||
|
||||
- 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-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:
|
||||
- **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.
|
||||
@@ -164,17 +178,14 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
- REQ-SHP-SENSOR: A ship perceives only entities within its sensor range. Behavior is driven by what is in sensor range; entities outside sensor range are ignored.
|
||||
- REQ-SHP-FIRING: All weapons — on ships and on defence stations — fire when off cooldown and the target is within attack range. Firing emits a fire event and starts a 0.15-second damage delay (half the beam duration). When that delay expires, damage is applied to the target — unless the target has already been destroyed, in which case the damage is silently dropped. If the shooter is destroyed before the delay expires, damage is still applied when the delay expires. There is no projectile entity and no intervening collision. The weapon's cooldown begins at the moment of firing, not at damage application.
|
||||
- REQ-SHP-FIRING-BEAM: Each weapon fire event (REQ-SHP-FIRING), repair-tool activation (REQ-SHP-REPAIR), and salvage activation (REQ-SHP-SALVAGE) produces a visual beam drawn from the acting ship's position to the target for 0.3 seconds; repair and salvage beams have the same duration as weapon beams. The beam is rendered in the tool type's beam color from `visuals.toml` (a distinct color for weapon, repair, and salvage beams). The beam endpoint is not the target's center but a point randomly offset from it: the offset direction is uniformly random and the offset magnitude is uniformly random up to half the target's visual size (for ships: half their rendered radius; for buildings/stations: half the shorter side of their tile footprint, in world units; for a scrap pile: half its rendered size). The offset is chosen once per activation event and held fixed for the beam's lifetime. The beam is a pure rendering effect and has no simulation state (does not block movement, does not re-apply its effect over its lifetime). Beams follow the acting ship and target positions if either moves during the 0.3-second window. The beam is rendered for its full 0.3-second duration even if the acting ship or target is destroyed before it expires.
|
||||
- REQ-SHP-COMBAT: Ships with at least one **weapon module** (player) — engage enemy ships within sensor range. When engaging an enemy, the ship orbits it at the combat orbit radius (REQ-SHP-ORBIT) rather than approaching its center. The player can configure the following per shipyard (applied to all ships produced by that shipyard):
|
||||
- Stance: aggressive (advance toward enemies) / defensive (hold position near asteroid).
|
||||
- Target priority: closest / highest HP / structures first.
|
||||
- REQ-SHP-RALLY: After spawning, aggressive-stance ships with weapon modules move to and orbit the **rally point** — the midpoint between the two player defence stations (center of their Y-span, at the player defence stations' X position) — at the rally orbit radius (REQ-SHP-ORBIT). While orbiting the rally point, ships still engage any enemy that enters sensor range (switching to the combat orbit per REQ-SHP-COMBAT). Every `world.toml [world].departure_interval_seconds` seconds (default 20), all ships with weapon modules currently at the rally point depart simultaneously and begin their normal aggressive advance toward the enemy. The departure timer is global and shared across all shipyards; it is not reset by individual ship arrivals at the rally point.
|
||||
- REQ-SHP-COMBAT: Ships with at least one **weapon module** (player) — engage enemy ships within sensor range. When engaging an enemy, the ship orbits it at the combat orbit radius (REQ-SHP-ORBIT) rather than approaching its center.
|
||||
- REQ-SHP-RALLY: After spawning, ships with weapon modules move to and orbit the **rally point** — the midpoint between the two player defence stations (center of their Y-span, at the player defence stations' X position) — at the rally orbit radius (REQ-SHP-ORBIT). While orbiting the rally point, ships still engage any enemy that enters sensor range (switching to the combat orbit per REQ-SHP-COMBAT). Every `world.toml [world].departure_interval_seconds` seconds (default 20), all ships with weapon modules currently at the rally point depart simultaneously and begin their normal aggressive advance toward the enemy. The departure timer is global and shared across all shipyards; it is not reset by individual ship arrivals at the rally point.
|
||||
- REQ-SHP-SALVAGE: Ships with at least one **salvage module** (player) — patrol by moving forward (rightward, away from the asteroid) while searching sensor range. If scrap enters sensor range, navigate toward it by orbiting it at the salvage orbit radius (REQ-SHP-ORBIT); when it is within a module's `collection_range`, that module begins collecting from it, one scrap per cycle (see below). Once the ship's cargo pool is full, fly to a Salvage Bay and deliver (a direct approach, not an orbit — the ship must reach the bay); after delivery, resume patrol. If an enemy ship enters sensor range, the ship retreats (REQ-SHP-RETREAT) until no enemy is in sensor range, then resumes patrol — this applies regardless of whether the ship is targeting or carrying scrap. Ships with salvage modules are vulnerable to enemy ships while operating.
|
||||
|
||||
All salvage modules on a ship deposit into a single shared **cargo pool** whose size is the ship's cargo capacity stat (REQ-MOD-CARGO-CAPACITY). Each salvage module instance still runs its own collection cycle independently, with its own collection range (`collection_range`) and collection rate (`collection_rate`, in collection cycles per second). A module starts a collection cycle when it is off cooldown, the shared cargo pool has free space, and a scrap pile is within its `collection_range`. Free space is measured against the pool's current contents **plus the collection cycles already in flight toward the pool** (scrap claimed by cycles whose effect delay has not yet elapsed); each in-flight cycle is registered against the ship so that concurrent modules on the same ship never start more cycles than the remaining capacity can hold. Starting a cycle emits a collection beam toward that scrap pile (REQ-SHP-FIRING-BEAM) and begins a 0.15-second effect delay (half the beam duration); the module's cooldown of `1 / collection_rate` seconds begins at cycle start, not at effect application. When the delay expires, exactly 1 scrap is removed from the targeted pile and added to the ship's cargo pool — unless the pile has already been fully depleted or despawned, or the pool is now full, in which case the collection is silently dropped. A scrap pile worth more than 1 (REQ-RES-SCRAP-DROP) is depleted one scrap per cycle and persists, with its remaining amount decremented, until it is fully collected or despawns. A ship with multiple salvage modules can therefore run multiple collection cycles concurrently (one per ready module), and instances of different module types may have different ranges and rates. The ship navigates based on the maximum collection range across all installed salvage modules.
|
||||
|
||||
Salvage collection cycles and delivery are processed regardless of which behavior the ship is currently executing; the salvage behavior only governs where the ship navigates (toward scrap, toward a Salvage Bay, or — when retreating — toward the rally point).
|
||||
- REQ-SHP-REPAIR: Ships with at least one **repair module** (player) — when no more urgent behavior applies, hold with the fleet (REQ-SHP-STANDBY) rather than charging the enemy, so damaged allies stay within sensor range. If a damaged player defence station or player ship enters sensor range, navigate toward it by orbiting it at the repair orbit radius (REQ-SHP-ORBIT) and repair. If an enemy ship enters sensor range, the ship retreats (REQ-SHP-RETREAT) until no enemy is in sensor range — except that it holds its ground and keeps repairing while a damaged friendly remains within sensor range (REQ-SHP-RETREAT), retreating only once there is nothing left to repair — then resumes patrol. The player can configure the target priority per shipyard:
|
||||
- Defence stations first / ships first / nearest target.
|
||||
- REQ-SHP-REPAIR: Ships with at least one **repair module** (player) — when no more urgent behavior applies, hold with the fleet (REQ-SHP-STANDBY) rather than charging the enemy, so damaged allies stay within sensor range. If a damaged player defence station or player ship enters sensor range, navigate toward it by orbiting it at the repair orbit radius (REQ-SHP-ORBIT) and repair. If an enemy ship enters sensor range, the ship retreats (REQ-SHP-RETREAT) until no enemy is in sensor range — except that it holds its ground and keeps repairing while a damaged friendly remains within sensor range (REQ-SHP-RETREAT), retreating only once there is nothing left to repair — then resumes patrol.
|
||||
|
||||
Each repair module instance operates independently: it has its own repair rate (`repair_rate`, in repair cycles per second), per-cycle heal amount (`repair_amount_hp`), and repair range (`repair_range`). A module starts a repair cycle when it is off cooldown and a valid repair target is in range. To choose the target, the module first considers the ship's current behavior-level navigation target if that target is within the module's `repair_range` and is damaged (HP above zero and below maximum HP). If those conditions are not met — because the target is out of the module's `repair_range`, already at full health, or destroyed — the module independently searches for the nearest damaged friendly (player ship or player defence station) within its own `repair_range`. If no valid target is found within range, the module idles and starts no cycle. On starting a cycle, the module emits a repair beam toward the chosen target (REQ-SHP-FIRING-BEAM) and begins a 0.15-second effect delay (half the beam duration); the module's cooldown of `1 / repair_rate` seconds begins at cycle start, not at effect application. When the delay expires, `repair_amount_hp` HP is restored to the targeted entity, clamped to its maximum HP — unless that entity is no longer damaged or has been destroyed, in which case the heal is silently dropped. A ship with multiple repair modules can therefore run multiple repair cycles concurrently, healing different targets. Navigation is driven solely by the behavior-level target; individual module fallback targets do not affect which direction the ship moves. Repair cycles are processed regardless of which behavior the ship is currently executing.
|
||||
- REQ-SHP-STANDBY: **Ships with at least one repair module hold with their fleet when idle**, whether or not they also carry weapon modules. Standby is a low-priority fallback — above the baseline forward advance (REQ-SHP-COMBAT/REQ-SHP-ENEMY-AI advance) but below rally (REQ-SHP-RALLY), so it only wins when no attack, repair, salvage, rally, or retreat behavior applies. A standing-by ship navigates toward the centroid of its other same-faction ships, falling back to the centroid of its own defence stations, and holding position when it has no allies. This keeps repair ships among the allies they exist to heal instead of advancing alone into the enemy. Armed repair ships therefore still rally and depart on the normal schedule (REQ-SHP-RALLY); standby only governs them once rally no longer applies.
|
||||
@@ -193,23 +204,23 @@ 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.
|
||||
- `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).
|
||||
- `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_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.
|
||||
- `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.
|
||||
- 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:
|
||||
- **Weapon** (`[module.weapon]`): `damage_formula`, `attack_range_formula`, `attack_rate_formula`.
|
||||
- **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).
|
||||
- **Repair** (`[module.repair]`): `repair_rate_formula` (repair cycles per second), `repair_amount_hp_formula` (HP restored per repair cycle), `repair_range_formula` (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.
|
||||
- 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`, `attack_range`, `attack_rate`.
|
||||
- **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` (repair cycles per second), `repair_amount_hp` (HP restored per repair cycle), `repair_range` (tiles).
|
||||
- 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.
|
||||
|
||||
### Module Placement
|
||||
|
||||
- REQ-MOD-PLACEMENT: In the layout configuration dialog (REQ-MOD-UI-DIALOG), the player places modules onto the ship's layout grid. Clicking a module button in the module selection grid enters module placement mode for that module type. While in placement mode, a ghost of the module's surface mask is rendered at the cell under the cursor. Clicking a valid position places one instance of the module. A position is valid if every `O` cell in the module's (rotated) surface mask coincides with an unoccupied buildable cell of the ship's layout. The player may place unlimited instances of the same module type.
|
||||
- REQ-MOD-ROTATION: While in module placement mode, pressing Q rotates the module ghost 90° counter-clockwise and E rotates it 90° clockwise. Rotation transforms the surface mask grid identically to building rotation (REQ-BLD-ROTATE).
|
||||
- REQ-MOD-ROTATION: While in module placement mode, pressing R rotates the module ghost 90° counter-clockwise and Shift+R rotates it 90° clockwise. Rotation transforms the surface mask grid identically to building rotation (REQ-BLD-ROTATE).
|
||||
- REQ-MOD-REMOVE: The module selection grid includes a "Remove" button. Clicking it enters remove mode. In remove mode, clicking on a cell occupied by a placed module removes that entire module instance from the layout. Remove mode is exited by clicking the Remove button again or by selecting a module for placement.
|
||||
|
||||
### Module Effects
|
||||
@@ -221,29 +232,30 @@ 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.
|
||||
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:
|
||||
- **Miner recipe**: the recipe's `duration_seconds`.
|
||||
- **Smelter recipe**: the recipe's `duration_seconds` plus the sum of each input's threat value multiplied by that input's required quantity.
|
||||
- **Assembler recipe**: the recipe's `duration_seconds` plus the sum of each input's threat value multiplied by that input's required quantity.
|
||||
- **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.
|
||||
- **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.
|
||||
- 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**: `duration_seconds / output_amount`, where `output_amount` is the number of units produced per cycle.
|
||||
- **Smelter recipe**: `(duration_seconds + Σ (input_threat × input_amount)) / output_amount`, where the sum is over all inputs.
|
||||
- **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 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 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:
|
||||
- `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).
|
||||
- `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_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`.
|
||||
- `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 the module's multiplicative modifier value.
|
||||
- `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.weapon]` — modifiers apply to every weapon 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.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
|
||||
|
||||
@@ -257,7 +269,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
- **Right** (below the grid): The layout blueprint panel (see REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD).
|
||||
- **Bottom**: A "Confirm" button and a "Cancel" button. Cancel discards all changes made in this dialog session and closes the dialog. Confirm applies the changes: the shipyard's configured layout is updated, the required materials and cycle time displayed in the selected building panel are recalculated, and the ship layout preview is refreshed.
|
||||
|
||||
- REQ-MOD-UI-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-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:
|
||||
- HP
|
||||
@@ -274,7 +286,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.
|
||||
- **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.
|
||||
|
||||
@@ -301,22 +313,23 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
- REQ-DEF-ENEMY-FIRE: Enemy defence stations automatically fire at player ships within 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-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). Up to three schematic options are drawn uniformly at random **without replacement** from the eligible drop pool. If the pool contains fewer than three entries, only that many options are shown. 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.
|
||||
- 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 ≥ 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.
|
||||
|
||||
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.
|
||||
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:
|
||||
- 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 in the dialog displays: the schematic name (ship `id` from `ships.toml`, module `id` from `modules.toml`, or assembler recipe `id` from `recipes.toml`) and the schematic type (ship, module, or assembler recipe). The artifact option (if present) is displayed as a distinct entry with the name "Artifact".
|
||||
|
||||
Each option additionally displays a vertical list of 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.
|
||||
|
||||
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. 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).
|
||||
|
||||
@@ -324,6 +337,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-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:
|
||||
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.
|
||||
@@ -342,11 +357,10 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
|
||||
## 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-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-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-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.
|
||||
@@ -361,7 +375,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
## 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-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
|
||||
|
||||
@@ -386,15 +400,22 @@ The screen is divided into two columns: a main column (75% width) containing the
|
||||
(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 and the current global building blocks stock on the left, the boss wave counter and boss countdown (REQ-UI-BOSS-STATUS) to the left of the speed buttons, and game speed controls on the right.
|
||||
- REQ-UI-HEADER: The header bar spans the width of the game world column (75% of the screen width) and always shows the elapsed survival time, the current global building blocks stock, and the artifact count (REQ-WIN-ARTIFACT-COUNT) displayed as `Artifacts: x/y` (where `x` is the current artifact count and `y` is `world.toml [world].artifact_win_count`) on the left, the boss wave counter and boss countdown (REQ-UI-BOSS-STATUS) and an asteroid expansion button (REQ-UI-EXPAND-BUTTON) to the left of the speed buttons, and game speed controls on the right.
|
||||
- REQ-UI-BOSS-STATUS: The header bar displays, to the left of the speed buttons, the current boss wave counter (REQ-WAV-BOSS-COUNTER) and the time remaining on the boss countdown (REQ-WAV-BOSS-COUNTDOWN). The boss wave counter is shown as `Boss Wave #<x>` and the countdown as `Next boss: <M:SS>`, where `<M:SS>` is the remaining seconds formatted as whole minutes and two-digit seconds. Both values update continuously as the simulation runs.
|
||||
- REQ-UI-SPEED: The game speed controls in the header bar are buttons for 0×, 0.5×, 1×, 2×, and 4× speed. The currently active speed is shown as selected. All game simulation (production, movement, threat accumulation, wave timing) scales with the selected speed. 0× pauses the game.
|
||||
- REQ-UI-EXPAND-BUTTON: The header bar shows an asteroid expansion button captioned `Expand: <x> Blocks`, where `<x>` is the current expansion cost computed from `world.toml [expansion].cost_building_blocks_formula` at the current number of purchased expansions (REQ-EXP-COST). Clicking the button unlocks the next asteroid expansion (REQ-EXP-UNLOCK, REQ-GW-ASTEROID-EXPAND), spending that many building blocks from the global stock. The button is disabled when the player cannot currently afford the cost (consistent with REQ-UI-BUILD-DISABLED). The caption updates as the cost changes with each purchased expansion.
|
||||
- REQ-UI-WORLD-SIZE: The game world view occupies the full height below the header bar in the main column (75% of the screen width).
|
||||
- REQ-UI-PANEL-COLUMN: The side panel column occupies 25% of the screen width and the full screen height. It is divided into three equal-height panels stacked top to bottom: selected building panel (top), build button grid (middle), and blueprint panel (bottom).
|
||||
|
||||
### 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-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.
|
||||
@@ -403,14 +424,18 @@ The screen is divided into two columns: a main column (75% width) containing the
|
||||
- **Space** — toggles pause. Pressing Space pauses (sets speed to 0×) and stores the previously selected non-zero speed; pressing Space again restores that speed.
|
||||
- **W** — increases game speed by one step in the sequence 0×, 0.5×, 1×, 2×, 4× (no wrap-around past 4×).
|
||||
- **S** — decreases game speed by one step in the same sequence (no wrap-around past 0×).
|
||||
- **Backspace** — activates demolish mode; Backspace again exits it. (See also REQ-UI-DEMOLISH-BUTTON for the equivalent button.)
|
||||
- **Q / E** — in builder mode, rotate the ghost counter-clockwise / clockwise (REQ-BLD-ROTATE).
|
||||
- **A / D** — scroll the view left / right (REQ-UI-SCROLL).
|
||||
- **Q** — context-sensitive. If a build mode is active (builder mode or blueprint placement mode), pressing Q exits it. Otherwise, pressing Q toggles demolish mode: it enters demolish mode if inactive, or exits demolish mode if already active. (See also REQ-UI-DEMOLISH-BUTTON for the equivalent button.)
|
||||
- **R / Shift+R** — in builder mode, rotate the ghost counter-clockwise / clockwise (REQ-BLD-ROTATE).
|
||||
- **T** — create a temporary blueprint from the current selection and enter its placement mode (REQ-UI-BLUEPRINT-TEMP).
|
||||
- **Escape** — opens the escape menu (REQ-UI-GAME-MENU).
|
||||
- **M** — toggles debug draw mode (REQ-UI-DEBUG-DRAW).
|
||||
- **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.
|
||||
- **Shift+1** — Miner, **Shift+2** — Smelter, **Shift+3** — Assembler, **Shift+4** — Shipyard, **Shift+5** — Salvage Bay, **Shift+6** — Reprocessing Plant.
|
||||
|
||||
### Debug Draw
|
||||
|
||||
- REQ-UI-DEBUG-DRAW: A debug draw mode can be toggled on and off with the **M** key (REQ-UI-HOTKEYS). It is inactive by default. While active, the sensor range of every ship — both player and enemy — is drawn as a circle centered on the ship, using that ship schematic's outline color from `visuals.toml`.
|
||||
- REQ-UI-DEBUG-DRAW: A debug draw mode can be toggled on and off with the **F3** key. It is inactive by default. While active, the sensor range of every ship — both player and enemy — is drawn as a circle centered on the ship, using that ship schematic's outline color from `visuals.toml`.
|
||||
|
||||
- REQ-UI-DEBUG-OVERLAY: While debug draw mode is active (REQ-UI-DEBUG-DRAW), a text overlay is drawn in the upper left corner of the game world view. The overlay has a semi-transparent black background sized to fit its content. It displays the following lines of text:
|
||||
- `Accumulated Threat Level: <level>` — where `<level>` is the current accumulated threat level (see REQ-WAV-THREAT-RATE).
|
||||
@@ -430,18 +455,18 @@ The screen is divided into two columns: a main column (75% width) containing the
|
||||
### Selected Building Panel
|
||||
|
||||
- REQ-UI-EMPTY-SELECTION: When no building is selected, the panel is empty.
|
||||
- REQ-UI-SINGLE-SELECTION: When one building is selected, the panel shows: building name, current recipe or schematic selection, input buffer contents, and output buffer contents. Buffer counts are displayed as `a/b` where `a` is the current item count and `b` is the per-cycle amount (items consumed per run for inputs; items produced per run for outputs).
|
||||
- REQ-UI-SINGLE-SELECTION: When one building is selected, the panel shows: building name, current recipe or schematic selection, input buffer contents, and output buffer contents. Buffer counts are displayed as `a/b` where `a` is the current item count and `b` is the per-cycle amount (items consumed per run for inputs; items produced per run for outputs). For a selected construction site, the recipe/schematic selection (and, for a shipyard, the layout preview and "Configure" button) are shown but the buffer rows are omitted (REQ-BLD-SITE-CONFIG).
|
||||
- REQ-UI-PRODUCTION-PROGRESS: For buildings that produce items or ships (miner, smelter, assembler, reprocessing plant, shipyard), the selected building panel also shows: (a) the cycle time of the currently selected recipe or schematic in seconds, and (b) the completion percentage of the active production cycle as an integer (e.g. `42%`), or the text `idle` when no production cycle is active. When no recipe or schematic is selected, neither the cycle time nor the progress indicator is shown.
|
||||
- REQ-UI-MULTI-SELECT: The player selects multiple buildings by box-drag or by Ctrl+clicking individual buildings to add or remove them from the selection.
|
||||
- REQ-UI-MULTI-SELECTION: When multiple buildings are selected, the panel shows how many of each building type are selected. No per-building detail is shown.
|
||||
- REQ-UI-CONFIG-INLINE: Recipe, schematic, ship stance, and target priority configuration for a selected building is shown within this panel. Ship stance and target priority are selected and changed inline. 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-TOOLTIP: **Selection info tooltip.** Hovering an option button in the selection dialog (REQ-UI-SELECT-BUTTON), and hovering the selection button in the selected building panel when a selection is set, displays an info tooltip:
|
||||
- For a **recipe** (Miner or Assembler): the recipe name; the name and quantity of each input item (no inputs are listed for miner recipes, which consume nothing); the completion time (`duration_seconds`); and the name and quantity of the produced output item.
|
||||
- For a **ship schematic** (Shipyard): the ship's `display_name`; the name and quantity of each base required material (`[ship.schematic].materials`, excluding any module contributions); the base production time (`[ship.schematic].production_time_seconds`); and "Produces: 1 <ship display name>".
|
||||
- REQ-UI-BELT-CLEAR: When one or more belt, splitter, tunnel entry, or tunnel exit tiles are selected, the panel shows a "Clear" button that removes all items from the selected tiles. Clearing a tunnel entry or exit also discards all items currently in transit through that tunnel (REQ-BLD-TUNNEL-TRANSIT). This can be used to resolve stalled belts, splitters, and tunnels.
|
||||
- REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. Clicking a ship or defence station clears any existing selection and establishes a single-entity selection containing only that entity. Ships and defence stations cannot participate in multi-select together with buildings. Clicking empty world space (no building, ship, or defence station) clears the selection.
|
||||
- REQ-UI-SHIP-STATS-PANEL: When a single ship is selected (REQ-UI-ENTITY-CLICK-SELECT), the selected building panel shows a **ship stats panel**. The panel structure mirrors REQ-MOD-UI-STATS-PANEL but reflects the ship's actual live state: stats are computed 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-STATION-STATS-PANEL: When a single defence station is selected (REQ-UI-ENTITY-CLICK-SELECT), the selected building panel shows a **station stats panel** displaying the station's stats computed at its current level: HP (current / maximum), damage, range, and fire rate.
|
||||
|
||||
### Build Button Grid
|
||||
@@ -449,27 +474,29 @@ The screen is divided into two columns: a main column (75% width) containing the
|
||||
- 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-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 pressing Backspace (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
|
||||
|
||||
- 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-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) at the time of capture. If no recipe or schematic was selected at capture time, none is stored. This structure maps directly to a TOML representation (e.g. one `[[building]]` array entry per constituent building).
|
||||
- REQ-UI-BLUEPRINT-TEMP: Pressing the **T** key (REQ-UI-HOTKEYS) creates a **temporary blueprint** from the current selection and immediately enters blueprint placement mode for it, without opening the naming dialog. It has effect only when at least one player-placeable building is currently selected — the same condition as REQ-UI-BLUEPRINT-CREATE; pressing T with an empty selection, or a selection containing only non-player-placeable buildings (HQ, defence stations), does nothing. Entering this mode replaces any currently active build, blueprint placement, or demolish mode. The temporary blueprint is captured exactly as a saved blueprint (REQ-UI-BLUEPRINT-STORAGE), silently excluding any non-player-placeable buildings from the selection, but it is never named, never shown in the blueprint panel (REQ-UI-BLUEPRINT-PANEL), and never persisted to `blueprints.toml` (REQ-UI-BLUEPRINT-SAVE). Placement behaves identically to a saved blueprint's placement mode (REQ-UI-BLUEPRINT-MODE, REQ-UI-BLUEPRINT-PLACE): a ghost is rendered per building, R / Shift+R rotate the entire constellation, placement follows the same per-building validity and total-cost rules, and after a successful placement the mode stays active so the blueprint can be placed again. Right-clicking in the game world exits placement mode, at which point the temporary blueprint is discarded.
|
||||
|
||||
- REQ-UI-BLUEPRINT-STORAGE: A blueprint stores its name and, for each building in the selection, the building type, its rotation, its tile offset (integer dx, dy) from the center of the bounding box of all selected buildings' footprints, and — where applicable — the selected recipe ID (miners and assemblers) or schematic ID (shipyards), and for splitters the two output filters (each a list of item types; an empty list means accept-all), at the time of capture. If no recipe or schematic was selected at capture time, none is stored; for a splitter with no filters set, no filter lists are stored. This structure maps directly to a TOML representation (e.g. one `[[building]]` array entry per constituent building, with the splitter filters as `filter_a`/`filter_b` arrays of item-type ids).
|
||||
|
||||
- REQ-UI-BLUEPRINT-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-MODE: In blueprint placement mode a ghost is rendered for every building in the blueprint at the position determined by its stored tile offset from the bounding-box center, which is anchored to the tile under the cursor. Each ghost is rendered individually as valid or invalid, applying REQ-BLD-PLACE-VALID conditions (a) and (b) per building (the other ghosts in the same blueprint do not count as existing buildings for the overlap check). Pressing Q/E rotates the entire constellation 90° counter-clockwise / clockwise: each building's tile offset is rotated around the bounding-box center and each building's own rotation is updated, consistent with REQ-BLD-ROTATE. Blueprint placement mode is exited by right-clicking in the game world. Clicking a different blueprint button exits the current mode and enters blueprint placement mode for the newly clicked blueprint.
|
||||
- REQ-UI-BLUEPRINT-MODE: In blueprint placement mode a ghost is rendered for every building in the blueprint at the position determined by its stored tile offset from the bounding-box center, which is anchored to the tile under the cursor. Each ghost is rendered individually as valid or invalid, applying REQ-BLD-PLACE-VALID conditions (a) and (b) per building (the other ghosts in the same blueprint do not count as existing buildings for the overlap check). A valid ghost uses its building type's semi-transparent per-building coloring (REQ-BLD-GHOST); an invalid ghost uses the distinct "invalid" color, as in single-building builder mode. Pressing R / Shift+R rotates the entire constellation 90° counter-clockwise / clockwise: each building's tile offset is rotated around the bounding-box center and each building's own rotation is updated, consistent with REQ-BLD-ROTATE. Blueprint placement mode is exited by right-clicking in the game world. Clicking a different blueprint button exits the current mode and enters blueprint placement mode for the newly clicked blueprint.
|
||||
|
||||
- REQ-UI-BLUEPRINT-PLACE: Left-clicking in blueprint placement mode places the blueprint if (a) every building in the constellation satisfies REQ-BLD-PLACE-VALID conditions (a) and (b) at its resolved tile, and (b) the player has enough building blocks to afford the total cost. If both conditions are met, a construction site is added to the build queue for each building in the blueprint and the full total cost is deducted from the global building blocks stock in one transaction. If a recipe ID is stored for a building, it is applied to the construction site immediately. If a schematic ID is stored, it is applied only if that schematic is currently unlocked; if it is not unlocked, the shipyard's schematic is left unset. Locked recipe IDs and splitter filter entries for locked item types are handled on placement per REQ-LOCK-UI-BLUEPRINT. After a successful placement the game remains in blueprint placement mode, allowing the player to place the same blueprint again immediately.
|
||||
- REQ-UI-BLUEPRINT-PLACE: Left-clicking in blueprint placement mode places the blueprint if (a) every building in the constellation satisfies REQ-BLD-PLACE-VALID conditions (a) and (b) at its resolved tile, and (b) the player has enough building blocks to afford the total cost. If both conditions are met, a construction site is added to the build queue for each building in the blueprint and the full total cost is deducted from the global building blocks stock in one transaction. If a recipe ID is stored for a building, it is applied to the construction site immediately. If a schematic ID is stored, it is applied only if that schematic is currently unlocked; if it is not unlocked, the shipyard's schematic is left unset. If splitter output filters are stored, they are applied to the construction site immediately and carry over when it finishes building (REQ-BLD-SITE-CONFIG). Locked recipe IDs and splitter filter entries for locked item types are handled on placement per REQ-LOCK-UI-BLUEPRINT. After a successful placement the game remains in blueprint placement mode, allowing the player to place the same blueprint again immediately.
|
||||
|
||||
- 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
|
||||
|
||||
@@ -478,7 +505,7 @@ A separate executable target (`balancing`) that links against `lib` but contains
|
||||
### 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-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
|
||||
|
||||
@@ -488,7 +515,7 @@ A separate executable target (`balancing`) that links against `lib` but contains
|
||||
- **World height** (in tiles).
|
||||
- Exactly **two teams**, each with a human-readable **team name**.
|
||||
- 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).
|
||||
- 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.
|
||||
@@ -496,20 +523,24 @@ A separate executable target (`balancing`) that links against `lib` but contains
|
||||
### Simulation
|
||||
|
||||
- REQ-BAL-SIM-ENV: Each arena simulates a pure-space environment using the same tick-based simulation as the main game. There is no asteroid, no buildings, no belts, no wave system, and no threat accumulation. Only ships, HQs, defence stations, and combat are active.
|
||||
- REQ-BAL-SIM-AI: Ships use the same AI and stats as in the main game. All ships use aggressive stance and closest-target priority. Ships with no target in sensor range advance toward the enemy team's HQ. Ships that detect an enemy in sensor range engage it as in the normal game (REQ-SHP-COMBAT, REQ-SHP-ENEMY-AI).
|
||||
- REQ-BAL-SIM-AI: Ships use the same AI and stats as in the main game. Ships with no target in sensor range advance toward the enemy team's HQ. Ships that detect an enemy in sensor range engage it as in the normal game (REQ-SHP-COMBAT, REQ-SHP-ENEMY-AI).
|
||||
- REQ-BAL-SIM-SPEED: Each arena that is not being inspected runs its simulation at maximum tick rate (as many ticks per second as the hardware allows), with no rendering. An inspected arena runs at a player-controllable game speed (same speed steps as the main game: 0×, 0.5×, 1×, 2×, 4×) with full rendering in the inspect window, defaulting to 1× on open.
|
||||
- REQ-BAL-SIM-PARALLEL: All arenas are simulated in parallel, each on its own thread.
|
||||
- REQ-BAL-SIM-END: An arena fight ends when either team's HQ is destroyed or all ships and defence stations of one team have been destroyed. If a team has no defence stations, destroying all its ships is sufficient. When the fight ends, the simulation for that arena stops.
|
||||
|
||||
### 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-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-BORDER: Each arena widget has a colored border indicating its state: grey when not yet started, blue while its simulation is running, and green when the fight has ended.
|
||||
- REQ-BAL-UI-INSPECT: Clicking an arena widget's "Inspect" button opens a new inspect window for that arena. Any previously open inspect window is closed first (its arena's simulation is aborted and its widget border returns to grey). The inspected arena is restarted with a fresh simulation that runs at controllable game speed with full rendering (REQ-BAL-SIM-SPEED). The arena widget updates live during inspection (surviving counts, border color, `[WON]` prefix) as it does for non-inspected arenas. Only one inspect window may be open at a time.
|
||||
- REQ-BAL-UI-INSPECT-WINDOW: The inspect window consists of three sections, top to bottom: a title bar area containing the arena name and game speed controls (same buttons as the main game: 0×, 0.5×, 1×, 2×, 4×, with Space to toggle pause — see REQ-UI-SPEED and REQ-UI-HOTKEYS), the arena view in the center, and an info panel at the bottom displaying the same team columns and entry format as the arena widget in the main window (REQ-BAL-UI-WIDGET), updated live.
|
||||
- REQ-BAL-UI-INSPECT-WINDOW: The inspect window consists of three sections, top to bottom: a title bar area containing the arena name and game speed controls (same buttons as the main game: 0×, 0.5×, 1×, 2×, 4×, with Space to toggle pause — see REQ-UI-SPEED and REQ-UI-HOTKEYS), the arena view in the center, and an info panel at the bottom displaying the same team columns and entry format as the arena widget in the main window (REQ-BAL-UI-WIDGET), updated live, including the arena's battle duration once the fight has ended (REQ-BAL-UI-WIDGET).
|
||||
- REQ-BAL-UI-INSPECT-VIEW: The arena view renders all tiles of the arena and displays ships, HQs, defence stations, and laser beams using the same visual elements and `visuals.toml` colors as the main game. Team 1 uses player visual styles; team 2 uses enemy visual styles. The view has a fixed zoom level — no zoom or scroll is possible. The tile size is derived so that the full arena (all tiles) fits within the view.
|
||||
- REQ-BAL-UI-INSPECT-CLOSE: Closing the inspect window (via the window's close button) aborts the inspected arena's simulation. The arena widget's border returns to grey and its surviving counts are left as they were at the moment of closing. All main window buttons and controls are re-enabled.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
set(TARGET_BASE_NAME "DotaFactory")
|
||||
set(TARGET_BASE_NAME "${PRODUCT_NAME}")
|
||||
|
||||
set(TARGET_APP_NAME "${TARGET_BASE_NAME}")
|
||||
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})
|
||||
|
||||
# Embed the Windows version resource so the version shows on the executable's
|
||||
# Details tab (right-click -> Properties). Values come from cmake/version.cmake
|
||||
# (version numbers) and the product identity variables in the top-level
|
||||
# CMakeLists.txt. MSVC compiles the .rc automatically once it is a target source.
|
||||
if (WIN32)
|
||||
configure_file(
|
||||
"${CMAKE_SOURCE_DIR}/cmake/version.rc.in"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/version.rc"
|
||||
@ONLY
|
||||
)
|
||||
target_sources(${TARGET_APP_NAME} PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/version.rc")
|
||||
endif ()
|
||||
|
||||
unset(APP_FILES)
|
||||
unset(RELATIVE_HDRS)
|
||||
unset(RELATIVE_SRCS)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <random>
|
||||
#include <string>
|
||||
|
||||
#include <QApplication>
|
||||
#include <QDir>
|
||||
@@ -8,6 +11,8 @@
|
||||
#include "logging.h"
|
||||
#include "LogManager.h"
|
||||
#include "MainWindow.h"
|
||||
#include "ReplayReader.h"
|
||||
#include "ReplayRecorder.h"
|
||||
#include "Simulation.h"
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
@@ -31,10 +36,54 @@ int main(int argc, char *argv[])
|
||||
QDir().mkdir(dataDir.dirName());
|
||||
}
|
||||
|
||||
GameConfig config = ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
std::unique_ptr<Simulation> sim = std::make_unique<Simulation>(std::move(config));
|
||||
// Optional "--replay <file>" launches view-only playback of a recorded run.
|
||||
std::optional<std::string> replayPath;
|
||||
for (int i = 1; i + 1 < argc; ++i)
|
||||
{
|
||||
if (std::string(argv[i]) == "--replay")
|
||||
{
|
||||
replayPath = argv[i + 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
MainWindow window(sim.get(), std::string(CONFIG_DIR));
|
||||
GameConfig config = ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
|
||||
unsigned int seed = 0;
|
||||
std::shared_ptr<ParsedReplay> replay;
|
||||
if (replayPath.has_value())
|
||||
{
|
||||
std::optional<ParsedReplay> parsed = readReplayFile(*replayPath);
|
||||
if (!parsed.has_value())
|
||||
{
|
||||
LOG_ERROR("Failed to read replay file: " + *replayPath);
|
||||
return 1;
|
||||
}
|
||||
// Warn (but proceed) on identity mismatches: a different config or build
|
||||
// can desync playback (see docs/replay_design.md).
|
||||
if (parsed->header.version != 1)
|
||||
{
|
||||
LOG_WARNING_STREAM(<< "Replay format version " << parsed->header.version
|
||||
<< " differs from 1; playback may fail");
|
||||
}
|
||||
if (computeReplayConfigHash(CONFIG_DIR) != parsed->header.configHash)
|
||||
{
|
||||
LOG_WARNING("Replay config hash mismatch; playback may desync");
|
||||
}
|
||||
seed = parsed->header.seed;
|
||||
replay = std::make_shared<ParsedReplay>(std::move(*parsed));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Random seed generated outside the sim so the Simulation stays a pure
|
||||
// function of (seed, config, commands); written to the replay header
|
||||
// (see docs/replay_design.md "Seed and config").
|
||||
seed = std::random_device{}();
|
||||
}
|
||||
|
||||
std::unique_ptr<Simulation> sim = std::make_unique<Simulation>(std::move(config), seed);
|
||||
|
||||
MainWindow window(sim.get(), std::string(CONFIG_DIR), replay);
|
||||
window.show();
|
||||
|
||||
const int ret = application.exec();
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
@@ -86,12 +88,49 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
|
||||
|
||||
placeStructures();
|
||||
spawnShips();
|
||||
computeTeamMaxEhp();
|
||||
|
||||
m_shipSystem->triggerRallyDeparture();
|
||||
|
||||
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;
|
||||
|
||||
BuildingId ArenaSimulation::allocateBuildingId()
|
||||
@@ -231,7 +270,7 @@ void ArenaSimulation::spawnShips()
|
||||
for (int i = 0; i < entry.count; ++i)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -248,7 +287,7 @@ void ArenaSimulation::spawnShips()
|
||||
for (int i = 0; i < entry.count; ++i)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -330,15 +369,11 @@ void ArenaSimulation::tickDeaths()
|
||||
{
|
||||
const ShipIdentityComponent& si = m_admin.get<ShipIdentityComponent>(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);
|
||||
m_scrapSystem->spawn(pos.value, def.loot.scrapDrop, despawnAt);
|
||||
break;
|
||||
}
|
||||
const Tick despawnAt = m_currentTick
|
||||
+ secondsToTicks(m_gameConfig.world.scrapDespawnSeconds);
|
||||
m_scrapSystem->spawn(pos.value, si.scrapDrop, despawnAt);
|
||||
}
|
||||
m_shipSystem->despawn(deadEntity);
|
||||
}
|
||||
@@ -398,10 +433,13 @@ void ArenaSimulation::tickDeaths()
|
||||
});
|
||||
|
||||
m_admin.forEach<StationBodyComponent, FactionComponent>(
|
||||
[&team1HasUnits, &team2HasUnits](entt::entity /*e*/,
|
||||
[this, &team1HasUnits, &team2HasUnits](entt::entity e,
|
||||
const StationBodyComponent& /*sb*/,
|
||||
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; }
|
||||
else { team1HasUnits = true; }
|
||||
});
|
||||
@@ -480,12 +518,41 @@ void ArenaSimulation::updateStatus()
|
||||
ArenaStatus newStatus;
|
||||
newStatus.finished = m_finished;
|
||||
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>(
|
||||
[¤tEhp](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, ¤tEhp](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)
|
||||
{
|
||||
ArenaStatus::TeamStatus& teamStatus = newStatus.teams[ti];
|
||||
teamStatus.name = m_arenaConfig.teams[ti].name;
|
||||
teamStatus.threatLevel = m_teamThreat[ti];
|
||||
teamStatus.currentEhp = currentEhp[ti];
|
||||
teamStatus.maxEhp = m_teamMaxEhp[ti];
|
||||
|
||||
// HQ entry (always first).
|
||||
{
|
||||
@@ -504,7 +571,7 @@ void ArenaSimulation::updateStatus()
|
||||
{
|
||||
ArenaStatus::Entry entry;
|
||||
entry.displayName = shipEntry.schematicId;
|
||||
entry.level = shipEntry.level;
|
||||
// Ships no longer carry a level (level suffix stays empty).
|
||||
entry.total = shipEntry.count;
|
||||
|
||||
int surviving = 0;
|
||||
@@ -516,7 +583,6 @@ void ArenaSimulation::updateStatus()
|
||||
{
|
||||
if (f.isEnemy == isEnemyTeam
|
||||
&& si.schematicId == shipEntry.schematicId
|
||||
&& si.level == shipEntry.level
|
||||
&& h.hp > 0.0f)
|
||||
{
|
||||
++surviving;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -32,7 +33,9 @@ struct ArenaStatus
|
||||
struct Entry
|
||||
{
|
||||
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 surviving;
|
||||
};
|
||||
@@ -41,12 +44,24 @@ struct ArenaStatus
|
||||
{
|
||||
std::string name;
|
||||
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
|
||||
|
||||
// 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];
|
||||
bool finished = false;
|
||||
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
|
||||
@@ -79,6 +94,7 @@ private:
|
||||
BuildingId allocateBuildingId();
|
||||
void placeStructures();
|
||||
void spawnShips();
|
||||
void computeTeamMaxEhp();
|
||||
void tick();
|
||||
void tickDeaths();
|
||||
void updateStatus();
|
||||
@@ -112,6 +128,10 @@ private:
|
||||
// Static accumulated threat per team, computed once from the configured roster.
|
||||
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;
|
||||
|
||||
mutable std::mutex m_statusMutex;
|
||||
|
||||
@@ -268,7 +268,7 @@ void ArenaView::mousePressEvent(QMouseEvent* event)
|
||||
|
||||
void ArenaView::keyPressEvent(QKeyEvent* event)
|
||||
{
|
||||
if (event->key() == Qt::Key_M)
|
||||
if (event->key() == Qt::Key_F3)
|
||||
{
|
||||
m_debugDraw = !m_debugDraw;
|
||||
return;
|
||||
|
||||
@@ -33,6 +33,9 @@ void ArenaWidget::buildLayout(const std::string& arenaName)
|
||||
m_titleLabel->setFont(titleFont);
|
||||
titleRow->addWidget(m_titleLabel);
|
||||
|
||||
m_durationLabel = new QLabel(this);
|
||||
titleRow->addWidget(m_durationLabel);
|
||||
|
||||
titleRow->addStretch();
|
||||
|
||||
m_inspectButton = new QPushButton(tr("Inspect"), this);
|
||||
@@ -63,6 +66,8 @@ void ArenaWidget::buildLayout(const std::string& arenaName)
|
||||
team1Layout->addWidget(m_team1Header);
|
||||
m_team1Threat = new QLabel(this);
|
||||
team1Layout->addWidget(m_team1Threat);
|
||||
m_team1Ehp = new QLabel(this);
|
||||
team1Layout->addWidget(m_team1Ehp);
|
||||
m_team1Content = new QLabel(this);
|
||||
team1Layout->addWidget(m_team1Content);
|
||||
team1Layout->addStretch();
|
||||
@@ -75,6 +80,8 @@ void ArenaWidget::buildLayout(const std::string& arenaName)
|
||||
team2Layout->addWidget(m_team2Header);
|
||||
m_team2Threat = new QLabel(this);
|
||||
team2Layout->addWidget(m_team2Threat);
|
||||
m_team2Ehp = new QLabel(this);
|
||||
team2Layout->addWidget(m_team2Ehp);
|
||||
m_team2Content = new QLabel(this);
|
||||
team2Layout->addWidget(m_team2Content);
|
||||
team2Layout->addStretch();
|
||||
@@ -99,13 +106,29 @@ void ArenaWidget::resetToGrey()
|
||||
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)
|
||||
{
|
||||
m_lastStatus = status;
|
||||
|
||||
for (int ti = 0; ti < 2; ++ti)
|
||||
{
|
||||
const ArenaStatus::TeamStatus& team = status.teams[ti];
|
||||
QLabel* header = (ti == 0) ? m_team1Header : m_team2Header;
|
||||
QLabel* threat = (ti == 0) ? m_team1Threat : m_team2Threat;
|
||||
QLabel* ehp = (ti == 0) ? m_team1Ehp : m_team2Ehp;
|
||||
QLabel* content = (ti == 0) ? m_team1Content : m_team2Content;
|
||||
|
||||
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)));
|
||||
ehp->setText(tr("EHP: %1").arg(QString::fromStdString(team.ehpPercentText())));
|
||||
|
||||
QString lines;
|
||||
for (const ArenaStatus::Entry& entry : team.entries)
|
||||
@@ -126,15 +150,22 @@ void ArenaWidget::updateStatus(const ArenaStatus& status)
|
||||
{
|
||||
lines += "\n";
|
||||
}
|
||||
lines += QString("%1/%2 %3 L%4")
|
||||
lines += QString("%1/%2 %3")
|
||||
.arg(entry.surviving)
|
||||
.arg(entry.total)
|
||||
.arg(QString::fromStdString(entry.displayName))
|
||||
.arg(entry.level);
|
||||
.arg(QString::fromStdString(entry.displayName));
|
||||
if (entry.level.has_value())
|
||||
{
|
||||
lines += QString(" L%1").arg(entry.level.value());
|
||||
}
|
||||
}
|
||||
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)
|
||||
{
|
||||
m_wasFinished = true;
|
||||
|
||||
@@ -14,21 +14,35 @@ class ArenaWidget : public QFrame
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
enum class State
|
||||
{
|
||||
NotStarted,
|
||||
Running,
|
||||
Ended
|
||||
};
|
||||
|
||||
ArenaWidget(int arenaIndex, const std::string& arenaName, QWidget* parent = nullptr);
|
||||
|
||||
void updateStatus(const ArenaStatus& status);
|
||||
void startSimulation();
|
||||
void resetToGrey();
|
||||
|
||||
const ArenaStatus& getLastStatus() const { return m_lastStatus; }
|
||||
State getState() const;
|
||||
|
||||
private:
|
||||
void buildLayout(const std::string& arenaName);
|
||||
|
||||
int m_arenaIndex;
|
||||
ArenaStatus m_lastStatus;
|
||||
QLabel* m_titleLabel;
|
||||
QLabel* m_durationLabel;
|
||||
QLabel* m_team1Header;
|
||||
QLabel* m_team2Header;
|
||||
QLabel* m_team1Threat;
|
||||
QLabel* m_team2Threat;
|
||||
QLabel* m_team1Ehp;
|
||||
QLabel* m_team2Ehp;
|
||||
QLabel* m_team1Content;
|
||||
QLabel* m_team2Content;
|
||||
QPushButton* m_inspectButton;
|
||||
|
||||
@@ -125,8 +125,6 @@ BalancingConfig loadBalancingConfig(const std::string& path)
|
||||
ArenaShipEntry entry;
|
||||
entry.schematicId = requireString((*shipTbl)["schematic"],
|
||||
sPrefix + ".schematic");
|
||||
entry.level = static_cast<int>(
|
||||
requireInt((*shipTbl)["level"], sPrefix + ".level"));
|
||||
entry.count = static_cast<int>(
|
||||
requireInt((*shipTbl)["count"], sPrefix + ".count"));
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ struct ArenaStationEntry
|
||||
struct ArenaShipEntry
|
||||
{
|
||||
std::string schematicId;
|
||||
int level;
|
||||
int count;
|
||||
std::optional<ShipLayoutConfig> layout;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,70 @@
|
||||
#include "BalancingWindow.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include <QFile>
|
||||
#include <QHBoxLayout>
|
||||
#include <QMessageBox>
|
||||
#include <QTextStream>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "ConfigLoader.h"
|
||||
#include "InspectWindow.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,
|
||||
GameConfig gameConfig,
|
||||
const std::string& configDir,
|
||||
@@ -31,13 +88,16 @@ BalancingWindow::BalancingWindow(const BalancingConfig& balancingConfig,
|
||||
QHBoxLayout* buttonRow = new QHBoxLayout();
|
||||
m_reloadButton = new QPushButton(tr("Reload Config"), this);
|
||||
m_startAllButton = new QPushButton(tr("Start All"), this);
|
||||
m_logButton = new QPushButton(tr("Log"), this);
|
||||
buttonRow->addWidget(m_reloadButton);
|
||||
buttonRow->addWidget(m_startAllButton);
|
||||
buttonRow->addWidget(m_logButton);
|
||||
buttonRow->addStretch();
|
||||
mainLayout->addLayout(buttonRow);
|
||||
|
||||
connect(m_reloadButton, &QPushButton::clicked, this, &BalancingWindow::reloadConfig);
|
||||
connect(m_startAllButton, &QPushButton::clicked, this, &BalancingWindow::startAll);
|
||||
connect(m_logButton, &QPushButton::clicked, this, &BalancingWindow::writeLog);
|
||||
|
||||
m_scrollArea = new QScrollArea(this);
|
||||
m_scrollArea->setWidgetResizable(true);
|
||||
@@ -255,6 +315,7 @@ void BalancingWindow::setMainControlsEnabled(bool enabled)
|
||||
{
|
||||
m_reloadButton->setEnabled(enabled);
|
||||
m_startAllButton->setEnabled(enabled);
|
||||
m_logButton->setEnabled(enabled);
|
||||
for (ArenaEntry& entry : m_arenas)
|
||||
{
|
||||
for (QPushButton* btn : entry.widget->findChildren<QPushButton*>())
|
||||
@@ -291,4 +352,58 @@ void BalancingWindow::updateButtons()
|
||||
|
||||
m_reloadButton->setEnabled(!anyRunning);
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ private slots:
|
||||
void pollStatuses();
|
||||
void reloadConfig();
|
||||
void startAll();
|
||||
void writeLog();
|
||||
|
||||
private:
|
||||
void startArena(int index);
|
||||
@@ -72,6 +73,7 @@ private:
|
||||
unsigned int m_nextSeed;
|
||||
QPushButton* m_reloadButton;
|
||||
QPushButton* m_startAllButton;
|
||||
QPushButton* m_logButton;
|
||||
QScrollArea* m_scrollArea;
|
||||
QTimer* m_pollTimer;
|
||||
|
||||
|
||||
@@ -52,6 +52,9 @@ InspectWindow::InspectWindow(ArenaSimulation* sim, const GameConfig* config,
|
||||
nameLabel->setFont(nameFont);
|
||||
headerLayout->addWidget(nameLabel);
|
||||
|
||||
m_durationLabel = new QLabel(header);
|
||||
headerLayout->addWidget(m_durationLabel);
|
||||
|
||||
headerLayout->addStretch();
|
||||
|
||||
const char* labels[] = { "0x", "0.5x", "1x", "2x", "10x" };
|
||||
@@ -93,6 +96,8 @@ InspectWindow::InspectWindow(ArenaSimulation* sim, const GameConfig* config,
|
||||
team1Layout->addWidget(m_team1Header);
|
||||
m_team1Threat = new QLabel(infoPanel);
|
||||
team1Layout->addWidget(m_team1Threat);
|
||||
m_team1Ehp = new QLabel(infoPanel);
|
||||
team1Layout->addWidget(m_team1Ehp);
|
||||
m_team1Content = new QLabel(infoPanel);
|
||||
team1Layout->addWidget(m_team1Content);
|
||||
team1Layout->addStretch();
|
||||
@@ -104,6 +109,8 @@ InspectWindow::InspectWindow(ArenaSimulation* sim, const GameConfig* config,
|
||||
team2Layout->addWidget(m_team2Header);
|
||||
m_team2Threat = new QLabel(infoPanel);
|
||||
team2Layout->addWidget(m_team2Threat);
|
||||
m_team2Ehp = new QLabel(infoPanel);
|
||||
team2Layout->addWidget(m_team2Ehp);
|
||||
m_team2Content = new QLabel(infoPanel);
|
||||
team2Layout->addWidget(m_team2Content);
|
||||
team2Layout->addStretch();
|
||||
@@ -198,11 +205,16 @@ void InspectWindow::pollStatus()
|
||||
|
||||
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)
|
||||
{
|
||||
const ArenaStatus::TeamStatus& team = status.teams[ti];
|
||||
QLabel* header = (ti == 0) ? m_team1Header : m_team2Header;
|
||||
QLabel* threat = (ti == 0) ? m_team1Threat : m_team2Threat;
|
||||
QLabel* ehp = (ti == 0) ? m_team1Ehp : m_team2Ehp;
|
||||
QLabel* content = (ti == 0) ? m_team1Content : m_team2Content;
|
||||
|
||||
if (status.finished && status.winnerTeam == ti)
|
||||
@@ -215,6 +227,7 @@ void InspectWindow::updateInfoPanel(const ArenaStatus& status)
|
||||
}
|
||||
|
||||
threat->setText(tr("Threat: %1").arg(QString::number(team.threatLevel, 'f', 0)));
|
||||
ehp->setText(tr("EHP: %1").arg(QString::fromStdString(team.ehpPercentText())));
|
||||
|
||||
QString lines;
|
||||
for (const ArenaStatus::Entry& entry : team.entries)
|
||||
@@ -223,11 +236,14 @@ void InspectWindow::updateInfoPanel(const ArenaStatus& status)
|
||||
{
|
||||
lines += "\n";
|
||||
}
|
||||
lines += QString("%1/%2 %3 L%4")
|
||||
lines += QString("%1/%2 %3")
|
||||
.arg(entry.surviving)
|
||||
.arg(entry.total)
|
||||
.arg(QString::fromStdString(entry.displayName))
|
||||
.arg(entry.level);
|
||||
.arg(QString::fromStdString(entry.displayName));
|
||||
if (entry.level.has_value())
|
||||
{
|
||||
lines += QString(" L%1").arg(entry.level.value());
|
||||
}
|
||||
}
|
||||
content->setText(lines);
|
||||
}
|
||||
@@ -256,9 +272,8 @@ void InspectWindow::handleEvent(std::shared_ptr<const EntitySelectedEvent> event
|
||||
const ShipIdentityComponent& identity = admin.get<ShipIdentityComponent>(entity);
|
||||
const HealthComponent& health = admin.get<HealthComponent>(entity);
|
||||
|
||||
m_entityTitleLabel->setText(tr("Ship: %1 (Lv %2)")
|
||||
.arg(QString::fromStdString(identity.schematicId))
|
||||
.arg(identity.level));
|
||||
m_entityTitleLabel->setText(tr("Ship: %1")
|
||||
.arg(QString::fromStdString(identity.schematicId)));
|
||||
m_entityTitleLabel->show();
|
||||
|
||||
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
|
||||
|
||||
@@ -54,10 +54,13 @@ private:
|
||||
ArenaView* m_arenaView;
|
||||
|
||||
std::vector<QPushButton*> m_speedButtons;
|
||||
QLabel* m_durationLabel;
|
||||
QLabel* m_team1Header;
|
||||
QLabel* m_team2Header;
|
||||
QLabel* m_team1Threat;
|
||||
QLabel* m_team2Threat;
|
||||
QLabel* m_team1Ehp;
|
||||
QLabel* m_team2Ehp;
|
||||
QLabel* m_team1Content;
|
||||
QLabel* m_team2Content;
|
||||
QTimer* m_pollTimer;
|
||||
|
||||
@@ -51,6 +51,24 @@ std::string serialize(const std::vector<Blueprint>& blueprints)
|
||||
bldTbl.insert("offset_x", static_cast<int64_t>(b.offset.x()));
|
||||
bldTbl.insert("offset_y", static_cast<int64_t>(b.offset.y()));
|
||||
bldTbl.insert("recipe_id", b.recipeId);
|
||||
if (!b.splitterFilterA.empty())
|
||||
{
|
||||
toml::array filterArr;
|
||||
for (const ItemType& item : b.splitterFilterA)
|
||||
{
|
||||
filterArr.push_back(item.id);
|
||||
}
|
||||
bldTbl.insert("filter_a", std::move(filterArr));
|
||||
}
|
||||
if (!b.splitterFilterB.empty())
|
||||
{
|
||||
toml::array filterArr;
|
||||
for (const ItemType& item : b.splitterFilterB)
|
||||
{
|
||||
filterArr.push_back(item.id);
|
||||
}
|
||||
bldTbl.insert("filter_b", std::move(filterArr));
|
||||
}
|
||||
if (b.shipLayout.has_value())
|
||||
{
|
||||
toml::array modArr;
|
||||
@@ -138,6 +156,28 @@ std::vector<Blueprint> deserialize(const std::string& tomlContent)
|
||||
bb.offset.setX(static_cast<int>((*bldTbl)["offset_x"].value_or(int64_t{0})));
|
||||
bb.offset.setY(static_cast<int>((*bldTbl)["offset_y"].value_or(int64_t{0})));
|
||||
bb.recipeId = (*bldTbl)["recipe_id"].value_or(std::string{});
|
||||
|
||||
const toml::array* filterAArr = (*bldTbl)["filter_a"].as_array();
|
||||
if (filterAArr)
|
||||
{
|
||||
for (std::size_t k = 0; k < filterAArr->size(); ++k)
|
||||
{
|
||||
const std::optional<std::string> itemId =
|
||||
(*filterAArr)[k].value<std::string>();
|
||||
if (itemId) { bb.splitterFilterA.push_back(ItemType{*itemId}); }
|
||||
}
|
||||
}
|
||||
const toml::array* filterBArr = (*bldTbl)["filter_b"].as_array();
|
||||
if (filterBArr)
|
||||
{
|
||||
for (std::size_t k = 0; k < filterBArr->size(); ++k)
|
||||
{
|
||||
const std::optional<std::string> itemId =
|
||||
(*filterBArr)[k].value<std::string>();
|
||||
if (itemId) { bb.splitterFilterB.push_back(ItemType{*itemId}); }
|
||||
}
|
||||
}
|
||||
|
||||
const toml::array* modArr = (*bldTbl)["modules"].as_array();
|
||||
if (modArr)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -18,6 +19,10 @@ struct BuildingDef
|
||||
// Stored as raw strings here; parsing into per-cell tiles + output ports
|
||||
// happens when buildings are placed, not at load time.
|
||||
std::vector<std::string> surfaceMask;
|
||||
|
||||
// Output-buffer holding size for buildings without a recipe-driven buffer.
|
||||
// Only the Salvage Bay sets this (REQ-BLD-SALVAGE-BAY).
|
||||
std::optional<int> outputBufferCapacity;
|
||||
};
|
||||
|
||||
struct BuildingsConfig
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
#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.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.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.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"));
|
||||
@@ -276,14 +278,13 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path)
|
||||
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.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.columnsPerExpansion_tiles = static_cast<int>(requireInt(tbl["expansion"]["columns_per_expansion_tiles"], file, "expansion.columns_per_expansion_tiles"));
|
||||
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.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.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.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");
|
||||
@@ -301,6 +302,13 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path)
|
||||
cfg.targeting.overclaimPenaltyFormula = requireFormula(tbl["targeting"]["overclaim_penalty_formula"], file, "targeting.overclaim_penalty_formula");
|
||||
cfg.targeting.hysteresis = requireDouble(tbl["targeting"]["target_hysteresis"], file, "targeting.target_hysteresis");
|
||||
|
||||
cfg.artifacts.artifactChanceFormula = requireFormula(tbl["artifacts"]["artifact_chance_formula"], file, "artifacts.artifact_chance_formula");
|
||||
cfg.artifacts.artifactWinCount = static_cast<int>(requireInt(tbl["artifacts"]["artifact_win_count"], file, "artifacts.artifact_win_count"));
|
||||
|
||||
cfg.scroll.panSpeedSlow_tps = requireDouble(tbl["scroll"]["pan_speed_slow_tiles_per_second"], file, "scroll.pan_speed_slow_tiles_per_second");
|
||||
cfg.scroll.panSpeedFast_tps = requireDouble(tbl["scroll"]["pan_speed_fast_tiles_per_second"], file, "scroll.pan_speed_fast_tiles_per_second");
|
||||
cfg.scroll.panRampBandWidth_tiles = static_cast<int>(requireInt(tbl["scroll"]["pan_ramp_band_width_tiles"], file, "scroll.pan_ramp_band_width_tiles"));
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
||||
@@ -329,6 +337,12 @@ BuildingsConfig ConfigLoader::loadBuildings(const std::string& path)
|
||||
def.constructionTimeSeconds = requireDouble(mt["construction_time_seconds"], file, elemPath + ".construction_time_seconds");
|
||||
def.surfaceMask = requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask");
|
||||
|
||||
if (mt.contains("output_buffer_capacity"))
|
||||
{
|
||||
def.outputBufferCapacity = static_cast<int>(
|
||||
requireInt(mt["output_buffer_capacity"], file, elemPath + ".output_buffer_capacity"));
|
||||
}
|
||||
|
||||
const std::optional<BuildingType> parsedType = parseBuildingType(def.id);
|
||||
if (!parsedType)
|
||||
{
|
||||
@@ -380,6 +394,11 @@ RecipesConfig ConfigLoader::loadRecipes(const std::string& path)
|
||||
{
|
||||
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.
|
||||
@@ -419,6 +438,10 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path)
|
||||
ShipDef def;
|
||||
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"));
|
||||
if (mt.contains("unlock_requires"))
|
||||
{
|
||||
def.unlockRequires = requireStringArray(mt["unlock_requires"], file, elemPath + ".unlock_requires");
|
||||
}
|
||||
def.layout = requireStringArray(mt["layout"], file, elemPath + ".layout");
|
||||
|
||||
// Schematic
|
||||
@@ -429,8 +452,6 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path)
|
||||
|
||||
const toml::array& materials = requireArray(bpMt["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(
|
||||
bpMt["production_time_seconds"], file, bpPath + ".production_time_seconds");
|
||||
}
|
||||
@@ -440,7 +461,7 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path)
|
||||
const std::string hPath = elemPath + ".health";
|
||||
const toml::table& hTable = requireTable(mt["health"], file, hPath);
|
||||
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
|
||||
@@ -448,11 +469,11 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path)
|
||||
const std::string mPath = elemPath + ".movement";
|
||||
const toml::table& mTable = requireTable(mt["movement"], file, mPath);
|
||||
toml::table& mMt = const_cast<toml::table&>(mTable);
|
||||
def.movement.speedFormula = requireFormula(mMt["speed_mps_formula"], file, mPath + ".speed_mps_formula");
|
||||
def.movement.mainAccelerationFormula = requireFormula(mMt["main_acceleration_mpss_formula"], file, mPath + ".main_acceleration_mpss_formula");
|
||||
def.movement.maneuveringAccelerationFormula = requireFormula(mMt["maneuvering_acceleration_mpss_formula"], file, mPath + ".maneuvering_acceleration_mpss_formula");
|
||||
def.movement.angularAccelerationFormula = requireFormula(mMt["angular_acceleration_radpss_formula"], file, mPath + ".angular_acceleration_radpss_formula");
|
||||
def.movement.maxRotationSpeedFormula = requireFormula(mMt["max_rotation_speed_radps_formula"], file, mPath + ".max_rotation_speed_radps_formula");
|
||||
def.movement.speed_mps = static_cast<float>(requireDouble(mMt["speed_mps"], file, mPath + ".speed_mps"));
|
||||
def.movement.mainAcceleration_mpss = static_cast<float>(requireDouble(mMt["main_acceleration_mpss"], file, mPath + ".main_acceleration_mpss"));
|
||||
def.movement.maneuveringAcceleration_mpss = static_cast<float>(requireDouble(mMt["maneuvering_acceleration_mpss"], file, mPath + ".maneuvering_acceleration_mpss"));
|
||||
def.movement.angularAcceleration_radpss = static_cast<float>(requireDouble(mMt["angular_acceleration_radpss"], file, mPath + ".angular_acceleration_radpss"));
|
||||
def.movement.maxRotationSpeed_radps = static_cast<float>(requireDouble(mMt["max_rotation_speed_radps"], file, mPath + ".max_rotation_speed_radps"));
|
||||
}
|
||||
|
||||
// Sensor
|
||||
@@ -460,15 +481,7 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path)
|
||||
const std::string snsPath = elemPath + ".sensor";
|
||||
const toml::table& snsTable = requireTable(mt["sensor"], file, snsPath);
|
||||
toml::table& snsMt = const_cast<toml::table&>(snsTable);
|
||||
def.sensor.sensorRangeFormula = requireFormula(snsMt["sensor_range_m_formula"], file, snsPath + ".sensor_range_m_formula");
|
||||
}
|
||||
|
||||
// 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"));
|
||||
def.sensor.sensorRange_m = static_cast<float>(requireDouble(snsMt["sensor_range_m"], file, snsPath + ".sensor_range_m"));
|
||||
}
|
||||
|
||||
// Optional: default_modules (REQ-WAV-DEFAULT-MODULES)
|
||||
@@ -580,9 +593,11 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
|
||||
def.id = requireString(mt["id"], file, elemPath + ".id");
|
||||
def.unlockAtStationLevel = static_cast<int>(
|
||||
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.playerProductionLevel = static_cast<int>(requireInt(
|
||||
mt["player_production_level"], file, elemPath + ".player_production_level"));
|
||||
def.productionTimeSeconds = requireDouble(
|
||||
mt["production_time_seconds"], file, elemPath + ".production_time_seconds");
|
||||
def.fillColor = requireString(mt["fill_color"], file, elemPath + ".fill_color");
|
||||
@@ -605,15 +620,15 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
|
||||
elemPath + "." + se.category);
|
||||
toml::table& catMt = const_cast<toml::table&>(catTable);
|
||||
|
||||
const std::string addedKey = std::string("added_") + se.stat + se.addedKeySuffix + "_formula";
|
||||
const std::string multipliedKey = std::string("multiplied_") + 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;
|
||||
|
||||
if (catMt.contains(addedKey))
|
||||
{
|
||||
ModuleStatModifier mod;
|
||||
mod.stat = se.stat;
|
||||
mod.modifierType = "additive";
|
||||
mod.formula = requireFormula(catMt[addedKey], file,
|
||||
mod.value = requireDouble(catMt[addedKey], file,
|
||||
elemPath + "." + se.category + "." + addedKey);
|
||||
def.statModifiers.push_back(std::move(mod));
|
||||
}
|
||||
@@ -623,7 +638,7 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
|
||||
ModuleStatModifier mod;
|
||||
mod.stat = se.stat;
|
||||
mod.modifierType = "multiplicative";
|
||||
mod.formula = requireFormula(catMt[multipliedKey], file,
|
||||
mod.value = requireDouble(catMt[multipliedKey], file,
|
||||
elemPath + "." + se.category + "." + multipliedKey);
|
||||
def.statModifiers.push_back(std::move(mod));
|
||||
}
|
||||
@@ -635,16 +650,16 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
|
||||
const std::string wPath = elemPath + ".weapon";
|
||||
const toml::table& wTable = requireTable(mt["weapon"], file, wPath);
|
||||
toml::table& wMt = const_cast<toml::table&>(wTable);
|
||||
if (wMt.contains("damage_formula") || wMt.contains("attack_range_m_formula")
|
||||
|| wMt.contains("attack_rate_hz_formula"))
|
||||
if (wMt.contains("damage") || wMt.contains("attack_range_m")
|
||||
|| wMt.contains("attack_rate_hz"))
|
||||
{
|
||||
ModuleWeaponCapability cap;
|
||||
cap.damageFormula = requireFormula(wMt["damage_formula"],
|
||||
file, wPath + ".damage_formula");
|
||||
cap.attackRangeFormula = requireFormula(wMt["attack_range_m_formula"],
|
||||
file, wPath + ".attack_range_m_formula");
|
||||
cap.attackRateFormula = requireFormula(wMt["attack_rate_hz_formula"],
|
||||
file, wPath + ".attack_rate_hz_formula");
|
||||
cap.damage = static_cast<float>(requireDouble(wMt["damage"],
|
||||
file, wPath + ".damage"));
|
||||
cap.attackRange_m = static_cast<float>(requireDouble(wMt["attack_range_m"],
|
||||
file, wPath + ".attack_range_m"));
|
||||
cap.attackRate_hz = static_cast<float>(requireDouble(wMt["attack_rate_hz"],
|
||||
file, wPath + ".attack_rate_hz"));
|
||||
def.weaponCapability = std::move(cap);
|
||||
}
|
||||
}
|
||||
@@ -655,16 +670,16 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
|
||||
const std::string sPath = elemPath + ".salvage";
|
||||
const toml::table& sTable = requireTable(mt["salvage"], file, sPath);
|
||||
toml::table& sMt = const_cast<toml::table&>(sTable);
|
||||
if (sMt.contains("collection_range_m_formula") || sMt.contains("cargo_capacity_formula")
|
||||
|| sMt.contains("collection_rate_hz_formula"))
|
||||
if (sMt.contains("collection_range_m") || sMt.contains("cargo_capacity")
|
||||
|| sMt.contains("collection_rate_hz"))
|
||||
{
|
||||
ModuleSalvageCapability cap;
|
||||
cap.collectionRangeFormula = requireFormula(sMt["collection_range_m_formula"],
|
||||
file, sPath + ".collection_range_m_formula");
|
||||
cap.cargoCapacityFormula = requireFormula(sMt["cargo_capacity_formula"],
|
||||
file, sPath + ".cargo_capacity_formula");
|
||||
cap.collectionRateFormula = requireFormula(sMt["collection_rate_hz_formula"],
|
||||
file, sPath + ".collection_rate_hz_formula");
|
||||
cap.collectionRange_m = static_cast<float>(requireDouble(sMt["collection_range_m"],
|
||||
file, sPath + ".collection_range_m"));
|
||||
cap.cargoCapacity = static_cast<float>(requireDouble(sMt["cargo_capacity"],
|
||||
file, sPath + ".cargo_capacity"));
|
||||
cap.collectionRate_hz = static_cast<float>(requireDouble(sMt["collection_rate_hz"],
|
||||
file, sPath + ".collection_rate_hz"));
|
||||
def.salvageCapability = std::move(cap);
|
||||
}
|
||||
}
|
||||
@@ -675,15 +690,15 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
|
||||
const std::string rPath = elemPath + ".repair";
|
||||
const toml::table& rTable = requireTable(mt["repair"], file, rPath);
|
||||
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;
|
||||
cap.repairRateFormula = requireFormula(rMt["repair_rate_hz_formula"],
|
||||
file, rPath + ".repair_rate_hz_formula");
|
||||
cap.repairAmountHpFormula = requireFormula(rMt["repair_amount_hp_formula"],
|
||||
file, rPath + ".repair_amount_hp_formula");
|
||||
cap.repairRangeFormula = requireFormula(rMt["repair_range_m_formula"],
|
||||
file, rPath + ".repair_range_m_formula");
|
||||
cap.repairRate_hz = static_cast<float>(requireDouble(rMt["repair_rate_hz"],
|
||||
file, rPath + ".repair_rate_hz"));
|
||||
cap.repairAmountHp = static_cast<float>(requireDouble(rMt["repair_amount_hp"],
|
||||
file, rPath + ".repair_amount_hp"));
|
||||
cap.repairRange_m = static_cast<float>(requireDouble(rMt["repair_range_m"],
|
||||
file, rPath + ".repair_range_m"));
|
||||
def.repairCapability = std::move(cap);
|
||||
}
|
||||
}
|
||||
@@ -694,6 +709,67 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
|
||||
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 cfg;
|
||||
@@ -703,6 +779,7 @@ GameConfig ConfigLoader::loadFromDirectory(const std::string& configDir)
|
||||
cfg.ships = loadShips(configDir + "/ships.toml");
|
||||
cfg.stations = loadStations(configDir + "/stations.toml");
|
||||
cfg.modules = loadModules(configDir + "/modules.toml");
|
||||
validateUnlockRequires(cfg);
|
||||
cfg.threatCosts = computeThreatCostTable(cfg);
|
||||
return cfg;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Formula.h"
|
||||
#include "RecipesConfig.h"
|
||||
|
||||
// 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 modifierType; // "additive" or "multiplicative"
|
||||
Formula formula;
|
||||
double value;
|
||||
};
|
||||
|
||||
// Capability sections — present when the module grants that capability.
|
||||
struct ModuleWeaponCapability
|
||||
{
|
||||
Formula damageFormula;
|
||||
Formula attackRangeFormula;
|
||||
Formula attackRateFormula;
|
||||
float damage;
|
||||
float attackRange_m;
|
||||
float attackRate_hz;
|
||||
};
|
||||
|
||||
struct ModuleSalvageCapability
|
||||
{
|
||||
Formula collectionRangeFormula;
|
||||
Formula cargoCapacityFormula;
|
||||
Formula collectionRateFormula;
|
||||
float collectionRange_m;
|
||||
float cargoCapacity;
|
||||
float collectionRate_hz;
|
||||
};
|
||||
|
||||
struct ModuleRepairCapability
|
||||
{
|
||||
Formula repairRateFormula; // repair cycles per second
|
||||
Formula repairAmountHpFormula; // HP restored per cycle
|
||||
Formula repairRangeFormula;
|
||||
float repairRate_hz; // repair cycles per second
|
||||
float repairAmountHp; // HP restored per cycle
|
||||
float repairRange_m;
|
||||
};
|
||||
|
||||
struct ModuleDef
|
||||
{
|
||||
std::string id;
|
||||
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<RecipeIngredient> materials;
|
||||
int playerProductionLevel;
|
||||
double productionTimeSeconds;
|
||||
std::string fillColor;
|
||||
std::string glyph;
|
||||
|
||||
@@ -36,6 +36,10 @@ struct RecipeDef
|
||||
// at game start. >= 0 = locked; schematic enters drop pool at that station
|
||||
// level once the output item is implicitly unlocked (REQ-LOCK-EXPLICIT).
|
||||
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
|
||||
|
||||
@@ -3,55 +3,48 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Formula.h"
|
||||
#include "RecipesConfig.h" // for RecipeIngredient
|
||||
#include "ShipLayout.h" // for PlacedModule
|
||||
|
||||
// Build materials and initial per-schematic production level
|
||||
// (REQ-BLD-SHIPYARD, REQ-DEF-SCHEMATIC-DROP).
|
||||
// Build materials and base production time (REQ-BLD-SHIPYARD, REQ-DEF-SCHEMATIC-DROP).
|
||||
struct ShipSchematic
|
||||
{
|
||||
std::vector<RecipeIngredient> materials;
|
||||
int playerProductionLevel;
|
||||
double productionTimeSeconds;
|
||||
};
|
||||
|
||||
struct ShipHealth
|
||||
{
|
||||
Formula hpFormula; // REQ-SHP-STATS
|
||||
float hp; // REQ-SHP-STATS
|
||||
};
|
||||
|
||||
struct ShipMovement
|
||||
{
|
||||
Formula speedFormula; // max linear speed cap, tiles/s (REQ-SHP-STATS, REQ-SHP-MOVEMENT)
|
||||
Formula mainAccelerationFormula; // forward acceleration, tiles/s²
|
||||
Formula maneuveringAccelerationFormula;// omnidirectional acceleration, tiles/s²
|
||||
Formula angularAccelerationFormula; // angular acceleration, rad/s²
|
||||
Formula maxRotationSpeedFormula; // angular velocity cap, rad/s
|
||||
float speed_mps; // max linear speed cap, m/s (REQ-SHP-STATS, REQ-SHP-MOVEMENT)
|
||||
float mainAcceleration_mpss; // forward acceleration, m/s²
|
||||
float maneuveringAcceleration_mpss;// omnidirectional acceleration, m/s²
|
||||
float angularAcceleration_radpss; // angular acceleration, rad/s²
|
||||
float maxRotationSpeed_radps; // angular velocity cap, rad/s
|
||||
};
|
||||
|
||||
struct ShipSensor
|
||||
{
|
||||
Formula sensorRangeFormula; // REQ-SHP-SENSOR, REQ-SHP-STATS
|
||||
};
|
||||
|
||||
// Scrap dropped on destruction (REQ-RES-SCRAP-DROP).
|
||||
struct ShipLoot
|
||||
{
|
||||
int scrapDrop;
|
||||
float sensorRange_m; // REQ-SHP-SENSOR, REQ-SHP-STATS
|
||||
};
|
||||
|
||||
struct ShipDef
|
||||
{
|
||||
std::string id;
|
||||
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;
|
||||
|
||||
ShipSchematic schematic;
|
||||
ShipHealth health;
|
||||
ShipMovement movement;
|
||||
ShipSensor sensor;
|
||||
ShipLoot loot;
|
||||
|
||||
// Module layout used for enemy wave ships (REQ-WAV-DEFAULT-MODULES).
|
||||
std::vector<PlacedModule> defaultModules;
|
||||
|
||||
@@ -14,8 +14,8 @@ struct WorldRegions
|
||||
// Asteroid expansion (REQ-EXP-UNLOCK, REQ-EXP-COST).
|
||||
struct WorldExpansion
|
||||
{
|
||||
int columnsPerExpansion_tiles;
|
||||
int costBuildingBlocks;
|
||||
int columnsPerExpansion_tiles;
|
||||
Formula costBuildingBlocksFormula; // cost in building blocks; x = expansions already purchased
|
||||
};
|
||||
|
||||
// Push effects (REQ-PSH-*, REQ-WAV-BOSS-ADVANCE).
|
||||
@@ -29,7 +29,6 @@ struct WorldPush
|
||||
struct WorldWaves
|
||||
{
|
||||
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 gapMaxSeconds;
|
||||
double spawnDurationSeconds;
|
||||
@@ -47,12 +46,28 @@ struct WorldTargeting
|
||||
double hysteresis; // fractional margin a challenger must beat the current target by
|
||||
};
|
||||
|
||||
// Artifact win condition (REQ-WIN-ARTIFACT-COUNT, REQ-WIN-SCREEN).
|
||||
struct WorldArtifacts
|
||||
{
|
||||
Formula artifactChanceFormula; // x = station level, result clamped to [0,1]
|
||||
int artifactWinCount;
|
||||
};
|
||||
|
||||
// View pan speed (REQ-UI-SCROLL-SPEED). Presentation-only; the simulation ignores these.
|
||||
struct WorldScroll
|
||||
{
|
||||
double panSpeedSlow_tps; // tiles/s, used outside the contest zone
|
||||
double panSpeedFast_tps; // tiles/s, used inside the contest zone
|
||||
int panRampBandWidth_tiles; // full width of the ramp band straddling each contest-zone boundary
|
||||
};
|
||||
|
||||
struct WorldConfig
|
||||
{
|
||||
int heightTiles; // REQ-GW-HEIGHT
|
||||
int refundPercentage; // REQ-BLD-DEMOLISH
|
||||
int startingBuildingBlocks; // REQ-HQ-STARTING-BLOCKS
|
||||
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 beltSpeed_tps; // REQ-GW-BELT-SPEED (tiles/s, converted from m/s in config)
|
||||
int tunnelMaxDistance_tiles; // REQ-BLD-TUNNEL-PAIR
|
||||
@@ -65,4 +80,6 @@ struct WorldConfig
|
||||
WorldPush push;
|
||||
WorldWaves waves;
|
||||
WorldTargeting targeting;
|
||||
WorldArtifacts artifacts;
|
||||
WorldScroll scroll;
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <QString>
|
||||
|
||||
#include "BuildingType.h"
|
||||
#include "ItemType.h"
|
||||
#include "Rotation.h"
|
||||
#include "ShipLayout.h"
|
||||
|
||||
@@ -18,6 +19,11 @@ struct BlueprintBuilding
|
||||
QPoint offset; // tile offset from bounding-box center (floor for even sizes)
|
||||
std::string recipeId; // empty = none selected
|
||||
std::optional<ShipLayoutConfig> shipLayout;
|
||||
|
||||
// Splitter output filters captured at blueprint creation (REQ-UI-BLUEPRINT-STORAGE).
|
||||
// Empty = accept all. Re-applied to the placed splitter site (REQ-UI-BLUEPRINT-PLACE).
|
||||
std::vector<ItemType> splitterFilterA;
|
||||
std::vector<ItemType> splitterFilterB;
|
||||
};
|
||||
|
||||
struct Blueprint
|
||||
|
||||
@@ -42,7 +42,7 @@ entt::entity EntityAdmin::spawnShip(QVector2D position, float hp, float maxHp,
|
||||
float maxSpeed_tpt, float mainAcceleration_tptt,
|
||||
float maneuveringAcceleration_tptt, float maxAngularAcceleration_rptt,
|
||||
float maxRotationSpeed_rpt, float sensorRange_tiles,
|
||||
int level, const std::string& schematicId, bool isEnemy)
|
||||
const std::string& schematicId, bool isEnemy)
|
||||
{
|
||||
entt::entity entity = createEntity();
|
||||
add<PositionComponent>(entity, PositionComponent{position});
|
||||
@@ -61,7 +61,7 @@ entt::entity EntityAdmin::spawnShip(QVector2D position, float hp, float maxHp,
|
||||
0.0f // angularAcceleration_rptt
|
||||
});
|
||||
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)});
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ public:
|
||||
float maxSpeed_tpt, float mainAcceleration_tptt,
|
||||
float maneuveringAcceleration_tptt, float maxAngularAcceleration_rptt,
|
||||
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,
|
||||
const std::vector<QPoint>& bodyCells,
|
||||
|
||||
@@ -7,7 +7,8 @@ enum class SchematicType
|
||||
{
|
||||
Ship,
|
||||
Module,
|
||||
Recipe
|
||||
Recipe,
|
||||
Artifact
|
||||
};
|
||||
|
||||
// One option presented to the player in the schematic choice dialog
|
||||
@@ -18,11 +19,9 @@ struct SchematicChoiceOption
|
||||
std::string schematicId;
|
||||
SchematicType type;
|
||||
std::string displayName;
|
||||
bool isNewUnlock;
|
||||
int targetLevel;
|
||||
|
||||
// Display names of items produced by recipes that would newly become
|
||||
// implicitly unlocked (REQ-LOCK-IMPLICIT) if this option is selected.
|
||||
// Deduplicated and sorted alphabetically; empty if none.
|
||||
std::vector<std::string> newlyUnlockedItemNames;
|
||||
// Ids of miner/assembler recipes that would newly become implicitly
|
||||
// unlocked (REQ-LOCK-IMPLICIT) if this option is selected. Sorted
|
||||
// alphabetically by display name; empty if none.
|
||||
std::vector<std::string> newlyUnlockedRecipeIds;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
struct ShipIdentityComponent
|
||||
{
|
||||
int level;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#include "ShipSystem.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <map>
|
||||
#include <stdexcept>
|
||||
#include <utility>
|
||||
@@ -26,7 +28,9 @@
|
||||
#include "SalvagerComponent.h"
|
||||
#include "SelectedBehaviorComponent.h"
|
||||
#include "SensorRangeComponent.h"
|
||||
#include "ShipIdentityComponent.h"
|
||||
#include "StandbyBehavior.h"
|
||||
#include "ThreatCostCalculator.h"
|
||||
#include "Tick.h"
|
||||
#include "tracing.h"
|
||||
#include "WeaponComponent.h"
|
||||
@@ -61,48 +65,46 @@ const ModuleDef* ShipSystem::findModuleDef(const std::string& id) const
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
|
||||
entt::entity ShipSystem::spawn(const std::string& schematicId,
|
||||
QVector2D position, bool isEnemy,
|
||||
const std::optional<ShipLayoutConfig>& layout,
|
||||
const std::map<std::string, int>& moduleLevelOverrides)
|
||||
const std::optional<ShipLayoutConfig>& layout)
|
||||
{
|
||||
const ShipDef* def = findShipDef(schematicId);
|
||||
assert(def != nullptr);
|
||||
|
||||
const double x = static_cast<double>(level);
|
||||
const float tickRate = static_cast<float>(kTickRateHz);
|
||||
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 maxSpeed_tpt = static_cast<float>(def->movement.speedFormula.evaluate(x))
|
||||
/ tileSize / tickRate;
|
||||
float mainAcceleration_tptt = static_cast<float>(
|
||||
def->movement.mainAccelerationFormula.evaluate(x))
|
||||
/ tileSize / tickRate;
|
||||
float maneuveringAcceleration_tptt = static_cast<float>(
|
||||
def->movement.maneuveringAccelerationFormula.evaluate(x))
|
||||
float maxSpeed_tpt = def->movement.speed_mps / tileSize / tickRate;
|
||||
float mainAcceleration_tptt = def->movement.mainAcceleration_mpss / tileSize / tickRate;
|
||||
float maneuveringAcceleration_tptt = def->movement.maneuveringAcceleration_mpss
|
||||
/ tileSize / tickRate;
|
||||
float maxAngularAcceleration_rptt = static_cast<float>(
|
||||
def->movement.angularAccelerationFormula.evaluate(x))
|
||||
/ tickRate;
|
||||
float maxRotationSpeed_rpt = static_cast<float>(
|
||||
def->movement.maxRotationSpeedFormula.evaluate(x))
|
||||
/ tickRate;
|
||||
float sensorRange_tiles = static_cast<float>(
|
||||
def->sensor.sensorRangeFormula.evaluate(x))
|
||||
/ tileSize;
|
||||
float maxAngularAcceleration_rptt = def->movement.angularAcceleration_radpss / tickRate;
|
||||
float maxRotationSpeed_rpt = def->movement.maxRotationSpeed_radps / tickRate;
|
||||
float sensorRange_tiles = def->sensor.sensorRange_m / tileSize;
|
||||
|
||||
entt::entity entity = m_admin.spawnShip(
|
||||
position, hp, maxHp,
|
||||
maxSpeed_tpt, mainAcceleration_tptt, maneuveringAcceleration_tptt,
|
||||
maxAngularAcceleration_rptt, maxRotationSpeed_rpt, sensorRange_tiles,
|
||||
level, schematicId, isEnemy);
|
||||
schematicId, isEnemy);
|
||||
|
||||
// Determine module list: configured layout takes precedence over default.
|
||||
const std::vector<PlacedModule>& modules =
|
||||
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 ----------------------------
|
||||
std::vector<entt::entity> weaponChildren;
|
||||
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);
|
||||
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)
|
||||
{
|
||||
WeaponComponent w;
|
||||
w.damage = static_cast<float>(
|
||||
modDef->weaponCapability->damageFormula.evaluate(mx));
|
||||
w.range_tiles = static_cast<float>(
|
||||
modDef->weaponCapability->attackRangeFormula.evaluate(mx)) / tileSize;
|
||||
w.fireRateHz = static_cast<float>(
|
||||
modDef->weaponCapability->attackRateFormula.evaluate(mx));
|
||||
w.damage = modDef->weaponCapability->damage;
|
||||
w.range_tiles = modDef->weaponCapability->attackRange_m / tileSize;
|
||||
w.fireRateHz = modDef->weaponCapability->attackRate_hz;
|
||||
w.cooldownTicks = 0.0f;
|
||||
w.currentTarget = std::nullopt;
|
||||
|
||||
@@ -141,12 +136,11 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
|
||||
|
||||
if (modDef->salvageCapability)
|
||||
{
|
||||
cargoCapacityBase += modDef->salvageCapability->cargoCapacityFormula.evaluate(mx);
|
||||
cargoCapacityBase += modDef->salvageCapability->cargoCapacity;
|
||||
|
||||
SalvagerComponent salvager;
|
||||
salvager.collectionRange_tiles = static_cast<float>(
|
||||
modDef->salvageCapability->collectionRangeFormula.evaluate(mx)) / tileSize;
|
||||
const double rate = modDef->salvageCapability->collectionRateFormula.evaluate(mx);
|
||||
salvager.collectionRange_tiles = modDef->salvageCapability->collectionRange_m / tileSize;
|
||||
const double rate = modDef->salvageCapability->collectionRate_hz;
|
||||
salvager.collectionIntervalTicks = (rate > 0.0)
|
||||
? static_cast<int>(kTickRateHz / rate + 0.5)
|
||||
: 0;
|
||||
@@ -161,16 +155,13 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
|
||||
if (modDef->repairCapability)
|
||||
{
|
||||
RepairToolComponent rt;
|
||||
const double repairRateHz =
|
||||
modDef->repairCapability->repairRateFormula.evaluate(mx);
|
||||
const double repairRateHz = modDef->repairCapability->repairRate_hz;
|
||||
rt.repairIntervalTicks = (repairRateHz > 0.0)
|
||||
? static_cast<int>(kTickRateHz / repairRateHz + 0.5)
|
||||
: 0;
|
||||
rt.repairAmountHp = static_cast<float>(
|
||||
modDef->repairCapability->repairAmountHpFormula.evaluate(mx));
|
||||
rt.repairAmountHp = modDef->repairCapability->repairAmountHp;
|
||||
rt.cooldownTicksRemaining = 0;
|
||||
rt.range_tiles = static_cast<float>(
|
||||
modDef->repairCapability->repairRangeFormula.evaluate(mx)) / tileSize;
|
||||
rt.range_tiles = modDef->repairCapability->repairRange_m / tileSize;
|
||||
rt.currentTarget = std::nullopt;
|
||||
|
||||
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);
|
||||
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)
|
||||
{
|
||||
const double val = sm.formula.evaluate(mx);
|
||||
const double val = sm.value;
|
||||
|
||||
// Route modifier to the correct accumulator by stat category.
|
||||
// weapon/salvage/repair stats go to the corresponding child map;
|
||||
|
||||
@@ -18,10 +18,9 @@ class ShipSystem
|
||||
public:
|
||||
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,
|
||||
const std::optional<ShipLayoutConfig>& layout = std::nullopt,
|
||||
const std::map<std::string, int>& moduleLevelOverrides = {});
|
||||
const std::optional<ShipLayoutConfig>& layout = std::nullopt);
|
||||
void despawn(entt::entity entity);
|
||||
|
||||
// Reset all movement intents to inactive before behavior systems run.
|
||||
|
||||
11
src/lib/eventsystem/event/ArtifactCountChangedEvent.h
Normal file
11
src/lib/eventsystem/event/ArtifactCountChangedEvent.h
Normal file
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "Event.h"
|
||||
|
||||
class ArtifactCountChangedEvent : public Event
|
||||
{
|
||||
public:
|
||||
ArtifactCountChangedEvent(int count, int winCount) : count(count), winCount(winCount) {}
|
||||
const int count;
|
||||
const int winCount;
|
||||
};
|
||||
11
src/lib/eventsystem/event/BuildHotkeyPressedEvent.h
Normal file
11
src/lib/eventsystem/event/BuildHotkeyPressedEvent.h
Normal file
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "BuildingType.h"
|
||||
#include "Event.h"
|
||||
|
||||
class BuildHotkeyPressedEvent : public Event
|
||||
{
|
||||
public:
|
||||
explicit BuildHotkeyPressedEvent(BuildingType type) : type(type) {}
|
||||
const BuildingType type;
|
||||
};
|
||||
@@ -3,21 +3,26 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/TracePrintRequestedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/TickAdvancedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingBlocksChangedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ExpansionCostChangedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/EntitySelectedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/GameSpeedChangedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BossWaveUpdatedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoicesAvailableEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SelectionChangedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/GameOverEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/WinEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ArtifactCountChangedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuilderModeExitedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintModeExitedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/EscapeMenuRequestedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/DemolishModeChangedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingTypeSelectedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildHotkeyPressedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ExitBuilderModeRequestedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/DemolishModeToggleRequestedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPlacementRequestedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ExitBlueprintModeRequestedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/TemporaryBlueprintRequestedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SpeedChangeRequestedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/LayoutDialogRequestedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionRequestedEvent.h
|
||||
@@ -26,6 +31,8 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ArenaInspectRequestedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BeamFiredEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/CommandRequestedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/PlayerCommandsAppliedEvent.h
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
|
||||
23
src/lib/eventsystem/event/CommandRequestedEvent.h
Normal file
23
src/lib/eventsystem/event/CommandRequestedEvent.h
Normal file
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "Event.h"
|
||||
|
||||
struct Command;
|
||||
|
||||
// UI fan-in for the command path: widgets emit this with a built command, and a
|
||||
// single subscriber (GameWorldView) enqueues it onto the CommandManager. This
|
||||
// keeps widgets decoupled (consistent with the rest of the UI), while the
|
||||
// sim-mutating command itself is routed through the dedicated CommandManager
|
||||
// queue rather than the EventManager bus (see docs/replay_design.md).
|
||||
class CommandRequestedEvent : public Event
|
||||
{
|
||||
public:
|
||||
explicit CommandRequestedEvent(std::shared_ptr<const Command> command)
|
||||
: command(std::move(command))
|
||||
{
|
||||
}
|
||||
|
||||
const std::shared_ptr<const Command> command;
|
||||
};
|
||||
17
src/lib/eventsystem/event/ExpansionCostChangedEvent.h
Normal file
17
src/lib/eventsystem/event/ExpansionCostChangedEvent.h
Normal 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
|
||||
13
src/lib/eventsystem/event/PlayerCommandsAppliedEvent.h
Normal file
13
src/lib/eventsystem/event/PlayerCommandsAppliedEvent.h
Normal 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
|
||||
{
|
||||
};
|
||||
@@ -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
|
||||
{
|
||||
};
|
||||
5
src/lib/eventsystem/event/WinEvent.h
Normal file
5
src/lib/eventsystem/event/WinEvent.h
Normal file
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include "Event.h"
|
||||
|
||||
class WinEvent : public Event {};
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "StateChecksum.h"
|
||||
#include "Tick.h"
|
||||
#include "tracing.h"
|
||||
|
||||
@@ -970,4 +971,91 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles,
|
||||
}
|
||||
}
|
||||
|
||||
void BeltSystem::appendItemSlots(Hasher& hasher, const std::vector<BeltItemSlot>& slotRun)
|
||||
{
|
||||
hasher.append(slotRun.size());
|
||||
for (const BeltItemSlot& slot : slotRun)
|
||||
{
|
||||
hasher.append(slot.item.type.id);
|
||||
hasher.append(slot.progress);
|
||||
}
|
||||
}
|
||||
|
||||
void BeltSystem::appendChecksum(Hasher& hasher) const
|
||||
{
|
||||
// std::map iterates in sorted key order, so all tile loops are deterministic.
|
||||
hasher.append(m_belts.size());
|
||||
for (const std::pair<const std::pair<int, int>, BeltTile>& entry : m_belts)
|
||||
{
|
||||
hasher.append(entry.first.first);
|
||||
hasher.append(entry.first.second);
|
||||
hasher.append(entry.second.direction);
|
||||
appendItemSlots(hasher, entry.second.itemSlots);
|
||||
}
|
||||
|
||||
hasher.append(m_splitters.size());
|
||||
for (const std::pair<const std::pair<int, int>, SplitterTile>& entry : m_splitters)
|
||||
{
|
||||
hasher.append(entry.first.first);
|
||||
hasher.append(entry.first.second);
|
||||
const SplitterTile& s = entry.second;
|
||||
hasher.append(s.outputA);
|
||||
hasher.append(s.outputB);
|
||||
hasher.append(s.filterA.size());
|
||||
for (const ItemType& type : s.filterA) { hasher.append(type.id); }
|
||||
hasher.append(s.filterB.size());
|
||||
for (const ItemType& type : s.filterB) { hasher.append(type.id); }
|
||||
hasher.append(s.nextOutputIsA);
|
||||
appendItemSlots(hasher, s.back);
|
||||
hasher.append(s.backDir.size());
|
||||
for (Rotation dir : s.backDir) { hasher.append(dir); }
|
||||
hasher.append(s.frontA.has_value());
|
||||
if (s.frontA.has_value())
|
||||
{
|
||||
hasher.append(s.frontA->item.type.id);
|
||||
hasher.append(s.frontA->progress);
|
||||
}
|
||||
hasher.append(s.frontB.has_value());
|
||||
if (s.frontB.has_value())
|
||||
{
|
||||
hasher.append(s.frontB->item.type.id);
|
||||
hasher.append(s.frontB->progress);
|
||||
}
|
||||
}
|
||||
|
||||
hasher.append(m_tunnelEntries.size());
|
||||
for (const std::pair<const std::pair<int, int>, TunnelEntryTile>& entry : m_tunnelEntries)
|
||||
{
|
||||
hasher.append(entry.first.first);
|
||||
hasher.append(entry.first.second);
|
||||
hasher.append(entry.second.direction);
|
||||
hasher.append(entry.second.maxDistance);
|
||||
appendItemSlots(hasher, entry.second.itemSlots);
|
||||
}
|
||||
|
||||
hasher.append(m_tunnelExits.size());
|
||||
for (const std::pair<const std::pair<int, int>, TunnelExitTile>& entry : m_tunnelExits)
|
||||
{
|
||||
hasher.append(entry.first.first);
|
||||
hasher.append(entry.first.second);
|
||||
hasher.append(entry.second.direction);
|
||||
appendItemSlots(hasher, entry.second.itemSlots);
|
||||
}
|
||||
|
||||
// m_tunnelLinks preserves insertion order, which is itself deterministic.
|
||||
hasher.append(m_tunnelLinks.size());
|
||||
for (const TunnelLink& link : m_tunnelLinks)
|
||||
{
|
||||
hasher.append(link.entryTile);
|
||||
hasher.append(link.exitTile);
|
||||
hasher.append(link.length);
|
||||
hasher.append(link.items.size());
|
||||
for (const TunnelTransitItem& item : link.items)
|
||||
{
|
||||
hasher.append(item.item.type.id);
|
||||
hasher.append(item.progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
#include "Port.h"
|
||||
#include "Rotation.h"
|
||||
|
||||
class Hasher;
|
||||
|
||||
// Carries item type and fractional world position for the renderer.
|
||||
// worldPos is in tile units (1 tile = 1.0 unit); origin matches tile coords.
|
||||
struct VisualItem
|
||||
@@ -92,6 +94,11 @@ public:
|
||||
void forEachVisualItem(QRect viewportTiles,
|
||||
std::function<void(VisualItem)> visit) const;
|
||||
|
||||
// -- Determinism ---------------------------------------------------------
|
||||
// Folds all transport state (belt/splitter/tunnel tiles and their items)
|
||||
// into the hasher in deterministic order (see docs/replay_design.md).
|
||||
void appendChecksum(Hasher& hasher) const;
|
||||
|
||||
private:
|
||||
void advanceProgress();
|
||||
void advanceTunnelProgress();
|
||||
@@ -170,6 +177,9 @@ private:
|
||||
std::vector<TunnelTransitItem> items; // front (highest progress) to back
|
||||
};
|
||||
|
||||
// Folds a run of item slots (front-to-back order is canonical) into the hasher.
|
||||
static void appendItemSlots(Hasher& hasher, const std::vector<BeltItemSlot>& slotRun);
|
||||
|
||||
double m_progressPerTick_tpt; // beltSpeed_tps / kTickRateHz
|
||||
|
||||
std::map<std::pair<int, int>, BeltTile> m_belts;
|
||||
|
||||
@@ -54,6 +54,11 @@ struct ConstructionSite
|
||||
std::string recipeId; // may be configured before completion
|
||||
Tick completesAt = 0; // 0 = queued but not yet started
|
||||
std::optional<ShipLayoutConfig> shipLayout;
|
||||
|
||||
// Splitter output filters configured before completion (REQ-BLD-SITE-CONFIG).
|
||||
// Empty = accept all; applied to the BeltSystem when the splitter is built.
|
||||
std::vector<ItemType> splitterFilterA;
|
||||
std::vector<ItemType> splitterFilterB;
|
||||
};
|
||||
|
||||
// A fully constructed, operational building.
|
||||
|
||||
51
src/lib/sim/BuildingConfig.cpp
Normal file
51
src/lib/sim/BuildingConfig.cpp
Normal file
@@ -0,0 +1,51 @@
|
||||
#include "BuildingConfig.h"
|
||||
|
||||
#include "BeltSystem.h"
|
||||
#include "Building.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "Simulation.h"
|
||||
|
||||
std::optional<BuildingConfig> readBuildingConfig(const Simulation& sim, BuildingId id)
|
||||
{
|
||||
const Building* building = sim.buildings().findBuilding(id);
|
||||
const ConstructionSite* site = building ? nullptr : sim.buildings().findSite(id);
|
||||
if (!building && !site)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
BuildingConfig config;
|
||||
config.type = building ? building->type : site->type;
|
||||
|
||||
const std::string& recipeId = building ? building->recipeId : site->recipeId;
|
||||
if (!recipeId.empty())
|
||||
{
|
||||
config.recipeId = recipeId;
|
||||
}
|
||||
|
||||
config.shipLayout = building ? building->shipLayout : site->shipLayout;
|
||||
|
||||
if (config.type == BuildingType::Splitter)
|
||||
{
|
||||
config.isSplitter = true;
|
||||
if (building)
|
||||
{
|
||||
// Operational splitter filters live in the BeltSystem, keyed by tile.
|
||||
const std::optional<BeltSystem::SplitterInfo> info =
|
||||
sim.belts().getSplitterInfo(building->anchor);
|
||||
if (info.has_value())
|
||||
{
|
||||
config.splitterFilterA = info->filterA;
|
||||
config.splitterFilterB = info->filterB;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// A site keeps its pre-completion filters on the ConstructionSite.
|
||||
config.splitterFilterA = site->splitterFilterA;
|
||||
config.splitterFilterB = site->splitterFilterB;
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
40
src/lib/sim/BuildingConfig.h
Normal file
40
src/lib/sim/BuildingConfig.h
Normal file
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "BuildingId.h"
|
||||
#include "BuildingType.h"
|
||||
#include "ItemType.h"
|
||||
#include "ShipLayout.h"
|
||||
|
||||
class Simulation;
|
||||
|
||||
// The user-configurable settings of a single building or construction site: the
|
||||
// selected recipe / ship schematic, the shipyard module layout, and (for
|
||||
// splitters) the two output filters. Shared by the copy-settings gesture
|
||||
// (REQ-BLD-COPY-CONFIG) and blueprint capture (REQ-UI-BLUEPRINT-STORAGE).
|
||||
struct BuildingConfig
|
||||
{
|
||||
BuildingType type = BuildingType::Miner;
|
||||
|
||||
// Selected recipe (Miner / Assembler) or ship schematic id (Shipyard); unset
|
||||
// when nothing is selected.
|
||||
std::optional<std::string> recipeId;
|
||||
|
||||
// Shipyard module layout (REQ-MOD-LAYOUT).
|
||||
std::optional<ShipLayoutConfig> shipLayout;
|
||||
|
||||
// Splitter output filters (empty = accept all). isSplitter distinguishes an
|
||||
// empty-filter splitter (a valid accept-all configuration) from a building
|
||||
// type that has no splitter filters at all.
|
||||
bool isSplitter = false;
|
||||
std::vector<ItemType> splitterFilterA;
|
||||
std::vector<ItemType> splitterFilterB;
|
||||
};
|
||||
|
||||
// Reads the current configuration of the building or construction site identified
|
||||
// by id, handling operational buildings and sites alike. Returns std::nullopt if
|
||||
// no such building or site exists.
|
||||
std::optional<BuildingConfig> readBuildingConfig(const Simulation& sim, BuildingId id);
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <random>
|
||||
#include <set>
|
||||
|
||||
#include "StateChecksum.h"
|
||||
#include "SurfaceMask.h"
|
||||
#include "tracing.h"
|
||||
|
||||
@@ -23,6 +24,7 @@ BuildingSystem::BuildingSystem(const GameConfig& config,
|
||||
, m_spawnShip(std::move(spawnShip))
|
||||
, m_isItemUnlocked(std::move(isItemUnlocked))
|
||||
, m_rng(rng)
|
||||
, m_asteroidWidth_tiles(config.world.regions.asteroidWidth_tiles)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -152,6 +154,16 @@ void BuildingSystem::initShipyardBuffers(Building& b) const
|
||||
}
|
||||
}
|
||||
|
||||
void BuildingSystem::initSalvageBayBuffer(Building& b) const
|
||||
{
|
||||
// Salvage Bay has no recipe-driven buffer; its output-buffer holding size for
|
||||
// ship drop-off is config-defined (REQ-BLD-SALVAGE-BAY).
|
||||
b.outputBuffer.items.clear();
|
||||
const BuildingDef* def = findBuildingDef(BuildingType::SalvageBay);
|
||||
b.outputBuffer.capacity =
|
||||
(def && def->outputBufferCapacity) ? *def->outputBufferCapacity : 0;
|
||||
}
|
||||
|
||||
std::vector<Port> BuildingSystem::computeInputPorts(const Building& b) const
|
||||
{
|
||||
// Build lookup sets for quick membership checks.
|
||||
@@ -280,7 +292,7 @@ bool BuildingSystem::bodyCellsWithinWorldBounds(const std::vector<QPoint>& bodyC
|
||||
QPoint anchor) const
|
||||
{
|
||||
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)
|
||||
{
|
||||
const QPoint worldCell = anchor + cell;
|
||||
@@ -407,6 +419,12 @@ void BuildingSystem::setRecipe(BuildingId id, const std::string& recipeId)
|
||||
{
|
||||
if (site.id == id)
|
||||
{
|
||||
// No-op if the recipe is unchanged, so a redundant selection does
|
||||
// not wipe an already-configured ship layout.
|
||||
if (site.recipeId == recipeId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
site.recipeId = recipeId;
|
||||
site.shipLayout = std::nullopt;
|
||||
return;
|
||||
@@ -418,6 +436,12 @@ void BuildingSystem::setRecipe(BuildingId id, const std::string& recipeId)
|
||||
{
|
||||
if (building.id == id)
|
||||
{
|
||||
// No-op if the recipe is unchanged, so a redundant selection does
|
||||
// not wipe an already-configured ship layout or reset buffers.
|
||||
if (building.recipeId == recipeId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
building.recipeId = recipeId;
|
||||
building.shipLayout = std::nullopt;
|
||||
building.inputBuffer.counts.clear();
|
||||
@@ -479,6 +503,44 @@ void BuildingSystem::setShipLayout(BuildingId id, const ShipLayoutConfig& layout
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<BeltSystem::SplitterInfo>
|
||||
BuildingSystem::getSiteSplitterInfo(BuildingId id) const
|
||||
{
|
||||
for (const ConstructionSite& site : m_constructionQueue)
|
||||
{
|
||||
if (site.id != id) { continue; }
|
||||
if (site.type != BuildingType::Splitter) { return std::nullopt; }
|
||||
|
||||
const BuildingDef* def = findBuildingDef(site.type);
|
||||
const ParsedSurfaceMask mask = parseSurfaceMask(
|
||||
def ? def->surfaceMask : std::vector<std::string>{}, site.rotation);
|
||||
if (mask.outputPorts.size() < 2) { return std::nullopt; }
|
||||
|
||||
BeltSystem::SplitterInfo info;
|
||||
info.outputA = mask.outputPorts[0].direction;
|
||||
info.outputB = mask.outputPorts[1].direction;
|
||||
info.filterA = site.splitterFilterA;
|
||||
info.filterB = site.splitterFilterB;
|
||||
return info;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
void BuildingSystem::setSiteSplitterFilters(BuildingId id,
|
||||
const std::vector<ItemType>& filterA,
|
||||
const std::vector<ItemType>& filterB)
|
||||
{
|
||||
for (ConstructionSite& site : m_constructionQueue)
|
||||
{
|
||||
if (site.id == id && site.type == BuildingType::Splitter)
|
||||
{
|
||||
site.splitterFilterA = filterA;
|
||||
site.splitterFilterB = filterB;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tick hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -537,7 +599,11 @@ void BuildingSystem::tickConstruction(Tick currentTick)
|
||||
}
|
||||
building.inputPorts = computeInputPorts(building);
|
||||
|
||||
if (!building.recipeId.empty())
|
||||
if (building.type == BuildingType::SalvageBay)
|
||||
{
|
||||
initSalvageBayBuffer(building);
|
||||
}
|
||||
else if (!building.recipeId.empty())
|
||||
{
|
||||
if (building.type == BuildingType::Shipyard)
|
||||
{
|
||||
@@ -564,6 +630,11 @@ void BuildingSystem::tickConstruction(Tick currentTick)
|
||||
m_belts.placeSplitter(front.anchor,
|
||||
mask.outputPorts[0].direction,
|
||||
mask.outputPorts[1].direction);
|
||||
// Carry over any filters configured while under construction
|
||||
// (REQ-BLD-SITE-CONFIG).
|
||||
m_belts.setSplitterFilters(front.anchor,
|
||||
front.splitterFilterA,
|
||||
front.splitterFilterB);
|
||||
}
|
||||
else if (front.type == BuildingType::TunnelEntry)
|
||||
{
|
||||
@@ -799,7 +870,16 @@ void BuildingSystem::tickShipyardProduction(Tick currentTick)
|
||||
{
|
||||
const Port& p = building.outputPorts[0];
|
||||
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;
|
||||
}
|
||||
@@ -1193,6 +1273,11 @@ BuildingId BuildingSystem::placeImmediate(BuildingType type,
|
||||
}
|
||||
building.inputPorts = computeInputPorts(building);
|
||||
|
||||
if (type == BuildingType::SalvageBay)
|
||||
{
|
||||
initSalvageBayBuffer(building);
|
||||
}
|
||||
|
||||
m_buildings.push_back(std::move(building));
|
||||
return id;
|
||||
}
|
||||
@@ -1245,3 +1330,87 @@ void BuildingSystem::unregisterTileOccupancy(const std::vector<QPoint>& cells)
|
||||
m_tileOccupancy.erase({cell.x(), cell.y()});
|
||||
}
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
void appendItems(Hasher& hasher, const std::vector<Item>& items)
|
||||
{
|
||||
hasher.append(items.size());
|
||||
for (const Item& item : items)
|
||||
{
|
||||
hasher.append(item.type.id);
|
||||
}
|
||||
}
|
||||
|
||||
void appendInputBuffer(Hasher& hasher, const InputBuffer& buffer)
|
||||
{
|
||||
// std::map<ItemType, int> iterates in sorted-id order (ItemType::operator<).
|
||||
hasher.append(buffer.counts.size());
|
||||
for (const std::pair<const ItemType, int>& entry : buffer.counts)
|
||||
{
|
||||
hasher.append(entry.first.id);
|
||||
hasher.append(entry.second);
|
||||
}
|
||||
hasher.append(buffer.caps.size());
|
||||
for (const std::pair<const ItemType, int>& entry : buffer.caps)
|
||||
{
|
||||
hasher.append(entry.first.id);
|
||||
hasher.append(entry.second);
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void BuildingSystem::appendChecksum(Hasher& hasher) const
|
||||
{
|
||||
// m_buildings keeps a stable, deterministic order (append on build, swap-free
|
||||
// erase aside — both runs perform identical operations, so order matches).
|
||||
hasher.append(m_buildings.size());
|
||||
for (const Building& b : m_buildings)
|
||||
{
|
||||
hasher.append(b.id);
|
||||
hasher.append(b.anchor);
|
||||
hasher.append(b.footprint.width());
|
||||
hasher.append(b.footprint.height());
|
||||
hasher.append(b.rotation);
|
||||
hasher.append(b.type);
|
||||
hasher.append(b.recipeId);
|
||||
appendInputBuffer(hasher, b.inputBuffer);
|
||||
appendItems(hasher, b.outputBuffer.items);
|
||||
hasher.append(b.outputBuffer.capacity);
|
||||
hasher.append(b.production.has_value());
|
||||
if (b.production.has_value())
|
||||
{
|
||||
hasher.append(b.production->recipeId);
|
||||
hasher.append(b.production->completesAt);
|
||||
appendItems(hasher, b.production->chosenOutputs);
|
||||
}
|
||||
hasher.append(b.shipLayout.has_value());
|
||||
}
|
||||
|
||||
hasher.append(m_constructionQueue.size());
|
||||
for (const ConstructionSite& s : m_constructionQueue)
|
||||
{
|
||||
hasher.append(s.id);
|
||||
hasher.append(s.anchor);
|
||||
hasher.append(s.footprint.width());
|
||||
hasher.append(s.footprint.height());
|
||||
hasher.append(s.rotation);
|
||||
hasher.append(s.type);
|
||||
hasher.append(s.recipeId);
|
||||
hasher.append(s.completesAt);
|
||||
hasher.append(s.shipLayout.has_value());
|
||||
hasher.append(s.splitterFilterA.size());
|
||||
for (const ItemType& type : s.splitterFilterA) { hasher.append(type.id); }
|
||||
hasher.append(s.splitterFilterB.size());
|
||||
for (const ItemType& type : s.splitterFilterB) { hasher.append(type.id); }
|
||||
}
|
||||
|
||||
// std::map iterates in sorted key order.
|
||||
hasher.append(m_tileOccupancy.size());
|
||||
for (const std::pair<const std::pair<int, int>, BuildingId>& entry : m_tileOccupancy)
|
||||
{
|
||||
hasher.append(entry.first.first);
|
||||
hasher.append(entry.first.second);
|
||||
hasher.append(entry.second);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
#include "ShipsConfig.h"
|
||||
#include "Tick.h"
|
||||
|
||||
class Hasher;
|
||||
|
||||
// Manages building placement, construction queuing, and the per-tick
|
||||
// production loop (belt→building pull, production, building→belt push).
|
||||
// All types including Belt and Splitter are stored as Building instances;
|
||||
@@ -57,6 +59,11 @@ public:
|
||||
bool isPlacementValid(BuildingType type, QPoint anchor,
|
||||
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
|
||||
// building blocks (floor(cost * refundPercentage / 100)). Returns 0 for
|
||||
// unknown ids.
|
||||
@@ -70,6 +77,18 @@ public:
|
||||
// (materials discarded) and reinitializes input buffers (REQ-BLD-SHIPYARD).
|
||||
void setShipLayout(BuildingId id, const ShipLayoutConfig& layout);
|
||||
|
||||
// Splitter filter configuration for a queued/under-construction Splitter
|
||||
// site (REQ-BLD-SITE-CONFIG). Operational splitters are configured through
|
||||
// BeltSystem by tile; these mirror that for sites, which are not yet
|
||||
// registered with BeltSystem. getSiteSplitterInfo returns the site's two
|
||||
// output directions (derived from its surface mask) and stored filters, or
|
||||
// nullopt if the id is not a Splitter site. The stored filters are applied
|
||||
// to BeltSystem when the splitter finishes building (tickConstruction).
|
||||
std::optional<BeltSystem::SplitterInfo> getSiteSplitterInfo(BuildingId id) const;
|
||||
void setSiteSplitterFilters(BuildingId id,
|
||||
const std::vector<ItemType>& filterA,
|
||||
const std::vector<ItemType>& filterB);
|
||||
|
||||
// -- Tick hooks (called from Simulation::tick in the documented order) ---
|
||||
void tickConstruction(Tick currentTick);
|
||||
void tickBeltPull();
|
||||
@@ -139,6 +158,11 @@ public:
|
||||
// Mutable iteration over all operational buildings.
|
||||
void forEachBuilding(std::function<void(Building&)> fn);
|
||||
|
||||
// -- Determinism ---------------------------------------------------------
|
||||
// Folds all building, construction-site, and tile-occupancy state into the
|
||||
// hasher in deterministic order (see docs/replay_design.md).
|
||||
void appendChecksum(Hasher& hasher) const;
|
||||
|
||||
private:
|
||||
const BuildingDef* findBuildingDef(BuildingType type) const;
|
||||
const RecipeDef* findRecipe(const std::string& id, BuildingType type) const;
|
||||
@@ -146,6 +170,7 @@ private:
|
||||
const ModuleDef* findModuleDef(const std::string& id) const;
|
||||
void initBuffers(Building& b, const RecipeDef& recipe) const;
|
||||
void initShipyardBuffers(Building& b) const;
|
||||
void initSalvageBayBuffer(Building& b) const;
|
||||
std::vector<Port> computeInputPorts(const Building& b) const;
|
||||
std::vector<Item> rollReprocessingOutput(const RecipeDef& recipe);
|
||||
bool bodyCellsWithinWorldBounds(
|
||||
@@ -160,6 +185,7 @@ private:
|
||||
const std::optional<ShipLayoutConfig>&)> m_spawnShip;
|
||||
std::function<bool(const std::string&)> m_isItemUnlocked;
|
||||
std::mt19937& m_rng;
|
||||
int m_asteroidWidth_tiles;
|
||||
|
||||
std::vector<Building> m_buildings;
|
||||
std::deque<ConstructionSite> m_constructionQueue;
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
SET(HDRS
|
||||
${HDRS}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/Simulation.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/Command.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/CommandSerializer.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ReplayRecorder.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ReplayPlayer.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/Building.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprint.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.h
|
||||
PARENT_SCOPE
|
||||
@@ -17,11 +25,18 @@ SET(HDRS
|
||||
SET(SRCS
|
||||
${SRCS}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/Simulation.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/CommandSerializer.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ReplayRecorder.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ReplayPlayer.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.cpp
|
||||
PARENT_SCOPE
|
||||
|
||||
150
src/lib/sim/Command.h
Normal file
150
src/lib/sim/Command.h
Normal file
@@ -0,0 +1,150 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <QPoint>
|
||||
|
||||
#include "BuildingId.h"
|
||||
#include "BuildingType.h"
|
||||
#include "ItemType.h"
|
||||
#include "Rotation.h"
|
||||
#include "ShipLayout.h"
|
||||
|
||||
class GameConfig;
|
||||
|
||||
// Player intent, resolved to domain ids / tile coordinates and serializable, that
|
||||
// mutates the simulation. Every sim mutation during play flows through a Command
|
||||
// applied at the single Simulation::apply chokepoint, so it can be recorded and
|
||||
// replayed (see docs/replay_design.md).
|
||||
//
|
||||
// Commands form a closed set dispatched by kind. They reference stable,
|
||||
// deterministic ids (BuildingId, tile coordinates, choice indices) — never raw
|
||||
// entt handles — so a recorded command resolves to the same target on replay.
|
||||
|
||||
enum class CommandKind
|
||||
{
|
||||
PlaceBuilding,
|
||||
Demolish,
|
||||
RotateInPlace,
|
||||
SetRecipe,
|
||||
SetShipLayout,
|
||||
SetSiteSplitterFilters,
|
||||
SetSplitterFilters,
|
||||
ClearBeltTiles,
|
||||
ApplySchematicChoice,
|
||||
ExpandAsteroid,
|
||||
Reset
|
||||
};
|
||||
|
||||
struct Command
|
||||
{
|
||||
explicit Command(CommandKind kind) : kind(kind) {}
|
||||
virtual ~Command() = default;
|
||||
|
||||
CommandKind kind;
|
||||
// Source of the command. Always 0 in single-player; lockstep multiplayer
|
||||
// later merges commands from multiple sources into one ordered stream.
|
||||
int playerId = 0;
|
||||
};
|
||||
|
||||
// Places a building and, atomically, configures it. The configuration fields are
|
||||
// bundled here (rather than as follow-up commands) because commands are applied
|
||||
// at a deferred tick boundary, so the caller never sees the new BuildingId — the
|
||||
// place-and-configure must happen as one unit inside apply().
|
||||
struct PlaceBuildingCommand : Command
|
||||
{
|
||||
PlaceBuildingCommand() : Command(CommandKind::PlaceBuilding) {}
|
||||
|
||||
BuildingType type = BuildingType::Miner;
|
||||
QPoint anchor;
|
||||
Rotation rotation = Rotation::East;
|
||||
|
||||
// Optional configuration applied to the freshly placed (still-construction)
|
||||
// building. The caller (UI) is responsible for unlock/validity pre-filtering;
|
||||
// only fields that should apply are set.
|
||||
std::optional<std::string> recipeId;
|
||||
std::optional<ShipLayoutConfig> shipLayout;
|
||||
bool hasSplitterFilters = false;
|
||||
std::vector<ItemType> splitterFilterA;
|
||||
std::vector<ItemType> splitterFilterB;
|
||||
};
|
||||
|
||||
struct DemolishCommand : Command
|
||||
{
|
||||
DemolishCommand() : Command(CommandKind::Demolish) {}
|
||||
BuildingId id = kInvalidBuildingId;
|
||||
};
|
||||
|
||||
struct RotateInPlaceCommand : Command
|
||||
{
|
||||
RotateInPlaceCommand() : Command(CommandKind::RotateInPlace) {}
|
||||
BuildingId id = kInvalidBuildingId;
|
||||
Rotation newRotation = Rotation::East;
|
||||
};
|
||||
|
||||
struct SetRecipeCommand : Command
|
||||
{
|
||||
SetRecipeCommand() : Command(CommandKind::SetRecipe) {}
|
||||
BuildingId id = kInvalidBuildingId;
|
||||
std::string recipeId;
|
||||
};
|
||||
|
||||
struct SetShipLayoutCommand : Command
|
||||
{
|
||||
SetShipLayoutCommand() : Command(CommandKind::SetShipLayout) {}
|
||||
BuildingId id = kInvalidBuildingId;
|
||||
ShipLayoutConfig layout;
|
||||
};
|
||||
|
||||
// Splitter filters for a queued / under-construction Splitter site (configured by
|
||||
// BuildingSystem before the splitter is registered with BeltSystem).
|
||||
struct SetSiteSplitterFiltersCommand : Command
|
||||
{
|
||||
SetSiteSplitterFiltersCommand() : Command(CommandKind::SetSiteSplitterFilters) {}
|
||||
BuildingId id = kInvalidBuildingId;
|
||||
std::vector<ItemType> filterA;
|
||||
std::vector<ItemType> filterB;
|
||||
};
|
||||
|
||||
// Splitter filters for an operational splitter, configured by tile via BeltSystem.
|
||||
struct SetSplitterFiltersCommand : Command
|
||||
{
|
||||
SetSplitterFiltersCommand() : Command(CommandKind::SetSplitterFilters) {}
|
||||
QPoint tile;
|
||||
std::vector<ItemType> filterA;
|
||||
std::vector<ItemType> filterB;
|
||||
};
|
||||
|
||||
struct ClearBeltTilesCommand : Command
|
||||
{
|
||||
ClearBeltTilesCommand() : Command(CommandKind::ClearBeltTiles) {}
|
||||
std::vector<QPoint> tiles;
|
||||
};
|
||||
|
||||
struct ApplySchematicChoiceCommand : Command
|
||||
{
|
||||
ApplySchematicChoiceCommand() : Command(CommandKind::ApplySchematicChoice) {}
|
||||
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
|
||||
// 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
|
||||
// config. One replay file = one run between Reset boundaries.
|
||||
struct ResetCommand : Command
|
||||
{
|
||||
ResetCommand() : Command(CommandKind::Reset) {}
|
||||
std::shared_ptr<GameConfig> config; // null = keep current config
|
||||
unsigned int seed = 0;
|
||||
};
|
||||
92
src/lib/sim/CommandManager.cpp
Normal file
92
src/lib/sim/CommandManager.cpp
Normal file
@@ -0,0 +1,92 @@
|
||||
#include "CommandManager.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
#include "Command.h"
|
||||
#include "ReplayRecorder.h"
|
||||
#include "Simulation.h"
|
||||
#include "Tick.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
// Periodic RNG checksum cadence (see docs/replay_design.md "Cadence").
|
||||
constexpr Tick kChecksumIntervalTicks = 30;
|
||||
} // namespace
|
||||
|
||||
CommandManager::CommandManager(Simulation& simulation)
|
||||
: m_simulation(simulation)
|
||||
{
|
||||
}
|
||||
|
||||
CommandManager::~CommandManager() = default;
|
||||
|
||||
void CommandManager::enqueue(std::shared_ptr<const Command> command)
|
||||
{
|
||||
if (m_replayMode)
|
||||
{
|
||||
return; // playback is driven by the recorded stream; ignore live input
|
||||
}
|
||||
if (command)
|
||||
{
|
||||
m_queue.push_back(std::move(command));
|
||||
}
|
||||
}
|
||||
|
||||
void CommandManager::drain()
|
||||
{
|
||||
// Apply in FIFO order. A queued ResetCommand reinitializes the simulation in
|
||||
// place; the reference stays valid and any commands after it apply to the
|
||||
// fresh state.
|
||||
for (const std::shared_ptr<const Command>& command : m_queue)
|
||||
{
|
||||
if (command->kind == CommandKind::Reset)
|
||||
{
|
||||
m_simulation.apply(*command);
|
||||
if (m_recorder)
|
||||
{
|
||||
// Restart is a file boundary: a fresh file with the new seed.
|
||||
m_recorder->startNewRun(m_simulation.getSeed(),
|
||||
m_simulation.rngFingerprint());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Commands drain before the tick batch, so currentTick is the count of
|
||||
// completed ticks the command is pinned to.
|
||||
const Tick tick = m_simulation.currentTick();
|
||||
m_simulation.apply(*command);
|
||||
if (m_recorder)
|
||||
{
|
||||
m_recorder->recordCommand(tick, *command, m_simulation.rngFingerprint());
|
||||
}
|
||||
}
|
||||
}
|
||||
m_queue.clear();
|
||||
}
|
||||
|
||||
bool CommandManager::hasPending() const
|
||||
{
|
||||
return !m_queue.empty();
|
||||
}
|
||||
|
||||
void CommandManager::setRecorder(std::unique_ptr<ReplayRecorder> recorder)
|
||||
{
|
||||
m_recorder = std::move(recorder);
|
||||
if (m_recorder)
|
||||
{
|
||||
m_recorder->startNewRun(m_simulation.getSeed(), m_simulation.rngFingerprint());
|
||||
}
|
||||
}
|
||||
|
||||
void CommandManager::setReplayMode(bool replayMode)
|
||||
{
|
||||
m_replayMode = replayMode;
|
||||
}
|
||||
|
||||
void CommandManager::recordTickCheckpoint()
|
||||
{
|
||||
if (m_recorder && (m_simulation.currentTick() % kChecksumIntervalTicks == 0))
|
||||
{
|
||||
m_recorder->recordChecksum(m_simulation.currentTick(), m_simulation.rngFingerprint());
|
||||
}
|
||||
}
|
||||
55
src/lib/sim/CommandManager.h
Normal file
55
src/lib/sim/CommandManager.h
Normal file
@@ -0,0 +1,55 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
struct Command;
|
||||
class ReplayRecorder;
|
||||
class Simulation;
|
||||
|
||||
// Ordered queue that funnels every player command into the single
|
||||
// Simulation::apply chokepoint (see docs/replay_design.md). This is deliberately
|
||||
// NOT the EventManager pub/sub bus: sim mutations must apply in a strict,
|
||||
// tick-pinned, recordable order to a single recipient.
|
||||
//
|
||||
// Live play pushes commands via enqueue(); they are applied at the next drain(),
|
||||
// which runs once per frame before the tick batch. Draining before the tick
|
||||
// (even at 0x game speed) lets a paused player see placed construction sites
|
||||
// immediately while staying deterministic — replay applies each command at its
|
||||
// recorded tick regardless of frame cadence.
|
||||
class CommandManager
|
||||
{
|
||||
public:
|
||||
explicit CommandManager(Simulation& simulation);
|
||||
// Defined out-of-line so the unique_ptr<ReplayRecorder> member can be
|
||||
// destroyed where ReplayRecorder is a complete type.
|
||||
~CommandManager();
|
||||
|
||||
// Append a command for application at the next drain (FIFO order).
|
||||
void enqueue(std::shared_ptr<const Command> command);
|
||||
|
||||
// Apply all queued commands in FIFO order through Simulation::apply, then
|
||||
// clear the queue. If a recorder is attached, each applied command is recorded
|
||||
// (a Reset rolls the recorder to a new file).
|
||||
void drain();
|
||||
|
||||
bool hasPending() const;
|
||||
|
||||
// Attach a recorder and start recording the current run. Ownership is taken.
|
||||
// Passing nullptr detaches/stops recording.
|
||||
void setRecorder(std::unique_ptr<ReplayRecorder> recorder);
|
||||
|
||||
// In replay mode, enqueue() is a no-op: the queue is driven by the recorded
|
||||
// stream (ReplayPlayer), so stray live input produces nothing.
|
||||
void setReplayMode(bool replayMode);
|
||||
|
||||
// Record a periodic RNG checksum if the current tick is on the checksum
|
||||
// cadence. Call once per simulated tick (from the tick loop).
|
||||
void recordTickCheckpoint();
|
||||
|
||||
private:
|
||||
Simulation& m_simulation;
|
||||
std::vector<std::shared_ptr<const Command>> m_queue;
|
||||
std::unique_ptr<ReplayRecorder> m_recorder;
|
||||
bool m_replayMode = false;
|
||||
};
|
||||
314
src/lib/sim/CommandSerializer.cpp
Normal file
314
src/lib/sim/CommandSerializer.cpp
Normal file
@@ -0,0 +1,314 @@
|
||||
#include "CommandSerializer.h"
|
||||
|
||||
#include <sstream>
|
||||
#include <vector>
|
||||
|
||||
#include "BuildingType.h"
|
||||
#include "Command.h"
|
||||
#include "Rotation.h"
|
||||
#include "ShipLayout.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
char rotationToChar(Rotation rotation)
|
||||
{
|
||||
switch (rotation)
|
||||
{
|
||||
case Rotation::North: return 'N';
|
||||
case Rotation::East: return 'E';
|
||||
case Rotation::South: return 'S';
|
||||
case Rotation::West: return 'W';
|
||||
}
|
||||
return 'E';
|
||||
}
|
||||
|
||||
Rotation rotationFromString(const std::string& token)
|
||||
{
|
||||
if (token == "N") { return Rotation::North; }
|
||||
if (token == "S") { return Rotation::South; }
|
||||
if (token == "W") { return Rotation::West; }
|
||||
return Rotation::East;
|
||||
}
|
||||
|
||||
// Reads "<count> (<moduleId> <x> <y> <rot>)*" from the stream. Sets ok=false on
|
||||
// a stream failure.
|
||||
ShipLayoutConfig parseLayout(std::istringstream& in, bool& ok)
|
||||
{
|
||||
ShipLayoutConfig layout;
|
||||
int count = 0;
|
||||
if (!(in >> count) || count < 0) { ok = false; return layout; }
|
||||
for (int i = 0; i < count; ++i)
|
||||
{
|
||||
PlacedModule placed;
|
||||
std::string rotToken;
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (!(in >> placed.moduleId >> x >> y >> rotToken)) { ok = false; return layout; }
|
||||
placed.position = QPoint(x, y);
|
||||
placed.rotation = rotationFromString(rotToken);
|
||||
layout.placedModules.push_back(placed);
|
||||
}
|
||||
return layout;
|
||||
}
|
||||
|
||||
// Reads "<countA> (<itemId>)* <countB> (<itemId>)*" from the stream.
|
||||
void parseFilters(std::istringstream& in,
|
||||
std::vector<ItemType>& filterA,
|
||||
std::vector<ItemType>& filterB,
|
||||
bool& ok)
|
||||
{
|
||||
int countA = 0;
|
||||
if (!(in >> countA) || countA < 0) { ok = false; return; }
|
||||
for (int i = 0; i < countA; ++i)
|
||||
{
|
||||
std::string id;
|
||||
if (!(in >> id)) { ok = false; return; }
|
||||
filterA.push_back(ItemType{id});
|
||||
}
|
||||
int countB = 0;
|
||||
if (!(in >> countB) || countB < 0) { ok = false; return; }
|
||||
for (int i = 0; i < countB; ++i)
|
||||
{
|
||||
std::string id;
|
||||
if (!(in >> id)) { ok = false; return; }
|
||||
filterB.push_back(ItemType{id});
|
||||
}
|
||||
}
|
||||
|
||||
// "<count> (<moduleId> <x> <y> <rot>)*"
|
||||
void appendLayout(std::ostringstream& out, const ShipLayoutConfig& layout)
|
||||
{
|
||||
out << layout.placedModules.size();
|
||||
for (const PlacedModule& placed : layout.placedModules)
|
||||
{
|
||||
out << ' ' << placed.moduleId
|
||||
<< ' ' << placed.position.x()
|
||||
<< ' ' << placed.position.y()
|
||||
<< ' ' << rotationToChar(placed.rotation);
|
||||
}
|
||||
}
|
||||
|
||||
// "<countA> (<itemId>)* <countB> (<itemId>)*"
|
||||
void appendFilters(std::ostringstream& out,
|
||||
const std::vector<ItemType>& filterA,
|
||||
const std::vector<ItemType>& filterB)
|
||||
{
|
||||
out << filterA.size();
|
||||
for (const ItemType& type : filterA) { out << ' ' << type.id; }
|
||||
out << ' ' << filterB.size();
|
||||
for (const ItemType& type : filterB) { out << ' ' << type.id; }
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::string serializeCommand(const Command& command)
|
||||
{
|
||||
std::ostringstream out;
|
||||
|
||||
switch (command.kind)
|
||||
{
|
||||
case CommandKind::PlaceBuilding:
|
||||
{
|
||||
const PlaceBuildingCommand& c = static_cast<const PlaceBuildingCommand&>(command);
|
||||
out << "place " << buildingTypeId(c.type)
|
||||
<< ' ' << c.anchor.x() << ' ' << c.anchor.y()
|
||||
<< ' ' << rotationToChar(c.rotation);
|
||||
if (c.recipeId.has_value())
|
||||
{
|
||||
out << " recipe " << *c.recipeId;
|
||||
}
|
||||
if (c.shipLayout.has_value())
|
||||
{
|
||||
out << " layout ";
|
||||
appendLayout(out, *c.shipLayout);
|
||||
}
|
||||
if (c.hasSplitterFilters)
|
||||
{
|
||||
out << " filters ";
|
||||
appendFilters(out, c.splitterFilterA, c.splitterFilterB);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case CommandKind::Demolish:
|
||||
out << "demolish " << static_cast<const DemolishCommand&>(command).id;
|
||||
break;
|
||||
case CommandKind::RotateInPlace:
|
||||
{
|
||||
const RotateInPlaceCommand& c = static_cast<const RotateInPlaceCommand&>(command);
|
||||
out << "rotate " << c.id << ' ' << rotationToChar(c.newRotation);
|
||||
break;
|
||||
}
|
||||
case CommandKind::SetRecipe:
|
||||
{
|
||||
const SetRecipeCommand& c = static_cast<const SetRecipeCommand&>(command);
|
||||
out << "setrecipe " << c.id << ' ' << c.recipeId;
|
||||
break;
|
||||
}
|
||||
case CommandKind::SetShipLayout:
|
||||
{
|
||||
const SetShipLayoutCommand& c = static_cast<const SetShipLayoutCommand&>(command);
|
||||
out << "setlayout " << c.id << ' ';
|
||||
appendLayout(out, c.layout);
|
||||
break;
|
||||
}
|
||||
case CommandKind::SetSiteSplitterFilters:
|
||||
{
|
||||
const SetSiteSplitterFiltersCommand& c =
|
||||
static_cast<const SetSiteSplitterFiltersCommand&>(command);
|
||||
out << "sitefilters " << c.id << ' ';
|
||||
appendFilters(out, c.filterA, c.filterB);
|
||||
break;
|
||||
}
|
||||
case CommandKind::SetSplitterFilters:
|
||||
{
|
||||
const SetSplitterFiltersCommand& c =
|
||||
static_cast<const SetSplitterFiltersCommand&>(command);
|
||||
out << "splitterfilters " << c.tile.x() << ' ' << c.tile.y() << ' ';
|
||||
appendFilters(out, c.filterA, c.filterB);
|
||||
break;
|
||||
}
|
||||
case CommandKind::ClearBeltTiles:
|
||||
{
|
||||
const ClearBeltTilesCommand& c = static_cast<const ClearBeltTilesCommand&>(command);
|
||||
out << "clearbelt " << c.tiles.size();
|
||||
for (const QPoint& tile : c.tiles)
|
||||
{
|
||||
out << ' ' << tile.x() << ' ' << tile.y();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case CommandKind::ApplySchematicChoice:
|
||||
out << "schematic " << static_cast<const ApplySchematicChoiceCommand&>(command).choiceIndex;
|
||||
break;
|
||||
case CommandKind::Reset:
|
||||
// A reset rolls the replay file; it is never written as a stream entry.
|
||||
break;
|
||||
}
|
||||
|
||||
return out.str();
|
||||
}
|
||||
|
||||
std::shared_ptr<Command> parseCommand(const std::string& tokens)
|
||||
{
|
||||
std::istringstream in(tokens);
|
||||
std::string verb;
|
||||
if (!(in >> verb))
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool ok = true;
|
||||
|
||||
if (verb == "place")
|
||||
{
|
||||
std::shared_ptr<PlaceBuildingCommand> c = std::make_shared<PlaceBuildingCommand>();
|
||||
std::string typeToken;
|
||||
std::string rotToken;
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (!(in >> typeToken >> x >> y >> rotToken)) { return nullptr; }
|
||||
const std::optional<BuildingType> type = parseBuildingType(typeToken);
|
||||
if (!type.has_value()) { return nullptr; }
|
||||
c->type = *type;
|
||||
c->anchor = QPoint(x, y);
|
||||
c->rotation = rotationFromString(rotToken);
|
||||
|
||||
std::string segment;
|
||||
while (in >> segment)
|
||||
{
|
||||
if (segment == "recipe")
|
||||
{
|
||||
std::string id;
|
||||
if (!(in >> id)) { return nullptr; }
|
||||
c->recipeId = id;
|
||||
}
|
||||
else if (segment == "layout")
|
||||
{
|
||||
c->shipLayout = parseLayout(in, ok);
|
||||
if (!ok) { return nullptr; }
|
||||
}
|
||||
else if (segment == "filters")
|
||||
{
|
||||
parseFilters(in, c->splitterFilterA, c->splitterFilterB, ok);
|
||||
if (!ok) { return nullptr; }
|
||||
c->hasSplitterFilters = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
return c;
|
||||
}
|
||||
if (verb == "demolish")
|
||||
{
|
||||
std::shared_ptr<DemolishCommand> c = std::make_shared<DemolishCommand>();
|
||||
if (!(in >> c->id)) { return nullptr; }
|
||||
return c;
|
||||
}
|
||||
if (verb == "rotate")
|
||||
{
|
||||
std::shared_ptr<RotateInPlaceCommand> c = std::make_shared<RotateInPlaceCommand>();
|
||||
std::string rotToken;
|
||||
if (!(in >> c->id >> rotToken)) { return nullptr; }
|
||||
c->newRotation = rotationFromString(rotToken);
|
||||
return c;
|
||||
}
|
||||
if (verb == "setrecipe")
|
||||
{
|
||||
std::shared_ptr<SetRecipeCommand> c = std::make_shared<SetRecipeCommand>();
|
||||
if (!(in >> c->id >> c->recipeId)) { return nullptr; }
|
||||
return c;
|
||||
}
|
||||
if (verb == "setlayout")
|
||||
{
|
||||
std::shared_ptr<SetShipLayoutCommand> c = std::make_shared<SetShipLayoutCommand>();
|
||||
if (!(in >> c->id)) { return nullptr; }
|
||||
c->layout = parseLayout(in, ok);
|
||||
if (!ok) { return nullptr; }
|
||||
return c;
|
||||
}
|
||||
if (verb == "sitefilters")
|
||||
{
|
||||
std::shared_ptr<SetSiteSplitterFiltersCommand> c =
|
||||
std::make_shared<SetSiteSplitterFiltersCommand>();
|
||||
if (!(in >> c->id)) { return nullptr; }
|
||||
parseFilters(in, c->filterA, c->filterB, ok);
|
||||
if (!ok) { return nullptr; }
|
||||
return c;
|
||||
}
|
||||
if (verb == "splitterfilters")
|
||||
{
|
||||
std::shared_ptr<SetSplitterFiltersCommand> c =
|
||||
std::make_shared<SetSplitterFiltersCommand>();
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (!(in >> x >> y)) { return nullptr; }
|
||||
c->tile = QPoint(x, y);
|
||||
parseFilters(in, c->filterA, c->filterB, ok);
|
||||
if (!ok) { return nullptr; }
|
||||
return c;
|
||||
}
|
||||
if (verb == "clearbelt")
|
||||
{
|
||||
std::shared_ptr<ClearBeltTilesCommand> c = std::make_shared<ClearBeltTilesCommand>();
|
||||
int count = 0;
|
||||
if (!(in >> count) || count < 0) { return nullptr; }
|
||||
for (int i = 0; i < count; ++i)
|
||||
{
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (!(in >> x >> y)) { return nullptr; }
|
||||
c->tiles.push_back(QPoint(x, y));
|
||||
}
|
||||
return c;
|
||||
}
|
||||
if (verb == "schematic")
|
||||
{
|
||||
std::shared_ptr<ApplySchematicChoiceCommand> c =
|
||||
std::make_shared<ApplySchematicChoiceCommand>();
|
||||
if (!(in >> c->choiceIndex)) { return nullptr; }
|
||||
return c;
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
21
src/lib/sim/CommandSerializer.h
Normal file
21
src/lib/sim/CommandSerializer.h
Normal file
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
struct Command;
|
||||
|
||||
// Serializes a command to a single-line, space-delimited token sequence for the
|
||||
// replay file (see docs/replay_design.md "File format: line-oriented ...").
|
||||
// Config ids (building types, recipes, items, modules) are whitespace-free
|
||||
// identifiers, so space delimiting is unambiguous; variable-length parts are
|
||||
// length-prefixed so the matching parser (added in Phase 3) is unambiguous.
|
||||
//
|
||||
// Reset is a file boundary (it rolls the replay file), not a stream entry, so it
|
||||
// is never serialized here.
|
||||
std::string serializeCommand(const Command& command);
|
||||
|
||||
// Inverse of serializeCommand: parses the command token sequence (the part of a
|
||||
// replay line after the leading tick) back into a Command. Returns nullptr if the
|
||||
// tokens are malformed or reference an unknown building type.
|
||||
std::shared_ptr<Command> parseCommand(const std::string& tokens);
|
||||
56
src/lib/sim/ReplayPlayer.cpp
Normal file
56
src/lib/sim/ReplayPlayer.cpp
Normal file
@@ -0,0 +1,56 @@
|
||||
#include "ReplayPlayer.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
#include "Command.h"
|
||||
#include "Simulation.h"
|
||||
|
||||
ReplayPlayer::ReplayPlayer(Simulation& simulation, std::vector<ReplayEntry> entries)
|
||||
: m_simulation(simulation)
|
||||
, m_entries(std::move(entries))
|
||||
{
|
||||
}
|
||||
|
||||
void ReplayPlayer::start()
|
||||
{
|
||||
processEntriesAt(0);
|
||||
}
|
||||
|
||||
void ReplayPlayer::advanceTo(Tick tick)
|
||||
{
|
||||
processEntriesAt(tick);
|
||||
}
|
||||
|
||||
bool ReplayPlayer::isFinished() const
|
||||
{
|
||||
return m_desyncTick.has_value() || m_cursor >= m_entries.size();
|
||||
}
|
||||
|
||||
std::optional<Tick> ReplayPlayer::getDesyncTick() const
|
||||
{
|
||||
return m_desyncTick;
|
||||
}
|
||||
|
||||
void ReplayPlayer::processEntriesAt(Tick tick)
|
||||
{
|
||||
// Consume entries for this tick in file order. The recording writes, at a
|
||||
// given tick: the periodic checksum (if any) first, then command lines each
|
||||
// followed by their post-apply checksum — so applying/verifying in file order
|
||||
// reproduces the original sequence exactly.
|
||||
while (!m_desyncTick.has_value()
|
||||
&& m_cursor < m_entries.size()
|
||||
&& m_entries[m_cursor].tick == tick)
|
||||
{
|
||||
const ReplayEntry& entry = m_entries[m_cursor];
|
||||
++m_cursor;
|
||||
|
||||
if (entry.isCommand)
|
||||
{
|
||||
m_simulation.apply(*entry.command);
|
||||
}
|
||||
else if (m_simulation.rngFingerprint() != entry.fingerprint)
|
||||
{
|
||||
m_desyncTick = tick;
|
||||
}
|
||||
}
|
||||
}
|
||||
48
src/lib/sim/ReplayPlayer.h
Normal file
48
src/lib/sim/ReplayPlayer.h
Normal file
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
#include "ReplayReader.h"
|
||||
#include "Tick.h"
|
||||
|
||||
class Simulation;
|
||||
|
||||
// Drives playback of a parsed replay against a Simulation: applies each recorded
|
||||
// command at its recorded tick (through Simulation::apply) and verifies the RNG
|
||||
// checksums, reporting the first desync. Entries are consumed in file order,
|
||||
// which reproduces the exact command/tick interleaving of the original run.
|
||||
//
|
||||
// Cadence (mirrors how the run was recorded so checksums line up):
|
||||
// player.start(); // process tick-0 entries before the first tick
|
||||
// each frame, for each tick to run:
|
||||
// if (player.isFinished()) break;
|
||||
// sim.tick();
|
||||
// player.advanceTo(sim.currentTick());
|
||||
class ReplayPlayer
|
||||
{
|
||||
public:
|
||||
ReplayPlayer(Simulation& simulation, std::vector<ReplayEntry> entries);
|
||||
|
||||
// Process all entries recorded at tick 0 (the initial checksum and any
|
||||
// commands issued before the first tick). Call once, before the first tick.
|
||||
void start();
|
||||
|
||||
// Process all entries recorded at `tick`. Call once after each sim.tick().
|
||||
void advanceTo(Tick tick);
|
||||
|
||||
// True once all entries are consumed or a desync was detected.
|
||||
bool isFinished() const;
|
||||
|
||||
// The tick at which the recomputed checksum first diverged, if any.
|
||||
std::optional<Tick> getDesyncTick() const;
|
||||
|
||||
private:
|
||||
void processEntriesAt(Tick tick);
|
||||
|
||||
Simulation& m_simulation;
|
||||
std::vector<ReplayEntry> m_entries;
|
||||
std::size_t m_cursor = 0;
|
||||
std::optional<Tick> m_desyncTick;
|
||||
};
|
||||
145
src/lib/sim/ReplayReader.cpp
Normal file
145
src/lib/sim/ReplayReader.cpp
Normal file
@@ -0,0 +1,145 @@
|
||||
#include "ReplayReader.h"
|
||||
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
#include "Command.h"
|
||||
#include "CommandSerializer.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
void stripCarriageReturn(std::string& line)
|
||||
{
|
||||
if (!line.empty() && line.back() == '\r')
|
||||
{
|
||||
line.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
// Splits "key value with spaces" into (key, remainder). Remainder may be empty.
|
||||
void splitKeyValue(const std::string& line, std::string& key, std::string& value)
|
||||
{
|
||||
const std::string::size_type space = line.find(' ');
|
||||
if (space == std::string::npos)
|
||||
{
|
||||
key = line;
|
||||
value.clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
key = line.substr(0, space);
|
||||
value = line.substr(space + 1);
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::optional<ParsedReplay> readReplayFile(const std::string& path)
|
||||
{
|
||||
std::ifstream stream(path, std::ios::in);
|
||||
if (!stream.is_open())
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
ParsedReplay replay;
|
||||
std::string line;
|
||||
|
||||
// --- Header (up to the "---" separator) ---
|
||||
bool sawSeparator = false;
|
||||
while (std::getline(stream, line))
|
||||
{
|
||||
stripCarriageReturn(line);
|
||||
if (line == "---")
|
||||
{
|
||||
sawSeparator = true;
|
||||
break;
|
||||
}
|
||||
if (line.empty() || line[0] == '#')
|
||||
{
|
||||
continue; // banner / blank
|
||||
}
|
||||
|
||||
std::string key;
|
||||
std::string value;
|
||||
splitKeyValue(line, key, value);
|
||||
try
|
||||
{
|
||||
if (key == "version") { replay.header.version = std::stoi(value); }
|
||||
else if (key == "build") { replay.header.build = value; }
|
||||
else if (key == "seed") { replay.header.seed = static_cast<unsigned int>(std::stoul(value)); }
|
||||
else if (key == "config_hash") { replay.header.configHash = value; }
|
||||
else if (key == "timestamp") { replay.header.timestamp = value; }
|
||||
}
|
||||
catch (const std::exception&)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sawSeparator)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// --- Command / checksum stream ---
|
||||
while (std::getline(stream, line))
|
||||
{
|
||||
stripCarriageReturn(line);
|
||||
if (line.empty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line[0] == '#')
|
||||
{
|
||||
// "# checksum <tick> <hex>"
|
||||
std::istringstream in(line);
|
||||
std::string hash;
|
||||
std::string keyword;
|
||||
ReplayEntry entry;
|
||||
in >> hash >> keyword >> entry.tick >> hash;
|
||||
if (keyword != "checksum")
|
||||
{
|
||||
continue; // unknown comment line — ignore
|
||||
}
|
||||
try
|
||||
{
|
||||
entry.fingerprint = std::stoull(hash, nullptr, 16);
|
||||
}
|
||||
catch (const std::exception&)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
entry.isCommand = false;
|
||||
replay.entries.push_back(std::move(entry));
|
||||
continue;
|
||||
}
|
||||
|
||||
// "<tick> <serialized command>"
|
||||
const std::string::size_type space = line.find(' ');
|
||||
if (space == std::string::npos)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
ReplayEntry entry;
|
||||
try
|
||||
{
|
||||
entry.tick = std::stoll(line.substr(0, space));
|
||||
}
|
||||
catch (const std::exception&)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
std::shared_ptr<Command> command = parseCommand(line.substr(space + 1));
|
||||
if (!command)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
entry.isCommand = true;
|
||||
entry.command = std::move(command);
|
||||
replay.entries.push_back(std::move(entry));
|
||||
}
|
||||
|
||||
return replay;
|
||||
}
|
||||
41
src/lib/sim/ReplayReader.h
Normal file
41
src/lib/sim/ReplayReader.h
Normal file
@@ -0,0 +1,41 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Tick.h"
|
||||
|
||||
struct Command;
|
||||
|
||||
struct ReplayHeader
|
||||
{
|
||||
int version = 0;
|
||||
std::string build;
|
||||
unsigned int seed = 0;
|
||||
std::string configHash;
|
||||
std::string timestamp;
|
||||
};
|
||||
|
||||
// One entry of the replay stream: either a command (applied at its tick) or an
|
||||
// RNG checksum (verified at its tick). Entries are kept in file order, which is
|
||||
// the canonical order the playback driver reproduces.
|
||||
struct ReplayEntry
|
||||
{
|
||||
Tick tick = 0;
|
||||
bool isCommand = false;
|
||||
std::shared_ptr<const Command> command; // set iff isCommand
|
||||
std::uint64_t fingerprint = 0; // set iff !isCommand
|
||||
};
|
||||
|
||||
struct ParsedReplay
|
||||
{
|
||||
ReplayHeader header;
|
||||
std::vector<ReplayEntry> entries;
|
||||
};
|
||||
|
||||
// Parses a replay file (header + command/checksum stream). Returns nullopt on an
|
||||
// I/O failure or malformed content (e.g. an unparseable command line).
|
||||
std::optional<ParsedReplay> readReplayFile(const std::string& path);
|
||||
130
src/lib/sim/ReplayRecorder.cpp
Normal file
130
src/lib/sim/ReplayRecorder.cpp
Normal file
@@ -0,0 +1,130 @@
|
||||
#include "ReplayRecorder.h"
|
||||
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
#include <utility>
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
#include "Command.h"
|
||||
#include "CommandSerializer.h"
|
||||
#include "StateChecksum.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr const char* kReplayFormatVersion = "1";
|
||||
|
||||
// Build fingerprint: even a new local build can desync old replays (float
|
||||
// reasons), so the header carries a per-build tag to warn on mismatch.
|
||||
const std::string kBuildTag = std::string(__DATE__) + " " + __TIME__;
|
||||
|
||||
std::string toHex(std::uint64_t value)
|
||||
{
|
||||
std::ostringstream out;
|
||||
out << std::hex << std::setw(16) << std::setfill('0') << value;
|
||||
return out.str();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
ReplayRecorder::ReplayRecorder(std::string configDir, std::string outputDir)
|
||||
: m_configDir(std::move(configDir))
|
||||
, m_outputDir(std::move(outputDir))
|
||||
{
|
||||
}
|
||||
|
||||
ReplayRecorder::~ReplayRecorder()
|
||||
{
|
||||
close();
|
||||
}
|
||||
|
||||
std::string computeReplayConfigHash(const std::string& configDir)
|
||||
{
|
||||
Hasher hasher;
|
||||
QDir dir(QString::fromStdString(configDir));
|
||||
const QStringList files =
|
||||
dir.entryList(QStringList() << "*.toml", QDir::Files, QDir::Name);
|
||||
for (const QString& name : files)
|
||||
{
|
||||
hasher.append(name.toStdString());
|
||||
QFile file(dir.filePath(name));
|
||||
if (file.open(QIODevice::ReadOnly))
|
||||
{
|
||||
const QByteArray bytes = file.readAll();
|
||||
hasher.appendBytes(bytes.constData(), static_cast<std::size_t>(bytes.size()));
|
||||
}
|
||||
}
|
||||
return toHex(hasher.value());
|
||||
}
|
||||
|
||||
void ReplayRecorder::startNewRun(unsigned int seed, std::uint64_t initialRngFingerprint)
|
||||
{
|
||||
close();
|
||||
|
||||
QDir().mkpath(QString::fromStdString(m_outputDir));
|
||||
const QString timestamp = QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss");
|
||||
const QString fileName = timestamp + "_" + QString::number(seed) + ".replay";
|
||||
m_filePath = QDir(QString::fromStdString(m_outputDir)).filePath(fileName).toStdString();
|
||||
|
||||
m_stream.open(m_filePath, std::ios::out | std::ios::trunc);
|
||||
if (!m_stream.is_open())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_stream << "# dota_factory replay\n";
|
||||
m_stream << "version " << kReplayFormatVersion << "\n";
|
||||
m_stream << "build " << kBuildTag << "\n";
|
||||
m_stream << "seed " << seed << "\n";
|
||||
m_stream << "config_hash " << computeReplayConfigHash(m_configDir) << "\n";
|
||||
m_stream << "timestamp "
|
||||
<< QDateTime::currentDateTime().toString(Qt::ISODate).toStdString() << "\n";
|
||||
m_stream << "---\n";
|
||||
m_stream << "# checksum 0 " << toHex(initialRngFingerprint) << "\n";
|
||||
m_stream.flush();
|
||||
}
|
||||
|
||||
void ReplayRecorder::recordCommand(Tick tick, const Command& command,
|
||||
std::uint64_t rngFingerprint)
|
||||
{
|
||||
if (!m_stream.is_open())
|
||||
{
|
||||
return;
|
||||
}
|
||||
m_stream << tick << ' ' << serializeCommand(command) << "\n";
|
||||
m_stream << "# checksum " << tick << ' ' << toHex(rngFingerprint) << "\n";
|
||||
m_stream.flush();
|
||||
}
|
||||
|
||||
void ReplayRecorder::recordChecksum(Tick tick, std::uint64_t rngFingerprint)
|
||||
{
|
||||
if (!m_stream.is_open())
|
||||
{
|
||||
return;
|
||||
}
|
||||
m_stream << "# checksum " << tick << ' ' << toHex(rngFingerprint) << "\n";
|
||||
m_stream.flush();
|
||||
}
|
||||
|
||||
void ReplayRecorder::close()
|
||||
{
|
||||
if (m_stream.is_open())
|
||||
{
|
||||
m_stream.flush();
|
||||
m_stream.close();
|
||||
}
|
||||
}
|
||||
|
||||
bool ReplayRecorder::isOpen() const
|
||||
{
|
||||
return m_stream.is_open();
|
||||
}
|
||||
|
||||
const std::string& ReplayRecorder::currentFilePath() const
|
||||
{
|
||||
return m_filePath;
|
||||
}
|
||||
54
src/lib/sim/ReplayRecorder.h
Normal file
54
src/lib/sim/ReplayRecorder.h
Normal file
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
#include "Tick.h"
|
||||
|
||||
struct Command;
|
||||
|
||||
// 64-bit hash (16-char hex) over the *.toml files in configDir. Stored in the
|
||||
// replay header and recomputed on playback to detect a config mismatch.
|
||||
std::string computeReplayConfigHash(const std::string& configDir);
|
||||
|
||||
// Writes a replay file as the game runs (see docs/replay_design.md). The format
|
||||
// is line-oriented append-friendly text: a small keyed header, then one line per
|
||||
// command (tick-tagged) interleaved with RNG-state checksum lines for desync
|
||||
// detection. Each line is flushed so a crash mid-run still leaves a valid partial
|
||||
// file.
|
||||
//
|
||||
// One file = one run between Reset boundaries; startNewRun() closes the current
|
||||
// file and opens a fresh one.
|
||||
class ReplayRecorder
|
||||
{
|
||||
public:
|
||||
// configDir: hashed (its *.toml files) into the header for config-mismatch
|
||||
// detection on playback. outputDir: where .replay files are written.
|
||||
ReplayRecorder(std::string configDir, std::string outputDir);
|
||||
~ReplayRecorder();
|
||||
|
||||
ReplayRecorder(const ReplayRecorder&) = delete;
|
||||
ReplayRecorder& operator=(const ReplayRecorder&) = delete;
|
||||
|
||||
// Close any current file, then open a fresh one (named <timestamp>_<seed>),
|
||||
// write the header, and record an initial tick-0 checksum. A run boundary.
|
||||
void startNewRun(unsigned int seed, std::uint64_t initialRngFingerprint);
|
||||
|
||||
// Append one command line tagged with the tick it was applied at, followed by
|
||||
// the post-apply RNG checksum.
|
||||
void recordCommand(Tick tick, const Command& command, std::uint64_t rngFingerprint);
|
||||
|
||||
// Append a periodic RNG checksum line.
|
||||
void recordChecksum(Tick tick, std::uint64_t rngFingerprint);
|
||||
|
||||
void close();
|
||||
bool isOpen() const;
|
||||
const std::string& currentFilePath() const;
|
||||
|
||||
private:
|
||||
std::string m_configDir;
|
||||
std::string m_outputDir;
|
||||
std::string m_filePath;
|
||||
std::ofstream m_stream;
|
||||
};
|
||||
@@ -17,9 +17,7 @@
|
||||
|
||||
ShipStats calculateShipStats(const GameConfig& config,
|
||||
const std::string& shipId,
|
||||
int level,
|
||||
const std::vector<PlacedModule>& modules,
|
||||
const std::map<std::string, int>& moduleLevelOverrides)
|
||||
const std::vector<PlacedModule>& modules)
|
||||
{
|
||||
ShipStats result{};
|
||||
|
||||
@@ -39,24 +37,20 @@ ShipStats calculateShipStats(const GameConfig& config,
|
||||
return nullptr;
|
||||
};
|
||||
|
||||
const double x = static_cast<double>(level);
|
||||
const double tileSize = config.world.tileSize_m;
|
||||
|
||||
// --- Base hull stats (convert from SI to display units) ------------------
|
||||
result.hp = static_cast<float>(
|
||||
shipDef->health.hpFormula.evaluate(x));
|
||||
result.hp = shipDef->health.hp;
|
||||
result.maxSpeed_tps = static_cast<float>(
|
||||
shipDef->movement.speedFormula.evaluate(x) / tileSize);
|
||||
shipDef->movement.speed_mps / tileSize);
|
||||
result.sensorRange_tiles = static_cast<float>(
|
||||
shipDef->sensor.sensorRangeFormula.evaluate(x) / tileSize);
|
||||
shipDef->sensor.sensorRange_m / tileSize);
|
||||
result.mainAcceleration_tpss = static_cast<float>(
|
||||
shipDef->movement.mainAccelerationFormula.evaluate(x) / tileSize);
|
||||
shipDef->movement.mainAcceleration_mpss / tileSize);
|
||||
result.maneuveringAcceleration_tpss = static_cast<float>(
|
||||
shipDef->movement.maneuveringAccelerationFormula.evaluate(x) / tileSize);
|
||||
result.angularAcceleration_radpss = static_cast<float>(
|
||||
shipDef->movement.angularAccelerationFormula.evaluate(x));
|
||||
result.maxRotationSpeed_radps = static_cast<float>(
|
||||
shipDef->movement.maxRotationSpeedFormula.evaluate(x));
|
||||
shipDef->movement.maneuveringAcceleration_mpss / tileSize);
|
||||
result.angularAcceleration_radpss = shipDef->movement.angularAcceleration_radpss;
|
||||
result.maxRotationSpeed_radps = shipDef->movement.maxRotationSpeed_radps;
|
||||
|
||||
// --- Pass 1: base capability stats per module instance -------------------
|
||||
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);
|
||||
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)
|
||||
{
|
||||
WeaponInstance wi;
|
||||
wi.damage = static_cast<float>(def->weaponCapability->damageFormula.evaluate(mx));
|
||||
wi.range_tiles = static_cast<float>(def->weaponCapability->attackRangeFormula.evaluate(mx) / tileSize);
|
||||
wi.rate_hz = static_cast<float>(def->weaponCapability->attackRateFormula.evaluate(mx));
|
||||
wi.damage = def->weaponCapability->damage;
|
||||
wi.range_tiles = static_cast<float>(def->weaponCapability->attackRange_m / tileSize);
|
||||
wi.rate_hz = def->weaponCapability->attackRate_hz;
|
||||
weaponInstances.push_back(wi);
|
||||
}
|
||||
if (def->salvageCapability)
|
||||
{
|
||||
cargoCapacityBase += def->salvageCapability->cargoCapacityFormula.evaluate(mx);
|
||||
cargoCapacityBase += def->salvageCapability->cargoCapacity;
|
||||
|
||||
SalvageInstance si;
|
||||
si.range_tiles = static_cast<float>(def->salvageCapability->collectionRangeFormula.evaluate(mx) / tileSize);
|
||||
si.rate = static_cast<float>(def->salvageCapability->collectionRateFormula.evaluate(mx));
|
||||
si.range_tiles = static_cast<float>(def->salvageCapability->collectionRange_m / tileSize);
|
||||
si.rate = def->salvageCapability->collectionRate_hz;
|
||||
salvageInstances.push_back(si);
|
||||
}
|
||||
if (def->repairCapability)
|
||||
{
|
||||
RepairInstance ri;
|
||||
ri.rate_hz = static_cast<float>(def->repairCapability->repairRateFormula.evaluate(mx));
|
||||
ri.amount_hp = static_cast<float>(def->repairCapability->repairAmountHpFormula.evaluate(mx));
|
||||
ri.range_tiles = static_cast<float>(def->repairCapability->repairRangeFormula.evaluate(mx) / tileSize);
|
||||
ri.rate_hz = def->repairCapability->repairRate_hz;
|
||||
ri.amount_hp = def->repairCapability->repairAmountHp;
|
||||
ri.range_tiles = static_cast<float>(def->repairCapability->repairRange_m / tileSize);
|
||||
repairInstances.push_back(ri);
|
||||
}
|
||||
}
|
||||
@@ -120,13 +110,9 @@ ShipStats calculateShipStats(const GameConfig& config,
|
||||
const ModuleDef* def = findModuleDef(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)
|
||||
{
|
||||
const double val = sm.formula.evaluate(mx);
|
||||
const double val = sm.value;
|
||||
|
||||
const bool isWeaponStat = (sm.stat == "damage"
|
||||
|| sm.stat == "attack_range"
|
||||
|
||||
@@ -51,9 +51,7 @@ struct ShipStats
|
||||
|
||||
ShipStats calculateShipStats(const GameConfig& config,
|
||||
const std::string& shipId,
|
||||
int level,
|
||||
const std::vector<PlacedModule>& modules,
|
||||
const std::map<std::string, int>& moduleLevelOverrides = {});
|
||||
const std::vector<PlacedModule>& modules);
|
||||
|
||||
ShipStats buildShipStatsFromEntity(const EntityAdmin& admin, entt::entity shipEntity);
|
||||
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
|
||||
#include "AiSystem.h"
|
||||
#include "Command.h"
|
||||
#include "DisplayName.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "CombatSystem.h"
|
||||
#include "DynamicBodyComponent.h"
|
||||
#include "DynamicBodySystem.h"
|
||||
#include "FacingComponent.h"
|
||||
#include "FactionComponent.h"
|
||||
#include "EventManager.h"
|
||||
#include "HealthComponent.h"
|
||||
@@ -16,9 +20,11 @@
|
||||
#include "PositionComponent.h"
|
||||
#include "RepairSystem.h"
|
||||
#include "SalvagerSystem.h"
|
||||
#include "ScrapDataComponent.h"
|
||||
#include "ScrapSystem.h"
|
||||
#include "ShipIdentityComponent.h"
|
||||
#include "ShipSystem.h"
|
||||
#include "StateChecksum.h"
|
||||
#include "StationBodyComponent.h"
|
||||
#include "SurfaceMask.h"
|
||||
#include "tracing.h"
|
||||
@@ -28,6 +34,7 @@
|
||||
Simulation::Simulation(GameConfig config, unsigned int seed)
|
||||
: m_config(std::move(config))
|
||||
, m_rng(seed)
|
||||
, m_seed(seed)
|
||||
, m_currentTick(0)
|
||||
, m_nextDepartureTick(secondsToTicks(m_config.world.departureIntervalSeconds))
|
||||
, m_nextBuildingId(1)
|
||||
@@ -55,13 +62,7 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
std::map<std::string, int> moduleLevels;
|
||||
for (const auto& [mId, mState] : m_moduleSchematicLevels)
|
||||
{
|
||||
moduleLevels[mId] = mState.level;
|
||||
}
|
||||
m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout,
|
||||
moduleLevels);
|
||||
m_shipSystem->spawn(id, pos, /*isEnemy=*/false, layout);
|
||||
},
|
||||
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
|
||||
m_rng);
|
||||
@@ -80,7 +81,6 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
|
||||
{
|
||||
SchematicState state;
|
||||
state.unlocked = (def.unlockAtStationLevel == -1);
|
||||
state.level = (def.unlockAtStationLevel == -1) ? def.schematic.playerProductionLevel : 0;
|
||||
m_schematicLevels[def.id] = state;
|
||||
}
|
||||
|
||||
@@ -89,7 +89,6 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
|
||||
{
|
||||
SchematicState state;
|
||||
state.unlocked = (def.unlockAtStationLevel == -1);
|
||||
state.level = (def.unlockAtStationLevel == -1) ? def.playerProductionLevel : 0;
|
||||
m_moduleSchematicLevels[def.id] = state;
|
||||
}
|
||||
|
||||
@@ -129,11 +128,15 @@ void Simulation::reset(unsigned int seed)
|
||||
{
|
||||
EventManager::getInstance()->clearEvents();
|
||||
m_rng.seed(seed);
|
||||
m_seed = seed;
|
||||
m_currentTick = 0;
|
||||
m_nextDepartureTick = secondsToTicks(m_config.world.departureIntervalSeconds);
|
||||
m_nextBuildingId = 1;
|
||||
m_buildingBlocksStock = m_config.world.startingBuildingBlocks;
|
||||
m_expansionsPurchased = 0;
|
||||
m_gameOver = false;
|
||||
m_isWon = false;
|
||||
m_artifactCount = 0;
|
||||
m_hqBuildingId = kInvalidBuildingId;
|
||||
m_hqProxyEntity = entt::null;
|
||||
m_playerStation1Entity = entt::null;
|
||||
@@ -158,13 +161,7 @@ void Simulation::reset(unsigned int seed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
std::map<std::string, int> moduleLevels;
|
||||
for (const auto& [mId, mState] : m_moduleSchematicLevels)
|
||||
{
|
||||
moduleLevels[mId] = mState.level;
|
||||
}
|
||||
m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout,
|
||||
moduleLevels);
|
||||
m_shipSystem->spawn(id, pos, /*isEnemy=*/false, layout);
|
||||
},
|
||||
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
|
||||
m_rng);
|
||||
@@ -183,7 +180,6 @@ void Simulation::reset(unsigned int seed)
|
||||
{
|
||||
SchematicState state;
|
||||
state.unlocked = (def.unlockAtStationLevel == -1);
|
||||
state.level = (def.unlockAtStationLevel == -1) ? def.schematic.playerProductionLevel : 0;
|
||||
m_schematicLevels[def.id] = state;
|
||||
}
|
||||
|
||||
@@ -192,7 +188,6 @@ void Simulation::reset(unsigned int seed)
|
||||
{
|
||||
SchematicState state;
|
||||
state.unlocked = (def.unlockAtStationLevel == -1);
|
||||
state.level = (def.unlockAtStationLevel == -1) ? def.playerProductionLevel : 0;
|
||||
m_moduleSchematicLevels[def.id] = state;
|
||||
}
|
||||
|
||||
@@ -215,6 +210,95 @@ void Simulation::reset(unsigned int seed)
|
||||
// tick
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void Simulation::apply(const Command& command)
|
||||
{
|
||||
switch (command.kind)
|
||||
{
|
||||
case CommandKind::PlaceBuilding:
|
||||
{
|
||||
const PlaceBuildingCommand& c = static_cast<const PlaceBuildingCommand&>(command);
|
||||
const BuildingId id = tryPlaceBuilding(c.type, c.anchor, c.rotation);
|
||||
if (id == kInvalidBuildingId)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (c.recipeId.has_value())
|
||||
{
|
||||
m_buildingSystem->setRecipe(id, *c.recipeId);
|
||||
}
|
||||
if (c.shipLayout.has_value())
|
||||
{
|
||||
m_buildingSystem->setShipLayout(id, *c.shipLayout);
|
||||
}
|
||||
if (c.hasSplitterFilters)
|
||||
{
|
||||
m_buildingSystem->setSiteSplitterFilters(id, c.splitterFilterA, c.splitterFilterB);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case CommandKind::Demolish:
|
||||
demolish(static_cast<const DemolishCommand&>(command).id);
|
||||
break;
|
||||
case CommandKind::RotateInPlace:
|
||||
{
|
||||
const RotateInPlaceCommand& c = static_cast<const RotateInPlaceCommand&>(command);
|
||||
m_buildingSystem->rotateInPlace(c.id, c.newRotation);
|
||||
break;
|
||||
}
|
||||
case CommandKind::SetRecipe:
|
||||
{
|
||||
const SetRecipeCommand& c = static_cast<const SetRecipeCommand&>(command);
|
||||
m_buildingSystem->setRecipe(c.id, c.recipeId);
|
||||
break;
|
||||
}
|
||||
case CommandKind::SetShipLayout:
|
||||
{
|
||||
const SetShipLayoutCommand& c = static_cast<const SetShipLayoutCommand&>(command);
|
||||
m_buildingSystem->setShipLayout(c.id, c.layout);
|
||||
break;
|
||||
}
|
||||
case CommandKind::SetSiteSplitterFilters:
|
||||
{
|
||||
const SetSiteSplitterFiltersCommand& c =
|
||||
static_cast<const SetSiteSplitterFiltersCommand&>(command);
|
||||
m_buildingSystem->setSiteSplitterFilters(c.id, c.filterA, c.filterB);
|
||||
break;
|
||||
}
|
||||
case CommandKind::SetSplitterFilters:
|
||||
{
|
||||
const SetSplitterFiltersCommand& c =
|
||||
static_cast<const SetSplitterFiltersCommand&>(command);
|
||||
m_beltSystem.setSplitterFilters(c.tile, c.filterA, c.filterB);
|
||||
break;
|
||||
}
|
||||
case CommandKind::ClearBeltTiles:
|
||||
m_beltSystem.clearTiles(static_cast<const ClearBeltTilesCommand&>(command).tiles);
|
||||
break;
|
||||
case CommandKind::ApplySchematicChoice:
|
||||
applySchematicChoice(static_cast<const ApplySchematicChoiceCommand&>(command).choiceIndex);
|
||||
break;
|
||||
case CommandKind::ExpandAsteroid:
|
||||
tryExpandAsteroid();
|
||||
break;
|
||||
case CommandKind::Reset:
|
||||
{
|
||||
const ResetCommand& c = static_cast<const ResetCommand&>(command);
|
||||
if (c.config)
|
||||
{
|
||||
// operator* on a const shared_ptr yields a mutable GameConfig&, so
|
||||
// the move-only config moves into reset without a copy. The command
|
||||
// is applied once, so leaving its config moved-from is fine.
|
||||
reset(std::move(*c.config), c.seed);
|
||||
}
|
||||
else
|
||||
{
|
||||
reset(c.seed);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Simulation::tick()
|
||||
{
|
||||
EventManager::getInstance()->processEvents();
|
||||
@@ -456,15 +540,11 @@ void Simulation::tickDeathsAndLoot()
|
||||
{
|
||||
const ShipIdentityComponent& si = m_admin.get<ShipIdentityComponent>(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);
|
||||
m_scrapSystem->spawn(pos.value, def.loot.scrapDrop, despawnAt);
|
||||
break;
|
||||
}
|
||||
const Tick despawnAt = m_currentTick
|
||||
+ secondsToTicks(m_config.world.scrapDespawnSeconds);
|
||||
m_scrapSystem->spawn(pos.value, si.scrapDrop, despawnAt);
|
||||
}
|
||||
m_shipSystem->despawn(deadEntity);
|
||||
}
|
||||
@@ -552,19 +632,23 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
|
||||
struct PoolEntry { std::string id; DropType type; };
|
||||
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)
|
||||
{
|
||||
if (def.unlockAtStationLevel == -1 || def.unlockAtStationLevel <= destroyedStationLevel)
|
||||
{
|
||||
pool.push_back({def.id, DropType::Ship});
|
||||
}
|
||||
if (def.unlockAtStationLevel < 0 || def.unlockAtStationLevel > destroyedStationLevel) { continue; }
|
||||
if (m_schematicLevels.at(def.id).unlocked) { continue; }
|
||||
if (!prerequisitesSatisfied(def.unlockRequires)) { continue; }
|
||||
pool.push_back({def.id, DropType::Ship});
|
||||
}
|
||||
for (const ModuleDef& def : m_config.modules.modules)
|
||||
{
|
||||
if (def.unlockAtStationLevel == -1 || def.unlockAtStationLevel <= destroyedStationLevel)
|
||||
{
|
||||
pool.push_back({def.id, DropType::Module});
|
||||
}
|
||||
if (def.unlockAtStationLevel < 0 || def.unlockAtStationLevel > destroyedStationLevel) { continue; }
|
||||
if (m_moduleSchematicLevels.at(def.id).unlocked) { continue; }
|
||||
if (!prerequisitesSatisfied(def.unlockRequires)) { continue; }
|
||||
pool.push_back({def.id, DropType::Module});
|
||||
}
|
||||
for (const RecipeDef& def : m_config.recipes.recipes)
|
||||
{
|
||||
@@ -579,12 +663,20 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
|
||||
if (m_unlockedItemIds.count(out.item) > 0) { outputUnlocked = true; break; }
|
||||
}
|
||||
if (!outputUnlocked) { continue; }
|
||||
if (!prerequisitesSatisfied(def.unlockRequires)) { continue; }
|
||||
pool.push_back({def.id, DropType::Recipe});
|
||||
}
|
||||
|
||||
if (pool.empty()) { return; }
|
||||
|
||||
const int numChoices = std::min(static_cast<int>(pool.size()), 3);
|
||||
const double artifactChance = std::clamp(
|
||||
m_config.world.artifacts.artifactChanceFormula.evaluate(
|
||||
static_cast<double>(destroyedStationLevel)),
|
||||
0.0, 1.0);
|
||||
std::uniform_real_distribution<double> realDist(0.0, 1.0);
|
||||
const bool artifactRolled = realDist(m_rng) < artifactChance;
|
||||
|
||||
const int numChoices = std::min(static_cast<int>(pool.size()), artifactRolled ? 2 : 3);
|
||||
m_pendingSchematicChoices.clear();
|
||||
|
||||
const std::set<std::string> currentShipIds = getUnlockedShipSchematicIds();
|
||||
@@ -606,31 +698,16 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
|
||||
{
|
||||
option.type = SchematicType::Ship;
|
||||
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)
|
||||
{
|
||||
option.type = SchematicType::Module;
|
||||
option.displayName = toDisplayName(entry.id);
|
||||
const SchematicState& state = m_moduleSchematicLevels.at(entry.id);
|
||||
option.isNewUnlock = !state.unlocked;
|
||||
option.targetLevel = state.level + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
option.type = SchematicType::Recipe;
|
||||
option.isNewUnlock = true;
|
||||
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;
|
||||
}
|
||||
}
|
||||
option.displayName = toDisplayName(entry.id);
|
||||
}
|
||||
|
||||
// REQ-DEF-SCHEMATIC-DROP: preview recipes newly implicitly unlocked by this option.
|
||||
@@ -638,11 +715,11 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
|
||||
std::set<std::string> hypotheticalModuleIds = currentModuleIds;
|
||||
std::set<std::string> hypotheticalRecipeSchematicIds = m_unlockedRecipeSchematicIds;
|
||||
|
||||
if (entry.type == DropType::Ship && option.isNewUnlock)
|
||||
if (entry.type == DropType::Ship)
|
||||
{
|
||||
hypotheticalShipIds.insert(entry.id);
|
||||
}
|
||||
else if (entry.type == DropType::Module && option.isNewUnlock)
|
||||
else if (entry.type == DropType::Module)
|
||||
{
|
||||
hypotheticalModuleIds.insert(entry.id);
|
||||
}
|
||||
@@ -653,10 +730,19 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
|
||||
|
||||
const UnlockedSets hypothetical = computeUnlockedSets(
|
||||
hypotheticalShipIds, hypotheticalModuleIds, hypotheticalRecipeSchematicIds);
|
||||
option.newlyUnlockedItemNames = computeNewlyUnlockedItemNames(hypothetical);
|
||||
option.newlyUnlockedRecipeIds = computeNewlyUnlockedRecipeIds(hypothetical);
|
||||
|
||||
m_pendingSchematicChoices.push_back(option);
|
||||
}
|
||||
|
||||
if (artifactRolled)
|
||||
{
|
||||
SchematicChoiceOption artifactOption;
|
||||
artifactOption.schematicId = "";
|
||||
artifactOption.type = SchematicType::Artifact;
|
||||
artifactOption.displayName = "Artifact";
|
||||
m_pendingSchematicChoices.push_back(std::move(artifactOption));
|
||||
}
|
||||
}
|
||||
|
||||
void Simulation::applySchematicChoice(int choiceIndex)
|
||||
@@ -664,6 +750,17 @@ void Simulation::applySchematicChoice(int choiceIndex)
|
||||
assert(choiceIndex >= 0 && choiceIndex < static_cast<int>(m_pendingSchematicChoices.size()));
|
||||
const SchematicChoiceOption& chosen = m_pendingSchematicChoices[static_cast<std::size_t>(choiceIndex)];
|
||||
|
||||
if (chosen.type == SchematicType::Artifact)
|
||||
{
|
||||
m_artifactCount += 1;
|
||||
if (m_artifactCount >= m_config.world.artifacts.artifactWinCount)
|
||||
{
|
||||
m_isWon = true;
|
||||
}
|
||||
m_pendingSchematicChoices.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (chosen.type == SchematicType::Recipe)
|
||||
{
|
||||
m_unlockedRecipeSchematicIds.insert(chosen.schematicId);
|
||||
@@ -674,7 +771,6 @@ void Simulation::applySchematicChoice(int choiceIndex)
|
||||
? m_moduleSchematicLevels.at(chosen.schematicId)
|
||||
: m_schematicLevels.at(chosen.schematicId);
|
||||
state.unlocked = true;
|
||||
state.level += 1;
|
||||
}
|
||||
|
||||
recomputeUnlocked();
|
||||
@@ -713,6 +809,32 @@ std::set<std::string> Simulation::getUnlockedModuleSchematicIds() const
|
||||
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(
|
||||
const std::set<std::string>& unlockedShipSchematicIds,
|
||||
const std::set<std::string>& unlockedModuleSchematicIds,
|
||||
@@ -796,23 +918,20 @@ Simulation::UnlockedSets Simulation::computeUnlockedSets(
|
||||
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)
|
||||
{
|
||||
if (m_unlockedRecipeIds.count(recipeId) > 0) { continue; }
|
||||
for (const RecipeDef& def : m_config.recipes.recipes)
|
||||
{
|
||||
if (def.id != recipeId) { continue; }
|
||||
for (const RecipeOutput& out : def.outputs)
|
||||
{
|
||||
itemNames.insert(toDisplayName(out.item));
|
||||
}
|
||||
break;
|
||||
}
|
||||
recipeIds.push_back(recipeId);
|
||||
}
|
||||
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
|
||||
@@ -825,6 +944,117 @@ bool Simulation::isItemUnlocked(const std::string& itemId) const
|
||||
return m_unlockedItemIds.count(itemId) > 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Determinism (see docs/replay_design.md)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void Simulation::appendSchematicMap(Hasher& hasher,
|
||||
const std::map<std::string, SchematicState>& levels)
|
||||
{
|
||||
hasher.append(levels.size());
|
||||
for (const std::pair<const std::string, SchematicState>& entry : levels)
|
||||
{
|
||||
hasher.append(entry.first);
|
||||
hasher.append(entry.second.unlocked);
|
||||
}
|
||||
}
|
||||
|
||||
void Simulation::appendStringSet(Hasher& hasher, const std::set<std::string>& ids)
|
||||
{
|
||||
hasher.append(ids.size());
|
||||
for (const std::string& id : ids)
|
||||
{
|
||||
hasher.append(id);
|
||||
}
|
||||
}
|
||||
|
||||
unsigned long long Simulation::rngFingerprint() const
|
||||
{
|
||||
return fingerprintRng(m_rng);
|
||||
}
|
||||
|
||||
unsigned long long Simulation::computeStateChecksum() const
|
||||
{
|
||||
Hasher hasher;
|
||||
|
||||
// RNG stream — the most sensitive signal of divergence.
|
||||
hasher.append(fingerprintRng(m_rng));
|
||||
|
||||
// Top-level scalars.
|
||||
hasher.append(m_currentTick);
|
||||
hasher.append(m_nextDepartureTick);
|
||||
hasher.append(m_nextBuildingId);
|
||||
hasher.append(m_buildingBlocksStock);
|
||||
hasher.append(m_gameOver);
|
||||
hasher.append(m_isWon);
|
||||
hasher.append(m_artifactCount);
|
||||
hasher.append(m_expansionsPurchased);
|
||||
|
||||
// WaveSystem scalar state, reached through existing accessors.
|
||||
hasher.append(threatLevel());
|
||||
hasher.append(threatAccumulationRate());
|
||||
hasher.append(bossWaveCounter());
|
||||
hasher.append(bossCountdownTicks());
|
||||
hasher.append(normalGapRemainingTicks());
|
||||
|
||||
// Schematic / unlock state (std::map and std::set iterate in sorted order).
|
||||
appendSchematicMap(hasher, m_schematicLevels);
|
||||
appendSchematicMap(hasher, m_moduleSchematicLevels);
|
||||
appendStringSet(hasher, m_unlockedRecipeSchematicIds);
|
||||
appendStringSet(hasher, m_unlockedRecipeIds);
|
||||
appendStringSet(hasher, m_unlockedItemIds);
|
||||
|
||||
// Subsystems contribute their own state.
|
||||
m_buildingSystem->appendChecksum(hasher);
|
||||
m_beltSystem.appendChecksum(hasher);
|
||||
|
||||
// ECS component state. View iteration order is a pure function of the
|
||||
// (identical) operation sequence on a fixed binary; each entity's raw id is
|
||||
// folded in so the fingerprint is keyed, not merely a sum of fields.
|
||||
m_admin.forEach<PositionComponent>(
|
||||
[&hasher](entt::entity entity, const PositionComponent& c)
|
||||
{
|
||||
hasher.append(static_cast<std::uint32_t>(entity));
|
||||
hasher.append(c.value);
|
||||
});
|
||||
m_admin.forEach<HealthComponent>(
|
||||
[&hasher](entt::entity entity, const HealthComponent& c)
|
||||
{
|
||||
hasher.append(static_cast<std::uint32_t>(entity));
|
||||
hasher.append(c.hp);
|
||||
hasher.append(c.maxHp);
|
||||
});
|
||||
m_admin.forEach<FacingComponent>(
|
||||
[&hasher](entt::entity entity, const FacingComponent& c)
|
||||
{
|
||||
hasher.append(static_cast<std::uint32_t>(entity));
|
||||
hasher.append(c.radians);
|
||||
});
|
||||
m_admin.forEach<DynamicBodyComponent>(
|
||||
[&hasher](entt::entity entity, const DynamicBodyComponent& c)
|
||||
{
|
||||
hasher.append(static_cast<std::uint32_t>(entity));
|
||||
hasher.append(c.velocity_tpt);
|
||||
hasher.append(c.angularVelocity_rpt);
|
||||
hasher.append(c.linearAcceleration_tptt);
|
||||
hasher.append(c.angularAcceleration_rptt);
|
||||
});
|
||||
m_admin.forEach<ScrapDataComponent>(
|
||||
[&hasher](entt::entity entity, const ScrapDataComponent& c)
|
||||
{
|
||||
hasher.append(static_cast<std::uint32_t>(entity));
|
||||
hasher.append(c.amount);
|
||||
});
|
||||
m_admin.forEach<ShipIdentityComponent>(
|
||||
[&hasher](entt::entity entity, const ShipIdentityComponent& c)
|
||||
{
|
||||
hasher.append(static_cast<std::uint32_t>(entity));
|
||||
hasher.append(c.schematicId);
|
||||
});
|
||||
|
||||
return hasher.value();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drains
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -856,16 +1086,56 @@ Tick Simulation::currentTick() const
|
||||
return m_currentTick;
|
||||
}
|
||||
|
||||
unsigned int Simulation::getSeed() const
|
||||
{
|
||||
return m_seed;
|
||||
}
|
||||
|
||||
int Simulation::buildingBlocksStock() const
|
||||
{
|
||||
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
|
||||
{
|
||||
return m_gameOver;
|
||||
}
|
||||
|
||||
bool Simulation::isWon() const
|
||||
{
|
||||
return m_isWon;
|
||||
}
|
||||
|
||||
int Simulation::artifactCount() const
|
||||
{
|
||||
return m_artifactCount;
|
||||
}
|
||||
|
||||
double Simulation::threatLevel() const
|
||||
{
|
||||
return m_waveSystem->threatLevel();
|
||||
@@ -901,17 +1171,6 @@ Tick Simulation::normalGapRemainingTicks() const
|
||||
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
|
||||
{
|
||||
const std::map<std::string, SchematicState>::const_iterator it =
|
||||
@@ -923,17 +1182,6 @@ bool Simulation::isSchematicUnlocked(const std::string& shipId) const
|
||||
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
|
||||
{
|
||||
const std::map<std::string, SchematicState>::const_iterator it =
|
||||
@@ -974,7 +1222,7 @@ void Simulation::demolish(BuildingId id)
|
||||
m_buildingBlocksStock += m_buildingSystem->demolish(id);
|
||||
}
|
||||
|
||||
BuildingSystem& Simulation::buildings()
|
||||
BuildingSystem& Simulation::buildingsMutable()
|
||||
{
|
||||
return *m_buildingSystem;
|
||||
}
|
||||
@@ -984,7 +1232,7 @@ const BuildingSystem& Simulation::buildings() const
|
||||
return *m_buildingSystem;
|
||||
}
|
||||
|
||||
BeltSystem& Simulation::belts()
|
||||
BeltSystem& Simulation::beltsMutable()
|
||||
{
|
||||
return m_beltSystem;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
|
||||
class AiSystem;
|
||||
class BuildingSystem;
|
||||
struct Command;
|
||||
class Hasher;
|
||||
class CombatSystem;
|
||||
class DynamicBodySystem;
|
||||
class MovementIntentSystem;
|
||||
@@ -50,6 +52,12 @@ public:
|
||||
// Advances the simulation by one tick. Tick order per architecture.md §Tick Order.
|
||||
void tick();
|
||||
|
||||
// The single command chokepoint: applies one player command by dispatching
|
||||
// to the underlying mutators. Every sim mutation during play must flow
|
||||
// through here so it can be recorded and replayed (see docs/replay_design.md
|
||||
// and CommandManager). Reached via CommandManager::drain.
|
||||
void apply(const Command& command);
|
||||
|
||||
// Returns all fire events accumulated since the last drain, clearing the
|
||||
// internal queue. Call once per rendered frame (REQ-SHP-FIRING-BEAM).
|
||||
std::vector<BeamFiredEvent> drainBeamFiredEvents();
|
||||
@@ -60,14 +68,19 @@ public:
|
||||
// Returns true if there are pending schematic choices waiting for player input.
|
||||
bool hasSchematicChoicesPending() const;
|
||||
|
||||
// Applies the player's chosen schematic from the pending choices.
|
||||
// choiceIndex must be in [0, pendingChoices.size()).
|
||||
// Clears the pending choices after application.
|
||||
void applySchematicChoice(int choiceIndex);
|
||||
|
||||
Tick currentTick() const;
|
||||
// The seed this run was (re)initialized with; written to the replay header.
|
||||
unsigned int getSeed() 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 isWon() const;
|
||||
int artifactCount() const;
|
||||
double threatLevel() const;
|
||||
double threatAccumulationRate() const;
|
||||
double maxFactoryProductionThreatRate() const;
|
||||
@@ -76,28 +89,31 @@ public:
|
||||
Tick bossCountdownTicks() const;
|
||||
Tick normalGapRemainingTicks() const;
|
||||
|
||||
// Ship schematic state queries.
|
||||
int schematicLevel(const std::string& shipId) const;
|
||||
// Ship schematic state query.
|
||||
bool isSchematicUnlocked(const std::string& shipId) const;
|
||||
|
||||
// Module schematic state queries.
|
||||
int moduleSchematicLevel(const std::string& moduleId) const;
|
||||
// Module schematic state query.
|
||||
bool isModuleSchematicUnlocked(const std::string& moduleId) const;
|
||||
|
||||
// Implicit recipe/item unlock queries (REQ-LOCK-IMPLICIT).
|
||||
bool isRecipeUnlocked(const std::string& recipeId) const;
|
||||
bool isItemUnlocked(const std::string& itemId) const;
|
||||
|
||||
// Checks affordability, deducts building blocks, and places the building.
|
||||
// Returns the new entity id, or kInvalidBuildingId if blocks are insufficient.
|
||||
BuildingId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation);
|
||||
// -- Determinism (see docs/replay_design.md) -----------------------------
|
||||
// 64-bit fingerprint of the RNG stream state. Cheap; written to the replay
|
||||
// file periodically + after each command for desync detection.
|
||||
unsigned long long rngFingerprint() const;
|
||||
|
||||
// Demolishes the building with the given id and refunds building blocks.
|
||||
void demolish(BuildingId id);
|
||||
// 64-bit fingerprint of the full simulation state (RNG, scalars, buildings,
|
||||
// belts, and ECS component state). Used by the double-run determinism test;
|
||||
// a superset of rngFingerprint().
|
||||
unsigned long long computeStateChecksum() const;
|
||||
|
||||
BuildingSystem& buildings();
|
||||
// Const subsystem accessors (queries only). The mutable counterparts are
|
||||
// private and reachable only through Simulation::apply (the command
|
||||
// chokepoint) or, in tests, SimulationTestAccess — so production code cannot
|
||||
// mutate the factory outside the recorded command path (docs/replay_design.md).
|
||||
const BuildingSystem& buildings() const;
|
||||
BeltSystem& belts();
|
||||
const BeltSystem& belts() const;
|
||||
ShipSystem& ships();
|
||||
const ShipSystem& ships() const;
|
||||
@@ -107,6 +123,34 @@ public:
|
||||
const EntityAdmin& admin() const;
|
||||
|
||||
private:
|
||||
// Grants tests access to the private player-action mutators below without
|
||||
// opening them to production code (see src/test/SimulationTestAccess.h).
|
||||
friend struct SimulationTestAccess;
|
||||
|
||||
// -- Player-action mutators (command chokepoint only) --------------------
|
||||
// Reached during play exclusively via apply(); never called by UI/app code.
|
||||
|
||||
// Checks affordability, deducts building blocks, and places the building.
|
||||
// Returns the new entity id, or kInvalidBuildingId if blocks are insufficient.
|
||||
BuildingId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation);
|
||||
|
||||
// Demolishes the building with the given id and refunds building blocks.
|
||||
void demolish(BuildingId id);
|
||||
|
||||
// Applies the player's chosen schematic from the pending choices.
|
||||
// choiceIndex must be in [0, pendingChoices.size()).
|
||||
// Clears the pending choices after application.
|
||||
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.
|
||||
BuildingSystem& buildingsMutable();
|
||||
BeltSystem& beltsMutable();
|
||||
|
||||
void handleEvent(std::shared_ptr<const TracePrintRequestedEvent> event) override;
|
||||
|
||||
BuildingId allocateBuildingId(); // Strictly increasing; never returns kInvalidBuildingId.
|
||||
@@ -126,12 +170,16 @@ private:
|
||||
|
||||
GameConfig m_config;
|
||||
std::mt19937 m_rng;
|
||||
unsigned int m_seed;
|
||||
|
||||
Tick m_currentTick;
|
||||
Tick m_nextDepartureTick;
|
||||
BuildingId m_nextBuildingId;
|
||||
int m_buildingBlocksStock;
|
||||
bool m_gameOver = false;
|
||||
int m_expansionsPurchased = 0; // REQ-EXP-COST formula variable x
|
||||
bool m_gameOver = false;
|
||||
bool m_isWon = false;
|
||||
int m_artifactCount = 0;
|
||||
|
||||
// Pre-placed structure IDs.
|
||||
BuildingId m_hqBuildingId; // Building id (for belt integration)
|
||||
@@ -144,11 +192,15 @@ private:
|
||||
struct SchematicState
|
||||
{
|
||||
bool unlocked;
|
||||
int level;
|
||||
};
|
||||
std::map<std::string, SchematicState> m_schematicLevels;
|
||||
std::map<std::string, SchematicState> m_moduleSchematicLevels;
|
||||
|
||||
// Determinism helpers — fold sub-state into the hasher in deterministic order.
|
||||
static void appendSchematicMap(Hasher& hasher,
|
||||
const std::map<std::string, SchematicState>& levels);
|
||||
static void appendStringSet(Hasher& hasher, const std::set<std::string>& ids);
|
||||
|
||||
// Explicitly unlocked assembler recipe schematics (REQ-LOCK-EXPLICIT).
|
||||
std::set<std::string> m_unlockedRecipeSchematicIds;
|
||||
|
||||
@@ -175,9 +227,15 @@ private:
|
||||
std::set<std::string> getUnlockedShipSchematicIds() 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.
|
||||
std::vector<std::string> computeNewlyUnlockedItemNames(const UnlockedSets& hypothetical) const;
|
||||
std::vector<std::string> computeNewlyUnlockedRecipeIds(const UnlockedSets& hypothetical) const;
|
||||
|
||||
EntityAdmin m_admin;
|
||||
BeltSystem m_beltSystem;
|
||||
|
||||
61
src/lib/sim/StateChecksum.cpp
Normal file
61
src/lib/sim/StateChecksum.cpp
Normal file
@@ -0,0 +1,61 @@
|
||||
#include "StateChecksum.h"
|
||||
|
||||
#include <sstream>
|
||||
|
||||
void Hasher::appendBytes(const void* data, std::size_t byteCount)
|
||||
{
|
||||
const unsigned char* bytes = static_cast<const unsigned char*>(data);
|
||||
for (std::size_t i = 0; i < byteCount; ++i)
|
||||
{
|
||||
m_state ^= bytes[i];
|
||||
m_state *= 1099511628211ull; // FNV-1a 64-bit prime
|
||||
}
|
||||
}
|
||||
|
||||
void Hasher::append(float value)
|
||||
{
|
||||
// Normalize -0.0f to +0.0f so the two equal values share a fingerprint.
|
||||
if (value == 0.0f) { value = 0.0f; }
|
||||
appendBytes(&value, sizeof(value));
|
||||
}
|
||||
|
||||
void Hasher::append(double value)
|
||||
{
|
||||
if (value == 0.0) { value = 0.0; }
|
||||
appendBytes(&value, sizeof(value));
|
||||
}
|
||||
|
||||
void Hasher::append(const QPoint& point)
|
||||
{
|
||||
const int coords[2] = { point.x(), point.y() };
|
||||
appendBytes(coords, sizeof(coords));
|
||||
}
|
||||
|
||||
void Hasher::append(const QPointF& point)
|
||||
{
|
||||
append(point.x());
|
||||
append(point.y());
|
||||
}
|
||||
|
||||
void Hasher::append(const QVector2D& vector)
|
||||
{
|
||||
append(vector.x());
|
||||
append(vector.y());
|
||||
}
|
||||
|
||||
void Hasher::append(const std::string& text)
|
||||
{
|
||||
appendBytes(text.data(), text.size());
|
||||
// Length terminator so "ab"+"c" and "a"+"bc" do not collide.
|
||||
const std::size_t length = text.size();
|
||||
appendBytes(&length, sizeof(length));
|
||||
}
|
||||
|
||||
std::uint64_t fingerprintRng(const std::mt19937& rng)
|
||||
{
|
||||
std::ostringstream stream;
|
||||
stream << rng; // full internal state as space-separated integers
|
||||
Hasher hasher;
|
||||
hasher.append(stream.str());
|
||||
return hasher.value();
|
||||
}
|
||||
55
src/lib/sim/StateChecksum.h
Normal file
55
src/lib/sim/StateChecksum.h
Normal file
@@ -0,0 +1,55 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
|
||||
#include <QPoint>
|
||||
#include <QPointF>
|
||||
#include <QVector2D>
|
||||
|
||||
// FNV-1a 64-bit accumulator used to fingerprint simulation state for
|
||||
// determinism verification (see docs/replay_design.md "Determinism").
|
||||
//
|
||||
// Subsystems contribute their own state through appendChecksum(Hasher&) so the
|
||||
// hash stays close to the data it covers and no state knowledge is duplicated.
|
||||
// The accumulator is order-sensitive; callers fold state in a deterministic
|
||||
// order (sorted containers, fixed view iteration).
|
||||
class Hasher
|
||||
{
|
||||
public:
|
||||
// Folds raw bytes into the running hash.
|
||||
void appendBytes(const void* data, std::size_t byteCount);
|
||||
|
||||
// Trivially-copyable scalars (ints, enums) are hashed by object representation.
|
||||
// Floating-point and Qt types have dedicated overloads below and bypass this.
|
||||
template <typename T>
|
||||
void append(const T& value)
|
||||
{
|
||||
static_assert(std::is_trivially_copyable<T>::value,
|
||||
"Hasher::append requires a trivially copyable type "
|
||||
"(add a dedicated overload otherwise)");
|
||||
appendBytes(&value, sizeof(T));
|
||||
}
|
||||
|
||||
// Floats are hashed by bit pattern so equal values always hash equally;
|
||||
// negative zero is normalized so -0.0 and +0.0 collapse to one value.
|
||||
void append(float value);
|
||||
void append(double value);
|
||||
void append(const QPoint& point);
|
||||
void append(const QPointF& point);
|
||||
void append(const QVector2D& vector);
|
||||
void append(const std::string& text);
|
||||
|
||||
std::uint64_t value() const { return m_state; }
|
||||
|
||||
private:
|
||||
std::uint64_t m_state = 14695981039346656037ull; // FNV-1a 64-bit offset basis
|
||||
};
|
||||
|
||||
// Folds the full mt19937 internal state into a 64-bit fingerprint. mt19937 has a
|
||||
// portable, bit-identical text serialization, so this fingerprint is stable
|
||||
// across platforms (see docs/replay_design.md "Cross-platform").
|
||||
std::uint64_t fingerprintRng(const std::mt19937& rng);
|
||||
@@ -1,6 +1,5 @@
|
||||
#include "ThreatCostCalculator.h"
|
||||
|
||||
#include <limits>
|
||||
#include <set>
|
||||
|
||||
#include "GameConfig.h"
|
||||
@@ -31,6 +30,7 @@ double computeMaterialThreat(const ThreatCostTable& table,
|
||||
return total;
|
||||
}
|
||||
|
||||
// Returns true if every input of the recipe has a resolved threat value.
|
||||
bool allInputsResolved(const RecipeDef& recipe,
|
||||
const std::map<std::string, double>& resolved)
|
||||
{
|
||||
@@ -44,15 +44,18 @@ bool allInputsResolved(const RecipeDef& recipe,
|
||||
return true;
|
||||
}
|
||||
|
||||
double computeRecipeThreat(const RecipeDef& recipe,
|
||||
const std::map<std::string, double>& resolved)
|
||||
// Computes the raw recipe threat (duration + sum of input threats × amounts),
|
||||
// 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;
|
||||
for (const RecipeIngredient& input : recipe.inputs)
|
||||
{
|
||||
threat += resolved.at(input.item) * input.amount;
|
||||
}
|
||||
return threat;
|
||||
return threat / static_cast<double>(outputAmount);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -62,138 +65,267 @@ ThreatCostTable computeThreatCostTable(const GameConfig& config)
|
||||
{
|
||||
ThreatCostTable table;
|
||||
|
||||
// Build lookup: output item → non-reprocessing recipes and reprocessing recipes.
|
||||
std::map<std::string, std::vector<RecipeRef>> nonReprocessingRecipes;
|
||||
// Scrap threat (REQ-THREAT-SCRAP) is the constant inverse of the scrap-drop
|
||||
// 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;
|
||||
|
||||
for (const RecipeDef& recipe : config.recipes.recipes)
|
||||
{
|
||||
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)
|
||||
{
|
||||
RecipeRef ref;
|
||||
ref.recipe = &recipe;
|
||||
ref.outputItem = out.item;
|
||||
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);
|
||||
}
|
||||
}
|
||||
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)
|
||||
{
|
||||
RecipeRef ref;
|
||||
ref.recipe = &recipe;
|
||||
ref.outputItem = out.item;
|
||||
ref.outputAmount = out.amount;
|
||||
ref.probability = 1.0;
|
||||
nonReprocessingRecipes[out.item].push_back(ref);
|
||||
if (!consumesScrap)
|
||||
{
|
||||
scrapFreeItems.insert(out.item);
|
||||
}
|
||||
|
||||
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.
|
||||
std::set<std::string> unresolved;
|
||||
for (const std::pair<const std::string, std::vector<RecipeRef>>& entry : nonReprocessingRecipes)
|
||||
// Filter nonReprocessingCandidates: for items that have at least one
|
||||
// scrap-free producer, drop their scrap-consuming recipes.
|
||||
// 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);
|
||||
}
|
||||
for (const std::pair<const std::string, std::vector<RecipeRef>>& entry : reprocessingRecipes)
|
||||
{
|
||||
unresolved.insert(entry.first);
|
||||
const std::string& item = it->first;
|
||||
const std::vector<EligiblePair>& candidates = it->second;
|
||||
bool hasScrapFree = (scrapFreeItems.find(item) != scrapFreeItems.end());
|
||||
|
||||
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;
|
||||
while (progress)
|
||||
// -------------------------------------------------------------------------
|
||||
// Resolution: seed resolved map with scrap, then alternate the
|
||||
// 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;
|
||||
std::set<std::string> newlyResolved;
|
||||
for (const std::string& item : unresolved)
|
||||
bool progress = false;
|
||||
std::map<std::string, double> newValues;
|
||||
|
||||
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 =
|
||||
nonReprocessingRecipes.find(item);
|
||||
if (it == nonReprocessingRecipes.end())
|
||||
const std::string& item = it->first;
|
||||
if (resolved.find(item) != resolved.end())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const std::vector<std::pair<const RecipeDef*, int>>& pairs = it->second;
|
||||
|
||||
bool allComputable = true;
|
||||
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);
|
||||
if (threat > maxThreat)
|
||||
allComputable = false;
|
||||
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)
|
||||
{
|
||||
table.itemThreat[item] = maxThreat;
|
||||
newlyResolved.insert(item);
|
||||
progress = true;
|
||||
newValues[item] = maxThreat;
|
||||
}
|
||||
}
|
||||
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
|
||||
// scrap_drop and use its threat cost.
|
||||
int minScrapDrop = std::numeric_limits<int>::max();
|
||||
const ShipDef* cheapestScrapShip = nullptr;
|
||||
for (const ShipDef& def : config.ships.ships)
|
||||
// Reprocessing pass: resolve items produced exclusively by reprocessing.
|
||||
// Items that also have a non-reprocessing recipe are skipped here (they are
|
||||
// covered by the non-reprocessing pass or do not need the reprocessing path).
|
||||
auto runReprocessingPass = [&]() -> bool
|
||||
{
|
||||
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;
|
||||
cheapestScrapShip = &def;
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
const std::string& item = it->first;
|
||||
if (resolved.find(item) != resolved.end())
|
||||
{
|
||||
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
|
||||
+ ref.recipe->durationSeconds) / ref.probability;
|
||||
std::map<std::string, double>::iterator existing = table.itemThreat.find(item);
|
||||
if (existing == table.itemThreat.end() || threat > existing->second)
|
||||
for (const RecipeRef& ref : it->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;
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ void WaveSystem::tickWaveScheduler(Tick currentTick, ShipSystem& ships,
|
||||
{
|
||||
if (currentTick >= entry.spawnAt)
|
||||
{
|
||||
ships.spawn(entry.schematicId, entry.level, entry.position,
|
||||
ships.spawn(entry.schematicId, entry.position,
|
||||
/*isEnemy=*/true, entry.layout);
|
||||
}
|
||||
else
|
||||
@@ -174,11 +174,7 @@ std::vector<WaveSystem::SpawnEntry> WaveSystem::selectWaveShips(double& budget,
|
||||
Tick currentTick,
|
||||
int worldHeightTiles)
|
||||
{
|
||||
const int shipLevel = std::max(1, static_cast<int>(
|
||||
m_config.world.waves.shipLevelFormula.evaluate(
|
||||
static_cast<double>(m_bossWaveCounter))));
|
||||
|
||||
// Build eligible ship list with their costs at the current level.
|
||||
// Build eligible ship list with their (level-independent) threat costs.
|
||||
struct EligibleShip
|
||||
{
|
||||
std::string schematicId;
|
||||
@@ -242,7 +238,6 @@ std::vector<WaveSystem::SpawnEntry> WaveSystem::selectWaveShips(double& budget,
|
||||
|
||||
SpawnEntry entry;
|
||||
entry.schematicId = chosen.schematicId;
|
||||
entry.level = shipLevel;
|
||||
entry.spawnAt = 0; // set below after all picks are done
|
||||
entry.position = QVector2D(xDist(m_rng),
|
||||
static_cast<float>(yDist(m_rng)) + 0.5f);
|
||||
|
||||
@@ -59,7 +59,6 @@ private:
|
||||
struct SpawnEntry
|
||||
{
|
||||
std::string schematicId;
|
||||
int level;
|
||||
Tick spawnAt;
|
||||
QVector2D position;
|
||||
ShipLayoutConfig layout;
|
||||
|
||||
254
src/test/ArtifactWinConditionTest.cpp
Normal file
254
src/test/ArtifactWinConditionTest.cpp
Normal file
@@ -0,0 +1,254 @@
|
||||
#include <algorithm>
|
||||
|
||||
#include "catch.hpp"
|
||||
|
||||
#include "ConfigLoader.h"
|
||||
#include "FactionComponent.h"
|
||||
#include "Formula.h"
|
||||
#include "GameConfig.h"
|
||||
#include "HealthComponent.h"
|
||||
#include "SchematicChoiceOption.h"
|
||||
#include "Simulation.h"
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "StationBodyComponent.h"
|
||||
|
||||
static GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
|
||||
static 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();
|
||||
}
|
||||
|
||||
// Returns the index of the Artifact option in the pending choices, or -1 if absent.
|
||||
static int findArtifactChoiceIndex(const Simulation& sim)
|
||||
{
|
||||
const auto& choices = sim.getPendingSchematicChoices();
|
||||
for (int i = 0; i < static_cast<int>(choices.size()); ++i)
|
||||
{
|
||||
if (choices[static_cast<std::size_t>(i)].type == SchematicType::Artifact)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config: new artifact fields are parsed correctly (REQ-WIN-ARTIFACT-COUNT)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("ArtifactWinCondition: artifact_chance_formula and artifact_win_count are loaded",
|
||||
"[artifact_win]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
CHECK(cfg.world.artifacts.artifactWinCount == 3);
|
||||
// 0.05 * x at x=2 should be 0.1
|
||||
CHECK(cfg.world.artifacts.artifactChanceFormula.evaluate(2.0) == Approx(0.1));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simulation initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("ArtifactWinCondition: artifact count is 0 and isWon is false at game start",
|
||||
"[artifact_win]")
|
||||
{
|
||||
const Simulation sim(loadConfig());
|
||||
CHECK(sim.artifactCount() == 0);
|
||||
CHECK_FALSE(sim.isWon());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Artifact option presence in schematic choices
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("ArtifactWinCondition: artifact option appears when chance formula returns 1",
|
||||
"[artifact_win]")
|
||||
{
|
||||
GameConfig cfg = loadConfig();
|
||||
cfg.world.artifacts.artifactChanceFormula = Formula::compile("1");
|
||||
Simulation sim(std::move(cfg));
|
||||
|
||||
killEnemyStations(sim);
|
||||
|
||||
REQUIRE(sim.hasSchematicChoicesPending());
|
||||
const auto& choices = sim.getPendingSchematicChoices();
|
||||
|
||||
const long artifactCount = std::count_if(choices.begin(), choices.end(),
|
||||
[](const SchematicChoiceOption& opt) { return opt.type == SchematicType::Artifact; });
|
||||
CHECK(artifactCount == 1);
|
||||
// Exactly 2 schematic picks + 1 artifact (or fewer if pool was small)
|
||||
CHECK(choices.size() <= 3);
|
||||
}
|
||||
|
||||
TEST_CASE("ArtifactWinCondition: at most 2 schematic options accompany the artifact",
|
||||
"[artifact_win]")
|
||||
{
|
||||
GameConfig cfg = loadConfig();
|
||||
cfg.world.artifacts.artifactChanceFormula = Formula::compile("1");
|
||||
Simulation sim(std::move(cfg));
|
||||
|
||||
killEnemyStations(sim);
|
||||
|
||||
REQUIRE(sim.hasSchematicChoicesPending());
|
||||
const auto& choices = sim.getPendingSchematicChoices();
|
||||
|
||||
const long schematicCount = std::count_if(choices.begin(), choices.end(),
|
||||
[](const SchematicChoiceOption& opt) { return opt.type != SchematicType::Artifact; });
|
||||
CHECK(schematicCount <= 2);
|
||||
}
|
||||
|
||||
TEST_CASE("ArtifactWinCondition: no artifact option when chance formula returns 0",
|
||||
"[artifact_win]")
|
||||
{
|
||||
GameConfig cfg = loadConfig();
|
||||
cfg.world.artifacts.artifactChanceFormula = Formula::compile("0");
|
||||
Simulation sim(std::move(cfg));
|
||||
|
||||
for (int i = 0; i < 20; ++i)
|
||||
{
|
||||
killEnemyStations(sim);
|
||||
if (!sim.hasSchematicChoicesPending()) { continue; }
|
||||
|
||||
CHECK(findArtifactChoiceIndex(sim) == -1);
|
||||
SimulationTestAccess::applySchematicChoice(sim,0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selecting the artifact option
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("ArtifactWinCondition: selecting artifact increments artifact count",
|
||||
"[artifact_win]")
|
||||
{
|
||||
GameConfig cfg = loadConfig();
|
||||
cfg.world.artifacts.artifactChanceFormula = Formula::compile("1");
|
||||
Simulation sim(std::move(cfg));
|
||||
|
||||
killEnemyStations(sim);
|
||||
REQUIRE(sim.hasSchematicChoicesPending());
|
||||
|
||||
const int index = findArtifactChoiceIndex(sim);
|
||||
REQUIRE(index >= 0);
|
||||
|
||||
SimulationTestAccess::applySchematicChoice(sim,index);
|
||||
|
||||
CHECK(sim.artifactCount() == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("ArtifactWinCondition: selecting a non-artifact option does not increment artifact count",
|
||||
"[artifact_win]")
|
||||
{
|
||||
GameConfig cfg = loadConfig();
|
||||
cfg.world.artifacts.artifactChanceFormula = Formula::compile("1");
|
||||
Simulation sim(std::move(cfg));
|
||||
|
||||
killEnemyStations(sim);
|
||||
REQUIRE(sim.hasSchematicChoicesPending());
|
||||
|
||||
// Pick the first non-artifact choice.
|
||||
const auto& choices = sim.getPendingSchematicChoices();
|
||||
const auto it = std::find_if(choices.begin(), choices.end(),
|
||||
[](const SchematicChoiceOption& opt) { return opt.type != SchematicType::Artifact; });
|
||||
REQUIRE(it != choices.end());
|
||||
|
||||
SimulationTestAccess::applySchematicChoice(sim,static_cast<int>(it - choices.begin()));
|
||||
|
||||
CHECK(sim.artifactCount() == 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Win condition (REQ-WIN-ARTIFACT-COUNT)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("ArtifactWinCondition: isWon becomes true when artifact count reaches win count",
|
||||
"[artifact_win]")
|
||||
{
|
||||
GameConfig cfg = loadConfig();
|
||||
cfg.world.artifacts.artifactChanceFormula = Formula::compile("1");
|
||||
cfg.world.artifacts.artifactWinCount = 1;
|
||||
Simulation sim(std::move(cfg));
|
||||
|
||||
CHECK_FALSE(sim.isWon());
|
||||
|
||||
killEnemyStations(sim);
|
||||
REQUIRE(sim.hasSchematicChoicesPending());
|
||||
|
||||
const int index = findArtifactChoiceIndex(sim);
|
||||
REQUIRE(index >= 0);
|
||||
SimulationTestAccess::applySchematicChoice(sim,index);
|
||||
|
||||
CHECK(sim.isWon());
|
||||
}
|
||||
|
||||
TEST_CASE("ArtifactWinCondition: isWon stays false when artifact count is below win count",
|
||||
"[artifact_win]")
|
||||
{
|
||||
GameConfig cfg = loadConfig();
|
||||
cfg.world.artifacts.artifactChanceFormula = Formula::compile("1");
|
||||
cfg.world.artifacts.artifactWinCount = 2;
|
||||
Simulation sim(std::move(cfg));
|
||||
|
||||
killEnemyStations(sim);
|
||||
REQUIRE(sim.hasSchematicChoicesPending());
|
||||
SimulationTestAccess::applySchematicChoice(sim,findArtifactChoiceIndex(sim));
|
||||
|
||||
CHECK(sim.artifactCount() == 1);
|
||||
CHECK_FALSE(sim.isWon());
|
||||
}
|
||||
|
||||
TEST_CASE("ArtifactWinCondition: isWon becomes true after collecting required number of artifacts",
|
||||
"[artifact_win]")
|
||||
{
|
||||
GameConfig cfg = loadConfig();
|
||||
cfg.world.artifacts.artifactChanceFormula = Formula::compile("1");
|
||||
cfg.world.artifacts.artifactWinCount = 2;
|
||||
Simulation sim(std::move(cfg));
|
||||
|
||||
for (int i = 0; i < 2; ++i)
|
||||
{
|
||||
killEnemyStations(sim);
|
||||
REQUIRE(sim.hasSchematicChoicesPending());
|
||||
const int index = findArtifactChoiceIndex(sim);
|
||||
REQUIRE(index >= 0);
|
||||
SimulationTestAccess::applySchematicChoice(sim,index);
|
||||
}
|
||||
|
||||
CHECK(sim.artifactCount() == 2);
|
||||
CHECK(sim.isWon());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reset() restores initial state (REQ-CFG-RELOAD)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("ArtifactWinCondition: reset clears artifact count and win state",
|
||||
"[artifact_win]")
|
||||
{
|
||||
GameConfig cfg = loadConfig();
|
||||
cfg.world.artifacts.artifactChanceFormula = Formula::compile("1");
|
||||
cfg.world.artifacts.artifactWinCount = 1;
|
||||
Simulation sim(std::move(cfg));
|
||||
|
||||
killEnemyStations(sim);
|
||||
REQUIRE(sim.hasSchematicChoicesPending());
|
||||
SimulationTestAccess::applySchematicChoice(sim,findArtifactChoiceIndex(sim));
|
||||
REQUIRE(sim.isWon());
|
||||
|
||||
sim.reset();
|
||||
|
||||
CHECK(sim.artifactCount() == 0);
|
||||
CHECK_FALSE(sim.isWon());
|
||||
}
|
||||
@@ -238,7 +238,7 @@ TEST_CASE("BehaviorSystem: clearMovementIntents resets all ships to inactive",
|
||||
"[behavior]")
|
||||
{
|
||||
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.ships.clearMovementIntents();
|
||||
@@ -254,7 +254,7 @@ TEST_CASE("BehaviorSystem: tickMovement advances ship by maxSpeed_tpt toward tar
|
||||
"[behavior]")
|
||||
{
|
||||
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;
|
||||
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]")
|
||||
{
|
||||
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 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]")
|
||||
{
|
||||
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.decide();
|
||||
@@ -301,7 +301,7 @@ TEST_CASE("BehaviorSystem: low-HP player ship retreats toward the rally point",
|
||||
Fixture f;
|
||||
const QVector2D rallyPoint(-50.0f, 0.0f);
|
||||
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.decide();
|
||||
@@ -316,8 +316,8 @@ TEST_CASE("BehaviorSystem: low-HP retreat outranks attacking a nearby enemy", "[
|
||||
Fixture f;
|
||||
const QVector2D rallyPoint(-50.0f, 0.0f);
|
||||
f.ships.setRallyPoint(rallyPoint);
|
||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f), /*isEnemy=*/true);
|
||||
const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
|
||||
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.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]")
|
||||
{
|
||||
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);
|
||||
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]")
|
||||
{
|
||||
Fixture f;
|
||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f),
|
||||
const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
f.decide();
|
||||
@@ -364,8 +364,8 @@ TEST_CASE("BehaviorSystem: player combat ship does not target friendly ships",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const entt::entity e1 = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f)); // also player
|
||||
const entt::entity e1 = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
|
||||
f.ships.spawn("interceptor", QVector2D(5.0f, 0.0f)); // also player
|
||||
|
||||
f.decide();
|
||||
|
||||
@@ -378,8 +378,8 @@ TEST_CASE("BehaviorSystem: player combat ship ignores enemy beyond engagement ra
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
f.ships.spawn("interceptor", 1, QVector2D(500.0f, 0.0f), /*isEnemy=*/true);
|
||||
const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
|
||||
f.ships.spawn("interceptor", QVector2D(500.0f, 0.0f), /*isEnemy=*/true);
|
||||
|
||||
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")
|
||||
{
|
||||
Fixture f;
|
||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity nearEnemy = f.ships.spawn("interceptor", 1, QVector2D(8.0f, 0.0f),
|
||||
const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
|
||||
const entt::entity nearEnemy = f.ships.spawn("interceptor", QVector2D(8.0f, 0.0f),
|
||||
/*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();
|
||||
|
||||
@@ -412,15 +412,15 @@ TEST_CASE("BehaviorSystem: overclaim penalty steers a ship off a claimed target"
|
||||
{
|
||||
const float d = 10.0f;
|
||||
Fixture f;
|
||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity enemyA = f.ships.spawn("interceptor", 1, QVector2D(d, 0.0f),
|
||||
const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
|
||||
const entt::entity enemyA = f.ships.spawn("interceptor", QVector2D(d, 0.0f),
|
||||
/*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);
|
||||
|
||||
// A second player ship already commits to enemyA, registering a claim, so
|
||||
// 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.decide();
|
||||
@@ -437,10 +437,10 @@ TEST_CASE("BehaviorSystem: hysteresis keeps a ship on its own claimed target",
|
||||
{
|
||||
const float d = 10.0f;
|
||||
Fixture f;
|
||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity enemyA = f.ships.spawn("interceptor", 1, QVector2D(d, 0.0f),
|
||||
const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
|
||||
const entt::entity enemyA = f.ships.spawn("interceptor", QVector2D(d, 0.0f),
|
||||
/*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'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;
|
||||
Fixture f;
|
||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity enemyA = f.ships.spawn("interceptor", 1, QVector2D(d, 0.0f),
|
||||
const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
|
||||
const entt::entity enemyA = f.ships.spawn("interceptor", QVector2D(d, 0.0f),
|
||||
/*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);
|
||||
|
||||
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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -488,8 +488,8 @@ TEST_CASE("BehaviorSystem: enemy ship acquires nearest player ship in range",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f),
|
||||
const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
f.decide();
|
||||
@@ -505,7 +505,7 @@ TEST_CASE("BehaviorSystem: enemy ship with no target advances leftward",
|
||||
"[behavior]")
|
||||
{
|
||||
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);
|
||||
|
||||
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, 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);
|
||||
// Player ships rally until departure; drop Rally so Advance is the fallback.
|
||||
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 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);
|
||||
|
||||
f.decide();
|
||||
@@ -576,9 +576,9 @@ TEST_CASE("BehaviorSystem: repair ship orbits damaged friendly ship",
|
||||
{
|
||||
Fixture f;
|
||||
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);
|
||||
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;
|
||||
|
||||
@@ -605,10 +605,10 @@ TEST_CASE("BehaviorSystem: idle repair ship stands by with the fleet instead of
|
||||
{
|
||||
Fixture f;
|
||||
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);
|
||||
// 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)};
|
||||
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;
|
||||
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
||||
f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, repairLayout);
|
||||
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f));
|
||||
f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f), false, repairLayout);
|
||||
const entt::entity friendly = f.ships.spawn("interceptor", QVector2D(1.0f, 0.0f));
|
||||
|
||||
const float initialHp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
|
||||
f.admin.get<HealthComponent>(friendly).hp = initialHp;
|
||||
@@ -644,8 +644,8 @@ TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
||||
f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, repairLayout);
|
||||
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f));
|
||||
f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f), false, repairLayout);
|
||||
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;
|
||||
|
||||
@@ -666,9 +666,9 @@ TEST_CASE("RepairSystem: tool heals the in-range damaged target chosen by the ex
|
||||
{
|
||||
Fixture f;
|
||||
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);
|
||||
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;
|
||||
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;
|
||||
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);
|
||||
// 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
|
||||
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 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;
|
||||
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);
|
||||
const entt::entity healed = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
||||
const entt::entity fallback = f.ships.spawn("interceptor", 1, QVector2D(15.0f, 0.0f));
|
||||
const entt::entity healed = f.ships.spawn("interceptor", QVector2D(10.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;
|
||||
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;
|
||||
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);
|
||||
const entt::entity gone = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
||||
const entt::entity fallback = f.ships.spawn("interceptor", 1, QVector2D(15.0f, 0.0f));
|
||||
const entt::entity gone = f.ships.spawn("interceptor", QVector2D(10.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;
|
||||
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;
|
||||
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);
|
||||
// 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;
|
||||
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;
|
||||
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);
|
||||
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;
|
||||
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;
|
||||
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);
|
||||
const entt::entity healed = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
||||
const entt::entity targetB = f.ships.spawn("interceptor", 1, QVector2D(20.0f, 0.0f));
|
||||
const entt::entity healed = f.ships.spawn("interceptor", QVector2D(10.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;
|
||||
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;
|
||||
|
||||
// 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();
|
||||
RepairToolComponent rt;
|
||||
rt.repairAmountHp = 1.0f;
|
||||
@@ -868,7 +868,7 @@ TEST_CASE("RepairSystem: repair tool does not repair an HQ", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
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);
|
||||
|
||||
// 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;
|
||||
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);
|
||||
|
||||
// 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;
|
||||
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);
|
||||
|
||||
const QVector2D scrapPos(100.0f, 0.0f);
|
||||
@@ -937,7 +937,7 @@ TEST_CASE("BehaviorSystem: salvage ship collects scrap on arrival", "[behavior]"
|
||||
{
|
||||
Fixture f;
|
||||
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);
|
||||
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);
|
||||
|
||||
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);
|
||||
{
|
||||
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());
|
||||
}
|
||||
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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.
|
||||
Fixture f;
|
||||
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);
|
||||
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.
|
||||
Fixture f;
|
||||
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);
|
||||
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;
|
||||
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);
|
||||
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;
|
||||
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);
|
||||
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;
|
||||
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);
|
||||
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;
|
||||
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);
|
||||
|
||||
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.
|
||||
Fixture f;
|
||||
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);
|
||||
|
||||
// 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]")
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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]")
|
||||
{
|
||||
Fixture f;
|
||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(190.0f, 0.0f),
|
||||
const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", QVector2D(190.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
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]")
|
||||
{
|
||||
Fixture f;
|
||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f), /*isEnemy=*/true);
|
||||
const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
|
||||
f.ships.spawn("interceptor", QVector2D(210.0f, 0.0f), /*isEnemy=*/true);
|
||||
|
||||
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]")
|
||||
{
|
||||
Fixture f;
|
||||
f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f),
|
||||
f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", QVector2D(210.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
f.decide();
|
||||
@@ -1194,9 +1233,9 @@ TEST_CASE("SensorRange: repair ship retreats from enemy within sensor range", "[
|
||||
const QVector2D rallyPoint(-100.0f, 0.0f);
|
||||
f.ships.setRallyPoint(rallyPoint);
|
||||
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);
|
||||
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();
|
||||
|
||||
@@ -1208,9 +1247,9 @@ TEST_CASE("SensorRange: repair ship does not retreat from enemy beyond sensor ra
|
||||
{
|
||||
Fixture f;
|
||||
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);
|
||||
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();
|
||||
|
||||
@@ -1225,9 +1264,9 @@ TEST_CASE("SensorRange: repair ship does not acquire damaged ally beyond sensor
|
||||
{
|
||||
Fixture f;
|
||||
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);
|
||||
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.decide();
|
||||
@@ -1243,7 +1282,7 @@ TEST_CASE("SensorRange: salvage ship ignores scrap beyond sensor range", "[senso
|
||||
{
|
||||
Fixture f;
|
||||
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);
|
||||
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]")
|
||||
{
|
||||
Fixture f;
|
||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f),
|
||||
const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
f.decide();
|
||||
@@ -1282,7 +1321,7 @@ TEST_CASE("Orbit: rally ship orbits the rally point at the configured rally radi
|
||||
Fixture f;
|
||||
const QVector2D rallyPoint(-50.0f, 0.0f);
|
||||
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();
|
||||
|
||||
@@ -1298,8 +1337,8 @@ TEST_CASE("Orbit: combat ship settles near the orbit radius and circles a statio
|
||||
{
|
||||
Fixture f;
|
||||
const QVector2D enemyPos(80.0f, 0.0f);
|
||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, enemyPos, /*isEnemy=*/true);
|
||||
const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f));
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", enemyPos, /*isEnemy=*/true);
|
||||
|
||||
const float orbitRadius = f.admin.get<AttackBehavior>(player).orbitRadius_tiles;
|
||||
REQUIRE(orbitRadius > 0.0f);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include "Blueprint.h"
|
||||
#include "BlueprintSerializer.h"
|
||||
#include "BuildingType.h"
|
||||
#include "ItemType.h"
|
||||
#include "Rotation.h"
|
||||
#include "ShipLayout.h"
|
||||
|
||||
@@ -263,3 +264,42 @@ TEST_CASE("BlueprintSerializer: shipyard with empty modules list round-trips", "
|
||||
REQUIRE(loaded[0].buildings[0].shipLayout.has_value());
|
||||
REQUIRE(loaded[0].buildings[0].shipLayout->placedModules.empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Splitter output filters round-trip through TOML serialization
|
||||
// (REQ-UI-BLUEPRINT-STORAGE).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BlueprintSerializer: splitter output filters round-trip", "[serializer]")
|
||||
{
|
||||
BlueprintBuilding splitter =
|
||||
makeBuilding(BuildingType::Splitter, Rotation::East, QPoint(0, 0));
|
||||
splitter.splitterFilterA = { ItemType{"iron_ore"}, ItemType{"copper_ore"} };
|
||||
splitter.splitterFilterB = { ItemType{"scrap"} };
|
||||
|
||||
const Blueprint original = makeBlueprintWith({ splitter }, "Sorter");
|
||||
const std::string toml = BlueprintSerializer::serialize({ original });
|
||||
const std::vector<Blueprint> loaded = BlueprintSerializer::deserialize(toml);
|
||||
|
||||
REQUIRE(loaded.size() == 1);
|
||||
REQUIRE(loaded[0].buildings.size() == 1);
|
||||
|
||||
const BlueprintBuilding& b = loaded[0].buildings[0];
|
||||
REQUIRE(b.splitterFilterA
|
||||
== std::vector<ItemType>{ ItemType{"iron_ore"}, ItemType{"copper_ore"} });
|
||||
REQUIRE(b.splitterFilterB == std::vector<ItemType>{ ItemType{"scrap"} });
|
||||
}
|
||||
|
||||
TEST_CASE("BlueprintSerializer: splitter with no filters round-trips empty", "[serializer]")
|
||||
{
|
||||
const Blueprint original = makeBlueprintWith(
|
||||
{ makeBuilding(BuildingType::Splitter, Rotation::East, QPoint(0, 0)) }, "Open");
|
||||
|
||||
const std::string toml = BlueprintSerializer::serialize({ original });
|
||||
const std::vector<Blueprint> loaded = BlueprintSerializer::deserialize(toml);
|
||||
|
||||
REQUIRE(loaded.size() == 1);
|
||||
REQUIRE(loaded[0].buildings.size() == 1);
|
||||
REQUIRE(loaded[0].buildings[0].splitterFilterA.empty());
|
||||
REQUIRE(loaded[0].buildings[0].splitterFilterB.empty());
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include "Rotation.h"
|
||||
#include "ShipLayout.h"
|
||||
#include "Simulation.h"
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "SurfaceMask.h"
|
||||
#include "Tick.h"
|
||||
|
||||
@@ -524,9 +525,9 @@ TEST_CASE("Blueprint placement: buildings land at anchor + offset from cursor",
|
||||
const QPoint offsetA(-1, 0);
|
||||
const QPoint offsetB( 1, 0);
|
||||
|
||||
const BuildingId idA = sim.tryPlaceBuilding(
|
||||
const BuildingId idA = SimulationTestAccess::place(sim,
|
||||
BuildingType::Belt, cursor + offsetA, Rotation::East);
|
||||
const BuildingId idB = sim.tryPlaceBuilding(
|
||||
const BuildingId idB = SimulationTestAccess::place(sim,
|
||||
BuildingType::Belt, cursor + offsetB, Rotation::East);
|
||||
|
||||
REQUIRE(idA != kInvalidBuildingId);
|
||||
@@ -551,10 +552,10 @@ TEST_CASE("Blueprint placement: cost is deducted for each building in sequence",
|
||||
const int startBlocks = sim.buildingBlocksStock();
|
||||
REQUIRE(startBlocks >= 2 * beltCost); // test config has enough starting blocks
|
||||
|
||||
sim.tryPlaceBuilding(BuildingType::Belt, QPoint(-6, 0), Rotation::East);
|
||||
SimulationTestAccess::place(sim,BuildingType::Belt, QPoint(-6, 0), Rotation::East);
|
||||
REQUIRE(sim.buildingBlocksStock() == startBlocks - beltCost);
|
||||
|
||||
sim.tryPlaceBuilding(BuildingType::Belt, QPoint(-4, 0), Rotation::East);
|
||||
SimulationTestAccess::place(sim,BuildingType::Belt, QPoint(-4, 0), Rotation::East);
|
||||
REQUIRE(sim.buildingBlocksStock() == startBlocks - 2 * beltCost);
|
||||
}
|
||||
|
||||
@@ -576,12 +577,12 @@ TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidBuildingId a
|
||||
int col = -2;
|
||||
while (sim.buildingBlocksStock() >= minerCost)
|
||||
{
|
||||
sim.tryPlaceBuilding(BuildingType::Miner, QPoint(col, 0), Rotation::East);
|
||||
SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(col, 0), Rotation::East);
|
||||
col -= 2;
|
||||
}
|
||||
|
||||
const int blocksBeforeAttempt = sim.buildingBlocksStock();
|
||||
const BuildingId id = sim.tryPlaceBuilding(
|
||||
const BuildingId id = SimulationTestAccess::place(sim,
|
||||
BuildingType::Miner, QPoint(col - 2, 0), Rotation::East);
|
||||
|
||||
// Placement must fail and leave the stock unchanged.
|
||||
@@ -598,7 +599,7 @@ TEST_CASE("Simulation: tryPlaceBuilding rejects terrain-invalid placement and ch
|
||||
// A miner is all-asteroid; placing it in space (x >= 0) violates the terrain
|
||||
// rule, so it must be rejected without consuming building blocks.
|
||||
const BuildingId id =
|
||||
sim.tryPlaceBuilding(BuildingType::Miner, QPoint(0, 0), Rotation::East);
|
||||
SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(0, 0), Rotation::East);
|
||||
|
||||
REQUIRE(id == kInvalidBuildingId);
|
||||
REQUIRE(sim.buildingBlocksStock() == startBlocks);
|
||||
@@ -621,7 +622,7 @@ TEST_CASE("Simulation: tryPlaceBuilding accepts a valid asteroid spot, occupies
|
||||
// Miner mask ["AA","A>"] East at (-3,0) → all-asteroid body at
|
||||
// (-3,0),(-2,0),(-3,1); a valid spot.
|
||||
const BuildingId id =
|
||||
sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-3, 0), Rotation::East);
|
||||
SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-3, 0), Rotation::East);
|
||||
|
||||
REQUIRE(id != kInvalidBuildingId);
|
||||
REQUIRE(sim.buildingBlocksStock() == startBlocks - minerCost);
|
||||
@@ -664,10 +665,10 @@ TEST_CASE("Blueprint placement: setRecipe on construction site stores recipe", "
|
||||
Simulation sim(loadConfig());
|
||||
|
||||
// Miner body cells: (0,0),(1,0),(0,1) — all at x < 0, valid for asteroid.
|
||||
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East);
|
||||
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East);
|
||||
REQUIRE(id != kInvalidBuildingId);
|
||||
|
||||
sim.buildings().setRecipe(id, "mine_iron_ore");
|
||||
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore");
|
||||
|
||||
const ConstructionSite* site = sim.buildings().findSite(id);
|
||||
REQUIRE(site != nullptr);
|
||||
@@ -679,9 +680,9 @@ TEST_CASE("Blueprint placement: recipe transfers to building after construction
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
|
||||
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East);
|
||||
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East);
|
||||
REQUIRE(id != kInvalidBuildingId);
|
||||
sim.buildings().setRecipe(id, "mine_copper_ore");
|
||||
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_copper_ore");
|
||||
|
||||
// Miner construction_time_seconds = 10 → completesAt = secondsToTicks(10) = 300.
|
||||
// Run 301 ticks (0..300) to process the completion tick.
|
||||
@@ -759,7 +760,7 @@ TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout
|
||||
// Shipyard surface_mask ["AAAS>","AAAS "] with Rotation::East:
|
||||
// A-tiles at (-3,0),(-2,0),(-1,0),(-3,1),(-2,1),(-1,1) — all x < 0, valid asteroid tiles.
|
||||
// S-tile at (0,0) and (0,1) — x >= 0, valid space tiles.
|
||||
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
|
||||
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
|
||||
REQUIRE(id != kInvalidBuildingId);
|
||||
|
||||
ShipLayoutConfig layout;
|
||||
@@ -769,7 +770,7 @@ TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout
|
||||
pm.rotation = Rotation::East;
|
||||
layout.placedModules.push_back(pm);
|
||||
|
||||
sim.buildings().setShipLayout(id, layout);
|
||||
SimulationTestAccess::buildings(sim).setShipLayout(id, layout);
|
||||
|
||||
const ConstructionSite* site = sim.buildings().findSite(id);
|
||||
REQUIRE(site != nullptr);
|
||||
@@ -783,7 +784,7 @@ TEST_CASE("Blueprint placement: ship layout transfers to building after construc
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
|
||||
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
|
||||
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
|
||||
REQUIRE(id != kInvalidBuildingId);
|
||||
|
||||
ShipLayoutConfig layout;
|
||||
@@ -793,7 +794,7 @@ TEST_CASE("Blueprint placement: ship layout transfers to building after construc
|
||||
pm.rotation = Rotation::North;
|
||||
layout.placedModules.push_back(pm);
|
||||
|
||||
sim.buildings().setShipLayout(id, layout);
|
||||
SimulationTestAccess::buildings(sim).setShipLayout(id, layout);
|
||||
|
||||
// Shipyard construction_time_seconds = 30 in the test config.
|
||||
double constructionTime = 0.0;
|
||||
|
||||
133
src/test/BuildingConfigTest.cpp
Normal file
133
src/test/BuildingConfigTest.cpp
Normal 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());
|
||||
}
|
||||
@@ -984,3 +984,42 @@ TEST_CASE("BuildingSystem: rotateInPlace re-registers a belt tile with BeltSyste
|
||||
// Belt tile must still be registered after rotation — items can be placed on it.
|
||||
REQUIRE(belts.tryPutItem(QPoint(0, 0), makeItem("iron_ore")));
|
||||
}
|
||||
|
||||
TEST_CASE("BuildingSystem: splitter filters configured on a construction site carry over "
|
||||
"to the built splitter (REQ-BLD-SITE-CONFIG)", "[building]")
|
||||
{
|
||||
PlacementFixture f;
|
||||
|
||||
const QPoint tile(5, 5);
|
||||
const BuildingId id = f.bs.place(BuildingType::Splitter, tile, Rotation::East, 0);
|
||||
REQUIRE(id != kInvalidBuildingId);
|
||||
REQUIRE(f.bs.findSite(id) != nullptr);
|
||||
|
||||
// Configure an output filter on the still-queued splitter site.
|
||||
const std::vector<ItemType> filterA{ ItemType{"iron_ore"} };
|
||||
const std::vector<ItemType> filterB{};
|
||||
f.bs.setSiteSplitterFilters(id, filterA, filterB);
|
||||
|
||||
// The site reports its two output directions and the stored filters before
|
||||
// it is built; it is not yet registered with BeltSystem.
|
||||
const std::optional<BeltSystem::SplitterInfo> siteInfo = f.bs.getSiteSplitterInfo(id);
|
||||
REQUIRE(siteInfo.has_value());
|
||||
REQUIRE(siteInfo->filterA == filterA);
|
||||
REQUIRE(siteInfo->filterB.empty());
|
||||
REQUIRE_FALSE(f.belts.getSplitterInfo(tile).has_value());
|
||||
|
||||
// Run until construction completes.
|
||||
Tick tick = 0;
|
||||
while (f.bs.allBuildings().empty() && tick < 100000)
|
||||
{
|
||||
runTicks(f.bs, f.belts, 1, tick);
|
||||
}
|
||||
REQUIRE(f.bs.allBuildings().size() == 1);
|
||||
REQUIRE(f.bs.allBuildings()[0].type == BuildingType::Splitter);
|
||||
|
||||
// The built splitter is registered with BeltSystem carrying the filters.
|
||||
const std::optional<BeltSystem::SplitterInfo> builtInfo = f.belts.getSplitterInfo(tile);
|
||||
REQUIRE(builtInfo.has_value());
|
||||
REQUIRE(builtInfo->filterA == filterA);
|
||||
REQUIRE(builtInfo->filterB.empty());
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ add_files(
|
||||
BeltSystemTest.cpp
|
||||
SurfaceMaskTest.cpp
|
||||
BuildingTest.cpp
|
||||
BuildingConfigTest.cpp
|
||||
ShipTest.cpp
|
||||
ScrapTest.cpp
|
||||
BehaviorSystemTest.cpp
|
||||
@@ -21,4 +22,10 @@ add_files(
|
||||
ShipModuleTest.cpp
|
||||
ThreatCostCalculatorTest.cpp
|
||||
RecipeSchematicTest.cpp
|
||||
UnlockPrereqTest.cpp
|
||||
ArtifactWinConditionTest.cpp
|
||||
DeterminismTest.cpp
|
||||
CommandTest.cpp
|
||||
ReplayRecorderTest.cpp
|
||||
ReplayPlaybackTest.cpp
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "HealthComponent.h"
|
||||
#include "HqProxyComponent.h"
|
||||
#include "ModuleOwnerComponent.h"
|
||||
#include "ScrapDataComponent.h"
|
||||
#include "ScrapSystem.h"
|
||||
#include "ShipSystem.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);
|
||||
REQUIRE(combatDef != nullptr);
|
||||
|
||||
const entt::entity enemy = f.ships.spawn(combatDef->id, 1, 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 enemy = f.ships.spawn(combatDef->id, QVector2D(5.0f, 5.0f), true);
|
||||
const entt::entity player = f.ships.spawn(combatDef->id, QVector2D(4.0f, 5.0f), false);
|
||||
f.wireEnemyTarget(enemy, player);
|
||||
|
||||
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);
|
||||
REQUIRE(combatDef != nullptr);
|
||||
|
||||
const entt::entity enemy = f.ships.spawn(combatDef->id, 1, 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 enemy = f.ships.spawn(combatDef->id, QVector2D(5.0f, 5.0f), true);
|
||||
const entt::entity player = f.ships.spawn(combatDef->id, QVector2D(4.0f, 5.0f), false);
|
||||
|
||||
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);
|
||||
REQUIRE(combatDef != nullptr);
|
||||
|
||||
const entt::entity enemy = f.ships.spawn(combatDef->id, 1, 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 enemy = f.ships.spawn(combatDef->id, QVector2D(0.0f, 0.0f), true);
|
||||
const entt::entity player = f.ships.spawn(combatDef->id, QVector2D(500.0f, 0.0f), false);
|
||||
f.wireEnemyTarget(enemy, player);
|
||||
|
||||
std::vector<BeamFiredEvent> events;
|
||||
@@ -199,7 +200,7 @@ TEST_CASE("CombatSystem: player station fires at enemy ship in range", "[combat]
|
||||
REQUIRE(combatDef != nullptr);
|
||||
|
||||
const entt::entity enemyShip = sim.ships().spawn(
|
||||
combatDef->id, 1,
|
||||
combatDef->id,
|
||||
QVector2D(stationCenter.x() + 1.0f, stationCenter.y()),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
@@ -237,7 +238,7 @@ TEST_CASE("CombatSystem: enemy station fires at player ship in range", "[combat]
|
||||
REQUIRE(combatDef != nullptr);
|
||||
|
||||
sim.ships().spawn(
|
||||
combatDef->id, 1,
|
||||
combatDef->id,
|
||||
QVector2D(stationCenter.x() - 1.0f, stationCenter.y()),
|
||||
/*isEnemy=*/false);
|
||||
|
||||
@@ -275,7 +276,7 @@ TEST_CASE("CombatSystem: player ship fires at enemy station in range", "[combat]
|
||||
REQUIRE(combatDef != nullptr);
|
||||
|
||||
const entt::entity playerShip = sim.ships().spawn(
|
||||
combatDef->id, 1,
|
||||
combatDef->id,
|
||||
QVector2D(stationCenter.x() - 1.0f, stationCenter.y()),
|
||||
/*isEnemy=*/false);
|
||||
|
||||
@@ -303,8 +304,8 @@ TEST_CASE("CombatSystem: damage not applied before impact tick", "[combat]")
|
||||
const ShipDef* combatDef = findCombatShip(f.cfg);
|
||||
REQUIRE(combatDef != nullptr);
|
||||
|
||||
const entt::entity enemy = f.ships.spawn(combatDef->id, 1, 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 enemy = f.ships.spawn(combatDef->id, QVector2D(5.0f, 5.0f), true);
|
||||
const entt::entity player = f.ships.spawn(combatDef->id, QVector2D(4.0f, 5.0f), false);
|
||||
f.wireEnemyTarget(enemy, player);
|
||||
|
||||
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);
|
||||
REQUIRE(combatDef != nullptr);
|
||||
|
||||
const entt::entity enemy = f.ships.spawn(combatDef->id, 1, 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 enemy = f.ships.spawn(combatDef->id, QVector2D(5.0f, 5.0f), true);
|
||||
const entt::entity player = f.ships.spawn(combatDef->id, QVector2D(4.0f, 5.0f), false);
|
||||
f.wireEnemyTarget(enemy, player);
|
||||
|
||||
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);
|
||||
REQUIRE(combatDef != nullptr);
|
||||
|
||||
const entt::entity enemy = f.ships.spawn(combatDef->id, 1, 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 enemy = f.ships.spawn(combatDef->id, QVector2D(5.0f, 5.0f), true);
|
||||
const entt::entity player = f.ships.spawn(combatDef->id, QVector2D(4.0f, 5.0f), false);
|
||||
f.wireEnemyTarget(enemy, player);
|
||||
|
||||
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);
|
||||
REQUIRE(combatDef != nullptr);
|
||||
|
||||
const entt::entity enemy = f.ships.spawn(combatDef->id, 1, 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 enemy = f.ships.spawn(combatDef->id, QVector2D(5.0f, 5.0f), true);
|
||||
const entt::entity player = f.ships.spawn(combatDef->id, QVector2D(4.0f, 5.0f), false);
|
||||
f.wireEnemyTarget(enemy, player);
|
||||
|
||||
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());
|
||||
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));
|
||||
|
||||
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);
|
||||
|
||||
const ShipDef* droppingDef = nullptr;
|
||||
for (const ShipDef& def : sim.config().ships.ships)
|
||||
{
|
||||
if (def.loot.scrapDrop > 0)
|
||||
{
|
||||
droppingDef = &def;
|
||||
break;
|
||||
}
|
||||
}
|
||||
REQUIRE(droppingDef != nullptr);
|
||||
|
||||
const entt::entity ship = sim.ships().spawn(droppingDef->id, 1,
|
||||
// Scrap dropped on death is derived from the ship's as-built threat cost
|
||||
// (REQ-RES-SCRAP-DROP): round(threat * scrap_per_threat). The interceptor's
|
||||
// threat is 59.0 and the test config sets scrap_per_threat = 1.0, so it drops
|
||||
// round(59.0 * 1.0) = 59 scrap.
|
||||
const entt::entity ship = sim.ships().spawn("interceptor",
|
||||
QVector2D(10.0f, 10.0f));
|
||||
sim.admin().get<HealthComponent>(ship).hp = -1.0f;
|
||||
|
||||
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]")
|
||||
|
||||
106
src/test/CommandTest.cpp
Normal file
106
src/test/CommandTest.cpp
Normal file
@@ -0,0 +1,106 @@
|
||||
#include "catch.hpp"
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "BuildingSystem.h"
|
||||
#include "Command.h"
|
||||
#include "CommandManager.h"
|
||||
#include "ConfigLoader.h"
|
||||
#include "GameConfig.h"
|
||||
#include "Rotation.h"
|
||||
#include "Simulation.h"
|
||||
#include "SimulationTestAccess.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// The command chokepoint (Simulation::apply) must produce exactly the same state
|
||||
// as driving the underlying mutators directly — that equivalence is what lets a
|
||||
// recorded command stream reproduce a live run (see docs/replay_design.md).
|
||||
|
||||
TEST_CASE("apply(PlaceBuildingCommand) matches direct placement", "[command]")
|
||||
{
|
||||
Simulation viaCommand(loadConfig(), 99);
|
||||
Simulation viaDirect(loadConfig(), 99);
|
||||
|
||||
PlaceBuildingCommand command;
|
||||
command.type = BuildingType::Miner;
|
||||
command.anchor = QPoint(-3, 0);
|
||||
command.rotation = Rotation::East;
|
||||
viaCommand.apply(command);
|
||||
|
||||
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
|
||||
|
||||
REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum());
|
||||
}
|
||||
|
||||
TEST_CASE("apply(PlaceBuildingCommand) with recipe matches place-then-setRecipe", "[command]")
|
||||
{
|
||||
Simulation viaCommand(loadConfig(), 99);
|
||||
Simulation viaDirect(loadConfig(), 99);
|
||||
|
||||
PlaceBuildingCommand command;
|
||||
command.type = BuildingType::Miner;
|
||||
command.anchor = QPoint(-2, 0);
|
||||
command.rotation = Rotation::East;
|
||||
command.recipeId = "mine_iron_ore";
|
||||
viaCommand.apply(command);
|
||||
|
||||
const BuildingId id =
|
||||
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
|
||||
SimulationTestAccess::buildings(viaDirect).setRecipe(id, "mine_iron_ore");
|
||||
|
||||
REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum());
|
||||
}
|
||||
|
||||
TEST_CASE("apply(DemolishCommand) matches direct demolish", "[command]")
|
||||
{
|
||||
Simulation viaCommand(loadConfig(), 99);
|
||||
Simulation viaDirect(loadConfig(), 99);
|
||||
|
||||
const BuildingId idA =
|
||||
SimulationTestAccess::place(viaCommand, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
|
||||
const BuildingId idB =
|
||||
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
|
||||
REQUIRE(idA == idB);
|
||||
|
||||
DemolishCommand command;
|
||||
command.id = idA;
|
||||
viaCommand.apply(command);
|
||||
|
||||
SimulationTestAccess::demolish(viaDirect, idB);
|
||||
|
||||
REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum());
|
||||
}
|
||||
|
||||
TEST_CASE("CommandManager drains queued commands in FIFO order through apply", "[command]")
|
||||
{
|
||||
Simulation viaManager(loadConfig(), 99);
|
||||
Simulation viaDirect(loadConfig(), 99);
|
||||
|
||||
CommandManager manager(viaManager);
|
||||
|
||||
std::shared_ptr<PlaceBuildingCommand> first = std::make_shared<PlaceBuildingCommand>();
|
||||
first->type = BuildingType::Miner;
|
||||
first->anchor = QPoint(-3, 0);
|
||||
std::shared_ptr<PlaceBuildingCommand> second = std::make_shared<PlaceBuildingCommand>();
|
||||
second->type = BuildingType::Belt;
|
||||
second->anchor = QPoint(-2, 0);
|
||||
|
||||
manager.enqueue(first);
|
||||
manager.enqueue(second);
|
||||
REQUIRE(manager.hasPending());
|
||||
|
||||
manager.drain();
|
||||
REQUIRE_FALSE(manager.hasPending());
|
||||
|
||||
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
|
||||
SimulationTestAccess::place(viaDirect, BuildingType::Belt, QPoint(-2, 0), Rotation::East);
|
||||
|
||||
REQUIRE(viaManager.computeStateChecksum() == viaDirect.computeStateChecksum());
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <vector>
|
||||
|
||||
#include "BuildingType.h"
|
||||
#include "ConfigLoader.h"
|
||||
@@ -75,9 +76,12 @@ TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[c
|
||||
REQUIRE(cfg.world.regions.playerBufferWidth_tiles == 10);
|
||||
REQUIRE(cfg.world.regions.enemyBufferWidth_tiles == 15);
|
||||
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.orbitFactor == Approx(0.8));
|
||||
REQUIRE(cfg.world.rallyOrbitRadius_tiles == Approx(5.0));
|
||||
REQUIRE(cfg.world.scrapPerThreat == Approx(1.0));
|
||||
|
||||
// Spot-check that a config-derived formula computes as expected.
|
||||
// threat_rate_formula = "x": evaluates to the input value.
|
||||
@@ -99,6 +103,15 @@ TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[c
|
||||
REQUIRE(minerIt != cfg.buildings.buildings.end());
|
||||
REQUIRE(minerIt->cost == 15);
|
||||
REQUIRE(minerIt->surfaceMask.size() == 2);
|
||||
// Miner has no output-buffer-capacity override; the Salvage Bay does.
|
||||
REQUIRE_FALSE(minerIt->outputBufferCapacity.has_value());
|
||||
|
||||
const auto salvageBayIt = std::find_if(
|
||||
cfg.buildings.buildings.begin(), cfg.buildings.buildings.end(),
|
||||
[](const BuildingDef& b) { return b.type == BuildingType::SalvageBay; });
|
||||
REQUIRE(salvageBayIt != cfg.buildings.buildings.end());
|
||||
REQUIRE(salvageBayIt->outputBufferCapacity.has_value());
|
||||
REQUIRE(*salvageBayIt->outputBufferCapacity == 20);
|
||||
|
||||
// recipes.toml — reprocessing cycle has three weighted outputs.
|
||||
const auto reproIt = std::find_if(
|
||||
@@ -167,6 +180,7 @@ TEST_CASE("Missing field in world.toml is rejected with the field path", "[confi
|
||||
height_tiles = 60
|
||||
refund_percentage = 75
|
||||
scrap_despawn_seconds = 30
|
||||
scrap_per_threat = 0.01
|
||||
tile_size_m = 10
|
||||
belt_speed_mps = 20
|
||||
starting_building_blocks = 100
|
||||
@@ -183,7 +197,7 @@ contest_zone_width_tiles = 30
|
||||
|
||||
[expansion]
|
||||
columns_per_expansion_tiles = 10
|
||||
cost_building_blocks = 200
|
||||
cost_building_blocks_formula = "400 * 2^x"
|
||||
|
||||
[push]
|
||||
push_expand_columns_tiles = 20
|
||||
@@ -217,6 +231,7 @@ TEST_CASE("Malformed formula in world.toml is rejected with field identification
|
||||
height_tiles = 60
|
||||
refund_percentage = 75
|
||||
scrap_despawn_seconds = 30
|
||||
scrap_per_threat = 0.01
|
||||
tile_size_m = 10
|
||||
belt_speed_mps = 20
|
||||
starting_building_blocks = 100
|
||||
@@ -233,7 +248,7 @@ enemy_buffer_width_tiles = 15
|
||||
|
||||
[expansion]
|
||||
columns_per_expansion_tiles = 10
|
||||
cost_building_blocks = 200
|
||||
cost_building_blocks_formula = "400 * 2^x"
|
||||
|
||||
[push]
|
||||
push_expand_columns_tiles = 20
|
||||
@@ -268,6 +283,7 @@ TEST_CASE("Inverted wave gap range is rejected", "[config]")
|
||||
height_tiles = 60
|
||||
refund_percentage = 75
|
||||
scrap_despawn_seconds = 30
|
||||
scrap_per_threat = 0.01
|
||||
tile_size_m = 10
|
||||
belt_speed_mps = 20
|
||||
|
||||
@@ -279,7 +295,7 @@ enemy_buffer_width_tiles = 15
|
||||
|
||||
[expansion]
|
||||
columns_per_expansion_tiles = 10
|
||||
cost_building_blocks = 200
|
||||
cost_building_blocks_formula = "400 * 2^x"
|
||||
|
||||
[push]
|
||||
push_expand_columns_tiles = 20
|
||||
@@ -339,3 +355,151 @@ duration_seconds = 1.0
|
||||
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()));
|
||||
}
|
||||
|
||||
|
||||
158
src/test/DeterminismTest.cpp
Normal file
158
src/test/DeterminismTest.cpp
Normal file
@@ -0,0 +1,158 @@
|
||||
#include "catch.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <random>
|
||||
#include <vector>
|
||||
|
||||
#include "ConfigLoader.h"
|
||||
#include "GameConfig.h"
|
||||
#include "Rotation.h"
|
||||
#include "Simulation.h"
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "StateChecksum.h"
|
||||
#include "Tick.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
|
||||
constexpr int kScriptTicks = 2000;
|
||||
|
||||
// Runs a fixed scripted session and returns the full-state checksum after every
|
||||
// tick. The script places a small factory, demolishes part of it mid-run, and
|
||||
// otherwise lets waves/combat run so the RNG stream and ECS state are exercised.
|
||||
std::vector<std::uint64_t> runScriptedSession(unsigned int seed)
|
||||
{
|
||||
Simulation sim(loadConfig(), seed);
|
||||
|
||||
// Tick 0: a miner feeding a short belt line on the asteroid.
|
||||
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
|
||||
SimulationTestAccess::place(sim, BuildingType::Belt, QPoint(-2, 0), Rotation::East);
|
||||
SimulationTestAccess::place(sim, BuildingType::Belt, QPoint(-1, 0), Rotation::East);
|
||||
|
||||
std::vector<std::uint64_t> checksums;
|
||||
checksums.reserve(kScriptTicks);
|
||||
|
||||
for (int t = 0; t < kScriptTicks; ++t)
|
||||
{
|
||||
if (t == 500)
|
||||
{
|
||||
// Demolish the second belt mid-run to exercise the removal paths.
|
||||
SimulationTestAccess::place(sim, BuildingType::Smelter, QPoint(-3, 3), Rotation::East);
|
||||
}
|
||||
|
||||
sim.tick();
|
||||
checksums.push_back(sim.computeStateChecksum());
|
||||
}
|
||||
|
||||
return checksums;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hasher
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("Hasher: identical inputs produce identical values", "[determinism]")
|
||||
{
|
||||
Hasher a;
|
||||
Hasher b;
|
||||
a.append(42);
|
||||
a.append(3.5f);
|
||||
a.append(std::string("ore"));
|
||||
b.append(42);
|
||||
b.append(3.5f);
|
||||
b.append(std::string("ore"));
|
||||
|
||||
REQUIRE(a.value() == b.value());
|
||||
}
|
||||
|
||||
TEST_CASE("Hasher: differing inputs produce differing values", "[determinism]")
|
||||
{
|
||||
Hasher a;
|
||||
Hasher b;
|
||||
a.append(42);
|
||||
b.append(43);
|
||||
|
||||
REQUIRE(a.value() != b.value());
|
||||
}
|
||||
|
||||
TEST_CASE("Hasher: string concatenation does not collide", "[determinism]")
|
||||
{
|
||||
Hasher a;
|
||||
Hasher b;
|
||||
a.append(std::string("ab"));
|
||||
a.append(std::string("c"));
|
||||
b.append(std::string("a"));
|
||||
b.append(std::string("bc"));
|
||||
|
||||
REQUIRE(a.value() != b.value());
|
||||
}
|
||||
|
||||
TEST_CASE("Hasher: negative and positive zero hash equally", "[determinism]")
|
||||
{
|
||||
Hasher a;
|
||||
Hasher b;
|
||||
a.append(-0.0f);
|
||||
b.append(0.0f);
|
||||
|
||||
REQUIRE(a.value() == b.value());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RNG fingerprint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("fingerprintRng: equal states match, advanced states differ", "[determinism]")
|
||||
{
|
||||
std::mt19937 a(12345);
|
||||
std::mt19937 b(12345);
|
||||
REQUIRE(fingerprintRng(a) == fingerprintRng(b));
|
||||
|
||||
a(); // advance one draw
|
||||
REQUIRE(fingerprintRng(a) != fingerprintRng(b));
|
||||
|
||||
b(); // advance b to the same point
|
||||
REQUIRE(fingerprintRng(a) == fingerprintRng(b));
|
||||
}
|
||||
|
||||
TEST_CASE("Simulation::rngFingerprint is stable for equal seeds", "[determinism]")
|
||||
{
|
||||
const Simulation a(loadConfig(), 777);
|
||||
const Simulation b(loadConfig(), 777);
|
||||
|
||||
REQUIRE(a.rngFingerprint() == b.rngFingerprint());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Double-run determinism
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("Simulation: two runs from the same seed produce identical per-tick state",
|
||||
"[determinism]")
|
||||
{
|
||||
const std::vector<std::uint64_t> first = runScriptedSession(424242);
|
||||
const std::vector<std::uint64_t> second = runScriptedSession(424242);
|
||||
|
||||
REQUIRE(first.size() == second.size());
|
||||
REQUIRE(first.size() == static_cast<std::size_t>(kScriptTicks));
|
||||
|
||||
for (std::size_t i = 0; i < first.size(); ++i)
|
||||
{
|
||||
INFO("divergence at tick " << i);
|
||||
REQUIRE(first[i] == second[i]);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Simulation: different seeds diverge in state checksum", "[determinism]")
|
||||
{
|
||||
const std::vector<std::uint64_t> a = runScriptedSession(111);
|
||||
const std::vector<std::uint64_t> b = runScriptedSession(222);
|
||||
|
||||
// The two sessions must differ at some point (the checksum is sensitive to
|
||||
// the RNG-driven divergence; a constant checksum would be a broken hash).
|
||||
REQUIRE(a != b);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user