Compare commits

..

21 Commits

Author SHA1 Message Date
c44936d1fe adapt config 2026-06-14 14:21:24 +02:00
68c1345660 recipe iteration 2026-06-14 14:06:12 +02:00
dbf334c829 add python script to verify module constraints against hulls 2026-06-14 14:06:11 +02:00
f225c1330e refinement 2026-06-14 14:06:11 +02:00
fba98c928f design config 2026-06-14 14:06:10 +02:00
282ace4c11 fix bug where splitters reduce belt throughput, even if one side is blocked 2026-06-14 14:03:50 +02:00
1ea1cc59fb show threat rate in debug output 2026-06-14 13:39:10 +02:00
123c544423 move ui panels to the right 2026-06-14 13:07:25 +02:00
10c5ad678f derive threat cost dynamically 2026-06-13 22:47:46 +02:00
3716c2b734 show implicitly unlocked items in schematic unlock dialog 2026-06-13 18:19:25 +02:00
5317f35198 switch to using own event system 2026-06-13 17:52:22 +02:00
ed17664ef1 fix bug where game simulation continues while dialog is shown 2026-06-13 14:31:03 +02:00
49f7129bd5 schematic selection dialog 2026-06-13 14:19:51 +02:00
1641189b75 explicit recipe unlocking 2026-06-12 17:15:06 +02:00
54a6056b77 implicit item locking 2026-06-12 16:14:21 +02:00
69b35d2bfc fix config 2026-06-10 22:37:46 +02:00
af96b95f61 allow to unlock modules when destroying defence stations 2026-06-10 22:37:38 +02:00
aad094f842 allow to configure when which schematic gets unlockable 2026-06-10 21:09:03 +02:00
26857e8414 throw if modules are referenced that don't exist in config 2026-06-09 23:34:30 +02:00
510e37c37b fix issue where upgrade modules are not working properly 2026-06-09 23:34:29 +02:00
121cd5407f add more modules 2026-06-09 23:31:58 +02:00
103 changed files with 4822 additions and 785 deletions

View File

@@ -41,7 +41,7 @@ cost = 20
player_placeable = true player_placeable = true
construction_time_seconds = 1 construction_time_seconds = 1
surface_mask = [ surface_mask = [
"AA ", "AA",
" v", " v",
] ]

View File

@@ -1,25 +1,90 @@
# 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.
#
# 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
# 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)
# 2x6 drone_hangar carrier only
# -----------------------------------------------------------------------------
# Weapons
# -----------------------------------------------------------------------------
[[module]] [[module]]
id = "laser_cannon" id = "laser_cannon_s"
unlock_at_station_level = -1
surface_mask = ["O"] surface_mask = ["O"]
materials = [{item = "laser_cannon_module", amount = 1}] materials = [{item = "laser_cannon_s_module", amount = 1}]
player_production_level = 1 player_production_level = 1
production_time_seconds = 2 production_time_seconds = 0.5
threat_cost = 5.0
fill_color = "#FF8040" fill_color = "#FF8040"
glyph = "L" glyph = "Ls"
[module.weapon] [module.weapon]
damage_formula = "2" damage_formula = "2"
attack_range_m_formula = "50" attack_range_m_formula = "50"
attack_rate_hz_formula = "2.0" attack_rate_hz_formula = "2.0"
[[module]]
id = "laser_cannon_m"
unlock_at_station_level = -1
surface_mask = [
"OO",
"OO"]
materials = [{item = "laser_cannon_m_module", amount = 1}]
player_production_level = 1
production_time_seconds = 2
fill_color = "#FF8040"
glyph = "Lm"
[module.weapon]
damage_formula = "10"
attack_range_m_formula = "70"
attack_rate_hz_formula = "1.5"
[[module]]
id = "laser_cannon_l"
unlock_at_station_level = -1
surface_mask = [
"OOO",
"OOO",
"OOO"]
materials = [{item = "laser_cannon_l_module", amount = 1}]
player_production_level = 1
production_time_seconds = 8
fill_color = "#FF8040"
glyph = "Ll"
[module.weapon]
damage_formula = "40"
attack_range_m_formula = "100"
attack_rate_hz_formula = "0.8"
# -----------------------------------------------------------------------------
# Utility tools
# -----------------------------------------------------------------------------
[[module]] [[module]]
id = "salvager" id = "salvager"
unlock_at_station_level = -1
surface_mask = ["O"] surface_mask = ["O"]
materials = [{item = "salvager_module", amount = 1}] materials = [{item = "salvager_module", amount = 1}]
player_production_level = 1 player_production_level = 1
production_time_seconds = 2 production_time_seconds = 2
threat_cost = 0.0
fill_color = "#AACC44" fill_color = "#AACC44"
glyph = "Sv" glyph = "Sv"
@@ -28,16 +93,168 @@ collection_range_m_formula = "500"
cargo_capacity_formula = "10" cargo_capacity_formula = "10"
collection_rate_hz_formula = "0.5" collection_rate_hz_formula = "0.5"
[[module]] [[module]]
id = "repair_tool" id = "repair_tool"
unlock_at_station_level = -1
surface_mask = ["O"] surface_mask = ["O"]
materials = [{item = "repair_tool_module", amount = 1}] materials = [{item = "repair_tool_module", amount = 1}]
player_production_level = 1 player_production_level = 1
production_time_seconds = 2 production_time_seconds = 2
threat_cost = 0.0
fill_color = "#66CCFF" fill_color = "#66CCFF"
glyph = "Rp" glyph = "Rp"
[module.repair] [module.repair]
repair_rate_hz_formula = "5 + x" repair_rate_hz_formula = "5 + x"
repair_range_m_formula = "800" repair_range_m_formula = "800"
# -----------------------------------------------------------------------------
# Propulsion
# -----------------------------------------------------------------------------
[[module]]
id = "afterburner"
unlock_at_station_level = -1
surface_mask = ["OOO"]
materials = [{item = "afterburner_module", 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"
[[module]]
id = "maneuvering_thrusters"
unlock_at_station_level = -1
surface_mask = ["OO"]
materials = [{item = "maneuvering_thrusters_module", 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"
# -----------------------------------------------------------------------------
# Defense & sensors
# -----------------------------------------------------------------------------
[[module]]
id = "armor_plates"
unlock_at_station_level = -1
surface_mask = ["OO"]
materials = [{item = "armor_plates_module", amount = 1}]
player_production_level = 1
production_time_seconds = 3
fill_color = "#808080"
glyph = "A"
[module.health]
added_hp_formula = "40"
[[module]]
id = "sensor_booster"
unlock_at_station_level = -1
surface_mask = ["OO"]
materials = [{item = "sensor_booster_module", amount = 1}]
player_production_level = 1
production_time_seconds = 2
fill_color = "#40A0FF"
glyph = "S"
[module.sensor]
added_sensor_range_m_formula = "50"
# -----------------------------------------------------------------------------
# Weapon modifiers
# -----------------------------------------------------------------------------
[[module]]
id = "weapon_upgrade"
unlock_at_station_level = -1
surface_mask = [
"OO",
"OX",
]
materials = [{item = "weapon_upgrade_module", amount = 1}]
player_production_level = 1
production_time_seconds = 4
fill_color = "#FF4040"
glyph = "Wu"
[module.weapon]
multiplied_damage_formula = "1.2"
[[module]]
id = "weapon_primer"
unlock_at_station_level = -1
surface_mask = [
"OO",
"OX",
]
materials = [{item = "weapon_primer_module", amount = 1}]
player_production_level = 1
production_time_seconds = 4
fill_color = "#FF4040"
glyph = "Wp"
[module.weapon]
multiplied_attack_rate_hz_formula = "1.2"
[[module]]
id = "weapon_stabilizer"
unlock_at_station_level = -1
surface_mask = [
"OO",
"OX",
]
materials = [{item = "weapon_stabilizer_module", 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"
# -----------------------------------------------------------------------------
# Drone modules
#
# Footprint-only placeholders: the drone launching capability is not
# implemented yet, so these modules define no capability section.
# -----------------------------------------------------------------------------
[[module]]
id = "drone_bay"
unlock_at_station_level = -1
surface_mask = [
"OO",
"OO"]
materials = [{item = "drone_bay_module", amount = 1}]
player_production_level = 1
production_time_seconds = 5
fill_color = "#CC66FF"
glyph = "Db"
[[module]]
id = "drone_hangar"
unlock_at_station_level = -1
surface_mask = [
"OOOOOO",
"OOOOOO"]
materials = [{item = "drone_hangar_module", amount = 1}]
player_production_level = 1
production_time_seconds = 20
fill_color = "#9933CC"
glyph = "Dh"

View File

@@ -1,3 +1,29 @@
# 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).
#
# Input chain per game phase — each phase 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.
#
# Run tools/verify_recipes.py after editing to check that every consumed
# item has a producer and every item has a visuals.toml entry.
# -----------------------------------------------------------------------------
# Mining (tier 0)
# -----------------------------------------------------------------------------
[[recipe]] [[recipe]]
id = "mine_iron_ore" id = "mine_iron_ore"
building = "miner" building = "miner"
@@ -12,6 +38,18 @@ inputs = []
outputs = [{item = "copper_ore", amount = 1}] outputs = [{item = "copper_ore", amount = 1}]
duration_seconds = 1.5 duration_seconds = 1.5
# Titanium is the midgame ore: mined three times slower than iron.
[[recipe]]
id = "mine_titanium_ore"
building = "miner"
inputs = []
outputs = [{item = "titanium_ore", amount = 1}]
duration_seconds = 3.0
# -----------------------------------------------------------------------------
# Smelting (tier 1)
# -----------------------------------------------------------------------------
[[recipe]] [[recipe]]
id = "iron_ingot" id = "iron_ingot"
building = "smelter" building = "smelter"
@@ -27,47 +65,18 @@ outputs = [{item = "copper_ingot", amount = 1}]
duration_seconds = 2.5 duration_seconds = 2.5
[[recipe]] [[recipe]]
id = "circuit_board" id = "titanium_ingot"
building = "assembler" building = "smelter"
inputs = [{item = "iron_ingot", amount = 1}, {item = "copper_ingot", amount = 2}] inputs = [{item = "titanium_ore", amount = 3}]
outputs = [{item = "circuit_board", amount = 1}] outputs = [{item = "titanium_ingot", amount = 1}]
duration_seconds = 5.0
[[recipe]]
id = "drone_hull"
building = "assembler"
inputs = [{item = "iron_ingot", amount = 5}, {item = "circuit_board", amount = 1}]
outputs = [{item = "drone_hull", amount = 1}]
duration_seconds = 12.0
[[recipe]]
id = "laser_cannon_module"
building = "assembler"
inputs = [{item = "iron_ingot", amount = 2}, {item = "circuit_board", amount = 1}]
outputs = [{item = "laser_cannon_module", amount = 1}]
duration_seconds = 6.0
[[recipe]]
id = "salvager_module"
building = "assembler"
inputs = [{item = "iron_ingot", amount = 1}, {item = "circuit_board", amount = 1}]
outputs = [{item = "salvager_module", amount = 1}]
duration_seconds = 6.0
[[recipe]]
id = "repair_tool_module"
building = "assembler"
inputs = [{item = "iron_ingot", amount = 1}, {item = "circuit_board", amount = 2}]
outputs = [{item = "repair_tool_module", amount = 1}]
duration_seconds = 6.0
[[recipe]]
id = "building_blocks"
building = "assembler"
inputs = [{item = "iron_ingot", amount = 4}]
outputs = [{item = "building_block", amount = 10}]
duration_seconds = 4.0 duration_seconds = 4.0
# -----------------------------------------------------------------------------
# Reprocessing
#
# The only source of advanced_alloy: salvaged scrap from destroyed ships.
# -----------------------------------------------------------------------------
[[recipe]] [[recipe]]
id = "reprocessing_cycle" id = "reprocessing_cycle"
building = "reprocessing_plant" building = "reprocessing_plant"
@@ -77,15 +86,354 @@ duration_seconds = 3.0
[[recipe.outputs]] [[recipe.outputs]]
item = "iron_ingot" item = "iron_ingot"
amount = 2 amount = 2
probability = 0.6 probability = 0.45
[[recipe.outputs]] [[recipe.outputs]]
item = "circuit_board" item = "copper_ingot"
amount = 1 amount = 1
probability = 0.3 probability = 0.25
[[recipe.outputs]]
item = "titanium_ingot"
amount = 1
probability = 0.15
[[recipe.outputs]] [[recipe.outputs]]
item = "advanced_alloy" item = "advanced_alloy"
amount = 1 amount = 1
probability = 0.1 probability = 0.15
# -----------------------------------------------------------------------------
# Basic components (tier 2, early game)
# -----------------------------------------------------------------------------
[[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 = 2.0
[[recipe]]
id = "circuit_board"
unlock_at_station_level = -1
building = "assembler"
inputs = [{item = "iron_ingot", amount = 1}, {item = "copper_wire", amount = 2}]
outputs = [{item = "circuit_board", amount = 1}]
duration_seconds = 2.0
[[recipe]]
id = "building_blocks"
unlock_at_station_level = -1
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}]
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}]
outputs = [{item = "drone_hull", amount = 1}]
duration_seconds = 4.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},
]
outputs = [{item = "frigate_hull", amount = 1}]
duration_seconds = 8.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},
]
outputs = [{item = "destroyer_hull", amount = 1}]
duration_seconds = 10.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},
]
outputs = [{item = "cruiser_hull", amount = 1}]
duration_seconds = 15.0
[[recipe]]
id = "battlecruiser_hull"
unlock_at_station_level = -1
building = "assembler"
inputs = [
{item = "titanium_frame", amount = 3},
{item = "steel_plate", amount = 6},
{item = "drive_unit", amount = 1},
{item = "targeting_unit", amount = 1},
]
outputs = [{item = "battlecruiser_hull", amount = 1}]
duration_seconds = 20.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},
]
outputs = [{item = "battleship_hull", amount = 1}]
duration_seconds = 30.0
[[recipe]]
id = "dreadnought_hull"
unlock_at_station_level = -1
building = "assembler"
inputs = [
{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
[[recipe]]
id = "carrier_hull"
unlock_at_station_level = -1
building = "assembler"
inputs = [
{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

View File

@@ -1,17 +1,31 @@
# ships.toml
#
# First real-content iteration: ship ids and layout grids are the designed
# content; stats, materials, and production times are placeholders until the
# recipe and balancing passes.
#
# Size classes:
# xs drone 1 cell — exactly one 1x1 module
# s frigate, destroyer no 2x2 area anywhere: only 1x1/1x2/1x3/L modules fit
# m cruiser, battlecruiser 2x2 areas (m guns, drone bays) but no 3x3 area
# l battleship four m guns, or exactly one 3x3 l gun at heavy
# opportunity cost
# xl dreadnought, carrier dreadnought fits three l guns but no drone
# hangar; carrier fits one drone hangar (2x6)
# but no l gun (its deck rows are broken up
# by elevator shafts)
[[ship]] [[ship]]
id = "drone" id = "drone"
available_from_start = true unlock_at_station_level = -1
layout = ["O"] layout = ["O"]
default_modules = [{type = "laser_cannon", x = 0, y = 0, rotation = "east"}] default_modules = [{type = "laser_cannon_s", x = 0, y = 0, rotation = "east"}]
[ship.schematic] [ship.schematic]
materials = [{item = "drone_hull", amount = 1}] materials = [{item = "iron_ore", amount = 1}]
player_production_level = 1 player_production_level = 1
production_time_seconds = 5 production_time_seconds = 5
[ship.threat]
cost_formula = "10"
[ship.health] [ship.health]
hp_formula = "3" hp_formula = "3"
@@ -27,3 +41,255 @@ sensor_range_m_formula = "150"
[ship.loot] [ship.loot]
scrap_drop = 2 scrap_drop = 2
# Frigate — 5 cells in a plus shape. Holds a couple of small guns plus at
# most one 1x2 support (every 1x2 placement crosses the center cell), or one
# L-shaped weapon modifier, or an afterburner spanning the full center line.
[[ship]]
id = "frigate"
unlock_at_station_level = -1
layout = [
"XOX",
"OOO",
"XOX",
]
[ship.schematic]
materials = [{item = "frigate_hull", amount = 1}]
player_production_level = 1
production_time_seconds = 10
[ship.health]
hp_formula = "30"
[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"
[ship.sensor]
sensor_range_m_formula = "200"
[ship.loot]
scrap_drop = 5
# Destroyer — 8 cells: a long gun deck with three turret bumps on top.
# Still no 2x2 area, so it packs more small guns than a frigate but can never
# mount medium hardware.
[[ship]]
id = "destroyer"
unlock_at_station_level = -1
layout = [
"OXOXO",
"OOOOO",
]
[ship.schematic]
materials = [{item = "destroyer_hull", amount = 1}]
player_production_level = 1
production_time_seconds = 15
[ship.health]
hp_formula = "50"
[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"
[ship.sensor]
sensor_range_m_formula = "220"
[ship.loot]
scrap_drop = 8
# Cruiser — 12 cells with notched corners. Fits at most two 2x2 m guns
# (stacked through the middle), leaving the four side cells for small
# supports; no 3x3 area exists for an l gun.
[[ship]]
id = "cruiser"
unlock_at_station_level = -1
layout = [
"XOOX",
"OOOO",
"OOOO",
"XOOX",
]
[ship.schematic]
materials = [{item = "cruiser_hull", amount = 1}]
player_production_level = 1
production_time_seconds = 25
[ship.health]
hp_formula = "120"
[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"
[ship.sensor]
sensor_range_m_formula = "250"
[ship.loot]
scrap_drop = 15
# Battlecruiser — 16 cells: a wide bow split into two gun cheeks, tapering
# toward the stern. Fits three 2x2 m guns (two in the cheeks, one through
# the middle) with small support slots left over; the split bow and tapered
# 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
layout = [
"OOXXOO",
"OOOOOO",
"XOOOOX",
"XXOOXX",
]
[ship.schematic]
materials = [{item = "battlecruiser_hull", amount = 1}]
player_production_level = 1
production_time_seconds = 35
[ship.health]
hp_formula = "180"
[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"
[ship.sensor]
sensor_range_m_formula = "260"
[ship.loot]
scrap_drop = 20
# Battleship — 24 cells: a broadside hull with notched flanks on every other
# row. Fits four 2x2 m guns (two per gun deck) with the bow, stern, and flank
# cells left for supports. All 3x3 placements crowd the center columns, so at
# most ONE l gun fits — and mounting it blocks every m gun mount, leaving
# only narrow support strips. The notched rows are never adjacent-and-full,
# so no 2x6 drone hangar fits.
[[ship]]
id = "battleship"
unlock_at_station_level = -1
layout = [
"XOOOOX",
"OOOOOO",
"XOOOOX",
"OOOOOO",
"XOOOOX",
]
[ship.schematic]
materials = [{item = "battleship_hull", amount = 1}]
player_production_level = 1
production_time_seconds = 60
[ship.health]
hp_formula = "350"
[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"
[ship.sensor]
sensor_range_m_formula = "280"
[ship.loot]
scrap_drop = 35
# Dreadnought — 36 cells: the main battery deck is split into three 3x3 gun
# slots by structural spacer columns, so exactly three l guns fit side by
# side (or m guns / supports in unused slots). The spacers cap every
# horizontal run at 5 cells, so the 2x6 drone hangar can never fit — carriers
# stay the only hangar hull. Bow and stern strips hold supports.
[[ship]]
id = "dreadnought"
unlock_at_station_level = -1
layout = [
"XXXOOOOOXXX",
"OOOXOOOXOOO",
"OOOXOOOXOOO",
"OOOXOOOXOOO",
"XXOOXXXOOXX",
]
[ship.schematic]
materials = [{item = "dreadnought_hull", amount = 1}]
player_production_level = 1
production_time_seconds = 120
[ship.health]
hp_formula = "800"
[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"
[ship.sensor]
sensor_range_m_formula = "300"
[ship.loot]
scrap_drop = 60
# Carrier — 37 cells: the top flight deck (rows 0-1) is the only place wide
# enough for the 2x6 drone hangar, and exactly one fits. The middle deck row
# is broken up by elevator shafts (the X cells) so no 3x3 l gun can ever fit;
# the lower decks hold supports and 2x2 point-defense m guns.
[[ship]]
id = "carrier"
unlock_at_station_level = -1
layout = [
"XOOOOOOOOX",
"OOOOOOOOOO",
"OOXOOXOOXO",
"XOOOOOOOOX",
"XXXOOOOXXX",
]
[ship.schematic]
materials = [{item = "carrier_hull", amount = 1}]
player_production_level = 1
production_time_seconds = 120
[ship.health]
hp_formula = "700"
[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"
[ship.sensor]
sensor_range_m_formula = "350"
[ship.loot]
scrap_drop = 60

View File

@@ -106,6 +106,8 @@ glyph = "E"
# drawn around it. One section per ItemType. # drawn around it. One section per ItemType.
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# --- ores ---
[items.iron_ore] [items.iron_ore]
fill = "#8a5a4a" fill = "#8a5a4a"
outline = "#201010" outline = "#201010"
@@ -114,6 +116,12 @@ outline = "#201010"
fill = "#c47a3a" fill = "#c47a3a"
outline = "#3a1a0a" outline = "#3a1a0a"
[items.titanium_ore]
fill = "#9aa3ad"
outline = "#2a2e33"
# --- ingots ---
[items.iron_ingot] [items.iron_ingot]
fill = "#b0b0b8" fill = "#b0b0b8"
outline = "#202028" outline = "#202028"
@@ -122,30 +130,80 @@ outline = "#202028"
fill = "#d48a4a" fill = "#d48a4a"
outline = "#402010" outline = "#402010"
[items.circuit_board] [items.titanium_ingot]
fill = "#2ea35a" fill = "#c8d2dc"
outline = "#0a2a14" outline = "#3a4048"
[items.advanced_alloy] # --- salvage loop ---
fill = "#a06acc"
outline = "#201030"
[items.building_block]
fill = "#c8b070"
outline = "#302810"
[items.scrap] [items.scrap]
fill = "#7a7268" fill = "#7a7268"
outline = "#201a14" outline = "#201a14"
[items.drone_hull] [items.advanced_alloy]
fill = "#1b1b1b" fill = "#a06acc"
outline = "#1402b3" outline = "#201030"
[items.laser_cannon_module] # --- basic components ---
[items.copper_wire]
fill = "#e09a50"
outline = "#3a2008"
[items.steel_plate]
fill = "#8a92a0"
outline = "#22262c"
[items.circuit_board]
fill = "#2ea35a"
outline = "#0a2a14"
[items.building_block]
fill = "#c8b070"
outline = "#302810"
# --- advanced components ---
[items.mechanical_parts]
fill = "#6f7a66"
outline = "#1c2018"
[items.targeting_unit]
fill = "#3a9e8c"
outline = "#0c2824"
[items.drive_unit]
fill = "#4a6ad0"
outline = "#101a38"
[items.titanium_frame]
fill = "#b8c4d4"
outline = "#343c48"
# --- capital components ---
[items.reinforced_plating]
fill = "#8a6ad0"
outline = "#1c1038"
[items.capital_core]
fill = "#b040d0"
outline = "#280c30"
# --- module items ---
[items.laser_cannon_s_module]
fill = "#691313" fill = "#691313"
outline = "#f3ff4f" outline = "#f3ff4f"
[items.laser_cannon_m_module]
fill = "#892020"
outline = "#f3ff4f"
[items.laser_cannon_l_module]
fill = "#a92d2d"
outline = "#f3ff4f"
[items.salvager_module] [items.salvager_module]
fill = "#b2cfdd" fill = "#b2cfdd"
outline = "#236137" outline = "#236137"
@@ -154,6 +212,76 @@ outline = "#236137"
fill = "#2e9ba3" fill = "#2e9ba3"
outline = "#689275" outline = "#689275"
[items.armor_plates_module]
fill = "#808080"
outline = "#202020"
[items.sensor_booster_module]
fill = "#40a0ff"
outline = "#102840"
[items.maneuvering_thrusters_module]
fill = "#5090e0"
outline = "#142438"
[items.afterburner_module]
fill = "#6080c0"
outline = "#182030"
[items.weapon_upgrade_module]
fill = "#ff4040"
outline = "#401010"
[items.weapon_primer_module]
fill = "#e03838"
outline = "#380e0e"
[items.weapon_stabilizer_module]
fill = "#c03030"
outline = "#300c0c"
[items.drone_bay_module]
fill = "#cc66ff"
outline = "#331040"
[items.drone_hangar_module]
fill = "#9933cc"
outline = "#260c33"
# --- ship hulls (outline matches the ship's fleet color in [ships.*]) ---
[items.drone_hull]
fill = "#1b1b1b"
outline = "#3366ff"
[items.frigate_hull]
fill = "#1b1b1b"
outline = "#44aaff"
[items.destroyer_hull]
fill = "#1b1b1b"
outline = "#33ccaa"
[items.cruiser_hull]
fill = "#1b1b1b"
outline = "#66cc33"
[items.battlecruiser_hull]
fill = "#1b1b1b"
outline = "#cccc33"
[items.battleship_hull]
fill = "#1b1b1b"
outline = "#ff9933"
[items.dreadnought_hull]
fill = "#1b1b1b"
outline = "#ff5533"
[items.carrier_hull]
fill = "#1b1b1b"
outline = "#cc66ff"
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Ships # Ships
# #
@@ -164,6 +292,34 @@ outline = "#689275"
fill = "#3366ff" fill = "#3366ff"
outline = "#ffffff" outline = "#ffffff"
[ships.frigate]
fill = "#44aaff"
outline = "#ffffff"
[ships.destroyer]
fill = "#33ccaa"
outline = "#ffffff"
[ships.cruiser]
fill = "#66cc33"
outline = "#ffffff"
[ships.battlecruiser]
fill = "#cccc33"
outline = "#ffffff"
[ships.battleship]
fill = "#ff9933"
outline = "#ffffff"
[ships.dreadnought]
fill = "#ff5533"
outline = "#ffffff"
[ships.carrier]
fill = "#cc66ff"
outline = "#ffffff"
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Laser beams (REQ-SHP-FIRING-BEAM) # Laser beams (REQ-SHP-FIRING-BEAM)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------

View File

@@ -12,7 +12,7 @@ enemy_buffer_width_tiles = 10
level = 1 level = 1
count = 5 count = 5
modules = [ modules = [
{type = "laser_cannon", x = 1, y = 1, rotation = "east"}, {type = "laser_cannon_s", x = 1, y = 1, rotation = "east"},
] ]
[[arena.team]] [[arena.team]]
@@ -20,11 +20,50 @@ enemy_buffer_width_tiles = 10
[[arena.team.ship]] [[arena.team.ship]]
schematic = "drone" schematic = "drone"
level = 1 level = 1
count = 1 count = 2
modules = [ modules = [
{type = "laser_cannon", x = 1, y = 1, rotation = "east"}, {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"},
] ]
[[arena]]
name = "Fighters vs Supported"
height_tiles = 20
player_buffer_width_tiles = 10
contest_zone_width_tiles = 60
enemy_buffer_width_tiles = 10
[[arena.team]]
name = "Fighters"
[[arena.team.ship]]
schematic = "drone"
level = 1
count = 5
modules = [
{type = "laser_cannon_s", x = 1, y = 1, rotation = "east"},
]
[[arena.team]]
name = "Supported"
[[arena.team.ship]]
schematic = "drone"
level = 1
count = 3
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"},
]
[[arena]] [[arena]]
name = "Stations and Ships" name = "Stations and Ships"
@@ -40,7 +79,7 @@ enemy_buffer_width_tiles = 15
level = 1 level = 1
count = 3 count = 3
modules = [ modules = [
{type = "laser_cannon", x = 1, y = 1, rotation = "east"}, {type = "laser_cannon_s", x = 1, y = 1, rotation = "east"},
] ]
[[arena.team.station]] [[arena.team.station]]
type = "player_station" type = "player_station"
@@ -60,5 +99,5 @@ enemy_buffer_width_tiles = 15
level = 1 level = 1
count = 8 count = 8
modules = [ modules = [
{type = "laser_cannon", x = 1, y = 1, rotation = "east"}, {type = "laser_cannon_s", x = 1, y = 1, rotation = "east"},
] ]

View File

@@ -1,10 +1,10 @@
[[module]] [[module]]
id = "armor_plate" id = "armor_plate"
unlock_at_station_level = -1
surface_mask = ["OO"] surface_mask = ["OO"]
materials = [{item = "iron_ingot", amount = 2}] materials = [{item = "iron_ingot", amount = 2}]
player_production_level = 1 player_production_level = 1
production_time_seconds = 3 production_time_seconds = 3
threat_cost = 2.0
fill_color = "#808080" fill_color = "#808080"
glyph = "A" glyph = "A"
@@ -13,11 +13,11 @@ multiplied_hp_formula = "1.5"
[[module]] [[module]]
id = "sensor_booster" id = "sensor_booster"
unlock_at_station_level = -1
surface_mask = ["O"] surface_mask = ["O"]
materials = [{item = "circuit_board", amount = 1}] materials = [{item = "circuit_board", amount = 1}]
player_production_level = 1 player_production_level = 1
production_time_seconds = 2 production_time_seconds = 2
threat_cost = 1.0
fill_color = "#40A0FF" fill_color = "#40A0FF"
glyph = "S" glyph = "S"
@@ -26,11 +26,11 @@ added_sensor_range_m_formula = "100"
[[module]] [[module]]
id = "weapon_upgrade" id = "weapon_upgrade"
unlock_at_station_level = -1
surface_mask = ["O"] surface_mask = ["O"]
materials = [{item = "iron_ingot", amount = 1}, {item = "circuit_board", amount = 1}] materials = [{item = "iron_ingot", amount = 1}, {item = "circuit_board", amount = 1}]
player_production_level = 1 player_production_level = 1
production_time_seconds = 4 production_time_seconds = 4
threat_cost = 3.0
fill_color = "#FF4040" fill_color = "#FF4040"
glyph = "W" glyph = "W"
@@ -39,11 +39,11 @@ multiplied_damage_formula = "1.2"
[[module]] [[module]]
id = "laser_cannon" id = "laser_cannon"
unlock_at_station_level = -1
surface_mask = ["O"] surface_mask = ["O"]
materials = [{item = "iron_ingot", amount = 1}] materials = [{item = "iron_ingot", amount = 1}]
player_production_level = 1 player_production_level = 1
production_time_seconds = 5 production_time_seconds = 5
threat_cost = 5.0
fill_color = "#FF8040" fill_color = "#FF8040"
glyph = "L" glyph = "L"
@@ -54,11 +54,11 @@ attack_rate_hz_formula = "2.0"
[[module]] [[module]]
id = "salvager" id = "salvager"
unlock_at_station_level = -1
surface_mask = ["OO"] surface_mask = ["OO"]
materials = [{item = "iron_ingot", amount = 2}] materials = [{item = "iron_ingot", amount = 2}]
player_production_level = 1 player_production_level = 1
production_time_seconds = 5 production_time_seconds = 5
threat_cost = 0.0
fill_color = "#AACC44" fill_color = "#AACC44"
glyph = "Sv" glyph = "Sv"
@@ -69,14 +69,69 @@ collection_rate_hz_formula = "0.5"
[[module]] [[module]]
id = "repair_tool" id = "repair_tool"
unlock_at_station_level = -1
surface_mask = ["O"] surface_mask = ["O"]
materials = [{item = "circuit_board", amount = 2}] materials = [{item = "circuit_board", amount = 2}]
player_production_level = 1 player_production_level = 1
production_time_seconds = 5 production_time_seconds = 5
threat_cost = 0.0
fill_color = "#66CCFF" fill_color = "#66CCFF"
glyph = "Rp" glyph = "Rp"
[module.repair] [module.repair]
repair_rate_hz_formula = "5 + x" repair_rate_hz_formula = "5 + x"
repair_range_m_formula = "800" repair_range_m_formula = "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"
[[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"
[[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"
[[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"

View File

@@ -40,6 +40,38 @@ inputs = [{item = "iron_ingot", amount = 4}]
outputs = [{item = "building_block", amount = 10}] outputs = [{item = "building_block", amount = 10}]
duration_seconds = 4.0 duration_seconds = 4.0
[[recipe]]
id = "premium_circuit"
building = "assembler"
unlock_at_station_level = -1
inputs = [{item = "circuit_board", amount = 1}]
outputs = [{item = "premium_circuit", amount = 1}]
duration_seconds = 8.0
[[recipe]]
id = "quick_circuit"
building = "assembler"
unlock_at_station_level = 0
inputs = [{item = "copper_ingot", amount = 3}]
outputs = [{item = "circuit_board", amount = 1}]
duration_seconds = 3.0
[[recipe]]
id = "advanced_circuit"
building = "assembler"
unlock_at_station_level = 1
inputs = [{item = "iron_ingot", amount = 5}]
outputs = [{item = "circuit_board", amount = 1}]
duration_seconds = 6.0
[[recipe]]
id = "exotic_alloy"
building = "assembler"
unlock_at_station_level = 0
inputs = [{item = "exotic_ore", amount = 2}]
outputs = [{item = "exotic_alloy", amount = 1}]
duration_seconds = 10.0
[[recipe]] [[recipe]]
id = "reprocessing_cycle" id = "reprocessing_cycle"
building = "reprocessing_plant" building = "reprocessing_plant"

View File

@@ -1,6 +1,6 @@
[[ship]] [[ship]]
id = "interceptor" id = "interceptor"
available_from_start = true unlock_at_station_level = -1
layout = ["XOX", "OOO", "XOX"] layout = ["XOX", "OOO", "XOX"]
default_modules = [{type = "laser_cannon", x = 1, y = 1, rotation = "east"}] default_modules = [{type = "laser_cannon", x = 1, y = 1, rotation = "east"}]
@@ -9,9 +9,6 @@ materials = [{item = "iron_ingot", amount = 3}, {item = "circuit_board", amount
player_production_level = 3 player_production_level = 3
production_time_seconds = 10 production_time_seconds = 10
[ship.threat]
cost_formula = "5 + 1*x"
[ship.health] [ship.health]
hp_formula = "40 + 5*x" hp_formula = "40 + 5*x"
@@ -31,7 +28,7 @@ scrap_drop = 2
[[ship]] [[ship]]
id = "destroyer" id = "destroyer"
available_from_start = true unlock_at_station_level = -1
layout = ["XOOX", "OOOO", "XOOX"] layout = ["XOOX", "OOOO", "XOOX"]
default_modules = [{type = "laser_cannon", x = 1, y = 1, rotation = "east"}] default_modules = [{type = "laser_cannon", x = 1, y = 1, rotation = "east"}]
@@ -40,9 +37,6 @@ materials = [{item = "iron_ingot", amount = 5}, {item = "circuit_board", amount
player_production_level = 5 player_production_level = 5
production_time_seconds = 20 production_time_seconds = 20
[ship.threat]
cost_formula = "10 + 2*x"
[ship.health] [ship.health]
hp_formula = "120 + 15*x" hp_formula = "120 + 15*x"
@@ -62,7 +56,7 @@ scrap_drop = 4
[[ship]] [[ship]]
id = "salvage_ship" id = "salvage_ship"
available_from_start = true unlock_at_station_level = -1
layout = ["OOO", "OOO"] layout = ["OOO", "OOO"]
[ship.schematic] [ship.schematic]
@@ -70,9 +64,6 @@ materials = [{item = "iron_ingot", amount = 4}]
player_production_level = 3 player_production_level = 3
production_time_seconds = 10 production_time_seconds = 10
[ship.threat]
cost_formula = "0"
[ship.health] [ship.health]
hp_formula = "40 + 4*x" hp_formula = "40 + 4*x"
@@ -92,7 +83,7 @@ scrap_drop = 2
[[ship]] [[ship]]
id = "repair_ship" id = "repair_ship"
available_from_start = false unlock_at_station_level = 0
layout = ["XOX", "OOO", "XOX"] layout = ["XOX", "OOO", "XOX"]
[ship.schematic] [ship.schematic]
@@ -100,9 +91,6 @@ materials = [{item = "iron_ingot", amount = 4}, {item = "circuit_board", amount
player_production_level = 3 player_production_level = 3
production_time_seconds = 15 production_time_seconds = 15
[ship.threat]
cost_formula = "0"
[ship.health] [ship.health]
hp_formula = "60 + 5*x" hp_formula = "60 + 5*x"

View File

@@ -59,23 +59,43 @@ Simulation types shared across subsystems:
- `Item``struct Item { ItemType type; }`. Items on belts have no persistent identity across ticks. - `Item``struct Item { ItemType type; }`. Items on belts have no persistent identity across ticks.
- `Port``struct Port { QPoint tile; Rotation direction; }`. Identifies a belt-adjacent cell and the direction of flow across that cell. - `Port``struct Port { QPoint tile; Rotation direction; }`. Identifies a belt-adjacent cell and the direction of flow across that cell.
- `MovementIntent``struct MovementIntent { int priority; QVector2D target; }`. Priority follows the order declared under Movement Arbitration. Cleared at the start of each tick; the highest-priority write wins; `tickMovement` reads the winner. - `MovementIntent``struct MovementIntent { int priority; QVector2D target; }`. Priority follows the order declared under Movement Arbitration. Cleared at the start of each tick; the highest-priority write wins; `tickMovement` reads the winner.
- `FireEvent``struct FireEvent { EntityId shooter; EntityId target; Tick emittedAt; }`. Transient record emitted each time a weapon fires (REQ-SHP-FIRING, REQ-SHP-FIRING-BEAM). Buffered in a sim-owned queue and drained by the renderer; see Sim → UI Events. - `WeaponFiredEvent``struct WeaponFiredEvent : public Event { entt::entity shooter; entt::entity target; Tick emittedAt; }`. Transient record emitted each time a weapon fires (REQ-SHP-FIRING, REQ-SHP-FIRING-BEAM). Buffered in a sim-owned vector during the tick, then drained and re-emitted via EventManager by the UI frame handler; see Sim → UI Events.
- `SchematicDropEvent``struct SchematicDropEvent { ShipSchematicId schematic; int newLevel; bool wasNewUnlock; }`. Emitted when a destroyed enemy-defence-station set awards a schematic (REQ-DEF-SCHEMATIC-DROP). The UI renders a toast (REQ-UI-SCHEMATIC-TOAST); `wasNewUnlock` chooses between the "unlocked" and "level → N" wording. - `SchematicChoiceOption``struct SchematicChoiceOption { string schematicId; SchematicType type; string displayName; bool isNewUnlock; int targetLevel; }`. Describes one option in the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP). Up to three are generated when an enemy station set is destroyed. `SchematicType` is `Ship`, `Module`, or `Recipe`.
- `SchematicChoicesAvailableEvent` — EventManager event carrying a `vector<SchematicChoiceOption>`. Sent by the UI each frame when pending choices are detected; handled by `MainWindow` which opens the schematic choice dialog.
## Sim → UI Events ## Event System
The sim owns a small set of per-frame event queues that the UI drains on each render. These carry one-shot signals that are not derivable from persistent state — currently weapon fires (REQ-SHP-FIRING-BEAM) and schematic drops (REQ-UI-SCHEMATIC-TOAST). Additional event types can be added here later (e.g., building-complete, unit-death flashes) without changing the pattern. All inter-component communication — both sim→UI and UI→UI — uses a unified `EventManager`/`EventHandler` system. No custom Qt signals/slots are used for inter-widget communication.
Implementation: a plain `std::vector<FireEvent>` owned by `Simulation`, one vector per event type. Combat resolution (tick-order step 8) appends to it. The UI calls `simulation.drainFireEvents()` once per rendered frame, which returns the accumulated vector by move and clears the internal one. Beams are tracked by the renderer for 0.3 s of wall time (9 ticks at 30 Hz) using the events' `emittedAt` tick, then discarded. If either the shooter or target entity is gone when the renderer looks them up, the beam is dropped early. ### EventManager
We deliberately do **not** use `QObject` signals/slots or `QEvent`: `EventManager` is a singleton (`EventManager::getInstance()`) that routes events to registered handlers.
- **Determinism.** A plain ordered vector preserves tick-order exactly; the queue is part of per-tick state, inspectable in tests. - `sendEventImmediately(shared_ptr<Event>)` — synchronous dispatch to all handlers of the event's type.
- **Sim/UI seam.** The sim exposes pull-style access only; the UI never subscribes into the sim, keeping the simulation/presentation split clean. - `addEvent(shared_ptr<Event>)` — queues the event for later batch processing.
- **Headless testability.** Catch2 tests read the queue directly after `tick()`; no event loop, no `QApplication`. - `processEvents()` — drains the queue, dispatching each event to its handlers.
- **Zero overhead.** Sim types remain plain structs — no `QObject`, no moc, no signal dispatch machinery.
If the number of event types grows past a handful, we can wrap them in a small `EventQueue<T>` template, still owned by the sim. Signals/slots would only be warranted if we needed multiple independent subscribers or cross-thread dispatch, and we need neither. The EventManager is thread-safe (mutex-guarded).
### EventHandler
`EventHandler<T>` is a CRTP-style template that a class inherits to receive events of type `T`. It provides `registerForEvent()` / `unregisterForEvent()` and requires an override of `handleEvent(shared_ptr<const T>)`.
`CombinedEventHandler<Ts...>` is a variadic template for classes that handle multiple event types. It provides `registerForEvents()` / `unregisterForEvents()` and requires one `handleEvent` override per type.
### Sim → UI Events
The simulation layer stays free of EventManager — it uses a plain `std::vector<WeaponFiredEvent>` internally (owned by `CombatSystem`). This preserves determinism, tick-order fidelity, and headless testability (Catch2 tests read the queue directly via `drainWeaponFiredEvents()` after `tick()`).
The UI frame handler (`GameWorldView::onFrame` / `ArenaView::onFrame`) bridges the gap: each frame it calls `simulation.drainWeaponFiredEvents()`, then re-emits each `WeaponFiredEvent` via `EventManager::sendEventImmediately()`. Subscribers (the same view's `handleEvent(WeaponFiredEvent)`) create `ActiveBeam` records tracked for 0.3 s of wall time, then discarded. If either the shooter or target entity is gone when the renderer looks them up, the beam is dropped early.
Schematic drops: when an enemy station set is destroyed, the simulation generates up to 3 `SchematicChoiceOption` entries and stores them as pending state. The UI polls `hasSchematicChoicesPending()` each frame and, when true, sends a `SchematicChoicesAvailableEvent` via EventManager. `MainWindow` handles this event by pausing the game and opening a modal `SchematicChoiceDialog`. The player's selection is fed back via `applySchematicChoice(index)`.
### UI Events
All UI interactions — building selection, builder/blueprint mode transitions, speed changes, demolish mode, escape menu, layout dialog requests — are communicated via EventManager events rather than Qt signals/slots. Each event is a small struct inheriting `Event` (e.g., `SelectionChangedEvent`, `BuildingTypeSelectedEvent`, `SpeedChangeRequestedEvent`). Widgets register as `CombinedEventHandler` for the events they care about and emit events via `EventManager::sendEventImmediately()`.
Bidirectional interactions use separate request/notification event types to avoid infinite recursion (e.g., `ExitBuilderModeRequestedEvent` from `BuildButtonGrid``GameWorldView`, vs. `BuilderModeExitedEvent` from `GameWorldView``BuildButtonGrid`).
## Tick Order ## Tick Order
@@ -88,8 +108,8 @@ Within a single simulation tick, subsystems run in this fixed order. The order i
5. **Building → belt push** — buildings push items from output buffer onto the belt tile at their output port (REQ-MAT-OUTPUT-PORT). 5. **Building → belt push** — buildings push items from output buffer onto the belt tile at their output port (REQ-MAT-OUTPUT-PORT).
6. **Belt tick** — advance items along belt tiles; apply splitter routing (REQ-BLD-SPLITTER). 6. **Belt tick** — advance items along belt tiles; apply splitter routing (REQ-BLD-SPLITTER).
7. **Ship behavior systems** — clear `MovementIntent` on each ship, then run `tickThreatResponse`, `tickScrapCollector`, `tickRepairBehavior`, `tickHomeReturn` in any order (arbitration is via intent priority). 7. **Ship behavior systems** — clear `MovementIntent` on each ship, then run `tickThreatResponse`, `tickScrapCollector`, `tickRepairBehavior`, `tickHomeReturn` in any order (arbitration is via intent priority).
8. **Combat resolution** — ships and defence stations acquire targets, fire, apply damage; queue deaths. Each fire appends a `FireEvent` to the sim's fire-event queue (REQ-SHP-FIRING-BEAM). 8. **Combat resolution** — ships and defence stations acquire targets, fire, apply damage; queue deaths. Each fire appends a `WeaponFiredEvent` to the sim's weapon-fired-event queue (REQ-SHP-FIRING-BEAM).
9. **Deaths & loot** — process queued deaths: drop scrap (REQ-RES-SCRAP-DROP); if a full enemy-defence-station set was destroyed this tick, award one schematic (REQ-DEF-SCHEMATIC-DROP) and append a `SchematicDropEvent`; remove entities. 9. **Deaths & loot** — process queued deaths: drop scrap (REQ-RES-SCRAP-DROP); if a full enemy-defence-station set was destroyed this tick, generate up to 3 schematic choice options (REQ-DEF-SCHEMATIC-DROP) stored as pending state for the UI to present; remove entities.
10. **`tickMovement`** — advance ship positions based on final `MovementIntent`. 10. **`tickMovement`** — advance ship positions based on final `MovementIntent`.
11. **Scrap despawn** — decrement scrap timers; remove expired scrap (REQ-RES-SCRAP-DROP). 11. **Scrap despawn** — decrement scrap timers; remove expired scrap (REQ-RES-SCRAP-DROP).
@@ -280,7 +300,7 @@ The game world is rendered by a single `GameWorldView` widget that inherits `QOp
### Threading ### Threading
Sim and UI run on the same thread for v1. `paintEvent` reads sim state directly without locks. If profiling later justifies moving the sim to a worker thread, the pull-style `drainFireEvents()` / `drainSchematicDropEvents()` / `forEachVisualItem()` APIs already support a clean snapshot-and-render split; a single mutex at the sim boundary would suffice. Sim and UI run on the same thread for v1. `paintEvent` reads sim state directly without locks. If profiling later justifies moving the sim to a worker thread, the pull-style `drainWeaponFiredEvents()` / `getPendingSchematicChoices()` / `applySchematicChoice()` / `forEachVisualItem()` APIs already support a clean snapshot-and-render split; a single mutex at the sim boundary would suffice. The `ArenaSimulation` used by the balancing tool runs headlessly on a worker thread; fire events accumulate in its internal vector and are only drained when `ArenaView` drives `tickOnce()` on the main thread during interactive inspection.
### Layer Order (back to front) ### Layer Order (back to front)
@@ -289,9 +309,9 @@ Sim and UI run on the same thread for v1. `paintEvent` reads sim state directly
3. **Belt items** — 10×10 colored squares emitted by `BeltSystem::forEachVisualItem`. 3. **Belt items** — 10×10 colored squares emitted by `BeltSystem::forEachVisualItem`.
4. **Scrap** — glyphs at world positions. 4. **Scrap** — glyphs at world positions.
5. **Ships** — colored arrows oriented by velocity; color keyed to role (player combat / salvage / repair / enemy). 5. **Ships** — colored arrows oriented by velocity; color keyed to role (player combat / salvage / repair / enemy).
6. **Laser beams** — lines derived from live `FireEvent`s kept by the renderer for 0.3 s (REQ-SHP-FIRING-BEAM). 6. **Laser beams** — lines derived from live `WeaponFiredEvent`s kept by the renderer for 0.3 s (REQ-SHP-FIRING-BEAM).
7. **Build overlays** — ghost in builder mode (REQ-BLD-GHOST), demolish-mode tint, tile highlight under cursor, box-drag selection rectangle. 7. **Build overlays** — ghost in builder mode (REQ-BLD-GHOST), demolish-mode tint, tile highlight under cursor, box-drag selection rectangle.
8. **Screen-space UI** schematic toasts (REQ-UI-SCHEMATIC-TOAST) and any other screen-anchored elements, drawn after resetting the world-space transform. 8. **Screen-space UI** — screen-anchored elements, drawn after resetting the world-space transform.
### Coordinates and Scrolling ### Coordinates and Scrolling

194
docs/content_design.md Normal file
View File

@@ -0,0 +1,194 @@
# Content Design — Ships & Modules
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.
## Design principle: footprint gating
Which module fits on which hull is controlled purely by geometry — no
explicit allow-lists. Each hull grid is shaped so that it physically cannot
contain the footprint of modules from a larger size class. This keeps the
rules transparent to the player ("it doesn't fit because there is no room")
and makes them trivially moddable through the config files alone.
### Module footprint ladder
| Footprint | Modules | Smallest hull that fits it |
|-----------|---------|----------------------------|
| 1x1 | laser_cannon_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 |
| 2x6 | drone_hangar | carrier (only) |
### Hull grids
`O` = buildable cell, `X` = hull structure (not buildable).
**drone (xs, 1 cell)** — exactly one 1x1 module: a small gun, a salvager, or
a repair tool. This is what makes drone roles swappable.
O
**frigate (s, 5 cells)** — plus shape. Every 1x2 placement crosses the center
cell, so at most ONE 1x2 support fits; alternatively one L-shaped weapon
modifier or one afterburner through the center line. Gun-boat with one or two
support modules, as intended.
XOX
OOO
XOX
**destroyer (s, 8 cells)** — gun deck with three turret bumps. More cells
than the frigate (more small guns), but still no 2x2 area anywhere, so medium
hardware can never be mounted.
OXOXO
OOOOO
**cruiser (m, 12 cells)** — notched corners. Fits at most two 2x2 m guns
(stacked through the middle), leaving the side cells for supports. No 3x3
area.
XOOX
OOOO
OOOO
XOOX
**battlecruiser (m, 16 cells)** — split bow with two gun cheeks, tapered
stern. Fits three 2x2 m guns — one more than the cruiser — with small support
slots left over. The bow split and stern taper prevent any 3x3 area (no l
gun) and any 2x6 area (no drone hangar).
OOXXOO
OOOOOO
XOOOOX
XXOOXX
**battleship (l, 24 cells)** — broadside hull with notched flanks on every
other row. Fits four 2x2 m guns (two per gun deck) — one more than the
battlecruiser — with bow, stern, and flank cells for supports. All 3x3
placements crowd the center columns, so at most ONE l gun fits: mounted
center it blocks every m gun mount (pure support strips remain), mounted
offset it still allows two m guns. The notched rows are never adjacent-and-
full, so no 2x6 drone hangar fits.
XOOOOX
OOOOOO
XOOOOX
OOOOOO
XOOOOX
**dreadnought (xl, 36 cells)** — the main battery deck is split into three
3x3 gun slots by structural spacer columns, so exactly three l guns fit side
by side (or m guns / supports in unused slots), plus bow/stern strips for
supports. The spacers cap every horizontal run at 5 cells, so the 2x6 drone
hangar can never fit — the carrier stays the only hangar hull.
XXXOOOOOXXX
OOOXOOOXOOO
OOOXOOOXOOO
OOOXOOOXOOO
XXOOXXXOOXX
**carrier (xl, 37 cells)** — the top flight deck (rows 01) is the only
region wide enough for the 2x6 drone hangar, and exactly one fits. The middle
deck row is broken up by elevator shafts (X cells placed so every 3-column
window hits one), which is what prevents any 3x3 l gun from ever fitting.
Lower decks hold supports and 2x2 point-defense m guns.
XOOOOOOOOX
OOOOOOOOOO
OOXOOXOOXO
XOOOOOOOOX
XXXOOOOXXX
### Verified gating matrix
Checked programmatically against the configs (all four mask rotations,
all placements) with `tools/verify_layouts.py` — re-run it after editing
layout grids or surface masks:
python dota_factory/tools/verify_layouts.py
| Footprint | drone | frigate | destroyer | cruiser | battlecruiser | battleship | dreadnought | carrier |
|-----------|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
| 1x1 | x | x | x | x | x | x | x | x |
| 1x2 | | x | x | x | x | x | x | x |
| 1x3 | | x | x | x | x | x | x | x |
| L-shape | | x | x | x | x | x | x | x |
| 2x2 | | | | x | x | x | x | x |
| 3x3 | | | | | | x | x | |
| 2x6 | | | | | | | | x |
Maximum simultaneous (disjoint) placements: m guns — cruiser 2,
battlecruiser 3, battleship 4; l guns — battleship 1, dreadnought 3;
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.
| Phase | New input | How acquired | Unlocks |
|-------|-----------|--------------|---------|
| early | iron_ore, copper_ore | mined | drone, frigate, destroyer; small guns and basic supports |
| mid | titanium_ore | mined (3x slower than iron) | cruiser, battlecruiser; m guns, drone bay, weapon modifiers |
| late | advanced_alloy | ONLY from reprocessing salvaged scrap | battleship, dreadnought, carrier; l guns, drone hangar |
The advanced_alloy gate is the core loop hook: capital ship production
requires fighting (salvaging scrap from kills and reprocessing it), not just
mining. The reprocessing plant turns 5 scrap into iron/copper/titanium ingots
or advanced_alloy probabilistically.
Intermediate components, by tier:
- **Tier 2 (early):** copper_wire (copper), steel_plate (iron), circuit_board
(iron + wire), building_block (iron).
- **Tier 3 (mid):** mechanical_parts (steel + iron), targeting_unit (circuits
+ wire), drive_unit (steel + mechanical_parts + circuit), titanium_frame
(titanium + steel).
- **Tier 4 (late):** reinforced_plating (steel + advanced_alloy),
capital_core (targeting_unit + drive_unit + 2 advanced_alloy).
Hulls and modules consume intermediates of their tier: early items are built
from tier-2 parts, midgame items require tier-3 parts (deeper chains, more
assemblers), capital items require tier-4 parts (and therefore combat). Hull
items are named `<ship>_hull`; module items `<module>_module`. Every item has
an `[items.*]` entry in visuals.toml; hull item outlines match the ship's
fleet color from `[ships.*]`.
Consistency is checked by `tools/verify_recipes.py` — re-run it after editing
recipes, ship/module materials, or visuals:
python dota_factory/tools/verify_recipes.py
It verifies every consumed item has a producer, every item has a visuals
entry, flags orphaned items, and prints which items are reprocessing-only
(currently exactly advanced_alloy).
## Deliberate placeholders / open questions for later passes
- All new hulls have `threat.cost_formula = "0"` so enemy waves do not spawn
them yet (WaveSystem treats any ship with positive threat cost as wave-
eligible, regardless of unlock level). The balancing pass should set real
threat costs together with `default_modules` loadouts so waves spawn them
armed.
- All new hulls and all assembler recipes are `unlock_at_station_level = -1`
(available from the start) to make testing easy; the balancing pass should
stagger these so mid/lategame recipes drop as schematics from enemy defence
stations.
- Recipe quantities and durations are a first guess, deliberately roughly
tiered (capital hulls ~60 s, drones 4 s); the balancing pass tunes them.
- `drone_bay` and `drone_hangar` are footprint-only placeholders: the drone
launching capability does not exist in the simulation yet, so they define
no capability section.
- Renames in this pass: `laser_cannon_xs``laser_cannon_s` (the old 2x2
`laser_cannon_s` became `laser_cannon_m`), `armor_plate``armor_plates`,
`manuvering_thrusters``maneuvering_thrusters` (typo fix). Test data
under `bin/test/data/config` intentionally still uses the old ids — it is
an independent fixture set.

View File

@@ -6,9 +6,9 @@ Config files use the TOML format. The following config files drive game paramete
- **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. - **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.
- **buildings.toml** — building block cost and construction time per building type. - **buildings.toml** — building block cost and construction time per building type.
- **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities. - **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 toasts and 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, threat cost formula, player production level, whether the schematic is available from game start, a layout grid defining the ship's module slots, and a `default_modules` list used for enemy wave ships (see REQ-WAV-DEFAULT-MODULES). - **ships.toml** — per schematic: a human-readable display name (used in the UI), hull stats (HP, max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, max rotation speed) as 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, player production level, production time, threat cost, fill color, glyph, and an optional capability section and/or stat modifier formulas. A module with a capability section (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat formulas is a **capability module** that grants the ship a weapon, salvage bay, or repair tool per instance (see REQ-MOD-CONFIG for the full list of formulas per capability type). A module with only `added_*`/`multiplied_*` formulas is a **passive module** that modifies stats on the ship or on capability module instances (see REQ-MOD-STAT-CALC). - **modules.toml** — per module type: id, surface mask, materials list, 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).
- **stations.toml** — HP, damage, range, fire rate, and scrap drop for player and enemy defence stations, defined as formulas of station level. - **stations.toml** — HP, damage, range, fire rate, and scrap drop for player and enemy defence stations, defined as formulas of station level.
- **visuals.toml** — rendering-only config (not game parameters): fill and outline colors and glyphs for every building type, item type, ship schematic, and station type; beam color and width; overlay and toast colors. Loaded by the UI at startup; the simulation does not read it. - **visuals.toml** — rendering-only config (not game parameters): fill and outline colors and glyphs for every building type, item type, ship schematic, and station type; beam color and width; overlay and toast colors. Loaded by the UI at startup; the simulation does not read it.
- **ship_layouts.toml** — named layout blueprints per ship type; written and read by the application to persist the layout blueprint panel (REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD). Not a game parameter file; the simulation does not read it. - **ship_layouts.toml** — named layout blueprints per ship type; written and read by the application to persist the layout blueprint panel (REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD). Not a game parameter file; the simulation does not read it.
@@ -107,14 +107,14 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
## Building Types ## Building Types
- REQ-BLD-MINER: **Miner** (2×2): The player selects which ore type it extracts. Each ore type corresponds to a `recipes.toml [[recipe]]` entry with `building = "miner"`, defining the output item and `duration_seconds`. Every asteroid tile is equivalent for mining — any miner can produce any ore type based solely on its selected recipe. Ore never depletes. - REQ-BLD-MINER: **Miner** (2×2): The player selects which ore type it extracts. Each ore type corresponds to a `recipes.toml [[recipe]]` entry with `building = "miner"`, defining the output item and `duration_seconds`. Every asteroid tile is equivalent for mining — any miner can produce any ore type based solely on its selected recipe. Ore never depletes. Only implicitly unlocked ore-type recipes are available for selection (REQ-LOCK-UI-RECIPE).
- REQ-BLD-SMELTER: **Smelter** (2×2): Converts ore or scrap into basic materials. No recipe selection required. Inputs, outputs, and rates are defined in `recipes.toml [[recipe]]` entries with `building = "smelter"`. - REQ-BLD-SMELTER: **Smelter** (2×2): Converts ore or scrap into basic materials. No recipe selection required. Inputs, outputs, and rates are defined in `recipes.toml [[recipe]]` entries with `building = "smelter"`.
- REQ-BLD-ASSEMBLER: **Assembler** (3×3): The player selects a recipe from the config-defined crafting tree. Produces the selected output item at the rate defined in the corresponding `recipes.toml [[recipe]]` entry with `building = "assembler"`. - 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 output buffer holds at most one cycle's output — see REQ-MAT-OUTPUT-BUFFER-REPROCESSING. - REQ-BLD-REPROCESSING: **Reprocessing Plant** (3×3): Consumes scrap per cycle and produces exactly one higher-level intermediate product per cycle via weighted random pick. The input quantity, possible output items, per-output weights, and amounts are defined in `recipes.toml [[recipe]]` entries with `building = "reprocessing_plant"` (`inputs`, `outputs[].item`, `outputs[].amount`, `outputs[].weight`). Weights are normalized at load time; their sum does not need to equal 1. The output is rolled at cycle start (see REQ-MAT-CYCLE); the pool of eligible outputs is restricted to implicitly unlocked item types (REQ-LOCK-REPROCESSING-POOL). The output buffer holds at most one cycle's output — see REQ-MAT-OUTPUT-BUFFER-REPROCESSING.
- REQ-BLD-SHIPYARD: **Shipyard** (4×2): The player selects a schematic. When all required materials — the ship's base materials (`[ship.schematic].materials`) plus the materials of all modules in the configured layout (REQ-MOD-MATERIALS) — are present in its input buffer, the shipyard consumes them and begins a production cycle lasting the ship's base `[ship.schematic].production_time_seconds` plus the sum of production times contributed by all module instances in the configured layout (REQ-MOD-PRODUCTION-TIME). One ship of that type is spawned at `ships.toml [ship.schematic].player_production_level` (initial value 5, incremented by duplicate schematic drops per REQ-DEF-SCHEMATIC-DROP) with the configured modules when the cycle completes. The shipyard cannot start a new cycle while one is in progress. If the player confirms a layout change (REQ-MOD-UI-DIALOG) while a production cycle is in progress, the current cycle is cancelled and all consumed materials are discarded; the shipyard returns to idle with the new layout configuration. - REQ-BLD-SHIPYARD: **Shipyard** (4×2): The player selects a schematic. When all required materials — the ship's base materials (`[ship.schematic].materials`) plus the materials of all modules in the configured layout (REQ-MOD-MATERIALS) — are present in its input buffer, the shipyard consumes them and begins a production cycle lasting the ship's base `[ship.schematic].production_time_seconds` plus the sum of production times contributed by all module instances in the configured layout (REQ-MOD-PRODUCTION-TIME). One ship of that type is spawned 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-SALVAGE-BAY: **Salvage Bay** (3×2): A dedicated drop-off point for salvage ships. Scrap delivered here is placed onto connected output belts.
- REQ-BLD-BELT: **Belt** (1×1): Transports items. A belt tile has one direction (N, S, E, W) set at placement (modified by rotation). Curved belts are auto-derived: when a belt tile's outgoing direction leads into another belt whose direction is orthogonal, the downstream belt is rendered and behaves as a curve. Belt speed is defined in `world.toml [world].belt_speed_tiles_per_second` (REQ-GW-BELT-SPEED). - REQ-BLD-BELT: **Belt** (1×1): Transports items. A belt tile has one direction (N, S, E, W) set at placement (modified by rotation). Curved belts are auto-derived: when a belt tile's outgoing direction leads into another belt whose direction is orthogonal, the downstream belt is rendered and behaves as a curve. Belt speed is defined in `world.toml [world].belt_speed_tiles_per_second` (REQ-GW-BELT-SPEED).
- REQ-BLD-SPLITTER: **Splitter** (1×1): Distributes incoming items between two output directions. Each output can optionally have a filter (a list of item types), configurable via the selected building panel. Routing rules: - REQ-BLD-SPLITTER: **Splitter** (1×1): Distributes incoming items between two output directions. Each output can optionally have a filter (a list of item types), configurable via the selected building panel; only implicitly unlocked item types are available as filter options (REQ-LOCK-UI-SPLITTER). Routing rules:
- An item matching only one output's filter is routed to that output. - An item matching only one output's filter is routed to that output.
- An item matching both outputs' filters is distributed by strict alternation between those outputs. - An item matching both outputs' filters is distributed by strict alternation between those outputs.
- An item matching neither output's filter is routed to the unfiltered output. If both outputs have a filter and the item matches neither, the splitter stalls and moves no items until the situation is resolved. - An item matching neither output's filter is routed to the unfiltered output. If both outputs have a filter and the item matches neither, the splitter stalls and moves no items until the situation is resolved.
@@ -149,7 +149,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
## Ships ## Ships
- REQ-SHP-AUTONOMOUS: Ships are produced by shipyards and are fully autonomous once produced. - REQ-SHP-AUTONOMOUS: Ships are produced by shipyards and are fully autonomous once produced.
- REQ-SHP-STATS: Base hull stats are defined as formulas of ship level in `ships.toml`: HP (`[ship.health].hp_formula`), max linear speed (`[ship.movement].speed_formula`), sensor range (`[ship.sensors].range_formula`), main acceleration (`[ship.movement].main_acceleration_formula`, tiles/s²), maneuvering acceleration (`[ship.movement].maneuvering_acceleration_formula`, tiles/s²), angular acceleration (`[ship.movement].angular_acceleration_formula`, rad/s²), max rotation speed (`[ship.movement].max_rotation_speed_formula`, rad/s). Required build materials (`[ship.schematic].materials`) and availability from game start (`[[ship]].available_from_start`) 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 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-SPAWN-PLAYER: A ship produced by a shipyard spawns centered on the shipyard's output port tile. - REQ-SHP-SPAWN-PLAYER: A ship produced by a shipyard spawns centered on the shipyard's output port tile.
- REQ-SHP-SPAWN-ENEMY: Enemy ships spawn at a uniformly random position within the current enemy buffer zone — random X across the buffer's width and random Y across the world height. - REQ-SHP-SPAWN-ENEMY: Enemy ships spawn at a uniformly random position within the current enemy buffer zone — random X across the buffer's width and random Y across the world height.
- REQ-SHP-MOVEMENT: Ships move using a physics-based model. Each ship has a velocity and a facing direction, both updated each tick. The main acceleration (`main_acceleration_formula`) is applied along the ship's current facing direction only. The maneuvering acceleration (`maneuvering_acceleration_formula`) can be applied in any direction independently of the facing direction, enabling lateral or braking movement without rotating. The angular acceleration (`angular_acceleration_formula`) controls how quickly the ship rotates. Linear speed is capped at the ship's `speed_formula` value; rotation rate is capped at the ship's `max_rotation_speed_formula` value. Ship position refers to the ship's center for all range, sensor, and attack checks. - REQ-SHP-MOVEMENT: Ships move using a physics-based model. Each ship has a velocity and a facing direction, both updated each tick. The main acceleration (`main_acceleration_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.
@@ -169,7 +169,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
Each repair module instance operates independently: it has its own repair rate (`repair_rate`) and repair range (`repair_range`). On each tick, a module first attempts to heal 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` and heals that instead. If no valid target is found within range, the module idles. A ship with multiple repair modules can therefore heal different targets simultaneously. Navigation is driven solely by the behavior-level target; individual module fallback targets do not affect which direction the ship moves. Each repair module instance operates independently: it has its own repair rate (`repair_rate`) and repair range (`repair_range`). On each tick, a module first attempts to heal 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` and heals that instead. If no valid target is found within range, the module idles. A ship with multiple repair modules can therefore heal different targets simultaneously. Navigation is driven solely by the behavior-level target; individual module fallback targets do not affect which direction the ship moves.
- REQ-SHP-ENEMY-AI: **Enemy ships** — engage the closest valid target (player defence station, HQ, or player ship) within their sensor range. If no target is in sensor range, they move toward the asteroid (leftward in world coordinates). - REQ-SHP-ENEMY-AI: **Enemy ships** — engage the closest valid target (player defence station, HQ, or player ship) within their sensor range. If no target is in sensor range, they move toward the asteroid (leftward in world coordinates).
- REQ-SHP-SCHEMATICS: The player selects a schematic per shipyard by clicking it. New schematics are unlocked automatically when an enemy defence station set is destroyed (REQ-DEF-SCHEMATIC-DROP) — there is no physical loot to collect. - REQ-SHP-SCHEMATICS: The player selects a schematic per shipyard by clicking it. New schematics are unlocked by destroying enemy defence station sets (REQ-DEF-SCHEMATIC-DROP) — there is no physical loot to collect.
## Ship Modules ## Ship Modules
@@ -179,9 +179,9 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- `id` — unique identifier, also used as the display name in the UI. - `id` — unique identifier, also used as the display name in the UI.
- `surface_mask` — footprint within the ship layout grid (see Module Surface Mask Format). - `surface_mask` — footprint within the ship layout grid (see Module Surface Mask Format).
- `materials` — list of materials required per instance (added to the ship's build cost). - `materials` — list of materials required per instance (added to the ship's build cost).
- `player_production_level`fixed level for this module type; used as `x` in its stat formulas. - `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.
- `production_time_seconds` — time added to the ship's production cycle per instance. - `production_time_seconds` — time added to the ship's production cycle per instance.
- `threat_cost` — threat cost added to the ship's threat cost per instance.
- `fill_color` — fill color used to render this module's cells in the layout grid. - `fill_color` — fill color used to render this module's cells in the layout grid.
- `glyph` — single character rendered on this module's cells in the layout grid and preview widget. - `glyph` — single character rendered on this module's cells in the layout grid and preview widget.
- An optional **capability section** (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat formulas. A module with base stat formulas is a capability module — each placed instance grants the ship an independent weapon, salvage bay, or repair tool with its own state (cooldown, target, cargo). A ship may have multiple capability module instances of the same or different types. Base stat formulas per capability type: - An optional **capability section** (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat 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, cargo). A ship may have multiple capability module instances of the same or different types. Base stat formulas per capability type:
@@ -202,7 +202,19 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- REQ-MOD-MATERIALS: The total materials required to build a ship are the union of the ship's base `[ship.schematic].materials` and the `materials` of every module instance in the configured layout. Quantities of the same item type are summed. - REQ-MOD-MATERIALS: The total materials required to build a ship are the union of the ship's base `[ship.schematic].materials` and the `materials` of every module instance in the configured layout. Quantities of the same item type are summed.
- REQ-MOD-PRODUCTION-TIME: The total production time is the ship's base `[ship.schematic].production_time_seconds` plus the sum of `production_time_seconds` for every module instance in the configured layout. - REQ-MOD-PRODUCTION-TIME: The total production time is the ship's base `[ship.schematic].production_time_seconds` plus the sum of `production_time_seconds` for every module instance in the configured layout.
- REQ-MOD-THREAT: The total threat cost of a ship is the ship's base `[ship.threat].cost_formula` evaluated at the ship's level, plus the sum of `threat_cost` for every module instance in the configured layout. - REQ-MOD-THREAT: The threat cost of a ship is dynamically derived from the accumulated total production time required to produce that ship from scratch. One second of production time equals one threat. The total production time is the sum of:
1. The ship's base `production_time_seconds`.
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-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-MOD-STAT-CALC: For each stat (on the ship hull or on a capability module instance), the final value is computed as: `final = base × total_multiplier + total_additive`, where: - REQ-MOD-STAT-CALC: For each stat (on the ship hull or on a capability module instance), the final value is computed as: `final = base × total_multiplier + total_additive`, where:
- `base` is the stat's base formula evaluated at the ship's production level (for hull stats) or at the capability module's `player_production_level` (for capability module stats). - `base` is the stat's base 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_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`.
@@ -224,7 +236,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
The dialog contains: The dialog contains:
- **Top**: The ship's layout grid rendered at full scale. Buildable cells are white; non-buildable cells are black. Placed modules are rendered with their `fill_color` and `glyph`. The ghost of the currently selected module is shown at the cursor position when in placement mode. - **Top**: The ship's layout grid rendered at full scale. Buildable cells are white; non-buildable cells are black. Placed modules are rendered with their `fill_color` and `glyph`. The ghost of the currently selected module is shown at the cursor position when in placement mode.
- **Left** (below the grid): The ship stats panel (see REQ-MOD-UI-STATS-PANEL). - **Left** (below the grid): The ship stats panel (see REQ-MOD-UI-STATS-PANEL).
- **Center** (below the grid): A grid of module selection buttons (one per module type defined in `modules.toml`) plus a "Remove" button. Each module button shows the module id and its glyph. - **Center** (below the grid): A grid of module selection buttons (one per **unlocked** module type; see REQ-DEF-SCHEMATIC-DROP) plus a "Remove" button. Each module button shows the module id and its glyph.
- **Right** (below the grid): The layout blueprint panel (see REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD). - **Right** (below the grid): The layout blueprint panel (see REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD).
- **Bottom**: A "Confirm" button and a "Cancel" button. Cancel discards all changes made in this dialog session and closes the dialog. Confirm applies the changes: the shipyard's configured layout is updated, the required materials and cycle time displayed in the selected building panel are recalculated, and the ship layout preview is refreshed. - **Bottom**: A "Confirm" button and a "Cancel" button. Cancel discards all changes made in this dialog session and closes the dialog. Confirm applies the changes: the shipyard's configured layout is updated, the required materials and cycle time displayed in the selected building panel are recalculated, and the ship layout preview is refreshed.
@@ -246,6 +258,8 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
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. Each capability module instance uses its own `player_production_level` for formula evaluation.
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.
- REQ-MOD-UI-LAYOUT-SIZE: Ship layouts are small enough to display in the layout configuration dialog without scrolling (maximum grid size fits within the dialog). - REQ-MOD-UI-LAYOUT-SIZE: Ship layouts are small enough to display in the layout configuration dialog without scrolling (maximum grid size fits within the dialog).
### Layout Blueprints ### Layout Blueprints
@@ -254,7 +268,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
- REQ-MOD-UI-BLUEPRINT-CREATE: Clicking "Create Blueprint" opens a modal dialog prompting for 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 module layout currently shown in the left-side layout grid (the in-progress state of the dialog, not the previously confirmed shipyard layout) and appends it to the blueprint list. - REQ-MOD-UI-BLUEPRINT-CREATE: Clicking "Create Blueprint" opens a modal dialog prompting for 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 module layout currently shown in the left-side layout grid (the in-progress state of the dialog, not the previously confirmed shipyard layout) and appends it to the blueprint list.
- REQ-MOD-UI-BLUEPRINT-ENTRY: Each blueprint entry shows the blueprint name and a delete icon ("×") to the right of the name. Clicking the entry (name area) loads that blueprint's module list into the left-side layout grid, replacing all currently placed modules. Module instances that are invalid for the current ship layout (unknown module type, position outside the grid, position on a non-buildable cell, or overlapping another module in the same blueprint) are silently skipped; the remaining valid instances are placed. Clicking the delete icon ("×") removes that blueprint entry from the list immediately. - REQ-MOD-UI-BLUEPRINT-ENTRY: Each blueprint entry shows the blueprint name and a delete icon ("×") to the right of the name. Clicking the entry (name area) loads that blueprint's module list into the left-side layout grid, replacing all currently placed modules. Module instances that are invalid for the current ship layout (unknown module type, locked module type, position outside the grid, position on a non-buildable cell, or overlapping another module in the same blueprint) are silently skipped; the remaining valid instances are placed. Clicking the delete icon ("×") removes that blueprint entry from the list immediately.
- REQ-MOD-UI-BLUEPRINT-STARTUP: At application startup, layout blueprints are loaded from `ship_layouts.toml` in the same directory as the application executable. Blueprint entries missing required fields (`name` or `ship_type`) are silently skipped. 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. - REQ-MOD-UI-BLUEPRINT-STARTUP: At application startup, layout blueprints are loaded from `ship_layouts.toml` in the same directory as the application executable. Blueprint entries missing required fields (`name` or `ship_type`) are silently skipped. 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.
@@ -269,15 +283,52 @@ 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-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-NO-CROSSFIRE: Enemy and player defence stations are never in each other's firing range.
- REQ-DEF-PUSH: When both enemy defence stations in a set are destroyed, the boss countdown is advanced (REQ-WAV-BOSS-ADVANCE), the scrollable area is extended (REQ-GW-PUSH-EXPAND), a new set of enemy defence stations is placed at the new boundary, and exactly one schematic drop is awarded for the destroyed set (REQ-DEF-SCHEMATIC-DROP). - REQ-DEF-PUSH: When both enemy defence stations in a set are destroyed, the boss countdown is advanced (REQ-WAV-BOSS-ADVANCE), the scrollable area is extended (REQ-GW-PUSH-EXPAND), a new set of enemy defence stations is placed at the new boundary, and exactly one schematic drop is awarded for the destroyed set (REQ-DEF-SCHEMATIC-DROP).
- REQ-DEF-SCHEMATIC-DROP: Each destroyed set of enemy defence stations awards exactly one schematic drop (not one per station). The drop is automatic — no physical item to collect. A schematic is chosen uniformly at random from all schematics defined in `ships.toml`. If the player does not yet have that schematic, it is unlocked. If the player already has it, the schematic's `[ship.schematic].player_production_level` is incremented by 1 — so subsequent ships of that type are produced at a higher level. The player is notified via a toast (REQ-UI-SCHEMATIC-TOAST). - 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.
- 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.
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.
- 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".
The player selects one option by clicking it. 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 an **assembler recipe schematic**: the recipe is explicitly unlocked and becomes available in the assembler recipe-selection dropdown (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).
## Progression & Locking
- 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-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 dropdown by virtue of REQ-LOCK-EXPLICIT; their inputs are also added to the implicit set in this step.
3. Repeat step 2 until no new item types are added.
Item types and miner/assembler recipes not reached by this process (and not explicitly unlocked) are locked. Smelter recipes participate in the traversal to propagate unlocking to their inputs but are never themselves shown in any UI dropdown.
- REQ-LOCK-REPROCESSING-POOL: The pool of possible outputs for a Reprocessing Plant cycle (REQ-BLD-REPROCESSING) is restricted to item types that are currently implicitly unlocked (REQ-LOCK-IMPLICIT). Weights are renormalized over the eligible outputs. If no eligible outputs remain, the Reprocessing Plant cannot start a production cycle.
- REQ-LOCK-UI-RECIPE: Locked miner ore-type recipes and assembler recipes are not shown in their respective recipe-selection dropdowns.
- REQ-LOCK-UI-SCHEMATIC: Locked ship schematics are not shown in the shipyard's schematic-selection dropdown.
- REQ-LOCK-UI-SPLITTER: Item types that are not implicitly unlocked are excluded from splitter filter dropdowns (REQ-BLD-SPLITTER).
- REQ-LOCK-UI-BLUEPRINT: When a blueprint is placed (REQ-UI-BLUEPRINT-PLACE): if a stored recipe ID for a miner or assembler is currently locked, that building's recipe is left unset rather than applied; if a stored splitter filter entry refers to a locked item type, that entry is silently removed. (The analogous rule for locked ship schematics is defined in REQ-UI-BLUEPRINT-PLACE.)
## Threat Level & Enemy Waves ## Threat Level & Enemy Waves
- REQ-WAV-BOSS-COUNTER: A global **boss wave counter** `x` starts at 1 at game start and increments by 1 immediately after each boss wave fires. It represents the current boss wave cycle number and is used as the variable in the threat rate and ship level formulas. - REQ-WAV-BOSS-COUNTER: A global **boss wave counter** `x` starts at 1 at game start and increments by 1 immediately after each boss wave fires. It represents the current boss wave cycle number and is used as the variable in the threat rate and ship level formulas.
- REQ-WAV-THREAT-RATE: A global **threat level** accumulates continuously over real game time. The rate of increase per second is determined by `world.toml [waves].threat_rate_formula` where `x` is the boss wave counter (REQ-WAV-BOSS-COUNTER), clamped to a minimum of 0 (negative formula values are treated as 0). The rate is constant within each boss wave cycle and steps up each time `x` increments. Threat accumulation is paused during quiet windows (REQ-WAV-QUIET). Example: `1*x - 30` yields 0 threat/s when x ≤ 30 and increases linearly beyond that. - REQ-WAV-THREAT-RATE: A global **threat level** accumulates continuously over real game time. The rate of increase per second is determined by `world.toml [waves].threat_rate_formula` where `x` is the boss wave counter (REQ-WAV-BOSS-COUNTER), clamped to a minimum of 0 (negative formula values are treated as 0). The rate is constant within each boss wave cycle and steps up each time `x` increments. Threat accumulation is paused during quiet windows (REQ-WAV-QUIET). Example: `1*x - 30` yields 0 threat/s when x ≤ 30 and increases linearly beyond that.
- REQ-WAV-GAP: At game start and immediately after each normal wave is triggered, a random inter-wave gap is drawn uniformly from [`world.toml [waves].gap_min_seconds`, `gap_max_seconds`]. The gap timer does not advance while inside a quiet window (REQ-WAV-QUIET); if a gap would expire inside a quiet window, its expiry is deferred until the quiet window ends. - REQ-WAV-GAP: At game start and immediately after each normal wave is triggered, a random inter-wave gap is drawn uniformly from [`world.toml [waves].gap_min_seconds`, `gap_max_seconds`]. The gap timer does not advance while inside a quiet window (REQ-WAV-QUIET); if a gap would expire inside a quiet window, its expiry is deferred until the quiet window ends.
- REQ-WAV-TRIGGER: When the gap timer expires outside a quiet window, a normal wave is triggered. Ships are selected one at a time: from all schematics whose `threat.cost_formula` evaluates to > 0 at the current enemy ship level, uniformly randomly pick one whose cost fits the remaining threat budget. Repeat until no eligible schematic fits. Any remaining threat carries over to the next normal wave. A longer gap results in a larger wave. Because enemy ship level increases with the boss wave counter (REQ-WAV-SHIP-LEVEL), threat cost per ship rises as the game progresses. - 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 and threat cost are computed from the ship level via the formulas in `ships.toml` (see REQ-SHP-STATS). - REQ-WAV-SHIP-LEVEL: Each wave's (normal and boss) enemy ships are assigned a level determined by `world.toml [waves].ship_level_formula` where `x` is the boss wave counter (REQ-WAV-BOSS-COUNTER). Per-ship stats are computed from the ship level via the formulas in `ships.toml` (see REQ-SHP-STATS). Threat cost is level-independent (REQ-MOD-THREAT).
- REQ-WAV-BOSS-COUNTDOWN: A **boss countdown** timer starts at `world.toml [waves].boss_countdown_seconds` (default 300) at game start and counts down continuously in real game-time seconds. It is not paused during quiet windows. When it reaches 0, a boss wave is triggered (REQ-WAV-BOSS-TRIGGER). Immediately after the boss wave fires, `x` increments (REQ-WAV-BOSS-COUNTER) and a fresh countdown starts at the same configured value. - REQ-WAV-BOSS-COUNTDOWN: A **boss countdown** timer starts at `world.toml [waves].boss_countdown_seconds` (default 300) at game start and counts down continuously in real game-time seconds. It is not paused during quiet windows. When it reaches 0, a boss wave is triggered (REQ-WAV-BOSS-TRIGGER). Immediately after the boss wave fires, `x` increments (REQ-WAV-BOSS-COUNTER) and a fresh countdown starts at the same configured value.
- REQ-WAV-BOSS-ADVANCE: When the player destroys a set of enemy defence stations, the boss countdown is reduced by `world.toml [push].boss_advance_seconds` (default 60), clamped to a minimum of 0. Threat that would have accumulated during the skipped time is not added. If the countdown reaches 0 by this reduction, the boss wave is triggered immediately. - REQ-WAV-BOSS-ADVANCE: When the player destroys a set of enemy defence stations, the boss countdown is reduced by `world.toml [push].boss_advance_seconds` (default 60), clamped to a minimum of 0. Threat that would have accumulated during the skipped time is not added. If the countdown reaches 0 by this reduction, the boss wave is triggered immediately.
- REQ-WAV-QUIET: A **quiet window** suppresses normal wave spawning around each boss wave. The pre-boss quiet window begins when the boss countdown falls to or below `world.toml [waves].boss_quiet_before_seconds` and ends when the countdown reaches 0. The post-boss quiet window begins immediately when the boss wave fires and lasts `world.toml [waves].boss_quiet_after_seconds` seconds. Threat accumulation is paused during both windows. The normal wave gap timer does not advance during either window (REQ-WAV-GAP). The new boss countdown runs during the post-boss quiet window. - REQ-WAV-QUIET: A **quiet window** suppresses normal wave spawning around each boss wave. The pre-boss quiet window begins when the boss countdown falls to or below `world.toml [waves].boss_quiet_before_seconds` and ends when the countdown reaches 0. The post-boss quiet window begins immediately when the boss wave fires and lasts `world.toml [waves].boss_quiet_after_seconds` seconds. Threat accumulation is paused during both windows. The normal wave gap timer does not advance during either window (REQ-WAV-GAP). The new boss countdown runs during the post-boss quiet window.
@@ -298,27 +349,30 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
### Layout ### Layout
The screen is divided into three vertical sections: The screen is divided into two columns: a main column (75% width) containing the header bar and game world, and a side panel column (25% width) containing the three UI panels stacked vertically:
``` ```
+--------------------------------------------------+ +--------------------------------------+--------------+
| Header Bar | | Header Bar | |
+--------------------------------------------------+ +--------------------------------------+ Selected |
| | | | Building |
| Game World (70%) | | | Panel |
| | | +--------------+
+-----------------+-----------------+--------------+ | Game World | Build |
| Selected | Build Button | Blueprint | | | Button |
| Building Panel | Grid | Panel | | | Grid |
| (left) | (center) | (right) | | +--------------+
+-----------------+-----------------+--------------+ | | Blueprint |
| | Panel |
+--------------------------------------+--------------+
(75% width) (25% width)
``` ```
- REQ-UI-HEADER: The header bar spans the full width above the game world 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 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-BOSS-STATUS: The header bar displays, to the left of the speed buttons, the current boss wave counter (REQ-WAV-BOSS-COUNTER) and the time remaining on the boss countdown (REQ-WAV-BOSS-COUNTDOWN). The boss wave counter is shown as `Boss Wave #<x>` and the countdown as `Next boss: <M:SS>`, where `<M:SS>` is the remaining seconds formatted as whole minutes and two-digit seconds. Both values update continuously as the simulation runs. - REQ-UI-BOSS-STATUS: The header bar displays, to the left of the speed buttons, the current boss wave counter (REQ-WAV-BOSS-COUNTER) and the time remaining on the boss countdown (REQ-WAV-BOSS-COUNTDOWN). The boss wave counter is shown as `Boss Wave #<x>` and the countdown as `Next boss: <M:SS>`, where `<M:SS>` is the remaining seconds formatted as whole minutes and two-digit seconds. Both values update continuously as the simulation runs.
- REQ-UI-SPEED: The game speed controls in the header bar are buttons for 0×, 0.5×, 1×, 2×, and 4× speed. The currently active speed is shown as selected. All game simulation (production, movement, threat accumulation, wave timing) scales with the selected speed. 0× pauses the game. - REQ-UI-SPEED: The game speed controls in the header bar are buttons for 0×, 0.5×, 1×, 2×, and 4× speed. The currently active speed is shown as selected. All game simulation (production, movement, threat accumulation, wave timing) scales with the selected speed. 0× pauses the game.
- REQ-UI-WORLD-HEIGHT: The game world view occupies 70% of the remaining screen height below the header bar. - 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-HEIGHT: The UI panel occupies the remaining 30% of the screen height, split horizontally into a selected building panel (left), a build button grid (center), and a blueprint panel (right). - REQ-UI-PANEL-COLUMN: The side panel column occupies 25% of the screen width and the full screen height. It is divided into three equal-height panels stacked top to bottom: selected building panel (top), build button grid (middle), and blueprint panel (bottom).
### Game World ### Game World
@@ -327,11 +381,6 @@ The screen is divided into three vertical sections:
- REQ-UI-PORT-GLYPH: Every output port of every building is indicated by a directional glyph drawn on the port's tile. The glyph is a `>` rotated to face the port's exit direction (`>` for East, `^` for North, `<` for West, `v` for South). It is drawn at the midpoint between the tile center and the tile edge that the port exits through (i.e. halfway from center toward the exit edge). The indicator is rendered for all building states: operational buildings, construction sites, and the builder-mode ghost. Buildings with multiple output ports (e.g. splitters) show one indicator per port. - REQ-UI-PORT-GLYPH: Every output port of every building is indicated by a directional glyph drawn on the port's tile. The glyph is a `>` rotated to face the port's exit direction (`>` for East, `^` for North, `<` for West, `v` for South). It is drawn at the midpoint between the tile center and the tile edge that the port exits through (i.e. halfway from center toward the exit edge). The indicator is rendered for all building states: operational buildings, construction sites, and the builder-mode ghost. Buildings with multiple output ports (e.g. splitters) show one indicator per port.
- REQ-UI-HP-BARS: All entities with HP — the HQ, player and enemy defence stations, and player and enemy ships — render an HP bar below them. The bar is always visible regardless of current HP. The bar's filled portion represents the fraction of current HP to maximum HP. - REQ-UI-HP-BARS: All entities with HP — the HQ, player and enemy defence stations, and player and enemy ships — render an HP bar below them. The bar is always visible regardless of current HP. The bar's filled portion represents the fraction of current HP to maximum HP.
- REQ-UI-NO-ZOOM: The view has a fixed zoom level; the player cannot zoom in or out. - REQ-UI-NO-ZOOM: The view has a fixed zoom level; the player cannot zoom in or out.
- REQ-UI-SCHEMATIC-TOAST: When a schematic is unlocked or leveled up (REQ-DEF-SCHEMATIC-DROP), a transient notification toast appears in the top-right corner of the game world view for 4 seconds and then fades out. `<Ship Name>` in the text below is the schematic's `ships.toml [ship.schematic].display_name`. Toast text:
- **New unlock**: `Schematic unlocked: <Ship Name>`
- **Level-up (duplicate drop)**: `<Ship Name> production level → N` (where N is the new level).
If multiple toasts arrive in close succession, they stack vertically in a queue (most recent at the top) and each fades out independently after its own 4-second lifetime.
- REQ-UI-HOTKEYS: Global keyboard shortcuts: - REQ-UI-HOTKEYS: Global keyboard shortcuts:
- **Space** — toggles pause. Pressing Space pauses (sets speed to 0×) and stores the previously selected non-zero speed; pressing Space again restores that speed. - **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×). - **W** — increases game speed by one step in the sequence 0×, 0.5×, 1×, 2×, 4× (no wrap-around past 4×).
@@ -348,6 +397,9 @@ The screen is divided into three vertical sections:
- 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: - 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). - `Accumulated Threat Level: <level>` — where `<level>` is the current accumulated threat level (see REQ-WAV-THREAT-RATE).
- `Time until Wave: <time_s>` — where `<time_s>` is the remaining time in seconds on the normal-wave inter-wave gap timer (see REQ-WAV-GAP). During a quiet window the gap timer is frozen; the displayed value reflects that frozen state. - `Time until Wave: <time_s>` — where `<time_s>` is the remaining time in seconds on the normal-wave inter-wave gap timer (see REQ-WAV-GAP). During a quiet window the gap timer is frozen; the displayed value reflects that frozen state.
- `Threat Accumulation Rate: <rate> threat/s` — the rate at which the accumulated threat level is currently increasing (see REQ-WAV-THREAT-RATE). During a quiet window (REQ-WAV-QUIET), this is 0, reflecting that accumulation is currently paused.
- `Max Factory Production: <rate> threat/s` — the threat-equivalent of the factory's total possible production: 1 threat/second for each completed (operational, not under construction) miner, smelter, assembler, reprocessing plant, and shipyard. One second of production equals one threat (see REQ-MOD-THREAT).
- `Current Factory Production: <rate> threat/s` — the threat-equivalent of the factory's current production: 1 threat/second for each completed miner, smelter, assembler, reprocessing plant, or shipyard that currently has an active production cycle (see REQ-MAT-CYCLE; for shipyards, an in-progress production cycle per REQ-BLD-SHIPYARD).
### Escape Menu ### Escape Menu
@@ -367,7 +419,7 @@ The screen is divided into three vertical sections:
- REQ-UI-CONFIG-INLINE: Recipe, schematic, ship stance, and target priority configuration for a selected building is shown and changed inline within this panel. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic dropdown (REQ-MOD-UI-PREVIEW). - REQ-UI-CONFIG-INLINE: Recipe, schematic, ship stance, and target priority configuration for a selected building is shown and changed inline within this panel. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic dropdown (REQ-MOD-UI-PREVIEW).
- REQ-UI-BELT-CLEAR: When one or more belt, splitter, tunnel entry, or tunnel exit tiles are selected, the panel shows a "Clear" button that removes all items from the selected tiles. Clearing a tunnel entry or exit also discards all items currently in transit through that tunnel (REQ-BLD-TUNNEL-TRANSIT). This can be used to resolve stalled belts, splitters, and tunnels. - REQ-UI-BELT-CLEAR: When one or more belt, splitter, tunnel entry, or tunnel exit tiles are selected, the panel shows a "Clear" button that removes all items from the selected tiles. Clearing a tunnel entry or exit also discards all items currently in transit through that tunnel (REQ-BLD-TUNNEL-TRANSIT). This can be used to resolve stalled belts, splitters, and tunnels.
- REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. Clicking a ship or defence station clears any existing selection and establishes a single-entity selection containing only that entity. Ships and defence stations cannot participate in multi-select together with buildings. Clicking empty world space (no building, ship, or defence station) clears the selection. - REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. Clicking a ship or defence station clears any existing selection and establishes a single-entity selection containing only that entity. Ships and defence stations cannot participate in multi-select together with buildings. Clicking 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. - 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-STATION-STATS-PANEL: When a single defence station is selected (REQ-UI-ENTITY-CLICK-SELECT), the selected building panel shows a **station stats panel** displaying the station's stats computed at its current level: HP (current / maximum), damage, range, and fire rate. - REQ-UI-STATION-STATS-PANEL: When a single defence station is selected (REQ-UI-ENTITY-CLICK-SELECT), the selected building panel shows a **station stats panel** displaying the station's stats computed at its current level: HP (current / maximum), damage, range, and fire rate.
### Build Button Grid ### Build Button Grid
@@ -389,7 +441,7 @@ The screen is divided into three vertical sections:
- 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). 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-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. 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. 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-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.

View File

@@ -47,6 +47,7 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
[this]() { return allocateBuildingId(); }, [this]() { return allocateBuildingId(); },
[](int) {}, [](int) {},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
m_rng); m_rng);
m_shipSystem = std::make_unique<ShipSystem>(m_gameConfig, m_admin); m_shipSystem = std::make_unique<ShipSystem>(m_gameConfig, m_admin);
@@ -258,9 +259,9 @@ void ArenaSimulation::tick()
m_aiSystem->tickSalvageBehavior(m_admin, *m_scrapSystem, *m_buildingSystem); m_aiSystem->tickSalvageBehavior(m_admin, *m_scrapSystem, *m_buildingSystem);
// Combat resolution (tick step 8). // Combat resolution (tick step 8).
std::vector<FireEvent> fireEvents; std::vector<WeaponFiredEvent> weaponFiredEvents;
m_combatSystem->tick(m_currentTick, m_admin, *m_buildingSystem, fireEvents); m_combatSystem->tick(m_currentTick, m_admin, *m_buildingSystem, weaponFiredEvents);
m_fireEvents.insert(m_fireEvents.end(), fireEvents.begin(), fireEvents.end()); m_weaponFiredEvents.insert(m_weaponFiredEvents.end(), weaponFiredEvents.begin(), weaponFiredEvents.end());
m_combatSystem->applyPendingDamage(m_currentTick, m_admin); m_combatSystem->applyPendingDamage(m_currentTick, m_admin);
// Deaths (tick step 9, simplified). // Deaths (tick step 9, simplified).
@@ -392,10 +393,10 @@ void ArenaSimulation::tickOnce()
} }
} }
std::vector<FireEvent> ArenaSimulation::drainFireEvents() std::vector<WeaponFiredEvent> ArenaSimulation::drainWeaponFiredEvents()
{ {
std::vector<FireEvent> result; std::vector<WeaponFiredEvent> result;
result.swap(m_fireEvents); result.swap(m_weaponFiredEvents);
return result; return result;
} }

View File

@@ -13,7 +13,7 @@
#include "BuildingId.h" #include "BuildingId.h"
#include "entt/entity/entity.hpp" #include "entt/entity/entity.hpp"
#include "FireEvent.h" #include "WeaponFiredEvent.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "Tick.h" #include "Tick.h"
@@ -58,7 +58,7 @@ public:
void requestStop(); void requestStop();
void tickOnce(); void tickOnce();
std::vector<FireEvent> drainFireEvents(); std::vector<WeaponFiredEvent> drainWeaponFiredEvents();
ArenaStatus status() const; ArenaStatus status() const;
bool isFinished() const; bool isFinished() const;
@@ -104,7 +104,7 @@ private:
int m_winnerTeam; int m_winnerTeam;
std::atomic<bool> m_stopRequested; std::atomic<bool> m_stopRequested;
std::vector<FireEvent> m_fireEvents; std::vector<WeaponFiredEvent> m_weaponFiredEvents;
mutable std::mutex m_statusMutex; mutable std::mutex m_statusMutex;
ArenaStatus m_status; ArenaStatus m_status;

View File

@@ -16,6 +16,7 @@
#include "EventManager.h" #include "EventManager.h"
#include "FacingComponent.h" #include "FacingComponent.h"
#include "FactionComponent.h" #include "FactionComponent.h"
#include "GameSpeedChangedEvent.h"
#include "HealthComponent.h" #include "HealthComponent.h"
#include "PositionComponent.h" #include "PositionComponent.h"
#include "ScrapSystem.h" #include "ScrapSystem.h"
@@ -45,6 +46,13 @@ ArenaView::ArenaView(ArenaSimulation* sim, const VisualsConfig* visuals,
connect(m_renderTimer, &QTimer::timeout, this, &ArenaView::onFrame); connect(m_renderTimer, &QTimer::timeout, this, &ArenaView::onFrame);
m_renderTimer->start(); m_renderTimer->start();
m_frameTimer.start(); m_frameTimer.start();
registerForEvent();
}
ArenaView::~ArenaView()
{
unregisterForEvent();
} }
void ArenaView::setGameSpeed(double multiplier) void ArenaView::setGameSpeed(double multiplier)
@@ -54,7 +62,8 @@ void ArenaView::setGameSpeed(double multiplier)
m_prevNonZeroSpeed = multiplier; m_prevNonZeroSpeed = multiplier;
} }
m_gameSpeedMultiplier = multiplier; m_gameSpeedMultiplier = multiplier;
emit speedChanged(multiplier); EventManager::getInstance()->sendEventImmediately(
std::make_shared<GameSpeedChangedEvent>(multiplier));
} }
double ArenaView::gameSpeed() const double ArenaView::gameSpeed() const
@@ -93,34 +102,17 @@ void ArenaView::onFrame()
} }
} }
// Emit fire events via EventManager
{ {
const std::vector<FireEvent> fires = m_sim->drainFireEvents(); const std::vector<WeaponFiredEvent> fires = m_sim->drainWeaponFiredEvents();
for (const FireEvent& fe : fires) for (const WeaponFiredEvent& fe : fires)
{ {
float maxRadius = 0.125f; EventManager::getInstance()->sendEventImmediately(
if (m_sim->admin().isValid(fe.target) std::make_shared<WeaponFiredEvent>(fe));
&& m_sim->admin().hasAll<StationBodyComponent>(fe.target))
{
const StationBodyComponent& sb = m_sim->admin().get<StationBodyComponent>(fe.target);
const int shorter = std::min(sb.footprint.width(),
sb.footprint.height());
maxRadius = shorter / 2.0f;
}
std::uniform_real_distribution<float> angleDist(0.0f, 6.28318530f);
std::uniform_real_distribution<float> radiusDist(0.0f, maxRadius);
const float angle = angleDist(m_rng);
const float radius = radiusDist(m_rng);
ActiveBeam beam;
beam.event = fe;
beam.emittedWallMs = m_wallMs;
beam.targetOffset = QVector2D(radius * std::cos(angle),
radius * std::sin(angle));
m_activeBeams.push_back(beam);
} }
} }
// Expire old beams
{ {
std::vector<ActiveBeam> live; std::vector<ActiveBeam> live;
for (const ActiveBeam& b : m_activeBeams) for (const ActiveBeam& b : m_activeBeams)
@@ -136,12 +128,36 @@ void ArenaView::onFrame()
if (m_sim->isFinished() && !m_finishedEmitted) if (m_sim->isFinished() && !m_finishedEmitted)
{ {
m_finishedEmitted = true; m_finishedEmitted = true;
emit finished();
} }
update(); update();
} }
void ArenaView::handleEvent(std::shared_ptr<const WeaponFiredEvent> event)
{
float maxRadius = 0.125f;
if (m_sim->admin().isValid(event->target)
&& m_sim->admin().hasAll<StationBodyComponent>(event->target))
{
const StationBodyComponent& sb = m_sim->admin().get<StationBodyComponent>(event->target);
const int shorter = std::min(sb.footprint.width(),
sb.footprint.height());
maxRadius = shorter / 2.0f;
}
std::uniform_real_distribution<float> angleDist(0.0f, 6.28318530f);
std::uniform_real_distribution<float> radiusDist(0.0f, maxRadius);
const float angle = angleDist(m_rng);
const float radius = radiusDist(m_rng);
ActiveBeam beam;
beam.event = *event;
beam.emittedWallMs = m_wallMs;
beam.targetOffset = QVector2D(radius * std::cos(angle),
radius * std::sin(angle));
m_activeBeams.push_back(beam);
}
void ArenaView::paintGL() void ArenaView::paintGL()
{ {
QPainter painter(this); QPainter painter(this);
@@ -414,4 +430,3 @@ void ArenaView::drawBeams(QPainter& painter)
worldToWidget(*targetPos + beam.targetOffset)); worldToWidget(*targetPos + beam.targetOffset));
} }
} }

View File

@@ -9,7 +9,8 @@
#include <QTimer> #include <QTimer>
#include <QVector2D> #include <QVector2D>
#include "FireEvent.h" #include "EventHandler.h"
#include "WeaponFiredEvent.h"
#include "entt/entity/entity.hpp" #include "entt/entity/entity.hpp"
#include "EntitySelectedEvent.h" #include "EntitySelectedEvent.h"
@@ -20,23 +21,21 @@
class ArenaSimulation; class ArenaSimulation;
class QPainter; class QPainter;
class ArenaView : public QOpenGLWidget class ArenaView : public QOpenGLWidget,
public EventHandler<WeaponFiredEvent>
{ {
Q_OBJECT Q_OBJECT
public: public:
ArenaView(ArenaSimulation* sim, const VisualsConfig* visuals, ArenaView(ArenaSimulation* sim, const VisualsConfig* visuals,
QWidget* parent = nullptr); QWidget* parent = nullptr);
~ArenaView() override;
void setGameSpeed(double multiplier); void setGameSpeed(double multiplier);
double gameSpeed() const; double gameSpeed() const;
void togglePause(); void togglePause();
void stopRendering(); void stopRendering();
signals:
void speedChanged(double multiplier);
void finished();
protected: protected:
void paintGL() override; void paintGL() override;
void mousePressEvent(QMouseEvent* event) override; void mousePressEvent(QMouseEvent* event) override;
@@ -45,6 +44,8 @@ private slots:
void onFrame(); void onFrame();
private: private:
void handleEvent(std::shared_ptr<const WeaponFiredEvent> event) override;
void drawTiles(QPainter& painter); void drawTiles(QPainter& painter);
void drawBuildings(QPainter& painter); void drawBuildings(QPainter& painter);
void drawStations(QPainter& painter); void drawStations(QPainter& painter);
@@ -62,7 +63,7 @@ private:
struct ActiveBeam struct ActiveBeam
{ {
FireEvent event; WeaponFiredEvent event;
qint64 emittedWallMs; qint64 emittedWallMs;
QVector2D targetOffset; QVector2D targetOffset;
}; };

View File

@@ -3,8 +3,13 @@
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QVBoxLayout> #include <QVBoxLayout>
ArenaWidget::ArenaWidget(const std::string& arenaName, QWidget* parent) #include "ArenaInspectRequestedEvent.h"
#include "ArenaStartRequestedEvent.h"
#include "EventManager.h"
ArenaWidget::ArenaWidget(int arenaIndex, const std::string& arenaName, QWidget* parent)
: QFrame(parent) : QFrame(parent)
, m_arenaIndex(arenaIndex)
, m_running(false) , m_running(false)
, m_wasFinished(false) , m_wasFinished(false)
{ {
@@ -31,11 +36,17 @@ void ArenaWidget::buildLayout(const std::string& arenaName)
titleRow->addStretch(); titleRow->addStretch();
m_inspectButton = new QPushButton(tr("Inspect"), this); m_inspectButton = new QPushButton(tr("Inspect"), this);
connect(m_inspectButton, &QPushButton::clicked, this, &ArenaWidget::inspectRequested); connect(m_inspectButton, &QPushButton::clicked, this, [this]() {
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ArenaInspectRequestedEvent>(m_arenaIndex));
});
titleRow->addWidget(m_inspectButton); titleRow->addWidget(m_inspectButton);
m_startButton = new QPushButton(tr("Start"), this); m_startButton = new QPushButton(tr("Start"), this);
connect(m_startButton, &QPushButton::clicked, this, &ArenaWidget::startRequested); connect(m_startButton, &QPushButton::clicked, this, [this]() {
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ArenaStartRequestedEvent>(m_arenaIndex));
});
titleRow->addWidget(m_startButton); titleRow->addWidget(m_startButton);
outerLayout->addLayout(titleRow); outerLayout->addLayout(titleRow);

View File

@@ -14,19 +14,16 @@ class ArenaWidget : public QFrame
Q_OBJECT Q_OBJECT
public: public:
explicit ArenaWidget(const std::string& arenaName, QWidget* parent = nullptr); ArenaWidget(int arenaIndex, const std::string& arenaName, QWidget* parent = nullptr);
void updateStatus(const ArenaStatus& status); void updateStatus(const ArenaStatus& status);
void startSimulation(); void startSimulation();
void resetToGrey(); void resetToGrey();
signals:
void startRequested();
void inspectRequested();
private: private:
void buildLayout(const std::string& arenaName); void buildLayout(const std::string& arenaName);
int m_arenaIndex;
QLabel* m_titleLabel; QLabel* m_titleLabel;
QLabel* m_team1Header; QLabel* m_team1Header;
QLabel* m_team2Header; QLabel* m_team2Header;

View File

@@ -48,14 +48,17 @@ BalancingWindow::BalancingWindow(const BalancingConfig& balancingConfig,
m_pollTimer = new QTimer(this); m_pollTimer = new QTimer(this);
connect(m_pollTimer, &QTimer::timeout, this, &BalancingWindow::pollStatuses); connect(m_pollTimer, &QTimer::timeout, this, &BalancingWindow::pollStatuses);
m_pollTimer->start(100); m_pollTimer->start(100);
registerForEvents();
} }
BalancingWindow::~BalancingWindow() BalancingWindow::~BalancingWindow()
{ {
unregisterForEvents();
m_pollTimer->stop(); m_pollTimer->stop();
if (m_inspectWindow) if (m_inspectWindow)
{ {
m_inspectWindow->disconnect(this);
delete m_inspectWindow; delete m_inspectWindow;
m_inspectWindow = nullptr; m_inspectWindow = nullptr;
} }
@@ -81,16 +84,11 @@ void BalancingWindow::populateArenas(const BalancingConfig& balancingConfig)
entry.config = arenaConfig; entry.config = arenaConfig;
entry.simulation = std::make_unique<ArenaSimulation>( entry.simulation = std::make_unique<ArenaSimulation>(
m_gameConfig, arenaConfig, m_nextSeed++); m_gameConfig, arenaConfig, m_nextSeed++);
entry.widget = new ArenaWidget(arenaConfig.name, scrollContent); entry.widget = new ArenaWidget(index, arenaConfig.name, scrollContent);
contentLayout->addWidget(entry.widget); contentLayout->addWidget(entry.widget);
entry.widget->updateStatus(entry.simulation->status()); entry.widget->updateStatus(entry.simulation->status());
connect(entry.widget, &ArenaWidget::startRequested,
this, [this, index]() { startArena(index); });
connect(entry.widget, &ArenaWidget::inspectRequested,
this, [this, index]() { inspectArena(index); });
m_arenas.push_back(std::move(entry)); m_arenas.push_back(std::move(entry));
} }
@@ -158,6 +156,21 @@ void BalancingWindow::startAll()
} }
} }
void BalancingWindow::handleEvent(std::shared_ptr<const ArenaStartRequestedEvent> event)
{
startArena(event->arenaIndex);
}
void BalancingWindow::handleEvent(std::shared_ptr<const ArenaInspectRequestedEvent> event)
{
inspectArena(event->arenaIndex);
}
void BalancingWindow::handleEvent(std::shared_ptr<const InspectWindowClosedEvent> /*event*/)
{
closeInspectWindow();
}
void BalancingWindow::startArena(int index) void BalancingWindow::startArena(int index)
{ {
ArenaEntry& entry = m_arenas[index]; ArenaEntry& entry = m_arenas[index];
@@ -179,7 +192,6 @@ void BalancingWindow::inspectArena(int index)
{ {
if (m_inspectWindow) if (m_inspectWindow)
{ {
m_inspectWindow->disconnect(this);
delete m_inspectWindow; delete m_inspectWindow;
m_inspectWindow = nullptr; m_inspectWindow = nullptr;
@@ -210,8 +222,6 @@ void BalancingWindow::inspectArena(int index)
m_inspectWindow = new InspectWindow( m_inspectWindow = new InspectWindow(
m_inspectedSim.get(), &m_gameConfig, &m_visuals, entry.config.name, nullptr); m_inspectedSim.get(), &m_gameConfig, &m_visuals, entry.config.name, nullptr);
connect(m_inspectWindow, &InspectWindow::closed,
this, &BalancingWindow::closeInspectWindow);
setMainControlsEnabled(false); setMainControlsEnabled(false);
m_inspectWindow->show(); m_inspectWindow->show();
@@ -224,7 +234,6 @@ void BalancingWindow::closeInspectWindow()
return; return;
} }
m_inspectWindow->disconnect(this);
m_inspectWindow->deleteLater(); m_inspectWindow->deleteLater();
m_inspectWindow = nullptr; m_inspectWindow = nullptr;

View File

@@ -10,15 +10,22 @@
#include <QTimer> #include <QTimer>
#include <QWidget> #include <QWidget>
#include "ArenaInspectRequestedEvent.h"
#include "ArenaStartRequestedEvent.h"
#include "ArenaWidget.h" #include "ArenaWidget.h"
#include "ArenaSimulation.h" #include "ArenaSimulation.h"
#include "BalancingConfig.h" #include "BalancingConfig.h"
#include "EventHandler.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "InspectWindowClosedEvent.h"
#include "VisualsConfig.h" #include "VisualsConfig.h"
class InspectWindow; class InspectWindow;
class BalancingWindow : public QWidget class BalancingWindow : public QWidget,
public CombinedEventHandler<ArenaStartRequestedEvent,
ArenaInspectRequestedEvent,
InspectWindowClosedEvent>
{ {
Q_OBJECT Q_OBJECT
@@ -30,15 +37,20 @@ public:
QWidget* parent = nullptr); QWidget* parent = nullptr);
~BalancingWindow() override; ~BalancingWindow() override;
private:
void handleEvent(std::shared_ptr<const ArenaStartRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const ArenaInspectRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const InspectWindowClosedEvent> event) override;
private slots: private slots:
void pollStatuses(); void pollStatuses();
void reloadConfig(); void reloadConfig();
void startAll(); void startAll();
private:
void startArena(int index); void startArena(int index);
void inspectArena(int index); void inspectArena(int index);
void closeInspectWindow(); void closeInspectWindow();
private:
void populateArenas(const BalancingConfig& balancingConfig); void populateArenas(const BalancingConfig& balancingConfig);
void stopAllArenas(); void stopAllArenas();
void updateButtons(); void updateButtons();

View File

@@ -10,7 +10,9 @@
#include "ArenaView.h" #include "ArenaView.h"
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include "EventManager.h"
#include "HealthComponent.h" #include "HealthComponent.h"
#include "InspectWindowClosedEvent.h"
#include "ModuleOwnerComponent.h" #include "ModuleOwnerComponent.h"
#include "ShipIdentityComponent.h" #include "ShipIdentityComponent.h"
#include "ShipStatsCalculator.h" #include "ShipStatsCalculator.h"
@@ -76,9 +78,6 @@ InspectWindow::InspectWindow(ArenaSimulation* sim, const GameConfig* config,
m_arenaView = new ArenaView(sim, visuals, this); m_arenaView = new ArenaView(sim, visuals, this);
mainLayout->addWidget(m_arenaView, 1); mainLayout->addWidget(m_arenaView, 1);
connect(m_arenaView, &ArenaView::speedChanged,
this, &InspectWindow::onSpeedChanged);
// Info panel (bottom) // Info panel (bottom)
{ {
QWidget* infoPanel = new QWidget(this); QWidget* infoPanel = new QWidget(this);
@@ -140,19 +139,20 @@ InspectWindow::InspectWindow(ArenaSimulation* sim, const GameConfig* config,
setFocusPolicy(Qt::StrongFocus); setFocusPolicy(Qt::StrongFocus);
registerForEvent(); registerForEvents();
} }
InspectWindow::~InspectWindow() InspectWindow::~InspectWindow()
{ {
unregisterForEvent(); unregisterForEvents();
} }
void InspectWindow::closeEvent(QCloseEvent* event) void InspectWindow::closeEvent(QCloseEvent* event)
{ {
m_arenaView->stopRendering(); m_arenaView->stopRendering();
m_pollTimer->stop(); m_pollTimer->stop();
emit closed(); EventManager::getInstance()->sendEventImmediately(
std::make_shared<InspectWindowClosedEvent>());
event->accept(); event->accept();
} }
@@ -176,11 +176,11 @@ void InspectWindow::onSpeedButton(int index)
} }
} }
void InspectWindow::onSpeedChanged(double multiplier) void InspectWindow::handleEvent(std::shared_ptr<const GameSpeedChangedEvent> event)
{ {
for (int i = 0; i < kSpeedCount; ++i) for (int i = 0; i < kSpeedCount; ++i)
{ {
const bool active = (std::abs(kSpeeds[i] - multiplier) < 0.001); const bool active = (std::abs(kSpeeds[i] - event->speed) < 0.001);
m_speedButtons[static_cast<std::size_t>(i)]->setChecked(active); m_speedButtons[static_cast<std::size_t>(i)]->setChecked(active);
} }
} }

View File

@@ -15,13 +15,15 @@
#include "EntitySelectedEvent.h" #include "EntitySelectedEvent.h"
#include "EventHandler.h" #include "EventHandler.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "GameSpeedChangedEvent.h"
#include "VisualsConfig.h" #include "VisualsConfig.h"
class ArenaView; class ArenaView;
class ShipStatsPanel; class ShipStatsPanel;
class InspectWindow : public QWidget, class InspectWindow : public QWidget,
public EventHandler<EntitySelectedEvent> public CombinedEventHandler<EntitySelectedEvent,
GameSpeedChangedEvent>
{ {
Q_OBJECT Q_OBJECT
@@ -31,19 +33,16 @@ public:
const std::string& arenaName, QWidget* parent = nullptr); const std::string& arenaName, QWidget* parent = nullptr);
~InspectWindow() override; ~InspectWindow() override;
signals:
void closed();
protected: protected:
void closeEvent(QCloseEvent* event) override; void closeEvent(QCloseEvent* event) override;
void keyPressEvent(QKeyEvent* event) override; void keyPressEvent(QKeyEvent* event) override;
private: private:
void handleEvent(std::shared_ptr<const EntitySelectedEvent> event) override; void handleEvent(std::shared_ptr<const EntitySelectedEvent> event) override;
void handleEvent(std::shared_ptr<const GameSpeedChangedEvent> event) override;
private slots: private slots:
void onSpeedButton(int index); void onSpeedButton(int index);
void onSpeedChanged(double multiplier);
void pollStatus(); void pollStatus();
private: private:

View File

@@ -367,6 +367,15 @@ RecipesConfig ConfigLoader::loadRecipes(const std::string& path)
} }
def.building = *parsedType; def.building = *parsedType;
if (def.building == BuildingType::Assembler)
{
const auto level = mt["unlock_at_station_level"].value<int64_t>();
if (level)
{
def.unlockAtStationLevel = static_cast<int>(*level);
}
}
// inputs may be omitted (e.g. miner recipes). An empty array is fine. // inputs may be omitted (e.g. miner recipes). An empty array is fine.
if (mt.contains("inputs")) if (mt.contains("inputs"))
{ {
@@ -403,7 +412,7 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path)
ShipDef def; ShipDef def;
def.id = requireString(mt["id"], file, elemPath + ".id"); def.id = requireString(mt["id"], file, elemPath + ".id");
def.availableFromStart = requireBool(mt["available_from_start"], file, elemPath + ".available_from_start"); def.unlockAtStationLevel = static_cast<int>(requireInt(mt["unlock_at_station_level"], file, elemPath + ".unlock_at_station_level"));
def.layout = requireStringArray(mt["layout"], file, elemPath + ".layout"); def.layout = requireStringArray(mt["layout"], file, elemPath + ".layout");
// Schematic // Schematic
@@ -420,14 +429,6 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path)
bpMt["production_time_seconds"], file, bpPath + ".production_time_seconds"); bpMt["production_time_seconds"], file, bpPath + ".production_time_seconds");
} }
// Threat
{
const std::string tPath = elemPath + ".threat";
const toml::table& tTable = requireTable(mt["threat"], file, tPath);
toml::table& tMt = const_cast<toml::table&>(tTable);
def.threat.costFormula = requireFormula(tMt["cost_formula"], file, tPath + ".cost_formula");
}
// Health // Health
{ {
const std::string hPath = elemPath + ".health"; const std::string hPath = elemPath + ".health";
@@ -530,17 +531,19 @@ struct StatEntry
}; };
static const StatEntry kKnownStats[] = { static const StatEntry kKnownStats[] = {
{"health", "hp", ""}, {"health", "hp", ""},
{"movement", "speed", "_mps"}, {"movement", "speed", "_mps"},
{"sensor", "sensor_range", "_m"}, {"movement", "main_acceleration", "_mpss"},
{"weapon", "damage", ""}, {"movement", "maneuvering_acceleration", "_mpss"},
{"weapon", "attack_range", "_m"}, {"sensor", "sensor_range", "_m"},
{"weapon", "attack_rate", "_hz"}, {"weapon", "damage", ""},
{"salvage", "collection_range", "_m"}, {"weapon", "attack_range", "_m"},
{"salvage", "cargo_capacity", ""}, {"weapon", "attack_rate", "_hz"},
{"salvage", "collection_rate", "_hz"}, {"salvage", "collection_range", "_m"},
{"repair", "repair_rate", "_hz"}, {"salvage", "cargo_capacity", ""},
{"repair", "repair_range", "_m"}, {"salvage", "collection_rate", "_hz"},
{"repair", "repair_rate", "_hz"},
{"repair", "repair_range", "_m"},
}; };
ModulesConfig ConfigLoader::loadModules(const std::string& path) ModulesConfig ConfigLoader::loadModules(const std::string& path)
@@ -568,13 +571,14 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
toml::table& mt = const_cast<toml::table&>(*st); toml::table& mt = const_cast<toml::table&>(*st);
ModuleDef def; ModuleDef def;
def.id = requireString(mt["id"], file, elemPath + ".id"); def.id = requireString(mt["id"], file, elemPath + ".id");
def.surfaceMask = requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask"); def.unlockAtStationLevel = static_cast<int>(
mt["unlock_at_station_level"].value_or<int64_t>(-1));
def.surfaceMask = requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask");
def.playerProductionLevel = static_cast<int>(requireInt( def.playerProductionLevel = static_cast<int>(requireInt(
mt["player_production_level"], file, elemPath + ".player_production_level")); mt["player_production_level"], file, elemPath + ".player_production_level"));
def.productionTimeSeconds = requireDouble( def.productionTimeSeconds = requireDouble(
mt["production_time_seconds"], file, elemPath + ".production_time_seconds"); mt["production_time_seconds"], file, elemPath + ".production_time_seconds");
def.threatCost = requireDouble(mt["threat_cost"], file, elemPath + ".threat_cost");
def.fillColor = requireString(mt["fill_color"], file, elemPath + ".fill_color"); def.fillColor = requireString(mt["fill_color"], file, elemPath + ".fill_color");
def.glyph = requireString(mt["glyph"], file, elemPath + ".glyph"); def.glyph = requireString(mt["glyph"], file, elemPath + ".glyph");
@@ -596,7 +600,7 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
toml::table& catMt = const_cast<toml::table&>(catTable); toml::table& catMt = const_cast<toml::table&>(catTable);
const std::string addedKey = std::string("added_") + se.stat + se.addedKeySuffix + "_formula"; const std::string addedKey = std::string("added_") + se.stat + se.addedKeySuffix + "_formula";
const std::string multipliedKey = std::string("multiplied_") + se.stat + "_formula"; const std::string multipliedKey = std::string("multiplied_") + se.stat + se.addedKeySuffix + "_formula";
if (catMt.contains(addedKey)) if (catMt.contains(addedKey))
{ {
@@ -691,5 +695,6 @@ GameConfig ConfigLoader::loadFromDirectory(const std::string& configDir)
cfg.ships = loadShips(configDir + "/ships.toml"); cfg.ships = loadShips(configDir + "/ships.toml");
cfg.stations = loadStations(configDir + "/stations.toml"); cfg.stations = loadStations(configDir + "/stations.toml");
cfg.modules = loadModules(configDir + "/modules.toml"); cfg.modules = loadModules(configDir + "/modules.toml");
cfg.threatCosts = computeThreatCostTable(cfg);
return cfg; return cfg;
} }

View File

@@ -6,6 +6,7 @@
#include "ShipsConfig.h" #include "ShipsConfig.h"
#include "StationsConfig.h" #include "StationsConfig.h"
#include "ModulesConfig.h" #include "ModulesConfig.h"
#include "ThreatCostCalculator.h"
// Aggregate of all simulation config files. Loaded at startup and reloaded // Aggregate of all simulation config files. Loaded at startup and reloaded
// from disk on each game restart (REQ-CFG-RELOAD). See architecture.md "Config Loading". // from disk on each game restart (REQ-CFG-RELOAD). See architecture.md "Config Loading".
@@ -17,4 +18,5 @@ struct GameConfig
ShipsConfig ships; ShipsConfig ships;
StationsConfig stations; StationsConfig stations;
ModulesConfig modules; ModulesConfig modules;
ThreatCostTable threatCosts;
}; };

View File

@@ -40,11 +40,11 @@ struct ModuleRepairCapability
struct ModuleDef struct ModuleDef
{ {
std::string id; std::string id;
int unlockAtStationLevel;
std::vector<std::string> surfaceMask; std::vector<std::string> surfaceMask;
std::vector<RecipeIngredient> materials; std::vector<RecipeIngredient> materials;
int playerProductionLevel; int playerProductionLevel;
double productionTimeSeconds; double productionTimeSeconds;
double threatCost;
std::string fillColor; std::string fillColor;
std::string glyph; std::string glyph;
std::vector<ModuleStatModifier> statModifiers; std::vector<ModuleStatModifier> statModifiers;

View File

@@ -32,6 +32,10 @@ struct RecipeDef
std::vector<RecipeIngredient> inputs; std::vector<RecipeIngredient> inputs;
std::vector<RecipeOutput> outputs; std::vector<RecipeOutput> outputs;
double durationSeconds; double durationSeconds;
// Assembler only. nullopt = implicit-only locking. -1 = explicitly unlocked
// 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;
}; };
struct RecipesConfig struct RecipesConfig

View File

@@ -16,13 +16,6 @@ struct ShipSchematic
double productionTimeSeconds; double productionTimeSeconds;
}; };
// Wave scheduling cost (REQ-WAV-THREAT-COST). Ships with cost_formula that
// always evaluates to 0 are ineligible as wave picks.
struct ShipThreat
{
Formula costFormula;
};
struct ShipHealth struct ShipHealth
{ {
Formula hpFormula; // REQ-SHP-STATS Formula hpFormula; // REQ-SHP-STATS
@@ -51,11 +44,10 @@ struct ShipLoot
struct ShipDef struct ShipDef
{ {
std::string id; std::string id;
bool availableFromStart; int unlockAtStationLevel;
std::vector<std::string> layout; std::vector<std::string> layout;
ShipSchematic schematic; ShipSchematic schematic;
ShipThreat threat;
ShipHealth health; ShipHealth health;
ShipMovement movement; ShipMovement movement;
ShipSensor sensor; ShipSensor sensor;

View File

@@ -6,11 +6,11 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/EntityAdmin.h ${CMAKE_CURRENT_SOURCE_DIR}/EntityAdmin.h
${CMAKE_CURRENT_SOURCE_DIR}/Blueprint.h ${CMAKE_CURRENT_SOURCE_DIR}/Blueprint.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingId.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingId.h
${CMAKE_CURRENT_SOURCE_DIR}/FireEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/ItemType.h ${CMAKE_CURRENT_SOURCE_DIR}/ItemType.h
${CMAKE_CURRENT_SOURCE_DIR}/Item.h ${CMAKE_CURRENT_SOURCE_DIR}/Item.h
${CMAKE_CURRENT_SOURCE_DIR}/Port.h ${CMAKE_CURRENT_SOURCE_DIR}/Port.h
${CMAKE_CURRENT_SOURCE_DIR}/SchematicDropEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceOption.h
${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.h
PARENT_SCOPE PARENT_SCOPE
) )
@@ -18,6 +18,7 @@ SET(SRCS
${SRCS} ${SRCS}
${CMAKE_CURRENT_SOURCE_DIR}/BuildingType.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingType.cpp
${CMAKE_CURRENT_SOURCE_DIR}/EntityAdmin.cpp ${CMAKE_CURRENT_SOURCE_DIR}/EntityAdmin.cpp
${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.cpp
PARENT_SCOPE PARENT_SCOPE
) )

View File

@@ -0,0 +1,27 @@
#include "DisplayName.h"
#include <cctype>
std::string toDisplayName(const std::string& id)
{
std::string result;
bool nextUpper = true;
for (char c : id)
{
if (c == '_')
{
result += ' ';
nextUpper = true;
}
else if (nextUpper)
{
result += static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
nextUpper = false;
}
else
{
result += c;
}
}
return result;
}

View File

@@ -0,0 +1,5 @@
#pragma once
#include <string>
std::string toDisplayName(const std::string& id);

View File

@@ -1,15 +0,0 @@
#pragma once
#include "Tick.h"
#include "entt/entity/entity.hpp"
// Transient record emitted each time a weapon fires (REQ-SHP-FIRING,
// REQ-SHP-FIRING-BEAM). Buffered in a sim-owned queue and drained by the
// renderer each frame to draw the 0.3-second laser beam.
struct FireEvent
{
entt::entity shooter;
entt::entity target;
Tick emittedAt;
};

View File

@@ -0,0 +1,28 @@
#pragma once
#include <string>
#include <vector>
enum class SchematicType
{
Ship,
Module,
Recipe
};
// One option presented to the player in the schematic choice dialog
// (REQ-DEF-SCHEMATIC-DROP). Built by the simulation when enemy stations are
// destroyed; the UI reads these to populate the dialog.
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;
};

View File

@@ -1,14 +0,0 @@
#pragma once
#include <string>
// Emitted in tick step 9 (Deaths & loot) when a destroyed enemy-defence-station
// set awards a schematic (REQ-DEF-SCHEMATIC-DROP). The UI renders a toast
// (REQ-UI-SCHEMATIC-TOAST); wasNewUnlock chooses between the "unlocked" and
// "level -> N" wording.
struct SchematicDropEvent
{
std::string schematicId; // matches ShipDef::id in the config.
int newLevel;
bool wasNewUnlock;
};

View File

@@ -21,7 +21,7 @@ CombatSystem::CombatSystem(const GameConfig& config)
void CombatSystem::tick(Tick currentTick, void CombatSystem::tick(Tick currentTick,
EntityAdmin& admin, EntityAdmin& admin,
BuildingSystem& /*buildings*/, BuildingSystem& /*buildings*/,
std::vector<FireEvent>& outFireEvents) std::vector<WeaponFiredEvent>& outWeaponFiredEvents)
{ {
TRACE(); TRACE();
// All weapons (ships and stations) are child entities linked via ModuleOwnerComponent. // All weapons (ships and stations) are child entities linked via ModuleOwnerComponent.
@@ -35,7 +35,7 @@ void CombatSystem::tick(Tick currentTick,
} }
const PositionComponent& pos = admin.get<PositionComponent>(owner.owner); const PositionComponent& pos = admin.get<PositionComponent>(owner.owner);
const FactionComponent& faction = admin.get<FactionComponent>(owner.owner); const FactionComponent& faction = admin.get<FactionComponent>(owner.owner);
resolveWeapon(owner.owner, weapon, pos, faction, currentTick, admin, outFireEvents); resolveWeapon(owner.owner, weapon, pos, faction, currentTick, admin, outWeaponFiredEvents);
}); });
} }
@@ -46,7 +46,7 @@ void CombatSystem::resolveWeapon(
const FactionComponent& ownFaction, const FactionComponent& ownFaction,
Tick currentTick, Tick currentTick,
EntityAdmin& admin, EntityAdmin& admin,
std::vector<FireEvent>& out) std::vector<WeaponFiredEvent>& out)
{ {
if (weapon.cooldownTicks > 0.0f) if (weapon.cooldownTicks > 0.0f)
{ {
@@ -115,7 +115,7 @@ void CombatSystem::resolveWeapon(
m_pendingDamage.push_back({targetEntity, weapon.damage, m_pendingDamage.push_back({targetEntity, weapon.damage,
currentTick + kWeaponImpactDelayTicks}); currentTick + kWeaponImpactDelayTicks});
FireEvent evt; WeaponFiredEvent evt;
evt.shooter = shipEntity; evt.shooter = shipEntity;
evt.target = targetEntity; evt.target = targetEntity;
evt.emittedAt = currentTick; evt.emittedAt = currentTick;

View File

@@ -7,7 +7,7 @@
#include "Building.h" #include "Building.h"
#include "FactionComponent.h" #include "FactionComponent.h"
#include "FireEvent.h" #include "WeaponFiredEvent.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "PositionComponent.h" #include "PositionComponent.h"
#include "Tick.h" #include "Tick.h"
@@ -26,7 +26,7 @@ public:
void tick(Tick currentTick, void tick(Tick currentTick,
EntityAdmin& admin, EntityAdmin& admin,
BuildingSystem& buildings, BuildingSystem& buildings,
std::vector<FireEvent>& outFireEvents); std::vector<WeaponFiredEvent>& outWeaponFiredEvents);
void applyPendingDamage(Tick currentTick, EntityAdmin& admin); void applyPendingDamage(Tick currentTick, EntityAdmin& admin);
@@ -47,7 +47,7 @@ private:
const FactionComponent& ownFaction, const FactionComponent& ownFaction,
Tick currentTick, Tick currentTick,
EntityAdmin& admin, EntityAdmin& admin,
std::vector<FireEvent>& out); std::vector<WeaponFiredEvent>& out);
const GameConfig& m_config; const GameConfig& m_config;
}; };

View File

@@ -2,6 +2,7 @@
#include <cassert> #include <cassert>
#include <map> #include <map>
#include <stdexcept>
#include <utility> #include <utility>
#include <vector> #include <vector>
@@ -55,7 +56,8 @@ const ModuleDef* ShipSystem::findModuleDef(const std::string& id) const
entt::entity ShipSystem::spawn(const std::string& schematicId, int level, entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
QVector2D position, bool isEnemy, QVector2D position, bool isEnemy,
const std::optional<ShipLayoutConfig>& layout) const std::optional<ShipLayoutConfig>& layout,
const std::map<std::string, int>& moduleLevelOverrides)
{ {
const ShipDef* def = findShipDef(schematicId); const ShipDef* def = findShipDef(schematicId);
assert(def != nullptr); assert(def != nullptr);
@@ -102,9 +104,11 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
for (const PlacedModule& pm : modules) for (const PlacedModule& pm : modules)
{ {
const ModuleDef* modDef = findModuleDef(pm.moduleId); const ModuleDef* modDef = findModuleDef(pm.moduleId);
if (!modDef) { continue; } if (!modDef) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); }
const double mx = static_cast<double>(modDef->playerProductionLevel); const auto overIt = moduleLevelOverrides.find(pm.moduleId);
const double mx = static_cast<double>(
overIt != moduleLevelOverrides.end() ? overIt->second : modDef->playerProductionLevel);
if (modDef->weaponCapability) if (modDef->weaponCapability)
{ {
@@ -173,9 +177,11 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
for (const PlacedModule& pm : modules) for (const PlacedModule& pm : modules)
{ {
const ModuleDef* modDef = findModuleDef(pm.moduleId); const ModuleDef* modDef = findModuleDef(pm.moduleId);
if (!modDef) { continue; } if (!modDef) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); }
const double mx = static_cast<double>(modDef->playerProductionLevel); const auto overIt2 = moduleLevelOverrides.find(pm.moduleId);
const double mx = static_cast<double>(
overIt2 != moduleLevelOverrides.end() ? overIt2->second : modDef->playerProductionLevel);
for (const ModuleStatModifier& sm : modDef->statModifiers) for (const ModuleStatModifier& sm : modDef->statModifiers)
{ {
@@ -210,7 +216,8 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
} }
// Range stat additive modifiers are expressed in metres in config; convert to tiles. // Range stat additive modifiers are expressed in metres in config; convert to tiles.
const double tileSizeD = static_cast<double>(m_config.world.tileSize_m); const double tileSizeD = static_cast<double>(m_config.world.tileSize_m);
const double tickRateD = static_cast<double>(kTickRateHz);
const char* const kRangeStats[] = { const char* const kRangeStats[] = {
"sensor_range", "attack_range", "collection_range", "repair_range" "sensor_range", "attack_range", "collection_range", "repair_range"
}; };
@@ -229,6 +236,23 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
} }
} }
// Acceleration additive modifiers are in m/s² in config; convert to tiles/tick
// (same as the base spawn conversion: / tileSize / tickRate).
const char* const kAccelerationStats[] = {
"main_acceleration", "maneuvering_acceleration"
};
for (const char* stat : kAccelerationStats)
{
for (std::map<std::string, std::pair<double, double>>* mods : allModMaps)
{
std::map<std::string, std::pair<double, double>>::iterator it = mods->find(stat);
if (it != mods->end())
{
it->second.second /= tileSizeD * tickRateD;
}
}
}
// Helper: apply a modifier map to a float stat. // Helper: apply a modifier map to a float stat.
auto applyMod = [](float& stat, const std::string& name, auto applyMod = [](float& stat, const std::string& name,
const std::map<std::string, std::pair<double, double>>& mods) const std::map<std::string, std::pair<double, double>>& mods)

View File

@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <map>
#include <optional> #include <optional>
#include <string> #include <string>
@@ -19,7 +20,8 @@ public:
entt::entity spawn(const std::string& schematicId, int level, QVector2D position, entt::entity spawn(const std::string& schematicId, int level, QVector2D position,
bool isEnemy = false, bool isEnemy = false,
const std::optional<ShipLayoutConfig>& layout = std::nullopt); const std::optional<ShipLayoutConfig>& layout = std::nullopt,
const std::map<std::string, int>& moduleLevelOverrides = {});
void despawn(entt::entity entity); void despawn(entt::entity entity);
// Reset all movement intents to priority 0 before behavior systems run. // Reset all movement intents to priority 0 before behavior systems run.

View File

@@ -0,0 +1,10 @@
#pragma once
#include "Event.h"
class ArenaInspectRequestedEvent : public Event
{
public:
explicit ArenaInspectRequestedEvent(int arenaIndex) : arenaIndex(arenaIndex) {}
const int arenaIndex;
};

View File

@@ -0,0 +1,10 @@
#pragma once
#include "Event.h"
class ArenaStartRequestedEvent : public Event
{
public:
explicit ArenaStartRequestedEvent(int arenaIndex) : arenaIndex(arenaIndex) {}
const int arenaIndex;
};

View File

@@ -0,0 +1,7 @@
#pragma once
#include "Event.h"
class BlueprintModeExitedEvent : public Event
{
};

View File

@@ -0,0 +1,12 @@
#pragma once
#include "Blueprint.h"
#include "Event.h"
class BlueprintPlacementRequestedEvent : public Event
{
public:
explicit BlueprintPlacementRequestedEvent(Blueprint blueprint)
: blueprint(std::move(blueprint)) {}
const Blueprint blueprint;
};

View File

@@ -0,0 +1,7 @@
#pragma once
#include "Event.h"
class BuilderModeExitedEvent : public Event
{
};

View File

@@ -0,0 +1,11 @@
#pragma once
#include "BuildingType.h"
#include "Event.h"
class BuildingTypeSelectedEvent : public Event
{
public:
explicit BuildingTypeSelectedEvent(BuildingType type) : type(type) {}
const BuildingType type;
};

View File

@@ -6,6 +6,25 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/EntitySelectedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/EntitySelectedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/GameSpeedChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/GameSpeedChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BossWaveUpdatedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BossWaveUpdatedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoicesAvailableEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/GameOverEvent.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}/ExitBuilderModeRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/DemolishModeToggleRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPlacementRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/ExitBlueprintModeRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/SpeedChangeRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/LayoutDialogRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/InspectWindowClosedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/ArenaStartRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/ArenaInspectRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/WeaponFiredEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h
PARENT_SCOPE PARENT_SCOPE
) )

View File

@@ -0,0 +1,14 @@
#pragma once
#include "Event.h"
class DebugDrawToggledEvent : public Event
{
public:
explicit DebugDrawToggledEvent(bool active)
: active(active)
{
}
const bool active;
};

View File

@@ -0,0 +1,10 @@
#pragma once
#include "Event.h"
class DemolishModeChangedEvent : public Event
{
public:
explicit DemolishModeChangedEvent(bool active) : active(active) {}
const bool active;
};

View File

@@ -0,0 +1,7 @@
#pragma once
#include "Event.h"
class DemolishModeToggleRequestedEvent : public Event
{
};

View File

@@ -0,0 +1,7 @@
#pragma once
#include "Event.h"
class EscapeMenuRequestedEvent : public Event
{
};

View File

@@ -0,0 +1,7 @@
#pragma once
#include "Event.h"
class ExitBlueprintModeRequestedEvent : public Event
{
};

View File

@@ -0,0 +1,7 @@
#pragma once
#include "Event.h"
class ExitBuilderModeRequestedEvent : public Event
{
};

View File

@@ -0,0 +1,7 @@
#pragma once
#include "Event.h"
class GameOverEvent : public Event
{
};

View File

@@ -0,0 +1,7 @@
#pragma once
#include "Event.h"
class InspectWindowClosedEvent : public Event
{
};

View File

@@ -0,0 +1,12 @@
#pragma once
#include "BuildingId.h"
#include "Event.h"
class LayoutDialogRequestedEvent : public Event
{
public:
explicit LayoutDialogRequestedEvent(BuildingId shipyardId)
: shipyardId(shipyardId) {}
const BuildingId shipyardId;
};

View File

@@ -0,0 +1,14 @@
#pragma once
#include <vector>
#include "Event.h"
#include "SchematicChoiceOption.h"
class SchematicChoicesAvailableEvent : public Event
{
public:
explicit SchematicChoicesAvailableEvent(std::vector<SchematicChoiceOption> choices)
: choices(std::move(choices)) {}
const std::vector<SchematicChoiceOption> choices;
};

View File

@@ -0,0 +1,14 @@
#pragma once
#include <vector>
#include "BuildingId.h"
#include "Event.h"
class SelectionChangedEvent : public Event
{
public:
explicit SelectionChangedEvent(std::vector<BuildingId> ids)
: ids(std::move(ids)) {}
const std::vector<BuildingId> ids;
};

View File

@@ -0,0 +1,10 @@
#pragma once
#include "Event.h"
class SpeedChangeRequestedEvent : public Event
{
public:
explicit SpeedChangeRequestedEvent(double multiplier) : multiplier(multiplier) {}
const double multiplier;
};

View File

@@ -0,0 +1,17 @@
#pragma once
#include "Event.h"
#include "Tick.h"
#include "entt/entity/entity.hpp"
struct WeaponFiredEvent : public Event
{
WeaponFiredEvent() = default;
WeaponFiredEvent(entt::entity shooter, entt::entity target, Tick emittedAt)
: shooter(shooter), target(target), emittedAt(emittedAt) {}
entt::entity shooter = entt::null;
entt::entity target = entt::null;
Tick emittedAt = 0;
};

View File

@@ -756,13 +756,13 @@ void BeltSystem::routeSplitterItems()
else if (preferA && !st.frontB) else if (preferA && !st.frontB)
{ {
// Preferred (A) is full — fall back to B; nextOutputIsA stays. // Preferred (A) is full — fall back to B; nextOutputIsA stays.
st.frontB = BeltItemSlot{item, 0.0}; st.frontB = BeltItemSlot{item, 0.75};
routed = true; routed = true;
} }
else if (!preferA && !st.frontA) else if (!preferA && !st.frontA)
{ {
// Preferred (B) is full — fall back to A; nextOutputIsA stays. // Preferred (B) is full — fall back to A; nextOutputIsA stays.
st.frontA = BeltItemSlot{item, 0.0}; st.frontA = BeltItemSlot{item, 0.75};
routed = true; routed = true;
} }
// else both fronts occupied — back stays. // else both fronts occupied — back stays.
@@ -963,3 +963,4 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles,
} }
} }

View File

@@ -14,12 +14,14 @@ BuildingSystem::BuildingSystem(const GameConfig& config,
std::function<void(int)> addBuildingBlocks, std::function<void(int)> addBuildingBlocks,
std::function<void(const std::string&, QVector2D, std::function<void(const std::string&, QVector2D,
const std::optional<ShipLayoutConfig>&)> spawnShip, const std::optional<ShipLayoutConfig>&)> spawnShip,
std::function<bool(const std::string&)> isItemUnlocked,
std::mt19937& rng) std::mt19937& rng)
: m_config(config) : m_config(config)
, m_belts(belts) , m_belts(belts)
, m_allocateBuildingId(std::move(allocateBuildingId)) , m_allocateBuildingId(std::move(allocateBuildingId))
, m_addBuildingBlocks(std::move(addBuildingBlocks)) , m_addBuildingBlocks(std::move(addBuildingBlocks))
, m_spawnShip(std::move(spawnShip)) , m_spawnShip(std::move(spawnShip))
, m_isItemUnlocked(std::move(isItemUnlocked))
, m_rng(rng) , m_rng(rng)
{ {
} }
@@ -203,17 +205,19 @@ std::vector<Port> BuildingSystem::computeInputPorts(const Building& b) const
std::vector<Item> BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe) std::vector<Item> BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe)
{ {
std::vector<const RecipeOutput*> eligible;
std::vector<double> weights; std::vector<double> weights;
weights.reserve(recipe.outputs.size());
for (const RecipeOutput& out : recipe.outputs) for (const RecipeOutput& out : recipe.outputs)
{ {
if (!m_isItemUnlocked(out.item)) { continue; }
eligible.push_back(&out);
weights.push_back(out.probability.value_or(1.0)); weights.push_back(out.probability.value_or(1.0));
} }
std::discrete_distribution<int> dist(weights.begin(), weights.end()); if (eligible.empty()) { return {}; }
const int idx = dist(m_rng);
const RecipeOutput& chosen = recipe.outputs[static_cast<std::size_t>(idx)]; std::discrete_distribution<int> dist(weights.begin(), weights.end());
const RecipeOutput& chosen = *eligible[static_cast<std::size_t>(dist(m_rng))];
std::vector<Item> result; std::vector<Item> result;
Item item; Item item;
item.type.id = chosen.item; item.type.id = chosen.item;
@@ -660,6 +664,7 @@ void BuildingSystem::tickProduction(Tick currentTick)
if (building.type == BuildingType::ReprocessingPlant) if (building.type == BuildingType::ReprocessingPlant)
{ {
chosen = rollReprocessingOutput(*recipe); chosen = rollReprocessingOutput(*recipe);
if (chosen.empty()) { continue; }
} }
else else
{ {
@@ -862,6 +867,44 @@ std::vector<ConstructionSite> BuildingSystem::allSites() const
m_constructionQueue.end()); m_constructionQueue.end());
} }
namespace
{
bool isProductionBuildingType(BuildingType type)
{
switch (type)
{
case BuildingType::Miner:
case BuildingType::Smelter:
case BuildingType::Assembler:
case BuildingType::ReprocessingPlant:
case BuildingType::Shipyard:
return true;
default:
return false;
}
}
} // namespace
int BuildingSystem::productionBuildingCount() const
{
int count = 0;
for (const Building& b : m_buildings)
{
if (isProductionBuildingType(b.type)) { ++count; }
}
return count;
}
int BuildingSystem::activeProductionBuildingCount() const
{
int count = 0;
for (const Building& b : m_buildings)
{
if (isProductionBuildingType(b.type) && b.production.has_value()) { ++count; }
}
return count;
}
std::vector<BuildingSystem::BeltTileInfo> BuildingSystem::allBeltTiles() const std::vector<BuildingSystem::BeltTileInfo> BuildingSystem::allBeltTiles() const
{ {
std::vector<BeltTileInfo> result; std::vector<BeltTileInfo> result;

View File

@@ -36,6 +36,7 @@ public:
std::function<void(int)> addBuildingBlocks, std::function<void(int)> addBuildingBlocks,
std::function<void(const std::string&, QVector2D, std::function<void(const std::string&, QVector2D,
const std::optional<ShipLayoutConfig>&)> spawnShip, const std::optional<ShipLayoutConfig>&)> spawnShip,
std::function<bool(const std::string&)> isItemUnlocked,
std::mt19937& rng); std::mt19937& rng);
// -- Placement / demolish ------------------------------------------------ // -- Placement / demolish ------------------------------------------------
@@ -78,6 +79,14 @@ public:
const ConstructionSite* findSite(BuildingId id) const; const ConstructionSite* findSite(BuildingId id) const;
std::vector<Building> allBuildings() const; std::vector<Building> allBuildings() const;
std::vector<ConstructionSite> allSites() const; std::vector<ConstructionSite> allSites() const;
// REQ-UI-DEBUG-OVERLAY "Max Factory Production": count of completed
// (operational) Miner/Smelter/Assembler/ReprocessingPlant/Shipyard buildings.
int productionBuildingCount() const;
// REQ-UI-DEBUG-OVERLAY "Current Factory Production": subset of the above
// that currently has an active production cycle.
int activeProductionBuildingCount() const;
std::vector<BeltTileInfo> allBeltTiles() const; std::vector<BeltTileInfo> allBeltTiles() const;
bool isTileOccupied(QPoint tile) const; bool isTileOccupied(QPoint tile) const;
@@ -134,6 +143,7 @@ private:
std::function<void(int)> m_addBuildingBlocks; std::function<void(int)> m_addBuildingBlocks;
std::function<void(const std::string&, QVector2D, std::function<void(const std::string&, QVector2D,
const std::optional<ShipLayoutConfig>&)> m_spawnShip; const std::optional<ShipLayoutConfig>&)> m_spawnShip;
std::function<bool(const std::string&)> m_isItemUnlocked;
std::mt19937& m_rng; std::mt19937& m_rng;
std::vector<Building> m_buildings; std::vector<Building> m_buildings;

View File

@@ -9,6 +9,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprint.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprint.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.h
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.h
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.h
PARENT_SCOPE PARENT_SCOPE
) )
@@ -21,6 +22,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp ${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.cpp
PARENT_SCOPE PARENT_SCOPE
) )

View File

@@ -1,6 +1,7 @@
#include "ShipStatsCalculator.h" #include "ShipStatsCalculator.h"
#include <map> #include <map>
#include <stdexcept>
#include <utility> #include <utility>
#include "DynamicBodyComponent.h" #include "DynamicBodyComponent.h"
@@ -16,7 +17,8 @@
ShipStats calculateShipStats(const GameConfig& config, ShipStats calculateShipStats(const GameConfig& config,
const std::string& shipId, const std::string& shipId,
int level, int level,
const std::vector<PlacedModule>& modules) const std::vector<PlacedModule>& modules,
const std::map<std::string, int>& moduleLevelOverrides)
{ {
ShipStats result{}; ShipStats result{};
@@ -67,9 +69,11 @@ ShipStats calculateShipStats(const GameConfig& config,
for (const PlacedModule& pm : modules) for (const PlacedModule& pm : modules)
{ {
const ModuleDef* def = findModuleDef(pm.moduleId); const ModuleDef* def = findModuleDef(pm.moduleId);
if (!def) { continue; } if (!def) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); }
const double mx = static_cast<double>(def->playerProductionLevel); const auto overIt = moduleLevelOverrides.find(pm.moduleId);
const double mx = static_cast<double>(
overIt != moduleLevelOverrides.end() ? overIt->second : def->playerProductionLevel);
if (def->weaponCapability) if (def->weaponCapability)
{ {
@@ -105,9 +109,11 @@ ShipStats calculateShipStats(const GameConfig& config,
for (const PlacedModule& pm : modules) for (const PlacedModule& pm : modules)
{ {
const ModuleDef* def = findModuleDef(pm.moduleId); const ModuleDef* def = findModuleDef(pm.moduleId);
if (!def) { continue; } if (!def) { throw std::runtime_error("unknown module id '" + pm.moduleId + "'"); }
const double mx = static_cast<double>(def->playerProductionLevel); const auto overIt = moduleLevelOverrides.find(pm.moduleId);
const double mx = static_cast<double>(
overIt != moduleLevelOverrides.end() ? overIt->second : def->playerProductionLevel);
for (const ModuleStatModifier& sm : def->statModifiers) for (const ModuleStatModifier& sm : def->statModifiers)
{ {
@@ -157,6 +163,22 @@ ShipStats calculateShipStats(const GameConfig& config,
} }
} }
// Acceleration additive modifiers are in m/s² in config; convert to tiles/s².
const char* const kAccelerationStats[] = {
"main_acceleration", "maneuvering_acceleration"
};
for (const char* stat : kAccelerationStats)
{
for (std::map<std::string, std::pair<double, double>>* mods : allModMaps)
{
std::map<std::string, std::pair<double, double>>::iterator it = mods->find(stat);
if (it != mods->end())
{
it->second.second /= tileSize;
}
}
}
auto applyMod = [](float& stat, const std::string& name, auto applyMod = [](float& stat, const std::string& name,
const std::map<std::string, std::pair<double, double>>& mods) const std::map<std::string, std::pair<double, double>>& mods)
{ {

View File

@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <map>
#include <optional> #include <optional>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -50,7 +51,8 @@ struct ShipStats
ShipStats calculateShipStats(const GameConfig& config, ShipStats calculateShipStats(const GameConfig& config,
const std::string& shipId, const std::string& shipId,
int level, int level,
const std::vector<PlacedModule>& modules); const std::vector<PlacedModule>& modules,
const std::map<std::string, int>& moduleLevelOverrides = {});
ShipStats buildShipStatsFromEntity(const EntityAdmin& admin, entt::entity shipEntity); ShipStats buildShipStatsFromEntity(const EntityAdmin& admin, entt::entity shipEntity);

View File

@@ -1,8 +1,10 @@
#include "Simulation.h" #include "Simulation.h"
#include <algorithm>
#include <cassert> #include <cassert>
#include "AiSystem.h" #include "AiSystem.h"
#include "DisplayName.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "CombatSystem.h" #include "CombatSystem.h"
#include "DynamicBodySystem.h" #include "DynamicBodySystem.h"
@@ -51,8 +53,15 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
{ {
return; return;
} }
m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout); 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);
}, },
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
m_rng); m_rng);
m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin); m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
m_aiSystem = std::make_unique<AiSystem>(); m_aiSystem = std::make_unique<AiSystem>();
@@ -62,15 +71,36 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng); m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng);
m_combatSystem = std::make_unique<CombatSystem>(m_config); m_combatSystem = std::make_unique<CombatSystem>(m_config);
// Initialize schematic unlock state. // Initialize ship schematic unlock state.
for (const ShipDef& def : m_config.ships.ships) for (const ShipDef& def : m_config.ships.ships)
{ {
SchematicState state; SchematicState state;
state.unlocked = def.availableFromStart; state.unlocked = (def.unlockAtStationLevel == -1);
state.level = def.availableFromStart ? def.schematic.playerProductionLevel : 0; state.level = (def.unlockAtStationLevel == -1) ? def.schematic.playerProductionLevel : 0;
m_schematicLevels[def.id] = state; m_schematicLevels[def.id] = state;
} }
// Initialize module schematic unlock state.
for (const ModuleDef& def : m_config.modules.modules)
{
SchematicState state;
state.unlocked = (def.unlockAtStationLevel == -1);
state.level = (def.unlockAtStationLevel == -1) ? def.playerProductionLevel : 0;
m_moduleSchematicLevels[def.id] = state;
}
// Initialize assembler recipe schematic unlock state.
for (const RecipeDef& def : m_config.recipes.recipes)
{
if (def.building == BuildingType::Assembler
&& def.unlockAtStationLevel.has_value()
&& def.unlockAtStationLevel.value() == -1)
{
m_unlockedRecipeSchematicIds.insert(def.id);
}
}
recomputeUnlocked();
placeInitialStructures(); placeInitialStructures();
registerForEvents(); registerForEvents();
} }
@@ -106,8 +136,8 @@ void Simulation::reset(unsigned int seed)
m_playerStation2Entity = entt::null; m_playerStation2Entity = entt::null;
m_currentEnemyStationEntities[0] = entt::null; m_currentEnemyStationEntities[0] = entt::null;
m_currentEnemyStationEntities[1] = entt::null; m_currentEnemyStationEntities[1] = entt::null;
m_fireEvents.clear(); m_weaponFiredEvents.clear();
m_schematicDropEvents.clear(); m_pendingSchematicChoices.clear();
m_admin.clear(); m_admin.clear();
m_beltSystem = BeltSystem(m_config.world.beltSpeed_tps); m_beltSystem = BeltSystem(m_config.world.beltSpeed_tps);
@@ -124,8 +154,15 @@ void Simulation::reset(unsigned int seed)
{ {
return; return;
} }
m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout); 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);
}, },
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
m_rng); m_rng);
m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin); m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
m_aiSystem = std::make_unique<AiSystem>(); m_aiSystem = std::make_unique<AiSystem>();
@@ -139,11 +176,32 @@ void Simulation::reset(unsigned int seed)
for (const ShipDef& def : m_config.ships.ships) for (const ShipDef& def : m_config.ships.ships)
{ {
SchematicState state; SchematicState state;
state.unlocked = def.availableFromStart; state.unlocked = (def.unlockAtStationLevel == -1);
state.level = def.availableFromStart ? def.schematic.playerProductionLevel : 0; state.level = (def.unlockAtStationLevel == -1) ? def.schematic.playerProductionLevel : 0;
m_schematicLevels[def.id] = state; m_schematicLevels[def.id] = state;
} }
m_moduleSchematicLevels.clear();
for (const ModuleDef& def : m_config.modules.modules)
{
SchematicState state;
state.unlocked = (def.unlockAtStationLevel == -1);
state.level = (def.unlockAtStationLevel == -1) ? def.playerProductionLevel : 0;
m_moduleSchematicLevels[def.id] = state;
}
m_unlockedRecipeSchematicIds.clear();
for (const RecipeDef& def : m_config.recipes.recipes)
{
if (def.building == BuildingType::Assembler
&& def.unlockAtStationLevel.has_value()
&& def.unlockAtStationLevel.value() == -1)
{
m_unlockedRecipeSchematicIds.insert(def.id);
}
}
recomputeUnlocked();
placeInitialStructures(); placeInitialStructures();
} }
@@ -188,7 +246,7 @@ void Simulation::tick()
// Step 8: combat resolution // Step 8: combat resolution
m_combatSystem->tick(m_currentTick, m_admin, m_combatSystem->tick(m_currentTick, m_admin,
*m_buildingSystem, m_fireEvents); *m_buildingSystem, m_weaponFiredEvents);
// Step 8b: deferred damage whose impact tick has arrived // Step 8b: deferred damage whose impact tick has arrived
m_combatSystem->applyPendingDamage(m_currentTick, m_admin); m_combatSystem->applyPendingDamage(m_currentTick, m_admin);
@@ -473,54 +531,314 @@ void Simulation::tickDeathsAndLoot()
if (es0Gone && es1Gone && if (es0Gone && es1Gone &&
m_currentEnemyStationEntities[0] != entt::null) m_currentEnemyStationEntities[0] != entt::null)
{ {
const int destroyedLevel = m_waveSystem->generation();
m_waveSystem->onEnemyStationsDestroyed(); m_waveSystem->onEnemyStationsDestroyed();
placeEnemyStationSet(m_waveSystem->generation()); placeEnemyStationSet(m_waveSystem->generation());
awardSchematicDrop(); generateSchematicChoices(destroyedLevel);
} }
} }
void Simulation::awardSchematicDrop() void Simulation::generateSchematicChoices(int destroyedStationLevel)
{ {
std::vector<std::string> ids; enum class DropType { Ship, Module, Recipe };
ids.reserve(m_config.ships.ships.size()); struct PoolEntry { std::string id; DropType type; };
std::vector<PoolEntry> pool;
for (const ShipDef& def : m_config.ships.ships) for (const ShipDef& def : m_config.ships.ships)
{ {
ids.push_back(def.id); if (def.unlockAtStationLevel == -1 || def.unlockAtStationLevel <= destroyedStationLevel)
{
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});
}
}
for (const RecipeDef& def : m_config.recipes.recipes)
{
if (def.building != BuildingType::Assembler) { continue; }
if (!def.unlockAtStationLevel.has_value()) { continue; }
const int level = def.unlockAtStationLevel.value();
if (level < 0 || level > destroyedStationLevel) { continue; }
if (m_unlockedRecipeSchematicIds.count(def.id) > 0) { continue; }
bool outputUnlocked = false;
for (const RecipeOutput& out : def.outputs)
{
if (m_unlockedItemIds.count(out.item) > 0) { outputUnlocked = true; break; }
}
if (!outputUnlocked) { continue; }
pool.push_back({def.id, DropType::Recipe});
} }
std::uniform_int_distribution<int> dist(0, static_cast<int>(ids.size()) - 1); if (pool.empty()) { return; }
const std::string chosen = ids[static_cast<std::size_t>(dist(m_rng))];
SchematicState& state = m_schematicLevels.at(chosen); const int numChoices = std::min(static_cast<int>(pool.size()), 3);
const bool wasNew = !state.unlocked; m_pendingSchematicChoices.clear();
state.unlocked = true;
state.level += 1;
SchematicDropEvent evt; const std::set<std::string> currentShipIds = getUnlockedShipSchematicIds();
evt.schematicId = chosen; const std::set<std::string> currentModuleIds = getUnlockedModuleSchematicIds();
evt.newLevel = state.level;
evt.wasNewUnlock = wasNew; for (int i = 0; i < numChoices; ++i)
m_schematicDropEvents.push_back(evt); {
std::uniform_int_distribution<int> dist(0, static_cast<int>(pool.size()) - 1 - i);
const int roll = dist(m_rng);
const std::size_t rollIdx = static_cast<std::size_t>(roll);
const std::size_t endIdx = pool.size() - 1 - static_cast<std::size_t>(i);
std::swap(pool[rollIdx], pool[endIdx]);
const PoolEntry& entry = pool[endIdx];
SchematicChoiceOption option;
option.schematicId = entry.id;
if (entry.type == DropType::Ship)
{
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;
}
}
}
// REQ-DEF-SCHEMATIC-DROP: preview recipes newly implicitly unlocked by this option.
std::set<std::string> hypotheticalShipIds = currentShipIds;
std::set<std::string> hypotheticalModuleIds = currentModuleIds;
std::set<std::string> hypotheticalRecipeSchematicIds = m_unlockedRecipeSchematicIds;
if (entry.type == DropType::Ship && option.isNewUnlock)
{
hypotheticalShipIds.insert(entry.id);
}
else if (entry.type == DropType::Module && option.isNewUnlock)
{
hypotheticalModuleIds.insert(entry.id);
}
else if (entry.type == DropType::Recipe)
{
hypotheticalRecipeSchematicIds.insert(entry.id);
}
const UnlockedSets hypothetical = computeUnlockedSets(
hypotheticalShipIds, hypotheticalModuleIds, hypotheticalRecipeSchematicIds);
option.newlyUnlockedItemNames = computeNewlyUnlockedItemNames(hypothetical);
m_pendingSchematicChoices.push_back(option);
}
}
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::Recipe)
{
m_unlockedRecipeSchematicIds.insert(chosen.schematicId);
}
else
{
SchematicState& state = (chosen.type == SchematicType::Module)
? m_moduleSchematicLevels.at(chosen.schematicId)
: m_schematicLevels.at(chosen.schematicId);
state.unlocked = true;
state.level += 1;
}
recomputeUnlocked();
m_pendingSchematicChoices.clear();
}
// ---------------------------------------------------------------------------
// Implicit unlock computation (REQ-LOCK-IMPLICIT)
// ---------------------------------------------------------------------------
void Simulation::recomputeUnlocked()
{
const UnlockedSets result = computeUnlockedSets(
getUnlockedShipSchematicIds(), getUnlockedModuleSchematicIds(), m_unlockedRecipeSchematicIds);
m_unlockedItemIds = result.itemIds;
m_unlockedRecipeIds = result.recipeIds;
}
std::set<std::string> Simulation::getUnlockedShipSchematicIds() const
{
std::set<std::string> ids;
for (const auto& [id, state] : m_schematicLevels)
{
if (state.unlocked) { ids.insert(id); }
}
return ids;
}
std::set<std::string> Simulation::getUnlockedModuleSchematicIds() const
{
std::set<std::string> ids;
for (const auto& [id, state] : m_moduleSchematicLevels)
{
if (state.unlocked) { ids.insert(id); }
}
return ids;
}
Simulation::UnlockedSets Simulation::computeUnlockedSets(
const std::set<std::string>& unlockedShipSchematicIds,
const std::set<std::string>& unlockedModuleSchematicIds,
const std::set<std::string>& unlockedRecipeSchematicIds) const
{
UnlockedSets result;
for (const ShipDef& def : m_config.ships.ships)
{
if (unlockedShipSchematicIds.count(def.id) == 0) { continue; }
for (const RecipeIngredient& mat : def.schematic.materials)
{
result.itemIds.insert(mat.item);
}
}
for (const ModuleDef& def : m_config.modules.modules)
{
if (unlockedModuleSchematicIds.count(def.id) == 0) { continue; }
for (const RecipeIngredient& mat : def.materials)
{
result.itemIds.insert(mat.item);
}
}
for (const RecipeDef& def : m_config.recipes.recipes)
{
if (def.building == BuildingType::Assembler
&& def.unlockAtStationLevel.has_value()
&& unlockedRecipeSchematicIds.count(def.id) > 0)
{
for (const RecipeOutput& out : def.outputs)
{
result.itemIds.insert(out.item);
}
}
}
bool changed = true;
while (changed)
{
changed = false;
for (const RecipeDef& recipe : m_config.recipes.recipes)
{
if (recipe.building != BuildingType::Miner
&& recipe.building != BuildingType::Smelter
&& recipe.building != BuildingType::Assembler)
{
continue;
}
if (recipe.building == BuildingType::Assembler
&& recipe.unlockAtStationLevel.has_value()
&& unlockedRecipeSchematicIds.count(recipe.id) == 0)
{
continue;
}
bool producesUnlocked = false;
for (const RecipeOutput& out : recipe.outputs)
{
if (result.itemIds.count(out.item) > 0)
{
producesUnlocked = true;
break;
}
}
if (!producesUnlocked) { continue; }
if (recipe.building == BuildingType::Miner
|| recipe.building == BuildingType::Assembler)
{
result.recipeIds.insert(recipe.id);
}
for (const RecipeIngredient& ing : recipe.inputs)
{
if (result.itemIds.insert(ing.item).second)
{
changed = true;
}
}
}
}
return result;
}
std::vector<std::string> Simulation::computeNewlyUnlockedItemNames(const UnlockedSets& hypothetical) const
{
std::set<std::string> itemNames;
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;
}
}
return std::vector<std::string>(itemNames.begin(), itemNames.end());
}
bool Simulation::isRecipeUnlocked(const std::string& recipeId) const
{
return m_unlockedRecipeIds.count(recipeId) > 0;
}
bool Simulation::isItemUnlocked(const std::string& itemId) const
{
return m_unlockedItemIds.count(itemId) > 0;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Drains // Drains
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
std::vector<FireEvent> Simulation::drainFireEvents() std::vector<WeaponFiredEvent> Simulation::drainWeaponFiredEvents()
{ {
std::vector<FireEvent> result; std::vector<WeaponFiredEvent> result;
result.swap(m_fireEvents); result.swap(m_weaponFiredEvents);
return result; return result;
} }
std::vector<SchematicDropEvent> Simulation::drainSchematicDropEvents() const std::vector<SchematicChoiceOption>& Simulation::getPendingSchematicChoices() const
{ {
std::vector<SchematicDropEvent> result; return m_pendingSchematicChoices;
result.swap(m_schematicDropEvents);
return result;
} }
bool Simulation::hasSchematicChoicesPending() const
{
return !m_pendingSchematicChoices.empty();
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Accessors // Accessors
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -545,6 +863,21 @@ double Simulation::threatLevel() const
return m_waveSystem->threatLevel(); return m_waveSystem->threatLevel();
} }
double Simulation::threatAccumulationRate() const
{
return m_waveSystem->threatAccumulationRate();
}
double Simulation::maxFactoryProductionThreatRate() const
{
return static_cast<double>(m_buildingSystem->productionBuildingCount());
}
double Simulation::currentFactoryProductionThreatRate() const
{
return static_cast<double>(m_buildingSystem->activeProductionBuildingCount());
}
int Simulation::bossWaveCounter() const int Simulation::bossWaveCounter() const
{ {
return m_waveSystem->bossWaveCounter(); return m_waveSystem->bossWaveCounter();
@@ -582,6 +915,28 @@ bool Simulation::isSchematicUnlocked(const std::string& shipId) const
return it->second.unlocked; return it->second.unlocked;
} }
int Simulation::moduleSchematicLevel(const std::string& moduleId) const
{
const std::map<std::string, SchematicState>::const_iterator it =
m_moduleSchematicLevels.find(moduleId);
if (it == m_moduleSchematicLevels.end())
{
return 0;
}
return it->second.level;
}
bool Simulation::isModuleSchematicUnlocked(const std::string& moduleId) const
{
const std::map<std::string, SchematicState>::const_iterator it =
m_moduleSchematicLevels.find(moduleId);
if (it == m_moduleSchematicLevels.end())
{
return false;
}
return it->second.unlocked;
}
BuildingId Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation) BuildingId Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)
{ {
int cost = 0; int cost = 0;

View File

@@ -3,6 +3,7 @@
#include <map> #include <map>
#include <memory> #include <memory>
#include <random> #include <random>
#include <set>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -11,11 +12,11 @@
#include "BeltSystem.h" #include "BeltSystem.h"
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include "entt/entity/entity.hpp" #include "entt/entity/entity.hpp"
#include "SchematicDropEvent.h" #include "SchematicChoiceOption.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "BuildingId.h" #include "BuildingId.h"
#include "EventHandler.h" #include "EventHandler.h"
#include "FireEvent.h" #include "WeaponFiredEvent.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "Rotation.h" #include "Rotation.h"
#include "Tick.h" #include "Tick.h"
@@ -49,23 +50,42 @@ public:
// Returns all fire events accumulated since the last drain, clearing the // Returns all fire events accumulated since the last drain, clearing the
// internal queue. Call once per rendered frame (REQ-SHP-FIRING-BEAM). // internal queue. Call once per rendered frame (REQ-SHP-FIRING-BEAM).
std::vector<FireEvent> drainFireEvents(); std::vector<WeaponFiredEvent> drainWeaponFiredEvents();
// Returns all schematic drop events since the last drain. // Returns the pending schematic choices (empty if no drop is pending).
std::vector<SchematicDropEvent> drainSchematicDropEvents(); const std::vector<SchematicChoiceOption>& getPendingSchematicChoices() const;
// 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; Tick currentTick() const;
int buildingBlocksStock() const; int buildingBlocksStock() const;
bool isGameOver() const; bool isGameOver() const;
double threatLevel() const; double threatLevel() const;
double threatAccumulationRate() const;
double maxFactoryProductionThreatRate() const;
double currentFactoryProductionThreatRate() const;
int bossWaveCounter() const; int bossWaveCounter() const;
Tick bossCountdownTicks() const; Tick bossCountdownTicks() const;
Tick normalGapRemainingTicks() const; Tick normalGapRemainingTicks() const;
// Schematic state queries. // Ship schematic state queries.
int schematicLevel(const std::string& shipId) const; int schematicLevel(const std::string& shipId) const;
bool isSchematicUnlocked(const std::string& shipId) const; bool isSchematicUnlocked(const std::string& shipId) const;
// Module schematic state queries.
int moduleSchematicLevel(const std::string& moduleId) const;
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. // Checks affordability, deducts building blocks, and places the building.
// Returns the new entity id, or kInvalidBuildingId if blocks are insufficient. // Returns the new entity id, or kInvalidBuildingId if blocks are insufficient.
BuildingId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation); BuildingId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation);
@@ -99,8 +119,8 @@ private:
// Tick step 9: remove dead ships and buildings, drop scrap, handle push. // Tick step 9: remove dead ships and buildings, drop scrap, handle push.
void tickDeathsAndLoot(); void tickDeathsAndLoot();
// Award a random schematic drop (REQ-DEF-SCHEMATIC-DROP) and emit the event. // Generate up to 3 schematic choices (REQ-DEF-SCHEMATIC-DROP) for the player.
void awardSchematicDrop(); void generateSchematicChoices(int destroyedStationLevel);
GameConfig m_config; GameConfig m_config;
std::mt19937 m_rng; std::mt19937 m_rng;
@@ -125,6 +145,37 @@ private:
int level; int level;
}; };
std::map<std::string, SchematicState> m_schematicLevels; std::map<std::string, SchematicState> m_schematicLevels;
std::map<std::string, SchematicState> m_moduleSchematicLevels;
// Explicitly unlocked assembler recipe schematics (REQ-LOCK-EXPLICIT).
std::set<std::string> m_unlockedRecipeSchematicIds;
// Implicit unlock sets derived from schematic state (REQ-LOCK-IMPLICIT).
std::set<std::string> m_unlockedRecipeIds;
std::set<std::string> m_unlockedItemIds;
// Recomputes m_unlockedRecipeIds and m_unlockedItemIds from current schematic state.
void recomputeUnlocked();
// Result of the REQ-LOCK-IMPLICIT traversal.
struct UnlockedSets
{
std::set<std::string> itemIds;
std::set<std::string> recipeIds;
};
// Pure REQ-LOCK-IMPLICIT traversal given hypothetical explicit-unlock sets.
UnlockedSets computeUnlockedSets(const std::set<std::string>& unlockedShipSchematicIds,
const std::set<std::string>& unlockedModuleSchematicIds,
const std::set<std::string>& unlockedRecipeSchematicIds) const;
// Current explicit-unlock id sets, derived from m_schematicLevels / m_moduleSchematicLevels.
std::set<std::string> getUnlockedShipSchematicIds() const;
std::set<std::string> getUnlockedModuleSchematicIds() const;
// Display names (deduplicated, alphabetical) of output items of recipes in
// hypothetical.recipeIds that are not yet in m_unlockedRecipeIds.
std::vector<std::string> computeNewlyUnlockedItemNames(const UnlockedSets& hypothetical) const;
EntityAdmin m_admin; EntityAdmin m_admin;
BeltSystem m_beltSystem; BeltSystem m_beltSystem;
@@ -137,6 +188,6 @@ private:
std::unique_ptr<WaveSystem> m_waveSystem; std::unique_ptr<WaveSystem> m_waveSystem;
std::unique_ptr<CombatSystem> m_combatSystem; std::unique_ptr<CombatSystem> m_combatSystem;
std::vector<FireEvent> m_fireEvents; std::vector<WeaponFiredEvent> m_weaponFiredEvents;
std::vector<SchematicDropEvent> m_schematicDropEvents; std::vector<SchematicChoiceOption> m_pendingSchematicChoices;
}; };

View File

@@ -0,0 +1,247 @@
#include "ThreatCostCalculator.h"
#include <limits>
#include <set>
#include "GameConfig.h"
namespace
{
struct RecipeRef
{
const RecipeDef* recipe;
std::string outputItem;
int outputAmount;
double probability;
};
double computeMaterialThreat(const ThreatCostTable& table,
const std::vector<RecipeIngredient>& materials)
{
double total = 0.0;
for (const RecipeIngredient& mat : materials)
{
std::map<std::string, double>::const_iterator it = table.itemThreat.find(mat.item);
if (it != table.itemThreat.end())
{
total += it->second * mat.amount;
}
}
return total;
}
bool allInputsResolved(const RecipeDef& recipe,
const std::map<std::string, double>& resolved)
{
for (const RecipeIngredient& input : recipe.inputs)
{
if (resolved.find(input.item) == resolved.end())
{
return false;
}
}
return true;
}
double computeRecipeThreat(const RecipeDef& recipe,
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;
}
} // namespace
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;
std::map<std::string, std::vector<RecipeRef>> reprocessingRecipes;
for (const RecipeDef& recipe : config.recipes.recipes)
{
if (recipe.building == BuildingType::ReprocessingPlant)
{
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);
reprocessingRecipes[out.item].push_back(ref);
}
}
else
{
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);
}
}
}
// Collect all item names that need resolving.
std::set<std::string> unresolved;
for (const std::pair<const std::string, std::vector<RecipeRef>>& entry : nonReprocessingRecipes)
{
unresolved.insert(entry.first);
}
for (const std::pair<const std::string, std::vector<RecipeRef>>& entry : reprocessingRecipes)
{
unresolved.insert(entry.first);
}
// Iteratively resolve non-reprocessing items.
bool progress = true;
while (progress)
{
progress = false;
std::set<std::string> newlyResolved;
for (const std::string& item : unresolved)
{
std::map<std::string, std::vector<RecipeRef>>::const_iterator it =
nonReprocessingRecipes.find(item);
if (it == nonReprocessingRecipes.end())
{
continue;
}
double maxThreat = -1.0;
for (const RecipeRef& ref : it->second)
{
if (allInputsResolved(*ref.recipe, table.itemThreat))
{
double threat = computeRecipeThreat(*ref.recipe, table.itemThreat);
if (threat > maxThreat)
{
maxThreat = threat;
}
}
}
if (maxThreat >= 0.0)
{
table.itemThreat[item] = maxThreat;
newlyResolved.insert(item);
progress = true;
}
}
for (const std::string& item : newlyResolved)
{
unresolved.erase(item);
}
}
// 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)
{
if (def.loot.scrapDrop > 0 && def.loot.scrapDrop < minScrapDrop)
{
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)
{
scrapPerCycle += input.amount;
}
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)
{
table.itemThreat[item] = threat;
}
}
}
return table;
}
double calculateShipThreatCost(const ThreatCostTable& table,
const GameConfig& config,
const std::string& shipId,
const std::vector<PlacedModule>& modules)
{
const ShipDef* shipDef = nullptr;
for (const ShipDef& d : config.ships.ships)
{
if (d.id == shipId)
{
shipDef = &d;
break;
}
}
if (shipDef == nullptr)
{
return 0.0;
}
double threat = shipDef->schematic.productionTimeSeconds;
// Add material threat for ship base materials.
threat += computeMaterialThreat(table, shipDef->schematic.materials);
// Add module production times and material threats.
for (const PlacedModule& pm : modules)
{
const ModuleDef* moduleDef = nullptr;
for (const ModuleDef& d : config.modules.modules)
{
if (d.id == pm.moduleId)
{
moduleDef = &d;
break;
}
}
if (moduleDef == nullptr)
{
continue;
}
threat += moduleDef->productionTimeSeconds;
threat += computeMaterialThreat(table, moduleDef->materials);
}
return threat;
}

View File

@@ -0,0 +1,22 @@
#pragma once
#include <map>
#include <string>
#include <vector>
#include "ShipLayout.h"
struct GameConfig;
struct ThreatCostTable
{
std::map<std::string, double> itemThreat;
double scrapThreat = 0.0;
};
ThreatCostTable computeThreatCostTable(const GameConfig& config);
double calculateShipThreatCost(const ThreatCostTable& table,
const GameConfig& config,
const std::string& shipId,
const std::vector<PlacedModule>& modules);

View File

@@ -3,6 +3,7 @@
#include <algorithm> #include <algorithm>
#include "ShipSystem.h" #include "ShipSystem.h"
#include "ThreatCostCalculator.h"
#include "tracing.h" #include "tracing.h"
WaveSystem::WaveSystem(const GameConfig& config, std::mt19937& rng) WaveSystem::WaveSystem(const GameConfig& config, std::mt19937& rng)
@@ -102,6 +103,16 @@ double WaveSystem::threatLevel() const
return m_threatLevel; return m_threatLevel;
} }
double WaveSystem::threatAccumulationRate() const
{
if (isInQuietWindow())
{
return 0.0;
}
const double x = static_cast<double>(m_bossWaveCounter);
return std::max(0.0, m_config.world.waves.threatRateFormula.evaluate(x));
}
int WaveSystem::generation() const int WaveSystem::generation() const
{ {
return m_generation; return m_generation;
@@ -177,7 +188,8 @@ std::vector<WaveSystem::SpawnEntry> WaveSystem::selectWaveShips(double& budget,
std::vector<EligibleShip> eligible; std::vector<EligibleShip> eligible;
for (const ShipDef& def : m_config.ships.ships) for (const ShipDef& def : m_config.ships.ships)
{ {
const double cost = def.threat.costFormula.evaluate(static_cast<double>(shipLevel)); const double cost = calculateShipThreatCost(m_config.threatCosts, m_config,
def.id, def.defaultModules);
if (cost > 0.0) if (cost > 0.0)
{ {
EligibleShip es; EligibleShip es;

View File

@@ -36,6 +36,11 @@ public:
double threatLevel() const; double threatLevel() const;
// Current rate at which threatLevel() is increasing, in threat/second
// (REQ-WAV-THREAT-RATE). 0 during a quiet window (REQ-WAV-QUIET) or when
// the rate formula evaluates to a negative value.
double threatAccumulationRate() const;
// Current enemy-station generation level (0 for initial set, // Current enemy-station generation level (0 for initial set,
// incremented by 1 after each push — REQ-PSH-STATION-STATS). // incremented by 1 after each push — REQ-PSH-STATION-STATS).
int generation() const; int generation() const;

View File

@@ -70,6 +70,7 @@ struct Fixture
[this]() { return nextBuildingId++; }, [this]() { return nextBuildingId++; },
[this](int n) { stock += n; }, [this](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng) rng)
, ships(cfg, admin) , ships(cfg, admin)
, scraps(admin) , scraps(admin)

View File

@@ -1,5 +1,7 @@
#include "catch.hpp" #include "catch.hpp"
#include <optional>
#include <string>
#include <vector> #include <vector>
#include <QPoint> #include <QPoint>
@@ -553,6 +555,64 @@ TEST_CASE("BeltSystem: splitter falls back to other output when preferred is blo
REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value()); REQUIRE_FALSE(bs.peekItem(Port{tileSpl, Rotation::South}).has_value());
} }
TEST_CASE("BeltSystem: splitter fallback enters the open output at progress 0.75", "[belt]")
{
// When the preferred output is blocked, the diverted item is dropped onto the
// open output near its edge (progress 0.75) instead of at progress 0.0. This
// closes the large gap that would otherwise appear between items leaving the
// open side of a half-blocked splitter.
//
// Progress/tick = 0.25 so the 0.0-vs-0.75 entry position is observable: a
// normally-routed item starts at 0.0, a fallback item starts at 0.75.
const double quarterSpeed = 0.25 * static_cast<double>(kTickRateHz);
BeltSystem bs(quarterSpeed);
const QPoint tileSpl(1, 0);
const QPoint tileB(1, 1); // South output belt; North output has no belt (blocked).
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
bs.placeBelt(tileB, Rotation::South);
// Reads a named item's progress along the South output via the rendering contract.
// slotWorldPos maps a South-bound slot on tileSpl (y = 0) to worldPos.y == progress.
// Matching by id avoids the blocked North item, which also renders at worldPos.y 0.
auto southProgressOf = [&bs](const std::string& id) -> std::optional<double>
{
std::optional<double> progress;
bs.forEachVisualItem(QRect(-5, -5, 20, 20), [&](VisualItem vi)
{
if (vi.type.id == id)
{
progress = vi.worldPos.y();
}
});
return progress;
};
// Permanently block output A: route one item to frontA where it sticks at 1.0
// (North has no downstream tile, so it can never move out).
bs.tryPutItem(tileSpl, makeItem("blockA"));
bs.tick(); // back: 0.25
bs.tick(); // back: 0.5 -> frontA at 0.0 (preferred A), nextOutputIsA = false
bs.tick(); bs.tick(); bs.tick(); bs.tick(); // frontA: 0.25 -> 0.5 -> 0.75 -> 1.0 (stuck)
// Item routed to B as the *preferred* output enters at progress 0.0.
bs.tryPutItem(tileSpl, makeItem("toB_pref"));
bs.tick(); // back: 0.25
bs.tick(); // back: 0.5 -> frontB at 0.0 (preferred B), nextOutputIsA = true
REQUIRE(southProgressOf("toB_pref") == Approx(0.0));
// Let it traverse and hand off to the downstream belt, freeing frontB.
bs.tick(); bs.tick(); bs.tick(); bs.tick(); // frontB: 0.25 -> 0.5 -> 0.75 -> 1.0 -> tileB
// Next item prefers A again (nextOutputIsA == true), but A is still blocked,
// so it falls back to B — and must enter near the edge at progress 0.75.
bs.tryPutItem(tileSpl, makeItem("toB_fallback"));
bs.tick(); // back: 0.25
bs.tick(); // back: 0.5 -> fallback routes to frontB at 0.75
REQUIRE(southProgressOf("toB_fallback") == Approx(0.75));
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Splitter — direct building input (no output belts) // Splitter — direct building input (no output belts)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -654,7 +654,7 @@ TEST_CASE("Blueprint placement: recipe transfers to building after construction
TEST_CASE("Blueprint placement: interceptor schematic is unlocked at game start", "[blueprint]") TEST_CASE("Blueprint placement: interceptor schematic is unlocked at game start", "[blueprint]")
{ {
// "interceptor" has available_from_start = true in the test config. // "interceptor" has unlock_at_station_level = -1 in the test config.
// This confirms the guard in placeBlueprintAtTile passes for start-unlocked schematics. // This confirms the guard in placeBlueprintAtTile passes for start-unlocked schematics.
Simulation sim(loadConfig()); Simulation sim(loadConfig());
REQUIRE(sim.isSchematicUnlocked("interceptor")); REQUIRE(sim.isSchematicUnlocked("interceptor"));
@@ -662,7 +662,7 @@ TEST_CASE("Blueprint placement: interceptor schematic is unlocked at game start"
TEST_CASE("Blueprint placement: repair_ship schematic is locked at game start", "[blueprint]") TEST_CASE("Blueprint placement: repair_ship schematic is locked at game start", "[blueprint]")
{ {
// "repair_ship" has available_from_start = false in the test config. // "repair_ship" has unlock_at_station_level = 0 in the test config.
// This confirms the guard in placeBlueprintAtTile blocks locked schematics, // This confirms the guard in placeBlueprintAtTile blocks locked schematics,
// leaving the shipyard's schematic unset. // leaving the shipyard's schematic unset.
Simulation sim(loadConfig()); Simulation sim(loadConfig());

View File

@@ -79,6 +79,7 @@ TEST_CASE("BuildingSystem: place miner occupies expected body tiles", "[building
[&nextBuildingId]() { return nextBuildingId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng); rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -104,6 +105,7 @@ TEST_CASE("BuildingSystem: placing a belt registers it with BeltSystem after con
[&nextBuildingId]() { return nextBuildingId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng); rng);
bs.place(BuildingType::Belt, QPoint(5, 5), Rotation::East, 0); bs.place(BuildingType::Belt, QPoint(5, 5), Rotation::East, 0);
@@ -132,6 +134,7 @@ TEST_CASE("BuildingSystem: placed building enters construction queue", "[buildin
[&nextBuildingId]() { return nextBuildingId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng); rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -152,6 +155,7 @@ TEST_CASE("BuildingSystem: demolish frees tiles and returns refund", "[building]
[&nextBuildingId]() { return nextBuildingId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng); rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -185,6 +189,7 @@ TEST_CASE("BuildingSystem: first queued building starts construction immediately
[&nextBuildingId]() { return nextBuildingId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng); rng);
bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -202,6 +207,7 @@ TEST_CASE("BuildingSystem: second queued building waits (completesAt == 0)", "[b
[&nextBuildingId]() { return nextBuildingId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng); rng);
bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -223,6 +229,7 @@ TEST_CASE("BuildingSystem: construction completes after configured duration", "[
[&nextBuildingId]() { return nextBuildingId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng); rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -247,6 +254,7 @@ TEST_CASE("BuildingSystem: second building starts after first completes", "[buil
[&nextBuildingId]() { return nextBuildingId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng); rng);
bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -276,6 +284,7 @@ TEST_CASE("BuildingSystem: miner produces iron_ore after recipe duration", "[bui
[&nextBuildingId]() { return nextBuildingId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng); rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -305,6 +314,7 @@ TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
[&nextBuildingId]() { return nextBuildingId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng); rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -327,6 +337,85 @@ TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
REQUIRE_FALSE(b->production.has_value()); REQUIRE_FALSE(b->production.has_value());
} }
// ---------------------------------------------------------------------------
// REQ-UI-DEBUG-OVERLAY production counts
// ---------------------------------------------------------------------------
TEST_CASE("BuildingSystem: productionBuildingCount excludes construction sites", "[building]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0;
std::mt19937 rng(0);
BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts,
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId minerId = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId smelterId = bs.place(BuildingType::Smelter, QPoint(10, 0), Rotation::East, 0);
(void)smelterId;
Tick tick = 0;
// Both still under construction.
REQUIRE(bs.productionBuildingCount() == 0);
// The queue builds one at a time: miner (10s) completes at tick 300, then
// the smelter (15s) starts and completes at tick 300 + 450 = 750.
runTicks(bs, belts, static_cast<int>(secondsToTicks(10.0)) + 1, tick);
REQUIRE(bs.productionBuildingCount() == 1);
runTicks(bs, belts, static_cast<int>(secondsToTicks(15.0)), tick);
REQUIRE(bs.productionBuildingCount() == 2);
// Neither has a recipe selected, so neither has an active cycle.
REQUIRE(bs.activeProductionBuildingCount() == 0);
bs.setRecipe(minerId, "mine_iron_ore");
runTicks(bs, belts, 1, tick);
REQUIRE(bs.activeProductionBuildingCount() == 1);
}
TEST_CASE("BuildingSystem: activeProductionBuildingCount tracks production cycle state",
"[building]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(cfg.world.beltSpeed_tps);
int stock = 0;
std::mt19937 rng(0);
BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts,
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(id, "mine_iron_ore");
Tick tick = 0;
// Not yet operational while under construction.
REQUIRE(bs.activeProductionBuildingCount() == 0);
// Construction completes at tick 300; cycle 1 starts the same tick (completesAt=330).
runTicks(bs, belts, static_cast<int>(secondsToTicks(10.0)) + 1, tick);
REQUIRE(bs.activeProductionBuildingCount() == 1);
// Run cycles 1 and 2 to completion (1s each); cycle 3 stalls once the
// output buffer (capacity 2) is full (REQ-MAT-OUTPUT-BUFFER).
runTicks(bs, belts, 2 * static_cast<int>(secondsToTicks(1.0)) + 1, tick);
const Building* b = bs.findBuilding(id);
REQUIRE(b != nullptr);
REQUIRE(static_cast<int>(b->outputBuffer.items.size()) == 2);
REQUIRE_FALSE(b->production.has_value());
REQUIRE(bs.activeProductionBuildingCount() == 0);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Belt pull → input buffer // Belt pull → input buffer
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -344,6 +433,7 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing
[&nextBuildingId]() { return nextBuildingId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng); rng);
// Smelter mask ["AA ","AA>"] → body (0,0),(1,0),(0,1),(1,1). // Smelter mask ["AA ","AA>"] → body (0,0),(1,0),(0,1),(1,1).
@@ -385,6 +475,7 @@ TEST_CASE("BuildingSystem: miner output buffer drains onto adjacent belt", "[bui
[&nextBuildingId]() { return nextBuildingId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng); rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -424,6 +515,7 @@ TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production"
[&nextBuildingId]() { return nextBuildingId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng); rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
@@ -464,6 +556,7 @@ TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max
[&nextBuildingId]() { return nextBuildingId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng); rng);
const BuildingId id = bs.place(BuildingType::ReprocessingPlant, const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
@@ -494,6 +587,7 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta
[&nextBuildingId]() { return nextBuildingId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng); rng);
const BuildingId id = bs.place(BuildingType::ReprocessingPlant, const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
@@ -552,6 +646,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when tile is
[&nextBuildingId]() { return nextBuildingId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng); rng);
REQUIRE_FALSE( REQUIRE_FALSE(
@@ -570,6 +665,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the site id for a que
[&nextBuildingId]() { return nextBuildingId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng); rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
@@ -592,6 +688,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the building id for a
[&nextBuildingId]() { return nextBuildingId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng); rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
@@ -618,6 +715,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when building
[&nextBuildingId]() { return nextBuildingId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng); rng);
bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
@@ -639,6 +737,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when footprin
[&nextBuildingId]() { return nextBuildingId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng); rng);
// Smelter at (0,0) occupies body tiles (0,0),(1,0),(0,1),(1,1). // Smelter at (0,0) occupies body tiles (0,0),(1,0),(0,1),(1,1).
@@ -662,6 +761,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-t
[&nextBuildingId]() { return nextBuildingId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng); rng);
// Smelter is a fully filled 2×2 footprint — rotating the ghost produces the // Smelter is a fully filled 2×2 footprint — rotating the ghost produces the
@@ -690,6 +790,7 @@ TEST_CASE("BuildingSystem: rotateInPlace updates the rotation field of a constru
[&nextBuildingId]() { return nextBuildingId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng); rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
@@ -712,6 +813,7 @@ TEST_CASE("BuildingSystem: rotateInPlace preserves the construction progress of
[&nextBuildingId]() { return nextBuildingId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng); rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
@@ -735,6 +837,7 @@ TEST_CASE("BuildingSystem: rotateInPlace updates rotation and output port direct
[&nextBuildingId]() { return nextBuildingId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng); rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
@@ -765,6 +868,7 @@ TEST_CASE("BuildingSystem: rotateInPlace re-registers a belt tile with BeltSyste
[&nextBuildingId]() { return nextBuildingId++; }, [&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; }, [&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng); rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);

View File

@@ -19,4 +19,6 @@ add_files(
BlueprintSerializerTest.cpp BlueprintSerializerTest.cpp
ModuleConfigTest.cpp ModuleConfigTest.cpp
ShipModuleTest.cpp ShipModuleTest.cpp
ThreatCostCalculatorTest.cpp
RecipeSchematicTest.cpp
) )

View File

@@ -10,7 +10,7 @@
#include "ConfigLoader.h" #include "ConfigLoader.h"
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include "FactionComponent.h" #include "FactionComponent.h"
#include "FireEvent.h" #include "WeaponFiredEvent.h"
#include "HealthComponent.h" #include "HealthComponent.h"
#include "HqProxyComponent.h" #include "HqProxyComponent.h"
#include "ModuleOwnerComponent.h" #include "ModuleOwnerComponent.h"
@@ -72,6 +72,7 @@ struct CombatFixture
[this]() { return nextBuildingId++; }, [this]() { return nextBuildingId++; },
[](int){}, [](int){},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {}, [](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng) rng)
, combat(cfg) , combat(cfg)
{ {
@@ -110,7 +111,7 @@ TEST_CASE("CombatSystem: ship fires when cooldown=0 and target in range", "[comb
const float hpBefore = f.admin.get<HealthComponent>(player).hp; const float hpBefore = f.admin.get<HealthComponent>(player).hp;
std::vector<FireEvent> events; std::vector<WeaponFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events); f.combat.tick(0, f.admin, f.buildings, events);
f.combat.applyPendingDamage(5, f.admin); f.combat.applyPendingDamage(5, f.admin);
@@ -134,16 +135,16 @@ TEST_CASE("CombatSystem: cooldown prevents firing before it expires", "[combat]"
f.admin.get<WeaponComponent>(wc).cooldownTicks = 3.0f; // override to 3 f.admin.get<WeaponComponent>(wc).cooldownTicks = 3.0f; // override to 3
} }
auto enemyFiredIn = [&enemy](const std::vector<FireEvent>& evts) auto enemyFiredIn = [&enemy](const std::vector<WeaponFiredEvent>& evts)
{ {
for (const FireEvent& evt : evts) for (const WeaponFiredEvent& evt : evts)
{ {
if (evt.shooter == enemy) { return true; } if (evt.shooter == enemy) { return true; }
} }
return false; return false;
}; };
std::vector<FireEvent> events; std::vector<WeaponFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events); f.combat.tick(0, f.admin, f.buildings, events);
REQUIRE_FALSE(enemyFiredIn(events)); REQUIRE_FALSE(enemyFiredIn(events));
@@ -164,7 +165,7 @@ TEST_CASE("CombatSystem: no fire when target is out of range", "[combat]")
const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(500.0f, 0.0f), false); const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(500.0f, 0.0f), false);
f.wireEnemyTarget(enemy, player); f.wireEnemyTarget(enemy, player);
std::vector<FireEvent> events; std::vector<WeaponFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events); f.combat.tick(0, f.admin, f.buildings, events);
REQUIRE(events.empty()); REQUIRE(events.empty());
} }
@@ -203,9 +204,9 @@ TEST_CASE("CombatSystem: player station fires at enemy ship in range", "[combat]
sim.tick(); sim.tick();
const std::vector<FireEvent> events = sim.drainFireEvents(); const std::vector<WeaponFiredEvent> events = sim.drainWeaponFiredEvents();
bool stationFired = false; bool stationFired = false;
for (const FireEvent& evt : events) for (const WeaponFiredEvent& evt : events)
{ {
if (evt.shooter == stationEntity) { stationFired = true; } if (evt.shooter == stationEntity) { stationFired = true; }
} }
@@ -241,9 +242,9 @@ TEST_CASE("CombatSystem: enemy station fires at player ship in range", "[combat]
sim.tick(); sim.tick();
const std::vector<FireEvent> events = sim.drainFireEvents(); const std::vector<WeaponFiredEvent> events = sim.drainWeaponFiredEvents();
bool stationFired = false; bool stationFired = false;
for (const FireEvent& evt : events) for (const WeaponFiredEvent& evt : events)
{ {
if (evt.shooter == stationEntity) { stationFired = true; } if (evt.shooter == stationEntity) { stationFired = true; }
} }
@@ -279,9 +280,9 @@ TEST_CASE("CombatSystem: player ship fires at enemy station in range", "[combat]
sim.tick(); sim.tick();
const std::vector<FireEvent> events = sim.drainFireEvents(); const std::vector<WeaponFiredEvent> events = sim.drainWeaponFiredEvents();
bool playerFiredAtStation = false; bool playerFiredAtStation = false;
for (const FireEvent& evt : events) for (const WeaponFiredEvent& evt : events)
{ {
if (evt.shooter == playerShip && evt.target == stationEntity) if (evt.shooter == playerShip && evt.target == stationEntity)
{ {
@@ -307,7 +308,7 @@ TEST_CASE("CombatSystem: damage not applied before impact tick", "[combat]")
const float hpBefore = f.admin.get<HealthComponent>(player).hp; const float hpBefore = f.admin.get<HealthComponent>(player).hp;
std::vector<FireEvent> events; std::vector<WeaponFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events); f.combat.tick(0, f.admin, f.buildings, events);
for (Tick t = 1; t < 5; ++t) for (Tick t = 1; t < 5; ++t)
@@ -329,7 +330,7 @@ TEST_CASE("CombatSystem: damage applied exactly at impact tick", "[combat]")
const float hpBefore = f.admin.get<HealthComponent>(player).hp; const float hpBefore = f.admin.get<HealthComponent>(player).hp;
std::vector<FireEvent> events; std::vector<WeaponFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events); f.combat.tick(0, f.admin, f.buildings, events);
f.combat.applyPendingDamage(5, f.admin); f.combat.applyPendingDamage(5, f.admin);
@@ -346,7 +347,7 @@ TEST_CASE("CombatSystem: damage silently dropped if target already dead", "[comb
const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false); const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
f.wireEnemyTarget(enemy, player); f.wireEnemyTarget(enemy, player);
std::vector<FireEvent> events; std::vector<WeaponFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events); f.combat.tick(0, f.admin, f.buildings, events);
f.ships.despawn(player); f.ships.despawn(player);
@@ -369,7 +370,7 @@ TEST_CASE("CombatSystem: damage still applied if shooter already dead", "[combat
const float hpBefore = f.admin.get<HealthComponent>(player).hp; const float hpBefore = f.admin.get<HealthComponent>(player).hp;
std::vector<FireEvent> events; std::vector<WeaponFiredEvent> events;
f.combat.tick(0, f.admin, f.buildings, events); f.combat.tick(0, f.admin, f.buildings, events);
f.ships.despawn(enemy); f.ships.despawn(enemy);

View File

@@ -8,6 +8,24 @@ static GameConfig loadConfig()
return ConfigLoader::loadFromDirectory(CONFIG_DIR); return ConfigLoader::loadFromDirectory(CONFIG_DIR);
} }
static const ModuleDef* findModule(const GameConfig& cfg, const std::string& id)
{
for (const ModuleDef& m : cfg.modules.modules)
{
if (m.id == id) { return &m; }
}
return nullptr;
}
static const ModuleStatModifier* findModifier(const ModuleDef& def, const std::string& stat)
{
for (const ModuleStatModifier& sm : def.statModifiers)
{
if (sm.stat == stat) { return &sm; }
}
return nullptr;
}
TEST_CASE("ConfigLoader: loadModules parses modules.toml", "[config][modules]") TEST_CASE("ConfigLoader: loadModules parses modules.toml", "[config][modules]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadConfig();
@@ -22,7 +40,6 @@ TEST_CASE("ConfigLoader: loadModules parses modules.toml", "[config][modules]")
CHECK(armor.materials[0].amount == 2); CHECK(armor.materials[0].amount == 2);
CHECK(armor.playerProductionLevel == 1); CHECK(armor.playerProductionLevel == 1);
CHECK(armor.productionTimeSeconds == Approx(3.0)); CHECK(armor.productionTimeSeconds == Approx(3.0));
CHECK(armor.threatCost == Approx(2.0));
CHECK(armor.fillColor == "#808080"); CHECK(armor.fillColor == "#808080");
CHECK(armor.glyph == "A"); CHECK(armor.glyph == "A");
REQUIRE(armor.statModifiers.size() == 1); REQUIRE(armor.statModifiers.size() == 1);
@@ -44,6 +61,72 @@ TEST_CASE("ConfigLoader: loadModules parses additive modifiers", "[config][modul
CHECK(sensor.statModifiers[0].formula.evaluate(1.0) == Approx(100.0)); CHECK(sensor.statModifiers[0].formula.evaluate(1.0) == Approx(100.0));
} }
TEST_CASE("ConfigLoader: multiplicative modifier with unit suffix is parsed (weapon_primer)", "[config][modules]")
{
const GameConfig cfg = loadConfig();
const ModuleDef* primer = findModule(cfg, "weapon_primer");
REQUIRE(primer != nullptr);
REQUIRE(primer->statModifiers.size() == 1);
const ModuleStatModifier* sm = findModifier(*primer, "attack_rate");
REQUIRE(sm != nullptr);
CHECK(sm->modifierType == "multiplicative");
CHECK(sm->formula.evaluate(1.0) == Approx(1.2));
}
TEST_CASE("ConfigLoader: weapon_stabilizer parses two multiplicative weapon modifiers", "[config][modules]")
{
const GameConfig cfg = loadConfig();
const ModuleDef* stab = findModule(cfg, "weapon_stabilizer");
REQUIRE(stab != nullptr);
REQUIRE(stab->statModifiers.size() == 2);
const ModuleStatModifier* rangeMod = findModifier(*stab, "attack_range");
REQUIRE(rangeMod != nullptr);
CHECK(rangeMod->modifierType == "multiplicative");
CHECK(rangeMod->formula.evaluate(1.0) == Approx(1.5));
const ModuleStatModifier* rateMod = findModifier(*stab, "attack_rate");
REQUIRE(rateMod != nullptr);
CHECK(rateMod->modifierType == "multiplicative");
CHECK(rateMod->formula.evaluate(1.0) == Approx(0.8));
}
TEST_CASE("ConfigLoader: afterburner parses multiplicative speed and additive main_acceleration", "[config][modules]")
{
const GameConfig cfg = loadConfig();
const ModuleDef* ab = findModule(cfg, "afterburner");
REQUIRE(ab != nullptr);
REQUIRE(ab->statModifiers.size() == 2);
const ModuleStatModifier* speedMod = findModifier(*ab, "speed");
REQUIRE(speedMod != nullptr);
CHECK(speedMod->modifierType == "multiplicative");
CHECK(speedMod->formula.evaluate(1.0) == Approx(1.6));
const ModuleStatModifier* accelMod = findModifier(*ab, "main_acceleration");
REQUIRE(accelMod != nullptr);
CHECK(accelMod->modifierType == "additive");
CHECK(accelMod->formula.evaluate(1.0) == Approx(60.0));
}
TEST_CASE("ConfigLoader: maneuvering_thrusters parses multiplicative speed and additive maneuvering_acceleration", "[config][modules]")
{
const GameConfig cfg = loadConfig();
const ModuleDef* mt = findModule(cfg, "maneuvering_thrusters");
REQUIRE(mt != nullptr);
REQUIRE(mt->statModifiers.size() == 2);
const ModuleStatModifier* speedMod = findModifier(*mt, "speed");
REQUIRE(speedMod != nullptr);
CHECK(speedMod->modifierType == "multiplicative");
CHECK(speedMod->formula.evaluate(1.0) == Approx(1.2));
const ModuleStatModifier* accelMod = findModifier(*mt, "maneuvering_acceleration");
REQUIRE(accelMod != nullptr);
CHECK(accelMod->modifierType == "additive");
CHECK(accelMod->formula.evaluate(1.0) == Approx(10.0));
}
TEST_CASE("ConfigLoader: loadShips parses layout field", "[config][ships]") TEST_CASE("ConfigLoader: loadShips parses layout field", "[config][ships]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadConfig();

View File

@@ -0,0 +1,362 @@
#include <algorithm>
#include "catch.hpp"
#include "ConfigLoader.h"
#include "DisplayName.h"
#include "FactionComponent.h"
#include "GameConfig.h"
#include "HealthComponent.h"
#include "RecipesConfig.h"
#include "SchematicChoiceOption.h"
#include "Simulation.h"
#include "StationBodyComponent.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
// Zeros the HP of both enemy defence stations and advances one tick so that
// tickDeathsAndLoot fires, triggering the push and schematic choices.
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();
}
// Kills enemy stations and applies the first schematic choice (index 0).
static void killEnemyStationsAndApply(Simulation& sim)
{
killEnemyStations(sim);
if (sim.hasSchematicChoicesPending())
{
sim.applySchematicChoice(0);
}
}
// Destroys station sets until recipeId is unlocked or maxDestructions is reached.
// Applies schematic choice 0 after each destruction. Returns true if unlocked.
static bool awaitRecipeUnlock(Simulation& sim, const std::string& recipeId,
int maxDestructions = 150)
{
for (int i = 0; i < maxDestructions; ++i)
{
if (sim.isRecipeUnlocked(recipeId)) { return true; }
killEnemyStationsAndApply(sim);
}
return sim.isRecipeUnlocked(recipeId);
}
// ---------------------------------------------------------------------------
// ConfigLoader: parsing unlock_at_station_level on assembler recipes
// ---------------------------------------------------------------------------
TEST_CASE("RecipeSchematic: unlock_at_station_level = -1 parsed correctly", "[recipe_schematic]")
{
const GameConfig cfg = loadConfig();
const auto it = std::find_if(cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
[](const RecipeDef& r) { return r.id == "premium_circuit"; });
REQUIRE(it != cfg.recipes.recipes.end());
REQUIRE(it->unlockAtStationLevel.has_value());
CHECK(it->unlockAtStationLevel.value() == -1);
}
TEST_CASE("RecipeSchematic: unlock_at_station_level = 0 parsed correctly", "[recipe_schematic]")
{
const GameConfig cfg = loadConfig();
const auto it = std::find_if(cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
[](const RecipeDef& r) { return r.id == "quick_circuit"; });
REQUIRE(it != cfg.recipes.recipes.end());
REQUIRE(it->unlockAtStationLevel.has_value());
CHECK(it->unlockAtStationLevel.value() == 0);
}
TEST_CASE("RecipeSchematic: assembler recipe without the key has nullopt", "[recipe_schematic]")
{
const GameConfig cfg = loadConfig();
const auto it = std::find_if(cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
[](const RecipeDef& r) { return r.id == "circuit_board"; });
REQUIRE(it != cfg.recipes.recipes.end());
CHECK_FALSE(it->unlockAtStationLevel.has_value());
}
// ---------------------------------------------------------------------------
// Initial explicit lock state (REQ-LOCK-EXPLICIT)
// ---------------------------------------------------------------------------
TEST_CASE("RecipeSchematic: recipe with unlock_at_station_level = -1 is unlocked at game start",
"[recipe_schematic]")
{
const Simulation sim(loadConfig());
REQUIRE(sim.isRecipeUnlocked("premium_circuit"));
}
TEST_CASE("RecipeSchematic: recipe with unlock_at_station_level = 0 is locked at game start",
"[recipe_schematic]")
{
const Simulation sim(loadConfig());
REQUIRE_FALSE(sim.isRecipeUnlocked("quick_circuit"));
}
TEST_CASE("RecipeSchematic: recipe with unlock_at_station_level = 1 is locked at game start",
"[recipe_schematic]")
{
const Simulation sim(loadConfig());
REQUIRE_FALSE(sim.isRecipeUnlocked("advanced_circuit"));
}
// ---------------------------------------------------------------------------
// Implicit unlock graph (REQ-LOCK-IMPLICIT)
// ---------------------------------------------------------------------------
TEST_CASE("RecipeSchematic: -1 recipe seeds its output item into the implicit unlock set",
"[recipe_schematic]")
{
// premium_circuit is not needed by any ship or module schematic, so it can
// only reach the implicit set via the -1 recipe seed in Phase 1.
const Simulation sim(loadConfig());
REQUIRE(sim.isItemUnlocked("premium_circuit"));
}
TEST_CASE("RecipeSchematic: -1 recipe's inputs are in the implicit unlock set",
"[recipe_schematic]")
{
// premium_circuit takes circuit_board as input; that item was already
// implicitly unlocked by ship schematics, so it must remain unlocked.
const Simulation sim(loadConfig());
REQUIRE(sim.isItemUnlocked("circuit_board"));
}
TEST_CASE("RecipeSchematic: locked recipe's unique input is not in the implicit unlock set",
"[recipe_schematic]")
{
// exotic_alloy has unlock_at_station_level = 0 (locked at start) and takes
// exotic_ore as input. exotic_ore is only reachable through this locked
// recipe, so it must not appear in the implicit set.
const Simulation sim(loadConfig());
REQUIRE_FALSE(sim.isItemUnlocked("exotic_ore"));
}
TEST_CASE("RecipeSchematic: locked recipe's output item is not in the implicit unlock set",
"[recipe_schematic]")
{
// exotic_alloy is produced only by the locked recipe of the same name and
// is not needed by any schematic, so neither the item nor the recipe should
// be unlocked at game start.
const Simulation sim(loadConfig());
REQUIRE_FALSE(sim.isItemUnlocked("exotic_alloy"));
REQUIRE_FALSE(sim.isRecipeUnlocked("exotic_alloy"));
}
TEST_CASE("RecipeSchematic: normal implicit unlock is unaffected for untagged assembler recipes",
"[recipe_schematic]")
{
// circuit_board carries no unlock_at_station_level and is needed by ships
// that start unlocked, so it must still be implicitly unlocked.
const Simulation sim(loadConfig());
REQUIRE(sim.isRecipeUnlocked("circuit_board"));
}
// ---------------------------------------------------------------------------
// Drop pool and station destruction (REQ-DEF-SCHEMATIC-DROP)
// ---------------------------------------------------------------------------
TEST_CASE("RecipeSchematic: eligible recipe schematic is eventually awarded on station destruction",
"[recipe_schematic]")
{
// quick_circuit has unlock_at_station_level = 0 and produces circuit_board
// (already implicitly unlocked), so it is eligible from the first station
// destruction. With up to 150 trials it must be awarded at least once.
Simulation sim(loadConfig());
REQUIRE(awaitRecipeUnlock(sim, "quick_circuit"));
}
TEST_CASE("RecipeSchematic: recipe whose output is not implicitly unlocked is never awarded",
"[recipe_schematic]")
{
// exotic_alloy produces exotic_alloy (not implicitly unlocked), so the
// output-unlocked guard must keep it out of the drop pool at every level.
Simulation sim(loadConfig());
for (int i = 0; i < 50; ++i)
{
killEnemyStationsAndApply(sim);
}
REQUIRE_FALSE(sim.isRecipeUnlocked("exotic_alloy"));
}
TEST_CASE("RecipeSchematic: recipe with level > destroyed station level is not awarded",
"[recipe_schematic]")
{
// advanced_circuit has unlock_at_station_level = 1. Destroying a single
// level-0 station set must not award it regardless of the RNG outcome.
Simulation sim(loadConfig());
killEnemyStationsAndApply(sim);
REQUIRE_FALSE(sim.isRecipeUnlocked("advanced_circuit"));
}
TEST_CASE("RecipeSchematic: recipe with higher level is awarded once eligible station level is reached",
"[recipe_schematic]")
{
// After enough destructions to pass station level 1, advanced_circuit must
// eventually be awarded.
Simulation sim(loadConfig());
REQUIRE(awaitRecipeUnlock(sim, "advanced_circuit", 300));
}
TEST_CASE("RecipeSchematic: awarded recipe schematic stays unlocked and is not awarded again",
"[recipe_schematic]")
{
Simulation sim(loadConfig());
awaitRecipeUnlock(sim, "quick_circuit");
REQUIRE(sim.isRecipeUnlocked("quick_circuit"));
// Destroy 30 more station sets; the recipe is no longer in the pool.
for (int i = 0; i < 30; ++i)
{
killEnemyStationsAndApply(sim);
}
REQUIRE(sim.isRecipeUnlocked("quick_circuit"));
}
TEST_CASE("RecipeSchematic: recipe schematic can appear in pending choices",
"[recipe_schematic]")
{
Simulation sim(loadConfig());
bool foundRecipeChoice = false;
for (int i = 0; i < 150 && !foundRecipeChoice; ++i)
{
killEnemyStations(sim);
if (sim.hasSchematicChoicesPending())
{
for (const SchematicChoiceOption& opt : sim.getPendingSchematicChoices())
{
if (opt.type == SchematicType::Recipe)
{
foundRecipeChoice = true;
break;
}
}
sim.applySchematicChoice(0);
}
}
CHECK(foundRecipeChoice);
}
// ---------------------------------------------------------------------------
// reset() restores initial lock state (REQ-LOCK-EXPLICIT, REQ-CFG-RELOAD)
// ---------------------------------------------------------------------------
TEST_CASE("RecipeSchematic: reset re-locks a previously awarded recipe schematic",
"[recipe_schematic]")
{
Simulation sim(loadConfig());
awaitRecipeUnlock(sim, "quick_circuit");
REQUIRE(sim.isRecipeUnlocked("quick_circuit"));
sim.reset();
REQUIRE_FALSE(sim.isRecipeUnlocked("quick_circuit"));
}
TEST_CASE("RecipeSchematic: reset keeps -1 recipes unlocked and their seed items accessible",
"[recipe_schematic]")
{
Simulation sim(loadConfig());
sim.reset();
REQUIRE(sim.isRecipeUnlocked("premium_circuit"));
REQUIRE(sim.isItemUnlocked("premium_circuit"));
}
// ---------------------------------------------------------------------------
// Unlock dialog: newly-unlocked recipe preview (REQ-DEF-SCHEMATIC-DROP)
// ---------------------------------------------------------------------------
TEST_CASE("RecipeSchematic: newlyUnlockedItemNames is sorted, deduplicated, and empty for level-ups",
"[recipe_schematic]")
{
Simulation sim(loadConfig());
for (int i = 0; i < 100; ++i)
{
killEnemyStations(sim);
if (!sim.hasSchematicChoicesPending()) { continue; }
for (const SchematicChoiceOption& opt : sim.getPendingSchematicChoices())
{
// Strictly ascending implies sorted and deduplicated.
for (std::size_t j = 1; j < opt.newlyUnlockedItemNames.size(); ++j)
{
CHECK(opt.newlyUnlockedItemNames[j - 1] < opt.newlyUnlockedItemNames[j]);
}
// A level-up doesn't change the explicit unlock state, so the
// implicit unlock set - and thus this preview - must be empty.
if (!opt.isNewUnlock)
{
CHECK(opt.newlyUnlockedItemNames.empty());
}
}
sim.applySchematicChoice(0);
}
}
TEST_CASE("RecipeSchematic: newlyUnlockedItemNames matches recipes that actually become unlocked",
"[recipe_schematic]")
{
Simulation sim(loadConfig());
const GameConfig cfg = loadConfig();
auto unlockedTrackedRecipeIds = [&]()
{
std::set<std::string> ids;
for (const RecipeDef& def : cfg.recipes.recipes)
{
if ((def.building == BuildingType::Miner || def.building == BuildingType::Assembler)
&& sim.isRecipeUnlocked(def.id))
{
ids.insert(def.id);
}
}
return ids;
};
for (int i = 0; i < 100; ++i)
{
killEnemyStations(sim);
if (!sim.hasSchematicChoicesPending()) { continue; }
const std::set<std::string> unlockedBefore = unlockedTrackedRecipeIds();
const SchematicChoiceOption choice = sim.getPendingSchematicChoices()[0];
sim.applySchematicChoice(0);
std::set<std::string> expectedNames;
for (const RecipeDef& def : cfg.recipes.recipes)
{
if ((def.building == BuildingType::Miner || def.building == BuildingType::Assembler)
&& sim.isRecipeUnlocked(def.id) && unlockedBefore.count(def.id) == 0)
{
for (const RecipeOutput& out : def.outputs)
{
expectedNames.insert(toDisplayName(out.item));
}
}
}
const std::vector<std::string> expected(expectedNames.begin(), expectedNames.end());
REQUIRE(choice.newlyUnlockedItemNames == expected);
}
}

View File

@@ -4,17 +4,21 @@
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "ConfigLoader.h" #include "ConfigLoader.h"
#include "DynamicBodyComponent.h"
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "HealthComponent.h" #include "HealthComponent.h"
#include "ItemType.h" #include "ItemType.h"
#include "ModuleOwnerComponent.h"
#include "ModulesConfig.h" #include "ModulesConfig.h"
#include "Rotation.h" #include "Rotation.h"
#include "SensorRangeComponent.h" #include "SensorRangeComponent.h"
#include "ShipLayout.h" #include "ShipLayout.h"
#include "ShipStatsCalculator.h"
#include "ShipSystem.h" #include "ShipSystem.h"
#include "Simulation.h" #include "Simulation.h"
#include "Tick.h" #include "Tick.h"
#include "WeaponComponent.h"
static GameConfig loadConfig() static GameConfig loadConfig()
{ {
@@ -33,6 +37,17 @@ static const ShipDef* findSchematic(const GameConfig& cfg, const std::string& id
return nullptr; return nullptr;
} }
static entt::entity findFirstWeaponChild(EntityAdmin& admin, entt::entity ship)
{
entt::entity result = entt::null;
admin.forEach<WeaponComponent, ModuleOwnerComponent>(
[&](entt::entity ce, const WeaponComponent&, const ModuleOwnerComponent& o)
{
if (o.owner == ship && result == entt::null) { result = ce; }
});
return result;
}
static const BuildingDef* findShipyardDef(const GameConfig& cfg) static const BuildingDef* findShipyardDef(const GameConfig& cfg)
{ {
for (const BuildingDef& def : cfg.buildings.buildings) for (const BuildingDef& def : cfg.buildings.buildings)
@@ -283,3 +298,219 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
REQUIRE(b2 != nullptr); REQUIRE(b2 != nullptr);
CHECK_FALSE(b2->shipLayout.has_value()); CHECK_FALSE(b2->shipLayout.has_value());
} }
// ---------------------------------------------------------------------------
// Weapon modifier simulation tests
// ---------------------------------------------------------------------------
TEST_CASE("Ship spawn: weapon_primer multiplies attack rate in simulation", "[modules]")
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findSchematic(sim.config(), "interceptor");
REQUIRE(def != nullptr);
ShipLayoutConfig layout;
for (const std::string& id : {"laser_cannon", "weapon_primer"})
{
PlacedModule pm;
pm.moduleId = id;
pm.position = QPoint(0, 0);
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
}
const entt::entity ship = sim.ships().spawn("interceptor",
def->schematic.playerProductionLevel,
QVector2D(5.0f, 5.0f), false, layout);
const entt::entity weapon = findFirstWeaponChild(sim.admin(), ship);
REQUIRE(sim.admin().isValid(weapon));
// base rate = 2.0 hz; weapon_primer multiplier = 1.2 → 2.4 hz
CHECK(sim.admin().get<WeaponComponent>(weapon).fireRateHz == Approx(2.4f));
}
TEST_CASE("Ship spawn: weapon_stabilizer multiplies attack range in simulation", "[modules]")
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findSchematic(sim.config(), "interceptor");
REQUIRE(def != nullptr);
const float tileSize = static_cast<float>(sim.config().world.tileSize_m);
ShipLayoutConfig layout;
for (const std::string& id : {"laser_cannon", "weapon_stabilizer"})
{
PlacedModule pm;
pm.moduleId = id;
pm.position = QPoint(0, 0);
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
}
const entt::entity ship = sim.ships().spawn("interceptor",
def->schematic.playerProductionLevel,
QVector2D(5.0f, 5.0f), false, layout);
const entt::entity weapon = findFirstWeaponChild(sim.admin(), ship);
REQUIRE(sim.admin().isValid(weapon));
// base range = 50 m / tileSize = 5 tiles; weapon_stabilizer multiplier = 1.5 → 7.5 tiles
CHECK(sim.admin().get<WeaponComponent>(weapon).range_tiles == Approx(50.0f / tileSize * 1.5f));
}
TEST_CASE("Ship spawn: afterburner additive main_acceleration is converted m/s² to tiles/tick", "[modules]")
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findSchematic(sim.config(), "interceptor");
REQUIRE(def != nullptr);
const double x = static_cast<double>(def->schematic.playerProductionLevel);
const float tileSize = static_cast<float>(sim.config().world.tileSize_m);
const float tickRate = static_cast<float>(kTickRateHz);
const float base_mpss = static_cast<float>(def->movement.mainAccelerationFormula.evaluate(x));
ShipLayoutConfig layout;
PlacedModule pm;
pm.moduleId = "afterburner";
pm.position = QPoint(0, 0);
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
const entt::entity e = sim.ships().spawn("interceptor",
def->schematic.playerProductionLevel,
QVector2D(5.0f, 5.0f), false, layout);
// added_main_acceleration_mpss = 60; same conversion as base: / tileSize / tickRate
const float expected = (base_mpss + 60.0f) / tileSize / tickRate;
CHECK(sim.admin().get<DynamicBodyComponent>(e).mainAcceleration_tptt == Approx(expected));
}
TEST_CASE("Ship spawn: maneuvering_thrusters additive maneuvering_acceleration is converted m/s² to tiles/tick", "[modules]")
{
Simulation sim(loadConfig(), 42);
const ShipDef* def = findSchematic(sim.config(), "interceptor");
REQUIRE(def != nullptr);
const double x = static_cast<double>(def->schematic.playerProductionLevel);
const float tileSize = static_cast<float>(sim.config().world.tileSize_m);
const float tickRate = static_cast<float>(kTickRateHz);
const float base_mpss = static_cast<float>(def->movement.maneuveringAccelerationFormula.evaluate(x));
ShipLayoutConfig layout;
PlacedModule pm;
pm.moduleId = "maneuvering_thrusters";
pm.position = QPoint(0, 0);
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
const entt::entity e = sim.ships().spawn("interceptor",
def->schematic.playerProductionLevel,
QVector2D(5.0f, 5.0f), false, layout);
// added_maneuvering_acceleration_mpss = 10; same conversion as base: / tileSize / tickRate
const float expected = (base_mpss + 10.0f) / tileSize / tickRate;
CHECK(sim.admin().get<DynamicBodyComponent>(e).maneuveringAcceleration_tptt == Approx(expected));
}
// ---------------------------------------------------------------------------
// Weapon modifier stats view tests (calculateShipStats)
// ---------------------------------------------------------------------------
TEST_CASE("calculateShipStats: weapon_primer multiplies attack rate in stats view", "[modules]")
{
const GameConfig cfg = loadConfig();
const ShipDef* def = findSchematic(cfg, "interceptor");
REQUIRE(def != nullptr);
std::vector<PlacedModule> modules;
for (const std::string& id : {"laser_cannon", "weapon_primer"})
{
PlacedModule pm;
pm.moduleId = id;
pm.position = QPoint(0, 0);
pm.rotation = Rotation::East;
modules.push_back(pm);
}
const ShipStats stats = calculateShipStats(cfg, "interceptor",
def->schematic.playerProductionLevel, modules);
REQUIRE(stats.weapons.has_value());
// base: damage = 2, rate = 2.0 hz; weapon_primer multiplies rate by 1.2 → DPS = 2 * 2.4 = 4.8
CHECK(stats.weapons->combinedDps == Approx(4.8f));
}
TEST_CASE("calculateShipStats: weapon_stabilizer multiplies attack range in stats view", "[modules]")
{
const GameConfig cfg = loadConfig();
const ShipDef* def = findSchematic(cfg, "interceptor");
REQUIRE(def != nullptr);
const float tileSize = static_cast<float>(cfg.world.tileSize_m);
std::vector<PlacedModule> modules;
for (const std::string& id : {"laser_cannon", "weapon_stabilizer"})
{
PlacedModule pm;
pm.moduleId = id;
pm.position = QPoint(0, 0);
pm.rotation = Rotation::East;
modules.push_back(pm);
}
const ShipStats stats = calculateShipStats(cfg, "interceptor",
def->schematic.playerProductionLevel, modules);
REQUIRE(stats.weapons.has_value());
// base range = 50 m / tileSize; weapon_stabilizer multiplier = 1.5
CHECK(stats.weapons->maxRange_tiles == Approx(50.0f / tileSize * 1.5f));
}
TEST_CASE("calculateShipStats: afterburner additive main_acceleration is converted m/s² to tiles/s²", "[modules]")
{
const GameConfig cfg = loadConfig();
const ShipDef* def = findSchematic(cfg, "interceptor");
REQUIRE(def != nullptr);
const double x = static_cast<double>(def->schematic.playerProductionLevel);
const float tileSize = static_cast<float>(cfg.world.tileSize_m);
const float base_mpss = static_cast<float>(def->movement.mainAccelerationFormula.evaluate(x));
std::vector<PlacedModule> modules;
PlacedModule pm;
pm.moduleId = "afterburner";
pm.position = QPoint(0, 0);
pm.rotation = Rotation::East;
modules.push_back(pm);
const ShipStats stats = calculateShipStats(cfg, "interceptor",
def->schematic.playerProductionLevel, modules);
// added_main_acceleration_mpss = 60; converted to tiles/s²: / tileSize
const float expected = (base_mpss + 60.0f) / tileSize;
CHECK(stats.mainAcceleration_tpss == Approx(expected));
}
TEST_CASE("calculateShipStats: maneuvering_thrusters additive maneuvering_acceleration is converted m/s² to tiles/s²", "[modules]")
{
const GameConfig cfg = loadConfig();
const ShipDef* def = findSchematic(cfg, "interceptor");
REQUIRE(def != nullptr);
const double x = static_cast<double>(def->schematic.playerProductionLevel);
const float tileSize = static_cast<float>(cfg.world.tileSize_m);
const float base_mpss = static_cast<float>(def->movement.maneuveringAccelerationFormula.evaluate(x));
std::vector<PlacedModule> modules;
PlacedModule pm;
pm.moduleId = "maneuvering_thrusters";
pm.position = QPoint(0, 0);
pm.rotation = Rotation::East;
modules.push_back(pm);
const ShipStats stats = calculateShipStats(cfg, "interceptor",
def->schematic.playerProductionLevel, modules);
// added_maneuvering_acceleration_mpss = 10; converted to tiles/s²: / tileSize
const float expected = (base_mpss + 10.0f) / tileSize;
CHECK(stats.maneuveringAcceleration_tpss == Approx(expected));
}

View File

@@ -23,7 +23,7 @@ static const ShipDef* findAvailableSchematic(const GameConfig& cfg)
{ {
for (const ShipDef& def : cfg.ships.ships) for (const ShipDef& def : cfg.ships.ships)
{ {
if (def.availableFromStart && !def.schematic.materials.empty()) if (def.unlockAtStationLevel == -1 && !def.schematic.materials.empty())
{ {
return &def; return &def;
} }

View File

@@ -43,29 +43,29 @@ TEST_CASE("Simulation::tick 10 times yields currentTick == 10", "[simulation]")
REQUIRE(sim.currentTick() == 10); REQUIRE(sim.currentTick() == 10);
} }
TEST_CASE("Simulation::drainFireEvents returns empty initially", "[simulation]") TEST_CASE("Simulation::drainWeaponFiredEvents returns empty initially", "[simulation]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadConfig());
REQUIRE(sim.drainFireEvents().empty()); REQUIRE(sim.drainWeaponFiredEvents().empty());
} }
TEST_CASE("Simulation::drainFireEvents clears queue on drain", "[simulation]") TEST_CASE("Simulation::drainWeaponFiredEvents clears queue on drain", "[simulation]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadConfig());
// First drain: empty. // First drain: empty.
sim.drainFireEvents(); sim.drainWeaponFiredEvents();
// Second drain must also be empty (not a double-return). // Second drain must also be empty (not a double-return).
REQUIRE(sim.drainFireEvents().empty()); REQUIRE(sim.drainWeaponFiredEvents().empty());
} }
TEST_CASE("Simulation::drainSchematicDropEvents returns empty initially", "[simulation]") TEST_CASE("Simulation::hasSchematicChoicesPending returns false initially", "[simulation]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadConfig());
REQUIRE(sim.drainSchematicDropEvents().empty()); REQUIRE_FALSE(sim.hasSchematicChoicesPending());
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -0,0 +1,110 @@
#include "catch.hpp"
#include "ConfigLoader.h"
#include "ThreatCostCalculator.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
TEST_CASE("ThreatCostCalculator: miner item threat equals duration", "[threat]")
{
const GameConfig cfg = loadConfig();
const ThreatCostTable& table = cfg.threatCosts;
CHECK(table.itemThreat.at("iron_ore") == Approx(1.0));
CHECK(table.itemThreat.at("copper_ore") == Approx(1.5));
}
TEST_CASE("ThreatCostCalculator: smelter item threat includes input costs", "[threat]")
{
const GameConfig cfg = loadConfig();
const ThreatCostTable& table = cfg.threatCosts;
// iron_ingot: duration 2.0 + iron_ore(1.0) * 2 = 4.0
CHECK(table.itemThreat.at("iron_ingot") == Approx(4.0));
// copper_ingot: duration 2.5 + copper_ore(1.5) * 2 = 5.5
CHECK(table.itemThreat.at("copper_ingot") == Approx(5.5));
}
TEST_CASE("ThreatCostCalculator: assembler takes max across recipes", "[threat]")
{
const GameConfig cfg = loadConfig();
const ThreatCostTable& table = cfg.threatCosts;
// circuit_board has three non-reprocessing recipes:
// 5.0 + iron_ingot(4.0)*3 + copper_ingot(5.5)*2 = 28.0
// 3.0 + copper_ingot(5.5)*3 = 19.5
// 6.0 + iron_ingot(4.0)*5 = 26.0
// max = 28.0
CHECK(table.itemThreat.at("circuit_board") == Approx(28.0));
}
TEST_CASE("ThreatCostCalculator: scrap threat from cheapest ship", "[threat]")
{
const GameConfig cfg = loadConfig();
const ThreatCostTable& table = cfg.threatCosts;
// Cheapest ship by scrap_drop is interceptor (scrap_drop=2).
// Interceptor threat: 10 + iron_ingot(4)*3 + circuit_board(28)*1
// + laser_cannon(5 + iron_ingot(4)*1) = 10 + 12 + 28 + 9 = 59.0
// scrapThreat = 59.0 / 2 = 29.5
CHECK(table.scrapThreat == Approx(29.5));
}
TEST_CASE("ThreatCostCalculator: reprocessing-only item threat", "[threat]")
{
const GameConfig cfg = loadConfig();
const ThreatCostTable& table = cfg.threatCosts;
// advanced_alloy: reprocessing recipe with scrap*5, duration 3.0, probability 0.1
// (29.5 * 5 + 3.0) / 0.1 = 1505.0
CHECK(table.itemThreat.at("advanced_alloy") == Approx(1505.0));
}
TEST_CASE("ThreatCostCalculator: ship threat with default modules", "[threat]")
{
const GameConfig cfg = loadConfig();
const ThreatCostTable& table = cfg.threatCosts;
// interceptor: 10 + iron_ingot(4)*3 + circuit_board(28)*1 + laser_cannon(5 + 4*1) = 59.0
double interceptorThreat = calculateShipThreatCost(
table, cfg, "interceptor", cfg.ships.ships[0].defaultModules);
CHECK(interceptorThreat == Approx(59.0));
// salvage_ship (no default modules): 10 + iron_ingot(4)*4 = 26.0
double salvageThreat = calculateShipThreatCost(
table, cfg, "salvage_ship", cfg.ships.ships[2].defaultModules);
CHECK(salvageThreat == Approx(26.0));
}
TEST_CASE("ThreatCostCalculator: ship threat with custom modules", "[threat]")
{
const GameConfig cfg = loadConfig();
const ThreatCostTable& table = cfg.threatCosts;
// interceptor base: 10 + iron_ingot(4)*3 + circuit_board(28)*1 = 50.0
// + armor_plate: 3 + iron_ingot(4)*2 = 11.0
// + sensor_booster: 2 + circuit_board(28)*1 = 30.0
// total = 50.0 + 11.0 + 30.0 = 91.0
std::vector<PlacedModule> modules;
PlacedModule armor;
armor.moduleId = "armor_plate";
modules.push_back(armor);
PlacedModule sensor;
sensor.moduleId = "sensor_booster";
modules.push_back(sensor);
double threat = calculateShipThreatCost(table, cfg, "interceptor", modules);
CHECK(threat == Approx(91.0));
}
TEST_CASE("ThreatCostCalculator: unknown ship returns zero", "[threat]")
{
const GameConfig cfg = loadConfig();
const ThreatCostTable& table = cfg.threatCosts;
double threat = calculateShipThreatCost(table, cfg, "nonexistent_ship", {});
CHECK(threat == Approx(0.0));
}

View File

@@ -16,8 +16,13 @@
#include "ShipSystem.h" #include "ShipSystem.h"
#include "StationBodyComponent.h" #include "StationBodyComponent.h"
#include "WeaponComponent.h" #include "WeaponComponent.h"
#include "ModulesConfig.h"
#include "RecipesConfig.h"
#include "SchematicChoiceOption.h"
#include "ShipsConfig.h"
#include "Simulation.h" #include "Simulation.h"
#include "Tick.h" #include "Tick.h"
#include "ThreatCostCalculator.h"
#include "WaveSystem.h" #include "WaveSystem.h"
static GameConfig loadConfig() static GameConfig loadConfig()
@@ -46,6 +51,35 @@ TEST_CASE("WaveSystem: threat accumulates at boss wave counter rate", "[wave]")
REQUIRE(ws.threatLevel() == Approx(1.0)); REQUIRE(ws.threatLevel() == Approx(1.0));
} }
TEST_CASE("WaveSystem: threatAccumulationRate matches the rate formula outside quiet windows",
"[wave]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
// threat_rate_formula = "x", boss wave counter starts at 1 → rate = 1 threat/s.
REQUIRE(ws.threatAccumulationRate() == Approx(1.0));
}
TEST_CASE("WaveSystem: threatAccumulationRate is 0 during a quiet window", "[wave]")
{
GameConfig cfg = loadConfig();
// Start with the boss countdown already at the pre-boss quiet threshold.
cfg.world.waves.bossCountdownSeconds = cfg.world.waves.bossQuietBeforeSeconds;
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
REQUIRE(ws.threatAccumulationRate() == Approx(0.0));
const double before = ws.threatLevel();
for (int i = 0; i < static_cast<int>(secondsToTicks(1.0)); ++i)
{
ws.tickThreatAccumulation();
}
REQUIRE(ws.threatLevel() == Approx(before));
}
TEST_CASE("WaveSystem: generation starts at 0 and increments on station destruction", "[wave]") TEST_CASE("WaveSystem: generation starts at 0 and increments on station destruction", "[wave]")
{ {
const GameConfig cfg = loadConfig(); const GameConfig cfg = loadConfig();
@@ -197,25 +231,16 @@ TEST_CASE("WaveSystem: enemy ships spawn after the initial gap elapses", "[wave]
REQUIRE(foundEnemyShip); REQUIRE(foundEnemyShip);
} }
TEST_CASE("WaveSystem: only eligible ships (cost > 0) appear in waves", "[wave]") TEST_CASE("WaveSystem: all ships have positive dynamic threat cost", "[wave]")
{ {
Simulation sim(loadConfig(), 42); const GameConfig cfg = loadConfig();
// Run long enough for several waves. for (const ShipDef& def : cfg.ships.ships)
const int limit = static_cast<int>(secondsToTicks(120.0));
for (int i = 0; i < limit; ++i)
{ {
sim.tick(); const double cost = calculateShipThreatCost(cfg.threatCosts, cfg,
def.id, def.defaultModules);
CHECK(cost > 0.0);
} }
sim.admin().forEach<ShipIdentityComponent, FactionComponent>(
[&](entt::entity /*e*/, const ShipIdentityComponent& si, const FactionComponent& f)
{
if (!f.isEnemy) { return; }
// salvage_ship and repair_ship have cost_formula = "0" and must not spawn.
REQUIRE(si.schematicId != "salvage_ship");
REQUIRE(si.schematicId != "repair_ship");
});
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -245,7 +270,7 @@ TEST_CASE("WaveSystem: destroying both enemy stations triggers a push", "[wave]"
REQUIRE(enemyCount == 2); REQUIRE(enemyCount == 2);
} }
TEST_CASE("WaveSystem: push emits exactly one SchematicDropEvent", "[wave]") TEST_CASE("WaveSystem: push generates pending schematic choices", "[wave]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadConfig(), 42);
@@ -257,11 +282,13 @@ TEST_CASE("WaveSystem: push emits exactly one SchematicDropEvent", "[wave]")
sim.tick(); sim.tick();
const std::vector<SchematicDropEvent> events = sim.drainSchematicDropEvents(); REQUIRE(sim.hasSchematicChoicesPending());
REQUIRE(events.size() == 1); const std::vector<SchematicChoiceOption>& choices = sim.getPendingSchematicChoices();
REQUIRE(choices.size() >= 1);
REQUIRE(choices.size() <= 3);
} }
TEST_CASE("WaveSystem: push schematic drop awards a known ship id", "[wave]") TEST_CASE("WaveSystem: push schematic choices have valid ids", "[wave]")
{ {
Simulation sim(loadConfig(), 42); Simulation sim(loadConfig(), 42);
@@ -272,20 +299,68 @@ TEST_CASE("WaveSystem: push schematic drop awards a known ship id", "[wave]")
}); });
sim.tick(); sim.tick();
const std::vector<SchematicDropEvent> events = sim.drainSchematicDropEvents(); const std::vector<SchematicChoiceOption>& choices = sim.getPendingSchematicChoices();
REQUIRE(events.size() == 1); REQUIRE_FALSE(choices.empty());
bool validId = false; for (const SchematicChoiceOption& opt : choices)
for (const ShipDef& def : sim.config().ships.ships)
{ {
if (def.id == events[0].schematicId) bool validId = false;
for (const ShipDef& def : sim.config().ships.ships)
{ {
validId = true; if (def.id == opt.schematicId) { validId = true; break; }
break;
} }
if (!validId)
{
for (const ModuleDef& def : sim.config().modules.modules)
{
if (def.id == opt.schematicId) { validId = true; break; }
}
}
if (!validId)
{
for (const RecipeDef& def : sim.config().recipes.recipes)
{
if (def.id == opt.schematicId) { validId = true; break; }
}
}
REQUIRE(validId);
} }
REQUIRE(validId); }
REQUIRE(events[0].newLevel >= 1);
TEST_CASE("WaveSystem: schematic choices have no duplicates", "[wave]")
{
Simulation sim(loadConfig(), 42);
sim.admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
{
if (f.isEnemy) { h.hp = -1.0f; }
});
sim.tick();
const std::vector<SchematicChoiceOption>& choices = sim.getPendingSchematicChoices();
std::set<std::string> ids;
for (const SchematicChoiceOption& opt : choices)
{
ids.insert(opt.schematicId);
}
REQUIRE(ids.size() == choices.size());
}
TEST_CASE("WaveSystem: applySchematicChoice clears pending and applies", "[wave]")
{
Simulation sim(loadConfig(), 42);
sim.admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
{
if (f.isEnemy) { h.hp = -1.0f; }
});
sim.tick();
REQUIRE(sim.hasSchematicChoicesPending());
sim.applySchematicChoice(0);
REQUIRE_FALSE(sim.hasSchematicChoicesPending());
} }
TEST_CASE("WaveSystem: push places new enemy stations further right", "[wave]") TEST_CASE("WaveSystem: push places new enemy stations further right", "[wave]")

View File

@@ -12,8 +12,11 @@
#include <QScrollArea> #include <QScrollArea>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "BlueprintPlacementRequestedEvent.h"
#include "BlueprintSerializer.h" #include "BlueprintSerializer.h"
#include "BuildingBlocksChangedEvent.h" #include "BuildingBlocksChangedEvent.h"
#include "EventManager.h"
#include "ExitBlueprintModeRequestedEvent.h"
#include "Building.h" #include "Building.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
@@ -62,12 +65,12 @@ BlueprintPanel::BlueprintPanel(Simulation* sim, const GameConfig* config, QWidge
connect(m_saveBtn, &QPushButton::clicked, this, &BlueprintPanel::onSaveClicked); connect(m_saveBtn, &QPushButton::clicked, this, &BlueprintPanel::onSaveClicked);
connect(m_loadBtn, &QPushButton::clicked, this, &BlueprintPanel::onLoadClicked); connect(m_loadBtn, &QPushButton::clicked, this, &BlueprintPanel::onLoadClicked);
registerForEvent(); registerForEvents();
} }
BlueprintPanel::~BlueprintPanel() BlueprintPanel::~BlueprintPanel()
{ {
unregisterForEvent(); unregisterForEvents();
} }
void BlueprintPanel::onSelectionChanged(const std::vector<BuildingId>& ids) void BlueprintPanel::onSelectionChanged(const std::vector<BuildingId>& ids)
@@ -114,7 +117,8 @@ void BlueprintPanel::onDeleteBlueprintClicked(int index)
if (m_activeIndex == index) if (m_activeIndex == index)
{ {
m_activeIndex = -1; m_activeIndex = -1;
emit exitBlueprintModeRequested(); EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExitBlueprintModeRequestedEvent>());
} }
else if (m_activeIndex > index) else if (m_activeIndex > index)
{ {
@@ -131,7 +135,8 @@ void BlueprintPanel::onBlueprintButtonClicked(int index)
if (m_activeIndex == index) if (m_activeIndex == index)
{ {
clearActiveBlueprintButton(); clearActiveBlueprintButton();
emit exitBlueprintModeRequested(); EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExitBlueprintModeRequestedEvent>());
return; return;
} }
@@ -142,7 +147,8 @@ void BlueprintPanel::onBlueprintButtonClicked(int index)
m_activeIndex = index; m_activeIndex = index;
m_blueprintButtons[static_cast<std::size_t>(index)]->setChecked(true); m_blueprintButtons[static_cast<std::size_t>(index)]->setChecked(true);
emit blueprintPlacementRequested(m_blueprints[static_cast<std::size_t>(index)]); EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintPlacementRequestedEvent>(m_blueprints[static_cast<std::size_t>(index)]));
} }
Blueprint BlueprintPanel::createBlueprintFromSelection() const Blueprint BlueprintPanel::createBlueprintFromSelection() const
@@ -312,7 +318,8 @@ void BlueprintPanel::onLoadClicked()
if (m_activeIndex >= 0) if (m_activeIndex >= 0)
{ {
emit exitBlueprintModeRequested(); EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExitBlueprintModeRequestedEvent>());
m_activeIndex = -1; m_activeIndex = -1;
} }
@@ -350,3 +357,13 @@ void BlueprintPanel::refreshButtonStates()
canAfford || m_activeIndex == i); canAfford || m_activeIndex == i);
} }
} }
void BlueprintPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> event)
{
onSelectionChanged(event->ids);
}
void BlueprintPanel::handleEvent(std::shared_ptr<const BlueprintModeExitedEvent> /*event*/)
{
clearActiveBlueprintButton();
}

View File

@@ -5,10 +5,12 @@
#include <QWidget> #include <QWidget>
#include "Blueprint.h" #include "Blueprint.h"
#include "BlueprintModeExitedEvent.h"
#include "BuildingBlocksChangedEvent.h" #include "BuildingBlocksChangedEvent.h"
#include "BuildingId.h" #include "BuildingId.h"
#include "EventHandler.h" #include "EventHandler.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "SelectionChangedEvent.h"
#include "Tick.h" #include "Tick.h"
class Simulation; class Simulation;
@@ -17,7 +19,9 @@ class QScrollArea;
class QVBoxLayout; class QVBoxLayout;
class BlueprintPanel : public QWidget, class BlueprintPanel : public QWidget,
public EventHandler<BuildingBlocksChangedEvent> public CombinedEventHandler<BuildingBlocksChangedEvent,
SelectionChangedEvent,
BlueprintModeExitedEvent>
{ {
Q_OBJECT Q_OBJECT
@@ -25,16 +29,10 @@ public:
BlueprintPanel(Simulation* sim, const GameConfig* config, QWidget* parent = nullptr); BlueprintPanel(Simulation* sim, const GameConfig* config, QWidget* parent = nullptr);
~BlueprintPanel() override; ~BlueprintPanel() override;
public slots:
void onSelectionChanged(const std::vector<BuildingId>& ids);
void clearActiveBlueprintButton();
signals:
void blueprintPlacementRequested(Blueprint blueprint);
void exitBlueprintModeRequested();
private: private:
void handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) override; void handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) override;
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const BlueprintModeExitedEvent> event) override;
private slots: private slots:
void onCreateClicked(); void onCreateClicked();
@@ -44,6 +42,8 @@ private slots:
void onLoadClicked(); void onLoadClicked();
private: private:
void onSelectionChanged(const std::vector<BuildingId>& ids);
void clearActiveBlueprintButton();
Blueprint createBlueprintFromSelection() const; Blueprint createBlueprintFromSelection() const;
int computeBlueprintCost(const Blueprint& bp) const; int computeBlueprintCost(const Blueprint& bp) const;
void rebuildButtons(); void rebuildButtons();

View File

@@ -1,6 +1,5 @@
#include "BuildButtonGrid.h" #include "BuildButtonGrid.h"
#include <cctype>
#include <string> #include <string>
#include <QGridLayout> #include <QGridLayout>
@@ -8,35 +7,11 @@
#include <QSignalMapper> #include <QSignalMapper>
#include "BuildingType.h" #include "BuildingType.h"
#include "BuildingTypeSelectedEvent.h"
namespace #include "DemolishModeToggleRequestedEvent.h"
{ #include "DisplayName.h"
#include "EventManager.h"
QString displayName(const std::string& id) #include "ExitBuilderModeRequestedEvent.h"
{
QString result;
bool nextUpper = true;
for (char c : id)
{
if (c == '_')
{
result += ' ';
nextUpper = true;
}
else if (nextUpper)
{
result += static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
nextUpper = false;
}
else
{
result += c;
}
}
return result;
}
} // namespace
BuildButtonGrid::BuildButtonGrid(const GameConfig* config, QWidget* parent) BuildButtonGrid::BuildButtonGrid(const GameConfig* config, QWidget* parent)
@@ -62,7 +37,7 @@ BuildButtonGrid::BuildButtonGrid(const GameConfig* config, QWidget* parent)
m_types.push_back(def.type); m_types.push_back(def.type);
m_costs[def.type] = def.cost; m_costs[def.type] = def.cost;
const QString label = displayName(def.id) const QString label = QString::fromStdString(toDisplayName(def.id))
+ "\n" + tr("%1 Blocks").arg(def.cost); + "\n" + tr("%1 Blocks").arg(def.cost);
QPushButton* btn = new QPushButton(label, this); QPushButton* btn = new QPushButton(label, this);
btn->setCheckable(true); btn->setCheckable(true);
@@ -87,7 +62,17 @@ BuildButtonGrid::BuildButtonGrid(const GameConfig* config, QWidget* parent)
m_demolishButton->setCheckable(true); m_demolishButton->setCheckable(true);
m_demolishButton->setFixedHeight(48); m_demolishButton->setFixedHeight(48);
layout->addWidget(m_demolishButton, row, col); layout->addWidget(m_demolishButton, row, col);
connect(m_demolishButton, SIGNAL(clicked()), this, SIGNAL(demolishModeToggleRequested())); connect(m_demolishButton, &QPushButton::clicked, this, [this]() {
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DemolishModeToggleRequestedEvent>());
});
registerForEvents();
}
BuildButtonGrid::~BuildButtonGrid()
{
unregisterForEvents();
} }
void BuildButtonGrid::updateAffordability(int buildingBlocks) void BuildButtonGrid::updateAffordability(int buildingBlocks)
@@ -101,11 +86,6 @@ void BuildButtonGrid::updateAffordability(int buildingBlocks)
} }
} }
void BuildButtonGrid::setDemolishModeActive(bool active)
{
m_demolishButton->setChecked(active);
}
void BuildButtonGrid::clearActiveButton() void BuildButtonGrid::clearActiveButton()
{ {
if (m_activeIndex >= 0 && m_activeIndex < static_cast<int>(m_buttons.size())) if (m_activeIndex >= 0 && m_activeIndex < static_cast<int>(m_buttons.size()))
@@ -125,7 +105,8 @@ void BuildButtonGrid::onBuildButton(int index)
if (m_activeIndex == index) if (m_activeIndex == index)
{ {
clearActiveButton(); clearActiveButton();
emit builderModeExited(); EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExitBuilderModeRequestedEvent>());
return; return;
} }
@@ -136,5 +117,16 @@ void BuildButtonGrid::onBuildButton(int index)
m_activeIndex = index; m_activeIndex = index;
m_buttons[static_cast<std::size_t>(index)]->setChecked(true); m_buttons[static_cast<std::size_t>(index)]->setChecked(true);
emit buildingTypeSelected(m_types[static_cast<std::size_t>(index)]); EventManager::getInstance()->sendEventImmediately(
std::make_shared<BuildingTypeSelectedEvent>(m_types[static_cast<std::size_t>(index)]));
}
void BuildButtonGrid::handleEvent(std::shared_ptr<const BuilderModeExitedEvent> /*event*/)
{
clearActiveButton();
}
void BuildButtonGrid::handleEvent(std::shared_ptr<const DemolishModeChangedEvent> event)
{
m_demolishButton->setChecked(event->active);
} }

View File

@@ -5,28 +5,30 @@
#include <QWidget> #include <QWidget>
#include "BuilderModeExitedEvent.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "DemolishModeChangedEvent.h"
#include "EventHandler.h"
#include "GameConfig.h" #include "GameConfig.h"
class QPushButton; class QPushButton;
class BuildButtonGrid : public QWidget class BuildButtonGrid : public QWidget,
public CombinedEventHandler<BuilderModeExitedEvent,
DemolishModeChangedEvent>
{ {
Q_OBJECT Q_OBJECT
public: public:
BuildButtonGrid(const GameConfig* config, QWidget* parent = nullptr); BuildButtonGrid(const GameConfig* config, QWidget* parent = nullptr);
~BuildButtonGrid() override;
void updateAffordability(int buildingBlocks); void updateAffordability(int buildingBlocks);
void clearActiveButton(); void clearActiveButton();
signals: private:
void buildingTypeSelected(BuildingType type); void handleEvent(std::shared_ptr<const BuilderModeExitedEvent> event) override;
void builderModeExited(); void handleEvent(std::shared_ptr<const DemolishModeChangedEvent> event) override;
void demolishModeToggleRequested();
public slots:
void setDemolishModeActive(bool active);
private slots: private slots:
void onBuildButton(int index); void onBuildButton(int index);

View File

@@ -11,6 +11,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.h
PARENT_SCOPE PARENT_SCOPE
) )
@@ -26,5 +27,6 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.cpp
PARENT_SCOPE PARENT_SCOPE
) )

View File

@@ -15,19 +15,23 @@
#include <QPainter> #include <QPainter>
#include <QPen> #include <QPen>
#include <QPolygonF> #include <QPolygonF>
#include <QStringList>
#include <QTimer> #include <QTimer>
#include "BeltSystem.h" #include "BeltSystem.h"
#include "Building.h" #include "Building.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "DemolishModeChangedEvent.h"
#include "EntityHitTest.h" #include "EntityHitTest.h"
#include "EntitySelectedEvent.h" #include "EntitySelectedEvent.h"
#include "EventManager.h" #include "EventManager.h"
#include "FacingComponent.h" #include "FacingComponent.h"
#include "FactionComponent.h" #include "FactionComponent.h"
#include "GameOverEvent.h"
#include "HealthComponent.h" #include "HealthComponent.h"
#include "PositionComponent.h" #include "PositionComponent.h"
#include "ScrapSystem.h" #include "ScrapSystem.h"
#include "SelectionChangedEvent.h"
#include "SensorRangeComponent.h" #include "SensorRangeComponent.h"
#include "ShipIdentityComponent.h" #include "ShipIdentityComponent.h"
#include "ShipSystem.h" #include "ShipSystem.h"
@@ -35,10 +39,14 @@
#include "StationBodyComponent.h" #include "StationBodyComponent.h"
#include "SurfaceMask.h" #include "SurfaceMask.h"
#include "Tick.h" #include "Tick.h"
#include "EscapeMenuRequestedEvent.h"
#include "TracePrintRequestedEvent.h" #include "TracePrintRequestedEvent.h"
#include "BossWaveUpdatedEvent.h" #include "BossWaveUpdatedEvent.h"
#include "BuilderModeExitedEvent.h"
#include "BlueprintModeExitedEvent.h"
#include "BuildingBlocksChangedEvent.h" #include "BuildingBlocksChangedEvent.h"
#include "GameSpeedChangedEvent.h" #include "GameSpeedChangedEvent.h"
#include "SchematicChoicesAvailableEvent.h"
#include "TickAdvancedEvent.h" #include "TickAdvancedEvent.h"
namespace namespace
@@ -68,31 +76,6 @@ Rotation rotateCounterClockwise(Rotation r)
return Rotation::East; return Rotation::East;
} }
QString toDisplayName(const std::string& id)
{
QString result;
bool nextUpper = true;
for (char c : id)
{
if (c == '_')
{
result += ' ';
nextUpper = true;
}
else if (nextUpper)
{
result += static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
nextUpper = false;
}
else
{
result += c;
}
}
return result;
}
QPoint portBodyTile(QPoint portTile, Rotation direction) QPoint portBodyTile(QPoint portTile, Rotation direction)
{ {
switch (direction) switch (direction)
@@ -129,6 +112,7 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
, m_scrollLeft(false) , m_scrollLeft(false)
, m_scrollRight(false) , m_scrollRight(false)
, m_gameOverShown(false) , m_gameOverShown(false)
, m_schematicChoiceShown(false)
{ {
setFocusPolicy(Qt::StrongFocus); setFocusPolicy(Qt::StrongFocus);
setMouseTracking(true); setMouseTracking(true);
@@ -138,6 +122,13 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
connect(m_renderTimer, &QTimer::timeout, this, &GameWorldView::onFrame); connect(m_renderTimer, &QTimer::timeout, this, &GameWorldView::onFrame);
m_renderTimer->start(); m_renderTimer->start();
m_frameTimer.start(); m_frameTimer.start();
registerForEvents();
}
GameWorldView::~GameWorldView()
{
unregisterForEvents();
} }
void GameWorldView::initializeGL() void GameWorldView::initializeGL()
@@ -160,53 +151,13 @@ void GameWorldView::onFrame()
} }
} }
// Drain fire events → active beams // Emit fire events via EventManager
{ {
const std::vector<FireEvent> fires = m_sim->drainFireEvents(); const std::vector<WeaponFiredEvent> fires = m_sim->drainWeaponFiredEvents();
for (const FireEvent& fe : fires) for (const WeaponFiredEvent& fe : fires)
{ {
float maxRadius = 0.125f; EventManager::getInstance()->sendEventImmediately(
if (m_sim->admin().isValid(fe.target) std::make_shared<WeaponFiredEvent>(fe));
&& m_sim->admin().hasAll<StationBodyComponent>(fe.target))
{
const StationBodyComponent& sb = m_sim->admin().get<StationBodyComponent>(fe.target);
const int shorter = std::min(sb.footprint.width(), sb.footprint.height());
maxRadius = shorter / 2.0f;
}
std::uniform_real_distribution<float> angleDist(0.0f, 6.28318530f);
std::uniform_real_distribution<float> radiusDist(0.0f, maxRadius);
const float angle = angleDist(m_rng);
const float radius = radiusDist(m_rng);
ActiveBeam beam;
beam.event = fe;
beam.emittedWallMs = m_wallMs;
beam.targetOffset = QVector2D(radius * std::cos(angle),
radius * std::sin(angle));
m_activeBeams.push_back(beam);
}
}
// Drain schematic drop events → toasts
{
const std::vector<SchematicDropEvent> drops =
m_sim->drainSchematicDropEvents();
for (const SchematicDropEvent& ev : drops)
{
const QString shipName = toDisplayName(ev.schematicId);
ToastEntry toast;
if (ev.wasNewUnlock)
{
toast.text = "Schematic unlocked: " + shipName;
}
else
{
toast.text = shipName + " production level -> "
+ QString::number(ev.newLevel);
}
toast.createdWallMs = m_wallMs;
m_toasts.push_back(toast);
} }
} }
@@ -223,19 +174,6 @@ void GameWorldView::onFrame()
m_activeBeams = std::move(live); m_activeBeams = std::move(live);
} }
// Expire old toasts
{
std::vector<ToastEntry> live;
for (const ToastEntry& t : m_toasts)
{
if (m_wallMs - t.createdWallMs < kToastLifetimeMs)
{
live.push_back(t);
}
}
m_toasts = std::move(live);
}
// Apply held scroll // Apply held scroll
{ {
const float delta = kScrollSpeedTilesPerSec const float delta = kScrollSpeedTilesPerSec
@@ -273,12 +211,25 @@ void GameWorldView::onFrame()
} }
} }
// Schematic choice available
if (m_sim->hasSchematicChoicesPending() && !m_schematicChoiceShown)
{
m_schematicChoiceShown = true;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SchematicChoicesAvailableEvent>(m_sim->getPendingSchematicChoices()));
}
if (!m_sim->hasSchematicChoicesPending())
{
m_schematicChoiceShown = false;
}
// Game over check // Game over check
if (m_sim->isGameOver() && !m_gameOverShown) if (m_sim->isGameOver() && !m_gameOverShown)
{ {
m_gameOverShown = true; m_gameOverShown = true;
m_gameSpeedMultiplier = 0.0; m_gameSpeedMultiplier = 0.0;
emit gameOver(); EventManager::getInstance()->sendEventImmediately(
std::make_shared<GameOverEvent>());
} }
update(); update();
@@ -557,7 +508,12 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
} }
else else
{ {
m_sim->buildings().setRecipe(id, bb.recipeId); const bool needsUnlockCheck = bb.type == BuildingType::Miner
|| bb.type == BuildingType::Assembler;
if (!needsUnlockCheck || m_sim->isRecipeUnlocked(bb.recipeId))
{
m_sim->buildings().setRecipe(id, bb.recipeId);
}
} }
} }
@@ -969,10 +925,18 @@ void GameWorldView::drawDebugOverlay(QPainter& painter)
{ {
painter.resetTransform(); painter.resetTransform();
const QString line1 = tr("Accumulated Threat Level: %1") const QStringList lines = {
.arg(m_sim->threatLevel(), 0, 'f', 1); tr("Accumulated Threat Level: %1")
const QString line2 = tr("Time until Wave: %1s") .arg(m_sim->threatLevel(), 0, 'f', 1),
.arg(ticksToSeconds(m_sim->normalGapRemainingTicks()), 0, 'f', 1); tr("Time until Wave: %1s")
.arg(ticksToSeconds(m_sim->normalGapRemainingTicks()), 0, 'f', 1),
tr("Threat Accumulation Rate: %1 threat/s")
.arg(m_sim->threatAccumulationRate(), 0, 'f', 1),
tr("Max Factory Production: %1 threat/s")
.arg(m_sim->maxFactoryProductionThreatRate(), 0, 'f', 1),
tr("Current Factory Production: %1 threat/s")
.arg(m_sim->currentFactoryProductionThreatRate(), 0, 'f', 1),
};
QFont font = painter.font(); QFont font = painter.font();
font.setPointSize(m_visuals->toast.fontSize); font.setPointSize(m_visuals->toast.fontSize);
@@ -982,19 +946,26 @@ void GameWorldView::drawDebugOverlay(QPainter& painter)
const int lineH = fm.height(); const int lineH = fm.height();
const int padding = 8; const int padding = 8;
const int spacing = 4; const int spacing = 4;
const int textW = std::max(fm.horizontalAdvance(line1),
fm.horizontalAdvance(line2)); int textW = 0;
for (const QString& line : lines)
{
textW = std::max(textW, fm.horizontalAdvance(line));
}
const int bgW = textW + padding * 2; const int bgW = textW + padding * 2;
const int bgH = lineH * 2 + spacing + padding * 2; const int bgH = lineH * lines.size() + spacing * (lines.size() - 1) + padding * 2;
const QRect bgRect(padding, padding, bgW, bgH); const QRect bgRect(padding, padding, bgW, bgH);
painter.fillRect(bgRect, QColor(0, 0, 0, 160)); painter.fillRect(bgRect, QColor(0, 0, 0, 160));
painter.setPen(Qt::white); painter.setPen(Qt::white);
const QRect textRect1(padding * 2, padding + padding, textW, lineH); int y = padding * 2;
const QRect textRect2(padding * 2, textRect1.bottom() + spacing, textW, lineH); for (const QString& line : lines)
painter.drawText(textRect1, Qt::AlignLeft | Qt::AlignVCenter, line1); {
painter.drawText(textRect2, Qt::AlignLeft | Qt::AlignVCenter, line2); const QRect textRect(padding * 2, y, textW, lineH);
painter.drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, line);
y += lineH + spacing;
}
} }
void GameWorldView::drawBeams(QPainter& painter) void GameWorldView::drawBeams(QPainter& painter)
@@ -1089,41 +1060,8 @@ void GameWorldView::drawOverlays(QPainter& painter)
} }
} }
void GameWorldView::drawScreenSpace(QPainter& painter) void GameWorldView::drawScreenSpace(QPainter& /*painter*/)
{ {
painter.resetTransform();
const int margin = 8;
const int toastW = 320;
const int toastH = 36;
const int spacing = 4;
QFont toastFont = painter.font();
toastFont.setPointSize(m_visuals->toast.fontSize);
painter.setFont(toastFont);
int y = margin;
for (const ToastEntry& toast : m_toasts)
{
const qint64 age = m_wallMs - toast.createdWallMs;
double opacity = 1.0;
if (age > kToastFadeStartMs)
{
opacity = 1.0 - static_cast<double>(age - kToastFadeStartMs)
/ static_cast<double>(kToastLifetimeMs - kToastFadeStartMs);
opacity = std::max(0.0, opacity);
}
painter.setOpacity(opacity);
const int x = width() - toastW - margin;
const QRect toastRect(x, y, toastW, toastH);
painter.fillRect(toastRect, m_visuals->toast.bg);
painter.setPen(m_visuals->toast.fg);
painter.drawText(toastRect.adjusted(8, 0, -8, 0),
Qt::AlignVCenter | Qt::AlignLeft, toast.text);
painter.setOpacity(1.0);
y += toastH + spacing;
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1214,13 +1152,16 @@ void GameWorldView::keyPressEvent(QKeyEvent* event)
} }
break; break;
case Qt::Key_Escape: case Qt::Key_Escape:
emit escapeMenuRequested(); EventManager::getInstance()->sendEventImmediately(
std::make_shared<EscapeMenuRequestedEvent>());
break; break;
case Qt::Key_Backspace: case Qt::Key_Backspace:
toggleDemolishMode(); toggleDemolishMode();
break; break;
case Qt::Key_M: case Qt::Key_M:
m_debugDraw = !m_debugDraw; m_debugDraw = !m_debugDraw;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DebugDrawToggledEvent>(m_debugDraw));
break; break;
case Qt::Key_L: case Qt::Key_L:
EventManager::getInstance()->addEvent(std::make_shared<TracePrintRequestedEvent>()); EventManager::getInstance()->addEvent(std::make_shared<TracePrintRequestedEvent>());
@@ -1301,7 +1242,8 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
if (hitEntity != entt::null) if (hitEntity != entt::null)
{ {
m_selectedBuildingIds.clear(); m_selectedBuildingIds.clear();
emit selectionChanged(m_selectedBuildingIds); EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
m_selectedEntity = hitEntity; m_selectedEntity = hitEntity;
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<EntitySelectedEvent>(hitEntity)); std::make_shared<EntitySelectedEvent>(hitEntity));
@@ -1338,14 +1280,16 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
{ {
m_selectedBuildingIds = { id }; m_selectedBuildingIds = { id };
} }
emit selectionChanged(m_selectedBuildingIds); EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
} }
else else
{ {
if (!(event->modifiers() & Qt::ControlModifier)) if (!(event->modifiers() & Qt::ControlModifier))
{ {
m_selectedBuildingIds.clear(); m_selectedBuildingIds.clear();
emit selectionChanged(m_selectedBuildingIds); EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
} }
m_boxSelecting = true; m_boxSelecting = true;
m_boxStartTile = tile; m_boxStartTile = tile;
@@ -1444,12 +1388,13 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
if (!found) { m_selectedBuildingIds.push_back(id); } if (!found) { m_selectedBuildingIds.push_back(id); }
} }
} }
emit selectionChanged(m_selectedBuildingIds); EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
} }
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Slots // Methods (formerly slots)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
void GameWorldView::toggleDemolishMode() void GameWorldView::toggleDemolishMode()
@@ -1465,7 +1410,8 @@ void GameWorldView::toggleDemolishMode()
if (m_blueprintMode.has_value()) { exitBlueprintMode(); } if (m_blueprintMode.has_value()) { exitBlueprintMode(); }
m_demolishMode = true; m_demolishMode = true;
} }
emit demolishModeChanged(m_demolishMode); EventManager::getInstance()->sendEventImmediately(
std::make_shared<DemolishModeChangedEvent>(m_demolishMode));
} }
void GameWorldView::enterBuilderMode(BuildingType type) void GameWorldView::enterBuilderMode(BuildingType type)
@@ -1475,14 +1421,16 @@ void GameWorldView::enterBuilderMode(BuildingType type)
m_ghostValid = false; m_ghostValid = false;
m_demolishMode = false; m_demolishMode = false;
m_blueprintMode.reset(); m_blueprintMode.reset();
emit demolishModeChanged(false); EventManager::getInstance()->sendEventImmediately(
std::make_shared<DemolishModeChangedEvent>(false));
} }
void GameWorldView::enterBlueprintMode(Blueprint blueprint) void GameWorldView::enterBlueprintMode(Blueprint blueprint)
{ {
if (m_builderType.has_value()) { exitBuilderMode(); } if (m_builderType.has_value()) { exitBuilderMode(); }
m_demolishMode = false; m_demolishMode = false;
emit demolishModeChanged(false); EventManager::getInstance()->sendEventImmediately(
std::make_shared<DemolishModeChangedEvent>(false));
m_blueprintGhostTile = m_ghostTile; m_blueprintGhostTile = m_ghostTile;
m_blueprintMode = std::move(blueprint); m_blueprintMode = std::move(blueprint);
} }
@@ -1490,7 +1438,8 @@ void GameWorldView::enterBlueprintMode(Blueprint blueprint)
void GameWorldView::exitBlueprintMode() void GameWorldView::exitBlueprintMode()
{ {
m_blueprintMode.reset(); m_blueprintMode.reset();
emit blueprintModeExited(); EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintModeExitedEvent>());
} }
void GameWorldView::exitBuilderMode() void GameWorldView::exitBuilderMode()
@@ -1498,7 +1447,8 @@ void GameWorldView::exitBuilderMode()
m_builderType.reset(); m_builderType.reset();
m_beltDragTiles.clear(); m_beltDragTiles.clear();
m_dragging = false; m_dragging = false;
emit builderModeExited(); EventManager::getInstance()->sendEventImmediately(
std::make_shared<BuilderModeExitedEvent>());
} }
double GameWorldView::gameSpeed() const double GameWorldView::gameSpeed() const
@@ -1506,6 +1456,16 @@ double GameWorldView::gameSpeed() const
return m_gameSpeedMultiplier; return m_gameSpeedMultiplier;
} }
bool GameWorldView::isDebugDrawEnabled() const
{
return m_debugDraw;
}
void GameWorldView::resetFrameTimer()
{
m_frameTimer.restart();
}
void GameWorldView::setGameSpeed(double multiplier) void GameWorldView::setGameSpeed(double multiplier)
{ {
m_gameSpeedMultiplier = multiplier; m_gameSpeedMultiplier = multiplier;
@@ -1518,12 +1478,13 @@ void GameWorldView::resetForNewGame()
exitBuilderMode(); exitBuilderMode();
exitBlueprintMode(); exitBlueprintMode();
m_activeBeams.clear(); m_activeBeams.clear();
m_toasts.clear(); m_schematicChoiceShown = false;
m_ghostRotation = Rotation::East; m_ghostRotation = Rotation::East;
m_ghostValid = false; m_ghostValid = false;
m_demolishMode = false; m_demolishMode = false;
m_demolishHoverBuildingId = kInvalidBuildingId; m_demolishHoverBuildingId = kInvalidBuildingId;
emit demolishModeChanged(false); EventManager::getInstance()->sendEventImmediately(
std::make_shared<DemolishModeChangedEvent>(false));
m_selectedBuildingIds.clear(); m_selectedBuildingIds.clear();
m_boxSelecting = false; m_boxSelecting = false;
m_scrollXTiles = 0.0f; m_scrollXTiles = 0.0f;
@@ -1535,7 +1496,66 @@ void GameWorldView::resetForNewGame()
m_lastBlocks = -1; m_lastBlocks = -1;
m_lastBossCounter = -1; m_lastBossCounter = -1;
m_lastBossCountdown = Tick(-1); m_lastBossCountdown = Tick(-1);
emit selectionChanged({}); EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionChangedEvent>(std::vector<BuildingId>{}));
setGameSpeed(1.0); setGameSpeed(1.0);
update(); update();
} }
// ---------------------------------------------------------------------------
// Event handlers
// ---------------------------------------------------------------------------
void GameWorldView::handleEvent(std::shared_ptr<const WeaponFiredEvent> event)
{
float maxRadius = 0.125f;
if (m_sim->admin().isValid(event->target)
&& m_sim->admin().hasAll<StationBodyComponent>(event->target))
{
const StationBodyComponent& sb = m_sim->admin().get<StationBodyComponent>(event->target);
const int shorter = std::min(sb.footprint.width(), sb.footprint.height());
maxRadius = shorter / 2.0f;
}
std::uniform_real_distribution<float> angleDist(0.0f, 6.28318530f);
std::uniform_real_distribution<float> radiusDist(0.0f, maxRadius);
const float angle = angleDist(m_rng);
const float radius = radiusDist(m_rng);
ActiveBeam beam;
beam.event = *event;
beam.emittedWallMs = m_wallMs;
beam.targetOffset = QVector2D(radius * std::cos(angle),
radius * std::sin(angle));
m_activeBeams.push_back(beam);
}
void GameWorldView::handleEvent(std::shared_ptr<const BuildingTypeSelectedEvent> event)
{
enterBuilderMode(event->type);
}
void GameWorldView::handleEvent(std::shared_ptr<const ExitBuilderModeRequestedEvent> /*event*/)
{
exitBuilderMode();
}
void GameWorldView::handleEvent(std::shared_ptr<const DemolishModeToggleRequestedEvent> /*event*/)
{
toggleDemolishMode();
}
void GameWorldView::handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> event)
{
enterBlueprintMode(event->blueprint);
}
void GameWorldView::handleEvent(std::shared_ptr<const ExitBlueprintModeRequestedEvent> /*event*/)
{
exitBlueprintMode();
}
void GameWorldView::handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent> event)
{
setGameSpeed(event->multiplier);
}

View File

@@ -13,10 +13,21 @@
#include <QVector2D> #include <QVector2D>
#include "Blueprint.h" #include "Blueprint.h"
#include "SchematicDropEvent.h" #include "BlueprintModeExitedEvent.h"
#include "BlueprintPlacementRequestedEvent.h"
#include "BuilderModeExitedEvent.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "BuildingTypeSelectedEvent.h"
#include "BuildingId.h" #include "BuildingId.h"
#include "FireEvent.h" #include "DemolishModeChangedEvent.h"
#include "DemolishModeToggleRequestedEvent.h"
#include "EventHandler.h"
#include "ExitBlueprintModeRequestedEvent.h"
#include "ExitBuilderModeRequestedEvent.h"
#include "DebugDrawToggledEvent.h"
#include "WeaponFiredEvent.h"
#include "SchematicChoiceOption.h"
#include "SpeedChangeRequestedEvent.h"
#include "entt/entity/entity.hpp" #include "entt/entity/entity.hpp"
#include "EntitySelectedEvent.h" #include "EntitySelectedEvent.h"
@@ -38,31 +49,25 @@ struct QPointCompare
} }
}; };
class GameWorldView : public QOpenGLWidget class GameWorldView : public QOpenGLWidget,
public CombinedEventHandler<WeaponFiredEvent,
BuildingTypeSelectedEvent,
ExitBuilderModeRequestedEvent,
DemolishModeToggleRequestedEvent,
BlueprintPlacementRequestedEvent,
ExitBlueprintModeRequestedEvent,
SpeedChangeRequestedEvent>
{ {
Q_OBJECT Q_OBJECT
public: public:
GameWorldView(Simulation* sim, const GameConfig* config, GameWorldView(Simulation* sim, const GameConfig* config,
const VisualsConfig* visuals, QWidget* parent = nullptr); const VisualsConfig* visuals, QWidget* parent = nullptr);
~GameWorldView() override;
signals:
void selectionChanged(const std::vector<BuildingId>& ids);
void gameOver();
void builderModeExited();
void blueprintModeExited();
void escapeMenuRequested();
void demolishModeChanged(bool active);
public:
double gameSpeed() const; double gameSpeed() const;
bool isDebugDrawEnabled() const;
public slots: void resetFrameTimer();
void enterBuilderMode(BuildingType type);
void exitBuilderMode();
void enterBlueprintMode(Blueprint blueprint);
void exitBlueprintMode();
void toggleDemolishMode();
void setGameSpeed(double multiplier); void setGameSpeed(double multiplier);
void resetForNewGame(); void resetForNewGame();
@@ -79,6 +84,14 @@ private slots:
void onFrame(); void onFrame();
private: private:
void handleEvent(std::shared_ptr<const WeaponFiredEvent> event) override;
void handleEvent(std::shared_ptr<const BuildingTypeSelectedEvent> event) override;
void handleEvent(std::shared_ptr<const ExitBuilderModeRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const DemolishModeToggleRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const ExitBlueprintModeRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent> event) override;
void drawTiles(QPainter& painter); void drawTiles(QPainter& painter);
void drawBuildings(QPainter& painter); void drawBuildings(QPainter& painter);
void drawStations(QPainter& painter); void drawStations(QPainter& painter);
@@ -118,22 +131,20 @@ private:
void stepSpeed(int delta); void stepSpeed(int delta);
void placeAtTile(QPoint tile); void placeAtTile(QPoint tile);
void enterBuilderMode(BuildingType type);
void exitBuilderMode();
void enterBlueprintMode(Blueprint blueprint);
void exitBlueprintMode();
void toggleDemolishMode();
struct ActiveBeam struct ActiveBeam
{ {
FireEvent event; WeaponFiredEvent event;
qint64 emittedWallMs; qint64 emittedWallMs;
QVector2D targetOffset; QVector2D targetOffset;
}; };
struct ToastEntry
{
QString text;
qint64 createdWallMs;
};
static constexpr qint64 kBeamLifetimeMs = 300; static constexpr qint64 kBeamLifetimeMs = 300;
static constexpr qint64 kToastLifetimeMs = 4000;
static constexpr qint64 kToastFadeStartMs = 3500;
static constexpr float kScrollSpeedTilesPerSec = 10.0f; static constexpr float kScrollSpeedTilesPerSec = 10.0f;
Simulation* m_sim; Simulation* m_sim;
@@ -151,7 +162,6 @@ private:
QTimer* m_renderTimer; QTimer* m_renderTimer;
std::vector<ActiveBeam> m_activeBeams; std::vector<ActiveBeam> m_activeBeams;
std::vector<ToastEntry> m_toasts;
std::optional<BuildingType> m_builderType; std::optional<BuildingType> m_builderType;
Rotation m_ghostRotation; Rotation m_ghostRotation;
@@ -176,6 +186,7 @@ private:
bool m_scrollLeft; bool m_scrollLeft;
bool m_scrollRight; bool m_scrollRight;
bool m_gameOverShown; bool m_gameOverShown;
bool m_schematicChoiceShown;
Tick m_lastTick = Tick(-1); Tick m_lastTick = Tick(-1);
int m_lastBlocks = -1; int m_lastBlocks = -1;

View File

@@ -8,6 +8,8 @@
#include <QPushButton> #include <QPushButton>
#include <QSignalMapper> #include <QSignalMapper>
#include "EventManager.h"
#include "SpeedChangeRequestedEvent.h"
#include "Tick.h" #include "Tick.h"
const double HeaderBar::kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 }; const double HeaderBar::kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 };
@@ -90,6 +92,7 @@ void HeaderBar::onSpeedButton(int index)
{ {
if (index >= 0 && index < kSpeedCount) if (index >= 0 && index < kSpeedCount)
{ {
emit speedChanged(kSpeeds[index]); EventManager::getInstance()->sendEventImmediately(
std::make_shared<SpeedChangeRequestedEvent>(kSpeeds[index]));
} }
} }

View File

@@ -26,9 +26,6 @@ public:
explicit HeaderBar(QWidget* parent = nullptr); explicit HeaderBar(QWidget* parent = nullptr);
~HeaderBar() override; ~HeaderBar() override;
signals:
void speedChanged(double multiplier);
private slots: private slots:
void onSpeedButton(int index); void onSpeedButton(int index);

View File

@@ -1,9 +1,11 @@
#include "MainWindow.h" #include "MainWindow.h"
#include <map>
#include <set>
#include <QApplication> #include <QApplication>
#include <QCloseEvent> #include <QCloseEvent>
#include <QFile> #include <QFile>
#include <QHBoxLayout>
#include <QMessageBox> #include <QMessageBox>
#include <QPushButton> #include <QPushButton>
#include <QResizeEvent> #include <QResizeEvent>
@@ -15,6 +17,7 @@
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "ConfigLoader.h" #include "ConfigLoader.h"
#include "GameWorldView.h" #include "GameWorldView.h"
#include "SchematicChoiceDialog.h"
#include "HeaderBar.h" #include "HeaderBar.h"
#include "SelectedBuildingPanel.h" #include "SelectedBuildingPanel.h"
#include "ShipLayoutBlueprintSerializer.h" #include "ShipLayoutBlueprintSerializer.h"
@@ -36,64 +39,18 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir, QWidget* p
m_gameWorldView = new GameWorldView(sim, &sim->config(), &m_visuals, this); m_gameWorldView = new GameWorldView(sim, &sim->config(), &m_visuals, this);
m_bottomPanel = new QWidget(this); m_sidePanel = new QWidget(this);
QHBoxLayout* bottomLayout = new QHBoxLayout(m_bottomPanel); QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel);
bottomLayout->setContentsMargins(0, 0, 0, 0); sideLayout->setContentsMargins(0, 0, 0, 0);
bottomLayout->setSpacing(0); sideLayout->setSpacing(0);
m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->config(), m_bottomPanel); m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->config(), m_sidePanel);
m_buildButtonGrid = new BuildButtonGrid(&sim->config(), m_bottomPanel); m_buildButtonGrid = new BuildButtonGrid(&sim->config(), m_sidePanel);
m_blueprintPanel = new BlueprintPanel(sim, &sim->config(), m_bottomPanel); m_blueprintPanel = new BlueprintPanel(sim, &sim->config(), m_sidePanel);
bottomLayout->addWidget(m_selectedBuildingPanel, 1); sideLayout->addWidget(m_selectedBuildingPanel, 1);
bottomLayout->addWidget(m_buildButtonGrid, 1); sideLayout->addWidget(m_buildButtonGrid, 1);
bottomLayout->addWidget(m_blueprintPanel, 1); sideLayout->addWidget(m_blueprintPanel, 1);
// Signals: game world → other panels
connect(m_gameWorldView, &GameWorldView::selectionChanged,
m_selectedBuildingPanel, &SelectedBuildingPanel::onSelectionChanged);
connect(m_gameWorldView, &GameWorldView::gameOver,
this, &MainWindow::onGameOver);
connect(m_gameWorldView, &GameWorldView::escapeMenuRequested,
this, &MainWindow::onEscapeMenuRequested);
connect(m_selectedBuildingPanel, &SelectedBuildingPanel::layoutDialogRequested,
this, &MainWindow::onLayoutDialogRequested);
// Signals: build grid → game world
connect(m_buildButtonGrid, &BuildButtonGrid::buildingTypeSelected,
m_gameWorldView, &GameWorldView::enterBuilderMode);
connect(m_buildButtonGrid, &BuildButtonGrid::builderModeExited,
m_gameWorldView, &GameWorldView::exitBuilderMode);
connect(m_gameWorldView, &GameWorldView::builderModeExited,
m_buildButtonGrid, &BuildButtonGrid::clearActiveButton);
connect(m_buildButtonGrid, &BuildButtonGrid::demolishModeToggleRequested,
m_gameWorldView, &GameWorldView::toggleDemolishMode);
connect(m_gameWorldView, &GameWorldView::demolishModeChanged,
m_buildButtonGrid, &BuildButtonGrid::setDemolishModeActive);
// Signals: blueprint panel ↔ game world
connect(m_gameWorldView, &GameWorldView::selectionChanged,
m_blueprintPanel, &BlueprintPanel::onSelectionChanged);
connect(m_blueprintPanel, &BlueprintPanel::blueprintPlacementRequested,
m_gameWorldView, &GameWorldView::enterBlueprintMode);
connect(m_blueprintPanel, &BlueprintPanel::exitBlueprintModeRequested,
m_gameWorldView, &GameWorldView::exitBlueprintMode);
connect(m_gameWorldView, &GameWorldView::blueprintModeExited,
m_blueprintPanel, &BlueprintPanel::clearActiveBlueprintButton);
// Signals: header bar → game world
connect(m_headerBar, &HeaderBar::speedChanged,
m_gameWorldView, &GameWorldView::setGameSpeed);
m_gameWorldView->setFocus(); m_gameWorldView->setFocus();
@@ -122,12 +79,12 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir, QWidget* p
} }
} }
registerForEvent(); registerForEvents();
} }
MainWindow::~MainWindow() MainWindow::~MainWindow()
{ {
unregisterForEvent(); unregisterForEvents();
} }
void MainWindow::resizeEvent(QResizeEvent* event) void MainWindow::resizeEvent(QResizeEvent* event)
@@ -159,13 +116,12 @@ void MainWindow::layoutPanels()
const int totalH = height(); const int totalH = height();
const int headerH = m_headerBar->sizeHint().height(); const int headerH = m_headerBar->sizeHint().height();
if (headerH <= 0) { return; } if (headerH <= 0) { return; }
const int remaining = totalH - headerH; const int mainW = totalW * 75 / 100;
const int gameH = remaining * 70 / 100; const int sideW = totalW - mainW;
const int panelH = remaining - gameH;
m_headerBar->setGeometry(0, 0, totalW, headerH); m_headerBar->setGeometry(0, 0, mainW, headerH);
m_gameWorldView->setGeometry(0, headerH, totalW, gameH); m_gameWorldView->setGeometry(0, headerH, mainW, totalH - headerH);
m_bottomPanel->setGeometry(0, headerH + gameH, totalW, panelH); m_sidePanel->setGeometry(mainW, 0, sideW, totalH);
} }
void MainWindow::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) void MainWindow::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event)
@@ -173,7 +129,21 @@ void MainWindow::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> e
m_buildButtonGrid->updateAffordability(event->blocks); m_buildButtonGrid->updateAffordability(event->blocks);
} }
void MainWindow::onEscapeMenuRequested() void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEvent> event)
{
const double prevSpeed = m_gameWorldView->gameSpeed();
m_gameWorldView->setGameSpeed(0.0);
SchematicChoiceDialog dialog(event->choices, this);
dialog.exec();
m_sim->applySchematicChoice(dialog.getChosenIndex());
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
}
void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*event*/)
{ {
const double prevSpeed = m_gameWorldView->gameSpeed(); const double prevSpeed = m_gameWorldView->gameSpeed();
m_gameWorldView->setGameSpeed(0.0); m_gameWorldView->setGameSpeed(0.0);
@@ -201,6 +171,7 @@ void MainWindow::onEscapeMenuRequested()
QMessageBox::critical(this, tr("Config Error"), QMessageBox::critical(this, tr("Config Error"),
tr("Failed to reload config:\n%1").arg(e.what())); tr("Failed to reload config:\n%1").arg(e.what()));
m_gameWorldView->setGameSpeed(prevSpeed); m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
return; return;
} }
m_gameWorldView->resetForNewGame(); m_gameWorldView->resetForNewGame();
@@ -212,18 +183,20 @@ void MainWindow::onEscapeMenuRequested()
else else
{ {
m_gameWorldView->setGameSpeed(prevSpeed); m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
} }
} }
void MainWindow::onLayoutDialogRequested(BuildingId shipyardId) void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event)
{ {
const double prevSpeed = m_gameWorldView->gameSpeed(); const double prevSpeed = m_gameWorldView->gameSpeed();
m_gameWorldView->setGameSpeed(0.0); m_gameWorldView->setGameSpeed(0.0);
const Building* b = m_sim->buildings().findBuilding(shipyardId); const Building* b = m_sim->buildings().findBuilding(event->shipyardId);
if (!b) if (!b)
{ {
m_gameWorldView->setGameSpeed(prevSpeed); m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
return; return;
} }
@@ -233,17 +206,33 @@ void MainWindow::onLayoutDialogRequested(BuildingId shipyardId)
currentLayout = *b->shipLayout; currentLayout = *b->shipLayout;
} }
std::set<std::string> unlockedModuleIds;
std::map<std::string, int> moduleLevels;
for (const ModuleDef& def : m_sim->config().modules.modules)
{
if (m_sim->isModuleSchematicUnlocked(def.id))
{
unlockedModuleIds.insert(def.id);
}
moduleLevels[def.id] = m_sim->moduleSchematicLevel(def.id);
}
ShipLayoutDialog dialog(&m_sim->config(), b->recipeId, currentLayout, ShipLayoutDialog dialog(&m_sim->config(), b->recipeId, currentLayout,
m_layoutBlueprints, this); m_layoutBlueprints,
std::move(unlockedModuleIds),
std::move(moduleLevels),
m_gameWorldView->isDebugDrawEnabled(),
this);
if (dialog.exec() == QDialog::Accepted && dialog.result().has_value()) if (dialog.exec() == QDialog::Accepted && dialog.result().has_value())
{ {
m_sim->buildings().setShipLayout(shipyardId, *dialog.result()); m_sim->buildings().setShipLayout(event->shipyardId, *dialog.result());
} }
m_gameWorldView->setGameSpeed(prevSpeed); m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
} }
void MainWindow::onGameOver() void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
{ {
const Tick tick = m_sim->currentTick(); const Tick tick = m_sim->currentTick();
const int totalSeconds = static_cast<int>(ticksToSeconds(tick)); const int totalSeconds = static_cast<int>(ticksToSeconds(tick));

View File

@@ -7,7 +7,11 @@
#include "BuildingBlocksChangedEvent.h" #include "BuildingBlocksChangedEvent.h"
#include "BuildingId.h" #include "BuildingId.h"
#include "EscapeMenuRequestedEvent.h"
#include "EventHandler.h" #include "EventHandler.h"
#include "GameOverEvent.h"
#include "LayoutDialogRequestedEvent.h"
#include "SchematicChoicesAvailableEvent.h"
#include "ShipLayoutBlueprint.h" #include "ShipLayoutBlueprint.h"
#include "Tick.h" #include "Tick.h"
#include "VisualsConfig.h" #include "VisualsConfig.h"
@@ -22,7 +26,11 @@ class QCloseEvent;
class QResizeEvent; class QResizeEvent;
class MainWindow : public QWidget, class MainWindow : public QWidget,
public EventHandler<BuildingBlocksChangedEvent> public CombinedEventHandler<BuildingBlocksChangedEvent,
SchematicChoicesAvailableEvent,
GameOverEvent,
EscapeMenuRequestedEvent,
LayoutDialogRequestedEvent>
{ {
Q_OBJECT Q_OBJECT
@@ -36,13 +44,12 @@ protected:
private: private:
void handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) override; void handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) override;
void handleEvent(std::shared_ptr<const SchematicChoicesAvailableEvent> event) override;
void handleEvent(std::shared_ptr<const GameOverEvent> event) override;
void handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event) override;
void layoutPanels(); void layoutPanels();
private slots:
void onGameOver();
void onEscapeMenuRequested();
void onLayoutDialogRequested(BuildingId shipyardId);
private: private:
std::string m_configDir; std::string m_configDir;
VisualsConfig m_visuals; VisualsConfig m_visuals;
@@ -52,7 +59,7 @@ private:
SelectedBuildingPanel* m_selectedBuildingPanel; SelectedBuildingPanel* m_selectedBuildingPanel;
BuildButtonGrid* m_buildButtonGrid; BuildButtonGrid* m_buildButtonGrid;
BlueprintPanel* m_blueprintPanel; BlueprintPanel* m_blueprintPanel;
QWidget* m_bottomPanel; QWidget* m_sidePanel;
std::vector<ShipLayoutBlueprint> m_layoutBlueprints; std::vector<ShipLayoutBlueprint> m_layoutBlueprints;
}; };

View File

@@ -0,0 +1,125 @@
#include "SchematicChoiceDialog.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QStringList>
#include <QVBoxLayout>
SchematicChoiceDialog::SchematicChoiceDialog(
const std::vector<SchematicChoiceOption>& options,
QWidget* parent)
: QDialog(parent)
, m_chosenIndex(0)
{
setWindowTitle(tr("Schematic Drop"));
setWindowFlags(windowFlags() & ~Qt::WindowCloseButtonHint);
setModal(true);
QVBoxLayout* mainLayout = new QVBoxLayout(this);
QLabel* titleLabel = new QLabel(tr("Choose a schematic to unlock:"), this);
QFont titleFont = titleLabel->font();
titleFont.setPointSize(titleFont.pointSize() + 2);
titleFont.setBold(true);
titleLabel->setFont(titleFont);
titleLabel->setAlignment(Qt::AlignCenter);
mainLayout->addWidget(titleLabel);
QHBoxLayout* optionsLayout = new QHBoxLayout();
mainLayout->addLayout(optionsLayout);
for (int i = 0; i < static_cast<int>(options.size()); ++i)
{
const SchematicChoiceOption& option = options[static_cast<std::size_t>(i)];
QWidget* card = new QWidget(this);
QVBoxLayout* cardLayout = new QVBoxLayout(card);
card->setStyleSheet("QWidget { border: 1px solid gray; padding: 8px; }");
QLabel* nameLabel = new QLabel(QString::fromStdString(option.displayName), card);
QFont nameFont = nameLabel->font();
nameFont.setPointSize(nameFont.pointSize() + 1);
nameFont.setBold(true);
nameLabel->setFont(nameFont);
nameLabel->setAlignment(Qt::AlignCenter);
cardLayout->addWidget(nameLabel);
QString typeText;
if (option.type == SchematicType::Ship)
{
typeText = tr("Ship");
}
else if (option.type == SchematicType::Module)
{
typeText = tr("Module");
}
else
{
typeText = tr("Recipe");
}
QLabel* typeLabel = new QLabel(typeText, card);
typeLabel->setAlignment(Qt::AlignCenter);
cardLayout->addWidget(typeLabel);
QString statusText;
if (option.isNewUnlock)
{
statusText = tr("New unlock");
}
else
{
statusText = tr("Level up -> %1").arg(option.targetLevel);
}
QLabel* statusLabel = new QLabel(statusText, card);
statusLabel->setAlignment(Qt::AlignCenter);
cardLayout->addWidget(statusLabel);
QLabel* unlocksHeaderLabel = new QLabel(tr("Unlocks recipes for:"), card);
QFont unlocksHeaderFont = unlocksHeaderLabel->font();
unlocksHeaderFont.setBold(true);
unlocksHeaderLabel->setFont(unlocksHeaderFont);
unlocksHeaderLabel->setAlignment(Qt::AlignCenter);
cardLayout->addWidget(unlocksHeaderLabel);
QString unlocksText;
if (option.newlyUnlockedItemNames.empty())
{
unlocksText = tr("None");
}
else
{
QStringList itemLines;
for (const std::string& itemName : option.newlyUnlockedItemNames)
{
itemLines << QString::fromStdString(itemName);
}
unlocksText = itemLines.join("\n");
}
QLabel* unlocksLabel = new QLabel(unlocksText, card);
unlocksLabel->setAlignment(Qt::AlignCenter);
cardLayout->addWidget(unlocksLabel);
QPushButton* selectButton = new QPushButton(tr("Select"), card);
cardLayout->addWidget(selectButton);
const int index = i;
connect(selectButton, &QPushButton::clicked, this, [this, index]()
{
onOptionClicked(index);
});
optionsLayout->addWidget(card);
}
}
int SchematicChoiceDialog::getChosenIndex() const
{
return m_chosenIndex;
}
void SchematicChoiceDialog::onOptionClicked(int index)
{
m_chosenIndex = index;
accept();
}

View File

@@ -0,0 +1,23 @@
#pragma once
#include <vector>
#include <QDialog>
#include "SchematicChoiceOption.h"
class SchematicChoiceDialog : public QDialog
{
Q_OBJECT
public:
SchematicChoiceDialog(const std::vector<SchematicChoiceOption>& options,
QWidget* parent = nullptr);
int getChosenIndex() const;
private:
void onOptionClicked(int index);
int m_chosenIndex;
};

View File

@@ -16,18 +16,21 @@
#include "DynamicBodyComponent.h" #include "DynamicBodyComponent.h"
#include "EntityAdmin.h" #include "EntityAdmin.h"
#include "EntitySelectedEvent.h" #include "EntitySelectedEvent.h"
#include "EventManager.h"
#include "FactionComponent.h" #include "FactionComponent.h"
#include "HealthComponent.h" #include "HealthComponent.h"
#include "ModuleOwnerComponent.h" #include "ModuleOwnerComponent.h"
#include "ShipIdentityComponent.h" #include "ShipIdentityComponent.h"
#include "ShipStatsCalculator.h" #include "ShipStatsCalculator.h"
#include "ShipStatsPanel.h" #include "ShipStatsPanel.h"
#include "ThreatCostCalculator.h"
#include "StationBodyComponent.h" #include "StationBodyComponent.h"
#include "TickAdvancedEvent.h" #include "TickAdvancedEvent.h"
#include "Building.h" #include "Building.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "ItemType.h" #include "ItemType.h"
#include "LayoutDialogRequestedEvent.h"
#include "ModulesConfig.h" #include "ModulesConfig.h"
#include "Rotation.h" #include "Rotation.h"
#include "ShipLayoutPreview.h" #include "ShipLayoutPreview.h"
@@ -139,7 +142,8 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
connect(m_configureLayoutBtn, &QPushButton::clicked, this, [this]() { connect(m_configureLayoutBtn, &QPushButton::clicked, this, [this]() {
if (m_singleBuildingId != kInvalidBuildingId) if (m_singleBuildingId != kInvalidBuildingId)
{ {
emit layoutDialogRequested(m_singleBuildingId); EventManager::getInstance()->sendEventImmediately(
std::make_shared<LayoutDialogRequestedEvent>(m_singleBuildingId));
} }
}); });
connect(m_filterAList, &QListWidget::itemChanged, connect(m_filterAList, &QListWidget::itemChanged,
@@ -296,12 +300,15 @@ void SelectedBuildingPanel::buildSingle(BuildingId id)
{ {
for (const RecipeDef& recipe : m_config->recipes.recipes) for (const RecipeDef& recipe : m_config->recipes.recipes)
{ {
if (recipe.building == b->type) if (recipe.building != b->type) { continue; }
if ((b->type == BuildingType::Miner || b->type == BuildingType::Assembler)
&& !m_sim->isRecipeUnlocked(recipe.id))
{ {
m_recipeCombo->addItem( continue;
QString::fromStdString(recipe.id),
QString::fromStdString(recipe.id));
} }
m_recipeCombo->addItem(
QString::fromStdString(recipe.id),
QString::fromStdString(recipe.id));
} }
} }
@@ -648,6 +655,7 @@ void SelectedBuildingPanel::buildSplitterFilters(QPoint splitterTile)
list->clear(); list->clear();
for (const std::string& itemId : items) for (const std::string& itemId : items)
{ {
if (!m_sim->isItemUnlocked(itemId)) { continue; }
QListWidgetItem* row = new QListWidgetItem( QListWidgetItem* row = new QListWidgetItem(
QString::fromStdString(itemId), list); QString::fromStdString(itemId), list);
const bool checked = filter.empty() const bool checked = filter.empty()
@@ -777,6 +785,19 @@ void SelectedBuildingPanel::buildEntityShip(entt::entity entity)
const ShipStats stats = buildShipStatsFromEntity(admin, entity); const ShipStats stats = buildShipStatsFromEntity(admin, entity);
m_entityStatsPanel->refreshFromLive(stats, health.hp); m_entityStatsPanel->refreshFromLive(stats, health.hp);
m_entityStatsPanel->setDebugDrawEnabled(m_debugDraw);
for (const ShipDef& def : m_config->ships.ships)
{
if (def.id == identity.schematicId)
{
double threat = calculateShipThreatCost(
m_config->threatCosts, *m_config, def.id, def.defaultModules);
m_entityStatsPanel->setThreatCost(threat);
break;
}
}
m_entityStatsPanel->show(); m_entityStatsPanel->show();
m_stationStatsLabel->hide(); m_stationStatsLabel->hide();
@@ -857,3 +878,14 @@ void SelectedBuildingPanel::clearEntityDisplay()
m_entityStatsPanel->hide(); m_entityStatsPanel->hide();
m_stationStatsLabel->hide(); m_stationStatsLabel->hide();
} }
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> event)
{
onSelectionChanged(event->ids);
}
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event)
{
m_debugDraw = event->active;
m_entityStatsPanel->setDebugDrawEnabled(event->active);
}

View File

@@ -11,10 +11,12 @@
#include "Building.h" #include "Building.h"
#include "BuildingId.h" #include "BuildingId.h"
#include "DebugDrawToggledEvent.h"
#include "EntitySelectedEvent.h" #include "EntitySelectedEvent.h"
#include "EventHandler.h" #include "EventHandler.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "RecipesConfig.h" #include "RecipesConfig.h"
#include "SelectionChangedEvent.h"
#include "ShipLayout.h" #include "ShipLayout.h"
#include "ShipsConfig.h" #include "ShipsConfig.h"
#include "Tick.h" #include "Tick.h"
@@ -30,7 +32,10 @@ class QPushButton;
class QVBoxLayout; class QVBoxLayout;
class SelectedBuildingPanel : public QWidget, class SelectedBuildingPanel : public QWidget,
public CombinedEventHandler<TickAdvancedEvent, EntitySelectedEvent> public CombinedEventHandler<TickAdvancedEvent,
EntitySelectedEvent,
SelectionChangedEvent,
DebugDrawToggledEvent>
{ {
Q_OBJECT Q_OBJECT
@@ -39,15 +44,11 @@ public:
QWidget* parent = nullptr); QWidget* parent = nullptr);
~SelectedBuildingPanel() override; ~SelectedBuildingPanel() override;
signals:
void layoutDialogRequested(BuildingId shipyardId);
public slots:
void onSelectionChanged(const std::vector<BuildingId>& ids);
private: private:
void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override; void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override;
void handleEvent(std::shared_ptr<const EntitySelectedEvent> event) override; void handleEvent(std::shared_ptr<const EntitySelectedEvent> event) override;
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event) override;
private slots: private slots:
void onRecipeChanged(int comboIndex); void onRecipeChanged(int comboIndex);
@@ -55,6 +56,7 @@ private slots:
void onSplitterFilterChanged(); void onSplitterFilterChanged();
private: private:
void onSelectionChanged(const std::vector<BuildingId>& ids);
void rebuild(); void rebuild();
void clearContent(); void clearContent();
void buildEmpty(); void buildEmpty();
@@ -87,6 +89,7 @@ private:
QPoint m_splitterTile; QPoint m_splitterTile;
std::string m_currentRecipeId; std::string m_currentRecipeId;
bool m_debugDraw = false;
std::optional<entt::entity> m_selectedEntity; std::optional<entt::entity> m_selectedEntity;
ShipStatsPanel* m_entityStatsPanel; ShipStatsPanel* m_entityStatsPanel;
QLabel* m_entityTitleLabel; QLabel* m_entityTitleLabel;

View File

@@ -1,9 +1,10 @@
#include "ShipLayoutDialog.h" #include "ShipLayoutDialog.h"
#include "ShipStatsPanel.h" #include "ShipStatsPanel.h"
#include <cctype>
#include <functional> #include <functional>
#include "DisplayName.h"
#include <QGridLayout> #include <QGridLayout>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QInputDialog> #include <QInputDialog>
@@ -20,30 +21,6 @@ namespace
const int kCellSize = 32; const int kCellSize = 32;
QString displayName(const std::string& id)
{
QString result;
bool nextUpper = true;
for (char c : id)
{
if (c == '_')
{
result += ' ';
nextUpper = true;
}
else if (nextUpper)
{
result += static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
nextUpper = false;
}
else
{
result += c;
}
}
return result;
}
std::vector<std::string> rotateMaskCW(const std::vector<std::string>& grid) std::vector<std::string> rotateMaskCW(const std::vector<std::string>& grid)
{ {
if (grid.empty()) if (grid.empty())
@@ -387,10 +364,15 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
const std::string& shipId, const std::string& shipId,
const ShipLayoutConfig& currentLayout, const ShipLayoutConfig& currentLayout,
std::vector<ShipLayoutBlueprint>& allBlueprints, std::vector<ShipLayoutBlueprint>& allBlueprints,
std::set<std::string> unlockedModuleIds,
std::map<std::string, int> moduleLevels,
bool debugDraw,
QWidget* parent) QWidget* parent)
: QDialog(parent) : QDialog(parent)
, m_config(config) , m_config(config)
, m_shipId(shipId) , m_shipId(shipId)
, m_unlockedModuleIds(std::move(unlockedModuleIds))
, m_moduleLevels(std::move(moduleLevels))
, m_rows(0) , m_rows(0)
, m_cols(0) , m_cols(0)
, m_placedModules(currentLayout.placedModules) , m_placedModules(currentLayout.placedModules)
@@ -399,6 +381,7 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
, m_removeButton(nullptr) , m_removeButton(nullptr)
, m_gridWidget(nullptr) , m_gridWidget(nullptr)
, m_statsPanel(nullptr) , m_statsPanel(nullptr)
, m_debugDraw(debugDraw)
{ {
setWindowTitle(tr("Configure Ship Layout")); setWindowTitle(tr("Configure Ship Layout"));
setModal(true); setModal(true);
@@ -453,6 +436,7 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
// Left column: ship stats panel. // Left column: ship stats panel.
m_statsPanel = new ShipStatsPanel(config, this); m_statsPanel = new ShipStatsPanel(config, this);
m_statsPanel->setDebugDrawEnabled(m_debugDraw);
columnsLayout->addWidget(m_statsPanel); columnsLayout->addWidget(m_statsPanel);
// Center column: module selection buttons. // Center column: module selection buttons.
@@ -469,7 +453,12 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
for (int i = 0; i < static_cast<int>(config->modules.modules.size()); ++i) for (int i = 0; i < static_cast<int>(config->modules.modules.size()); ++i)
{ {
const ModuleDef& def = config->modules.modules[i]; const ModuleDef& def = config->modules.modules[i];
const QString label = displayName(def.id) if (m_unlockedModuleIds.count(def.id) == 0)
{
m_moduleButtons.push_back(nullptr);
continue;
}
const QString label = QString::fromStdString(toDisplayName(def.id))
+ "\n" + QString::fromStdString(def.glyph); + "\n" + QString::fromStdString(def.glyph);
QPushButton* btn = new QPushButton(label, this); QPushButton* btn = new QPushButton(label, this);
btn->setCheckable(true); btn->setCheckable(true);
@@ -509,7 +498,7 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
{ {
for (QPushButton* btn : m_moduleButtons) for (QPushButton* btn : m_moduleButtons)
{ {
btn->setChecked(false); if (btn) { btn->setChecked(false); }
} }
m_activeModuleIndex = -1; m_activeModuleIndex = -1;
m_removeButton->setChecked(true); m_removeButton->setChecked(true);
@@ -628,14 +617,14 @@ void ShipLayoutDialog::onModuleButtonClicked(int index)
{ {
if (m_activeModuleIndex == index) if (m_activeModuleIndex == index)
{ {
m_moduleButtons[index]->setChecked(false); if (m_moduleButtons[index]) { m_moduleButtons[index]->setChecked(false); }
m_activeModuleIndex = -2; m_activeModuleIndex = -2;
} }
else else
{ {
for (int i = 0; i < static_cast<int>(m_moduleButtons.size()); ++i) for (int i = 0; i < static_cast<int>(m_moduleButtons.size()); ++i)
{ {
m_moduleButtons[i]->setChecked(i == index); if (m_moduleButtons[i]) { m_moduleButtons[i]->setChecked(i == index); }
} }
m_removeButton->setChecked(false); m_removeButton->setChecked(false);
m_activeModuleIndex = index; m_activeModuleIndex = index;
@@ -717,7 +706,7 @@ void ShipLayoutDialog::updateStats()
break; break;
} }
} }
m_statsPanel->refresh(m_shipId, level, m_placedModules); m_statsPanel->refresh(m_shipId, level, m_placedModules, m_moduleLevels);
} }
bool ShipLayoutDialog::canPlaceModule(const ModuleDef& def, QPoint position, bool ShipLayoutDialog::canPlaceModule(const ModuleDef& def, QPoint position,
@@ -779,13 +768,13 @@ void ShipLayoutDialog::loadLayoutBlueprint(const std::vector<PlacedModule>& modu
for (const PlacedModule& pm : modules) for (const PlacedModule& pm : modules)
{ {
// Validate module type exists. // Validate module type exists and is unlocked.
const ModuleDef* def = nullptr; const ModuleDef* def = nullptr;
for (const ModuleDef& d : m_config->modules.modules) for (const ModuleDef& d : m_config->modules.modules)
{ {
if (d.id == pm.moduleId) { def = &d; break; } if (d.id == pm.moduleId) { def = &d; break; }
} }
if (!def) { continue; } if (!def || m_unlockedModuleIds.count(def->id) == 0) { continue; }
const std::vector<std::string> mask = rotatedMask(*def, pm.rotation); const std::vector<std::string> mask = rotatedMask(*def, pm.rotation);
bool valid = true; bool valid = true;

View File

@@ -1,6 +1,8 @@
#pragma once #pragma once
#include <map>
#include <optional> #include <optional>
#include <set>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -24,6 +26,9 @@ public:
const std::string& shipId, const std::string& shipId,
const ShipLayoutConfig& currentLayout, const ShipLayoutConfig& currentLayout,
std::vector<ShipLayoutBlueprint>& allBlueprints, std::vector<ShipLayoutBlueprint>& allBlueprints,
std::set<std::string> unlockedModuleIds,
std::map<std::string, int> moduleLevels,
bool debugDraw,
QWidget* parent = nullptr); QWidget* parent = nullptr);
std::optional<ShipLayoutConfig> result() const; std::optional<ShipLayoutConfig> result() const;
@@ -57,6 +62,8 @@ private:
const GameConfig* m_config; const GameConfig* m_config;
std::string m_shipId; std::string m_shipId;
std::set<std::string> m_unlockedModuleIds;
std::map<std::string, int> m_moduleLevels;
std::vector<std::string> m_shipLayout; std::vector<std::string> m_shipLayout;
int m_rows; int m_rows;
int m_cols; int m_cols;
@@ -71,6 +78,7 @@ private:
QPushButton* m_removeButton; QPushButton* m_removeButton;
QWidget* m_gridWidget; QWidget* m_gridWidget;
ShipStatsPanel* m_statsPanel; ShipStatsPanel* m_statsPanel;
bool m_debugDraw;
std::optional<ShipLayoutConfig> m_result; std::optional<ShipLayoutConfig> m_result;
}; };

View File

@@ -6,6 +6,7 @@
#include "GameConfig.h" #include "GameConfig.h"
#include "ShipStatsCalculator.h" #include "ShipStatsCalculator.h"
#include "ThreatCostCalculator.h"
namespace namespace
{ {
@@ -103,16 +104,27 @@ ShipStatsPanel::ShipStatsPanel(const GameConfig* config, QWidget* parent)
m_repairSection->setVisible(false); m_repairSection->setVisible(false);
layout->addWidget(m_repairSection); layout->addWidget(m_repairSection);
// Threat cost — debug-only, initially hidden.
m_threatCostLabel = makeStatLabel(this);
m_threatCostLabel->setVisible(false);
layout->addWidget(m_threatCostLabel);
layout->addStretch(); layout->addStretch();
} }
void ShipStatsPanel::refresh(const std::string& shipId, void ShipStatsPanel::refresh(const std::string& shipId,
int level, int level,
const std::vector<PlacedModule>& modules) const std::vector<PlacedModule>& modules,
const std::map<std::string, int>& moduleLevelOverrides)
{ {
const ShipStats stats = calculateShipStats(*m_config, shipId, level, modules); const ShipStats stats = calculateShipStats(*m_config, shipId, level, modules,
moduleLevelOverrides);
const QString hpText = tr("HP: %1").arg(static_cast<int>(stats.hp + 0.5f)); const QString hpText = tr("HP: %1").arg(static_cast<int>(stats.hp + 0.5f));
applyStats(stats, hpText); applyStats(stats, hpText);
const double threat = calculateShipThreatCost(m_config->threatCosts, *m_config,
shipId, modules);
setThreatCost(threat);
} }
void ShipStatsPanel::refreshFromLive(const ShipStats& stats, float currentHp) void ShipStatsPanel::refreshFromLive(const ShipStats& stats, float currentHp)
@@ -178,3 +190,15 @@ void ShipStatsPanel::applyStats(const ShipStats& stats, const QString& hpText)
m_repairSection->setVisible(false); m_repairSection->setVisible(false);
} }
} }
void ShipStatsPanel::setThreatCost(double cost)
{
m_threatCostLabel->setText(tr("Threat Cost: %1").arg(cost, 0, 'f', 1));
m_threatCostLabel->setVisible(m_debugDraw);
}
void ShipStatsPanel::setDebugDrawEnabled(bool enabled)
{
m_debugDraw = enabled;
m_threatCostLabel->setVisible(m_debugDraw);
}

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