Compare commits
4 Commits
config
...
e8dd73bcb0
| Author | SHA1 | Date | |
|---|---|---|---|
| e8dd73bcb0 | |||
| 8451f5a281 | |||
| 0a1b58442c | |||
| 997a7778e0 |
@@ -1,122 +1,36 @@
|
|||||||
[[module]]
|
# modules.toml
|
||||||
id = "armor_plate"
|
#
|
||||||
unlock_at_station_level = -1
|
# First real-content iteration: module ids and surface masks are the designed
|
||||||
surface_mask = ["OO"]
|
# content; stats, materials, and threat costs are placeholders until the
|
||||||
materials = [{item = "armor_plate_module", amount = 1}]
|
# recipe and balancing passes.
|
||||||
player_production_level = 1
|
#
|
||||||
production_time_seconds = 3
|
# Surface mask footprint ladder — footprints gate which hulls can mount a
|
||||||
fill_color = "#808080"
|
# module, purely through geometry (see ships.toml for the matching hull
|
||||||
glyph = "A"
|
# grids):
|
||||||
|
#
|
||||||
[module.health]
|
# 1x1 laser_cannon_s, salvager, repair_tool fits every hull, incl. drones
|
||||||
added_hp_formula = "40"
|
# 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 = "sensor_booster"
|
id = "laser_cannon_s"
|
||||||
unlock_at_station_level = -1
|
unlock_at_station_level = -1
|
||||||
surface_mask = ["O"]
|
surface_mask = ["O"]
|
||||||
materials = [{item = "sensor_booster_module", amount = 1}]
|
materials = [{item = "laser_cannon_s_module", amount = 1}]
|
||||||
player_production_level = 1
|
|
||||||
production_time_seconds = 2
|
|
||||||
fill_color = "#40A0FF"
|
|
||||||
glyph = "S"
|
|
||||||
|
|
||||||
[module.sensor]
|
|
||||||
added_sensor_range_m_formula = "50"
|
|
||||||
|
|
||||||
|
|
||||||
[[module]]
|
|
||||||
id = "manuvering_thrusters"
|
|
||||||
unlock_at_station_level = -1
|
|
||||||
surface_mask = ["O"]
|
|
||||||
materials = [{item = "manuvering_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"
|
|
||||||
|
|
||||||
|
|
||||||
[[module]]
|
|
||||||
id = "afterburner"
|
|
||||||
unlock_at_station_level = -1
|
|
||||||
surface_mask = ["O"]
|
|
||||||
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 = "weapon_upgrade"
|
|
||||||
unlock_at_station_level = -1
|
|
||||||
surface_mask = [
|
|
||||||
"OO",
|
|
||||||
"O ",
|
|
||||||
]
|
|
||||||
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",
|
|
||||||
"O ",
|
|
||||||
]
|
|
||||||
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",
|
|
||||||
"O ",
|
|
||||||
]
|
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
[[module]]
|
|
||||||
id = "laser_cannon_xs"
|
|
||||||
unlock_at_station_level = -1
|
|
||||||
surface_mask = ["O"]
|
|
||||||
materials = [{item = "iron_ore", amount = 1}]
|
|
||||||
player_production_level = 1
|
player_production_level = 1
|
||||||
production_time_seconds = 0.5
|
production_time_seconds = 0.5
|
||||||
fill_color = "#FF8040"
|
fill_color = "#FF8040"
|
||||||
glyph = "L"
|
glyph = "Ls"
|
||||||
|
|
||||||
[module.weapon]
|
[module.weapon]
|
||||||
damage_formula = "2"
|
damage_formula = "2"
|
||||||
@@ -125,16 +39,16 @@ attack_rate_hz_formula = "2.0"
|
|||||||
|
|
||||||
|
|
||||||
[[module]]
|
[[module]]
|
||||||
id = "laser_cannon_s"
|
id = "laser_cannon_m"
|
||||||
unlock_at_station_level = -1
|
unlock_at_station_level = -1
|
||||||
surface_mask = [
|
surface_mask = [
|
||||||
"OO",
|
"OO",
|
||||||
"OO"]
|
"OO"]
|
||||||
materials = [{item = "laser_cannon_s_module", amount = 1}]
|
materials = [{item = "laser_cannon_m_module", amount = 1}]
|
||||||
player_production_level = 1
|
player_production_level = 1
|
||||||
production_time_seconds = 0.5
|
production_time_seconds = 2
|
||||||
fill_color = "#FF8040"
|
fill_color = "#FF8040"
|
||||||
glyph = "L"
|
glyph = "Lm"
|
||||||
|
|
||||||
[module.weapon]
|
[module.weapon]
|
||||||
damage_formula = "10"
|
damage_formula = "10"
|
||||||
@@ -142,6 +56,28 @@ attack_range_m_formula = "70"
|
|||||||
attack_rate_hz_formula = "1.5"
|
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
|
unlock_at_station_level = -1
|
||||||
@@ -171,3 +107,154 @@ 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"
|
||||||
|
|||||||
@@ -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,54 +65,17 @@ 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 = 2.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 = 4.0
|
duration_seconds = 4.0
|
||||||
|
|
||||||
[[recipe]]
|
# -----------------------------------------------------------------------------
|
||||||
id = "laser_cannon_xs_module"
|
# Reprocessing
|
||||||
building = "assembler"
|
#
|
||||||
inputs = [{item = "iron_ingot", amount = 2}, {item = "circuit_board", amount = 1}]
|
# The only source of advanced_alloy: salvaged scrap from destroyed ships.
|
||||||
outputs = [{item = "laser_cannon_xs_module", amount = 1}]
|
# -----------------------------------------------------------------------------
|
||||||
duration_seconds = 3.0
|
|
||||||
|
|
||||||
[[recipe]]
|
|
||||||
id = "laser_cannon_s_module"
|
|
||||||
building = "assembler"
|
|
||||||
inputs = [{item = "iron_ingot", amount = 4}, {item = "circuit_board", amount = 2}]
|
|
||||||
outputs = [{item = "laser_cannon_s_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"
|
|
||||||
unlock_at_station_level = -1
|
|
||||||
building = "assembler"
|
|
||||||
inputs = [{item = "iron_ingot", amount = 4}]
|
|
||||||
outputs = [{item = "building_block", amount = 10}]
|
|
||||||
duration_seconds = 4.0
|
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "reprocessing_cycle"
|
id = "reprocessing_cycle"
|
||||||
@@ -85,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
|
||||||
|
|||||||
@@ -1,8 +1,25 @@
|
|||||||
|
# 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"
|
||||||
unlock_at_station_level = -1
|
unlock_at_station_level = -1
|
||||||
layout = ["O"]
|
layout = ["O"]
|
||||||
default_modules = [{type = "laser_cannon_xs", x = 0, y = 0, rotation = "east"}]
|
default_modules = [{type = "laser_cannon_s", x = 0, y = 0, rotation = "east"}]
|
||||||
|
|
||||||
[ship.schematic]
|
[ship.schematic]
|
||||||
materials = [{item = "iron_ore", amount = 1}]
|
materials = [{item = "iron_ore", amount = 1}]
|
||||||
@@ -24,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
|
||||||
|
|||||||
@@ -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_xs_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)
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ enemy_buffer_width_tiles = 10
|
|||||||
level = 1
|
level = 1
|
||||||
count = 5
|
count = 5
|
||||||
modules = [
|
modules = [
|
||||||
{type = "laser_cannon_xs", x = 1, y = 1, rotation = "east"},
|
{type = "laser_cannon_s", x = 1, y = 1, rotation = "east"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[arena.team]]
|
[[arena.team]]
|
||||||
@@ -22,7 +22,7 @@ enemy_buffer_width_tiles = 10
|
|||||||
level = 1
|
level = 1
|
||||||
count = 2
|
count = 2
|
||||||
modules = [
|
modules = [
|
||||||
{type = "laser_cannon_xs", 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_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 = "weapon_upgrade", x = 1, y = 1, rotation = "east"},
|
||||||
@@ -44,7 +44,7 @@ enemy_buffer_width_tiles = 10
|
|||||||
level = 1
|
level = 1
|
||||||
count = 5
|
count = 5
|
||||||
modules = [
|
modules = [
|
||||||
{type = "laser_cannon_xs", x = 1, y = 1, rotation = "east"},
|
{type = "laser_cannon_s", x = 1, y = 1, rotation = "east"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[arena.team]]
|
[[arena.team]]
|
||||||
@@ -54,7 +54,7 @@ enemy_buffer_width_tiles = 10
|
|||||||
level = 1
|
level = 1
|
||||||
count = 3
|
count = 3
|
||||||
modules = [
|
modules = [
|
||||||
{type = "laser_cannon_xs", x = 1, y = 1, rotation = "east"},
|
{type = "laser_cannon_s", x = 1, y = 1, rotation = "east"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[arena.team.ship]]
|
[[arena.team.ship]]
|
||||||
@@ -79,7 +79,7 @@ enemy_buffer_width_tiles = 15
|
|||||||
level = 1
|
level = 1
|
||||||
count = 3
|
count = 3
|
||||||
modules = [
|
modules = [
|
||||||
{type = "laser_cannon_xs", 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"
|
||||||
@@ -99,5 +99,5 @@ enemy_buffer_width_tiles = 15
|
|||||||
level = 1
|
level = 1
|
||||||
count = 8
|
count = 8
|
||||||
modules = [
|
modules = [
|
||||||
{type = "laser_cannon_xs", x = 1, y = 1, rotation = "east"},
|
{type = "laser_cannon_s", x = 1, y = 1, rotation = "east"},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -52,13 +52,13 @@ See REQ-GW-COORDS for the authoritative tile-coordinate convention. This section
|
|||||||
|
|
||||||
Simulation types shared across subsystems:
|
Simulation types shared across subsystems:
|
||||||
|
|
||||||
- `EntityId` — strictly increasing integer handle, allocated centrally by the simulation. Assigned to every targetable entity: ships, scrap drops, **and** buildings (including HQ and defence stations). Buildings additionally retain their anchor tile for spatial lookups and placement; the `EntityId` is the canonical reference used by ship-component target fields (`Weapon.currentTarget`, `RepairTool.currentTarget`, `ThreatResponse.currentTarget`, etc.), so a combat ship can target either another ship or a defence station uniformly.
|
- `EntityId` — strictly increasing integer handle, allocated centrally by the simulation. Assigned to every targetable entity: ships, scrap drops, **and** buildings (including HQ and defence stations). Buildings additionally retain their anchor tile for spatial lookups and placement; the `EntityId` is the canonical reference used by ship-component target fields (`Weapon.currentTarget`, `RepairTool.currentTarget`, `AttackBehavior.currentTarget`, etc.), so a combat ship can target either another ship or a defence station uniformly.
|
||||||
- `Rotation` — enum `{ North, East, South, West }`. The rotation applied to a building's surface_mask when placed.
|
- `Rotation` — enum `{ North, East, South, West }`. The rotation applied to a building's surface_mask when placed.
|
||||||
- `BuildingType` — enum covering every building type in requirements.md (Miner, Smelter, Assembler, ReprocessingPlant, Shipyard, SalvageBay, Belt, Splitter, Hq, PlayerDefenceStation, EnemyDefenceStation). `Belt` and `Splitter` share the enum for cost, construction, placement, and `visuals.toml` lookup, but their runtime data lives inside the belt subsystem rather than in `Building` instances (see Belt Subsystem).
|
- `BuildingType` — enum covering every building type in requirements.md (Miner, Smelter, Assembler, ReprocessingPlant, Shipyard, SalvageBay, Belt, Splitter, Hq, PlayerDefenceStation, EnemyDefenceStation). `Belt` and `Splitter` share the enum for cost, construction, placement, and `visuals.toml` lookup, but their runtime data lives inside the belt subsystem rather than in `Building` instances (see Belt Subsystem).
|
||||||
- `ItemType` — tagged id of every transportable material (ores, ingots, intermediates, building_blocks, scrap).
|
- `ItemType` — tagged id of every transportable material (ores, ingots, intermediates, building_blocks, scrap).
|
||||||
- `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 { bool active; QVector2D target; }`. Written by the winning behavior's executor (see Movement Arbitration). Cleared (`active = false`) at the start of each tick; `tickMovement` brakes when inactive, otherwise drives toward `target`.
|
||||||
- `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.
|
- `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.
|
||||||
- `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`.
|
- `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.
|
- `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.
|
||||||
@@ -107,8 +107,8 @@ Within a single simulation tick, subsystems run in this fixed order. The order i
|
|||||||
4. **Building production** — advance production timers; start new cycles when inputs and output-buffer space permit (REQ-MAT-CYCLE); on completion, deposit output.
|
4. **Building production** — advance production timers; start new cycles when inputs and output-buffer space permit (REQ-MAT-CYCLE); on completion, deposit output.
|
||||||
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 the `AiSystem` runs three batched phases: every behavior **evaluator** scores its behavior and sets its target data; a **selection** pass records the highest-scoring behavior per ship in `SelectedBehaviorComponent`; each behavior **executor** runs for the winner, writing `MovementIntent` and preferred module targets. The module systems then perform world mutation: `SalvagerSystem` (scrap collection/delivery) and `RepairSystem` (healing). See Movement Arbitration.
|
||||||
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).
|
8. **Combat resolution** — ships and defence stations validate/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, generate up to 3 schematic choice options (REQ-DEF-SCHEMATIC-DROP) stored as pending state for the UI to present; 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).
|
||||||
@@ -217,16 +217,20 @@ struct RepairTool { float ratePerTick; std::optional<EntityId> currentTarget;
|
|||||||
|
|
||||||
### Behavior Components
|
### Behavior Components
|
||||||
|
|
||||||
Behaviors are decomposed, not bundled into per-role monolithic AIs. This is the critical modeling choice: adding a capability (e.g., putting a `Weapon` on a repair ship) must not require rewriting AI code.
|
Behaviors are decomposed, not bundled into per-role monolithic AIs. This is the critical modeling choice: adding a capability (e.g., putting a `Weapon` on a repair ship) must not require rewriting AI code. Each behavior is a small component carrying its own target data plus a `float score` written by its evaluator each tick.
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
struct ThreatResponse { float engagementRange; CombatStance stance;
|
struct AdvanceBehavior { float score; }; // baseline fallback, all ships
|
||||||
CombatTargetPriority priority;
|
struct RallyBehavior { QVector2D rallyPoint; float score; }; // player combat ships
|
||||||
std::optional<EntityId> currentTarget; };
|
struct RetreatBehavior { float retreatHpFraction; QVector2D retreatPoint; // player ships
|
||||||
struct ScrapCollector { std::optional<QVector2D> scrapTarget; EntityId deliveryBay; };
|
float score; };
|
||||||
struct RepairBehavior { RepairTargetPriority priority;
|
struct AttackBehavior { std::optional<EntityId> currentTarget; float score; };
|
||||||
std::optional<EntityId> currentTarget; };
|
struct RepairBehavior { std::optional<EntityId> currentTarget;
|
||||||
struct HomeReturn { float retreatHpFraction; QVector2D homePos; };
|
float maxRepairRange_tiles; float score; };
|
||||||
|
struct SalvageScrapBehavior { std::optional<QVector2D> scrapTarget;
|
||||||
|
float maxCollectionRange_tiles; float score; };
|
||||||
|
struct DeliverScrapBehavior { BuildingId deliveryBay; float score; };
|
||||||
|
struct SelectedBehaviorComponent { BehaviorKind winner; float bestScore; }; // selection result
|
||||||
```
|
```
|
||||||
|
|
||||||
### Ship
|
### Ship
|
||||||
@@ -246,38 +250,42 @@ struct Ship {
|
|||||||
std::optional<SalvageCargo> cargo;
|
std::optional<SalvageCargo> cargo;
|
||||||
std::optional<RepairTool> repairTool;
|
std::optional<RepairTool> repairTool;
|
||||||
|
|
||||||
// Behaviors
|
// Behaviors (attached per capability; AdvanceBehavior + SelectedBehaviorComponent
|
||||||
std::optional<ThreatResponse> threatResponse;
|
// on every ship, RetreatBehavior on player ships, etc.)
|
||||||
std::optional<ScrapCollector> scrapCollector;
|
std::optional<AttackBehavior> attackBehavior;
|
||||||
std::optional<RepairBehavior> repairBehavior;
|
std::optional<SalvageScrapBehavior> salvageScrapBehavior;
|
||||||
std::optional<HomeReturn> homeReturn;
|
std::optional<DeliverScrapBehavior> deliverScrapBehavior;
|
||||||
|
std::optional<RepairBehavior> repairBehavior;
|
||||||
|
|
||||||
// Written by behavior systems, read by movement.
|
// Written by the winning behavior's executor, read by movement.
|
||||||
MovementIntent intent;
|
MovementIntent intent;
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
### Systems
|
### Systems
|
||||||
|
|
||||||
Each behavior has its own tick system. A system iterates a flat `std::vector<Ship>` and skips ships that do not have the relevant components.
|
Each behavior is split into a stateless **evaluator** and **executor** class (one per behavior, e.g. `AttackEvaluator`/`AttackExecutor`), orchestrated by `AiSystem`. Evaluators and executors only read/write behavior components and module target fields — they never mutate the game world. World mutation lives in dedicated module systems that run every tick, independent of which behavior won:
|
||||||
|
|
||||||
- `tickThreatResponse` — requires `threatResponse` + `weapon`. Acquires target, fires, manages cooldown.
|
- `CombatSystem` — validates each weapon's executor-set target, falls back to nearest-target acquisition, fires, applies damage.
|
||||||
- `tickScrapCollector` — requires `scrapCollector` + `cargo`. Flies to scrap, picks up, returns to delivery bay.
|
- `SalvagerSystem` — collects scrap into cargo and delivers full cargo at a `SalvageBay`.
|
||||||
- `tickRepairBehavior` — requires `repairBehavior` + `repairTool`. Finds damaged target, moves to range, repairs.
|
- `RepairSystem` — validates each repair tool's target, falls back to nearest damaged friendly, applies healing.
|
||||||
- `tickHomeReturn` — requires `homeReturn`. Overrides movement if hp drops below threshold.
|
- `MovementIntentSystem` (`tickMovement`) — reads `MovementIntent`, advances `position`; brakes when inactive.
|
||||||
- `tickMovement` — reads `intent`, advances `position`.
|
|
||||||
|
|
||||||
### Movement Arbitration
|
### Movement Arbitration
|
||||||
|
|
||||||
When multiple behaviors want to drive movement, a fixed global priority resolves the conflict. Each behavior system writes a `MovementIntent` carrying its priority; a higher-priority write overwrites a lower-priority one. `tickMovement` reads the final winner.
|
Arbitration is **score-based**, not fixed-priority. In a single tick `AiSystem` runs three phases:
|
||||||
|
|
||||||
Initial priority order (subject to tuning):
|
1. **Evaluate** — every behavior's evaluator iterates the ships that have its component, sets its target data, and writes a `float score` (see `BehaviorScores.h`). An evaluator returns an inactive score when its behavior does not apply.
|
||||||
|
2. **Select** — `selectWinningBehaviors` resets each `SelectedBehaviorComponent`, then compares every behavior's score per ship, recording the highest as `winner`. Behaviors are considered highest-band first so a strict `>` breaks ties toward the more urgent behavior.
|
||||||
|
3. **Execute** — each behavior's executor runs only for ships where it is the `winner`, writing the single `MovementIntent` and any preferred module targets.
|
||||||
|
|
||||||
|
`AdvanceBehavior` is present on every ship with the lowest score, guaranteeing a winner. The resulting band order:
|
||||||
|
|
||||||
```
|
```
|
||||||
HomeReturn > ThreatResponse > RepairBehavior > ScrapCollector
|
Retreat > Attack / Repair / SalvageScrap / DeliverScrap > Rally > Advance
|
||||||
```
|
```
|
||||||
|
|
||||||
`tickMovement` runs last. Intents are cleared at the start of each tick.
|
`MovementIntent` is cleared (inactive) at the start of each tick; `tickMovement` runs last.
|
||||||
|
|
||||||
### Why Not ECS
|
### Why Not ECS
|
||||||
|
|
||||||
|
|||||||
194
docs/content_design.md
Normal file
194
docs/content_design.md
Normal 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 0–1) 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.
|
||||||
@@ -161,13 +161,16 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
|||||||
- Stance: aggressive (advance toward enemies) / defensive (hold position near asteroid).
|
- Stance: aggressive (advance toward enemies) / defensive (hold position near asteroid).
|
||||||
- Target priority: closest / highest HP / structures first.
|
- Target priority: closest / highest HP / structures first.
|
||||||
- REQ-SHP-RALLY: After spawning, aggressive-stance ships with weapon modules move to and loiter at the **rally point** — the midpoint between the two player defence stations (center of their Y-span, at the player defence stations' X position). While at the rally point, ships still engage any enemy that enters sensor range. Every `world.toml [world].departure_interval_seconds` seconds (default 20), all ships with weapon modules currently at the rally point depart simultaneously and begin their normal aggressive advance toward the enemy. The departure timer is global and shared across all shipyards; it is not reset by individual ship arrivals at the rally point.
|
- REQ-SHP-RALLY: After spawning, aggressive-stance ships with weapon modules move to and loiter at the **rally point** — the midpoint between the two player defence stations (center of their Y-span, at the player defence stations' X position). While at the rally point, ships still engage any enemy that enters sensor range. Every `world.toml [world].departure_interval_seconds` seconds (default 20), all ships with weapon modules currently at the rally point depart simultaneously and begin their normal aggressive advance toward the enemy. The departure timer is global and shared across all shipyards; it is not reset by individual ship arrivals at the rally point.
|
||||||
- REQ-SHP-SALVAGE: Ships with at least one **salvage module** (player) — patrol by moving forward (rightward, away from the asteroid) while searching sensor range. If scrap enters sensor range, move to it; when it is within a module's `collection_range`, that module collects it (consuming the scrap entity). Once all cargo is full, fly to a Salvage Bay and deliver; after delivery, resume patrol. If an enemy ship enters sensor range while not currently targeting or carrying scrap, turn back (move toward the asteroid) until the enemy is no longer in sensor range, then resume patrol. Ships with salvage modules are vulnerable to enemy ships while operating.
|
- REQ-SHP-SALVAGE: Ships with at least one **salvage module** (player) — patrol by moving forward (rightward, away from the asteroid) while searching sensor range. If scrap enters sensor range, move to it; when it is within a module's `collection_range`, that module collects it (consuming the scrap entity). Once all cargo is full, fly to a Salvage Bay and deliver; after delivery, resume patrol. If an enemy ship enters sensor range, the ship retreats (REQ-SHP-RETREAT) until no enemy is in sensor range, then resumes patrol — this applies regardless of whether the ship is targeting or carrying scrap. Ships with salvage modules are vulnerable to enemy ships while operating.
|
||||||
|
|
||||||
Each salvage module instance operates independently: it has its own cargo hold (`cargo_capacity`), collection range (`collection_range`), and collection rate (`collection_rate`, in collections per second). After collecting a piece of scrap, the module cannot collect again until `1 / collection_rate` seconds have elapsed. A ship with multiple salvage modules can therefore collect multiple pieces of scrap per tick (one per ready module), and installs of different module types may have different ranges and rates. The ship navigates based on the maximum collection range across all installed salvage modules.
|
Each salvage module instance operates independently: it has its own cargo hold (`cargo_capacity`), collection range (`collection_range`), and collection rate (`collection_rate`, in collections per second). After collecting a piece of scrap, the module cannot collect again until `1 / collection_rate` seconds have elapsed. A ship with multiple salvage modules can therefore collect multiple pieces of scrap per tick (one per ready module), and installs of different module types may have different ranges and rates. The ship navigates based on the maximum collection range across all installed salvage modules.
|
||||||
- REQ-SHP-REPAIR: Ships with at least one **repair module** (player) — patrol by moving forward (rightward, away from the asteroid) while searching sensor range. If a damaged player defence station or player ship enters sensor range, move to it and repair. If an enemy ship enters sensor range while not currently repairing, turn back (move toward the asteroid) until the enemy is no longer in sensor range, then resume patrol. The player can configure the target priority per shipyard:
|
|
||||||
|
Salvage collection and delivery are world-state changes performed every tick regardless of which behavior the ship is currently executing; the salvage behavior only governs where the ship navigates (toward scrap, toward a Salvage Bay, or — when retreating — toward the rally point).
|
||||||
|
- REQ-SHP-REPAIR: Ships with at least one **repair module** (player) — patrol by moving forward (rightward, away from the asteroid) while searching sensor range. If a damaged player defence station or player ship enters sensor range, move to it and repair. If an enemy ship enters sensor range, the ship retreats (REQ-SHP-RETREAT) until no enemy is in sensor range, then resumes patrol. The player can configure the target priority per shipyard:
|
||||||
- Defence stations first / ships first / nearest target.
|
- Defence stations first / ships first / nearest target.
|
||||||
|
|
||||||
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. Repair healing is a world-state change applied every tick regardless of which behavior the ship is currently executing.
|
||||||
|
- REQ-SHP-RETREAT: **Player ships retreat to the rally point (REQ-SHP-RALLY) when threatened.** A ship retreats while either condition holds: (a) its HP is below a low-HP threshold (currently 30% of its maximum HP); or (b) it has no weapon modules and an enemy ship is within its sensor range. Retreating takes priority over the ship's other behaviors and moves it toward the rally point; the ship resumes its normal behavior once neither condition holds. Enemy ships never retreat (REQ-SHP-ENEMY-AI).
|
||||||
- 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 by destroying enemy defence station sets (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.
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
#include "ModuleOwnerComponent.h"
|
#include "ModuleOwnerComponent.h"
|
||||||
#include "MovementIntentSystem.h"
|
#include "MovementIntentSystem.h"
|
||||||
#include "PositionComponent.h"
|
#include "PositionComponent.h"
|
||||||
|
#include "RepairSystem.h"
|
||||||
|
#include "SalvagerSystem.h"
|
||||||
#include "ScrapSystem.h"
|
#include "ScrapSystem.h"
|
||||||
#include "ShipIdentityComponent.h"
|
#include "ShipIdentityComponent.h"
|
||||||
#include "ShipSystem.h"
|
#include "ShipSystem.h"
|
||||||
@@ -51,11 +53,16 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
|
|||||||
m_rng);
|
m_rng);
|
||||||
|
|
||||||
m_shipSystem = std::make_unique<ShipSystem>(m_gameConfig, m_admin);
|
m_shipSystem = std::make_unique<ShipSystem>(m_gameConfig, m_admin);
|
||||||
|
// Arena fights are symmetric and aggressive: player-faction ships must not
|
||||||
|
// retreat (REQ-BAL-SIM-AI). Only one faction would otherwise get retreat.
|
||||||
|
m_shipSystem->setRetreatEnabled(false);
|
||||||
m_aiSystem = std::make_unique<AiSystem>();
|
m_aiSystem = std::make_unique<AiSystem>();
|
||||||
m_movementIntentSystem = std::make_unique<MovementIntentSystem>();
|
m_movementIntentSystem = std::make_unique<MovementIntentSystem>();
|
||||||
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
|
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
|
||||||
m_combatSystem = std::make_unique<CombatSystem>(m_gameConfig);
|
m_combatSystem = std::make_unique<CombatSystem>(m_gameConfig);
|
||||||
m_scrapSystem = std::make_unique<ScrapSystem>(m_admin);
|
m_scrapSystem = std::make_unique<ScrapSystem>(m_admin);
|
||||||
|
m_salvagerSystem = std::make_unique<SalvagerSystem>(m_admin);
|
||||||
|
m_repairSystem = std::make_unique<RepairSystem>(m_admin);
|
||||||
|
|
||||||
placeStructures();
|
placeStructures();
|
||||||
spawnShips();
|
spawnShips();
|
||||||
@@ -250,13 +257,11 @@ ArenaStatus ArenaSimulation::status() const
|
|||||||
|
|
||||||
void ArenaSimulation::tick()
|
void ArenaSimulation::tick()
|
||||||
{
|
{
|
||||||
// Ship behavior systems (tick step 7).
|
// Ship behavior systems (tick step 7): evaluate, select winner, execute.
|
||||||
m_shipSystem->clearMovementIntents();
|
m_shipSystem->clearMovementIntents();
|
||||||
m_aiSystem->tickHomeReturnBehavior(m_admin);
|
m_aiSystem->tick(m_admin, *m_buildingSystem, *m_scrapSystem);
|
||||||
m_aiSystem->tickThreatResponseBehavior(m_admin, *m_buildingSystem);
|
m_salvagerSystem->tick(*m_scrapSystem, *m_buildingSystem);
|
||||||
m_aiSystem->tickRepairBehavior(m_admin, *m_buildingSystem);
|
m_repairSystem->tick();
|
||||||
m_aiSystem->tickRepairTools(m_admin);
|
|
||||||
m_aiSystem->tickSalvageBehavior(m_admin, *m_scrapSystem, *m_buildingSystem);
|
|
||||||
|
|
||||||
// Combat resolution (tick step 8).
|
// Combat resolution (tick step 8).
|
||||||
std::vector<WeaponFiredEvent> weaponFiredEvents;
|
std::vector<WeaponFiredEvent> weaponFiredEvents;
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class BuildingSystem;
|
|||||||
class CombatSystem;
|
class CombatSystem;
|
||||||
class DynamicBodySystem;
|
class DynamicBodySystem;
|
||||||
class MovementIntentSystem;
|
class MovementIntentSystem;
|
||||||
|
class RepairSystem;
|
||||||
|
class SalvagerSystem;
|
||||||
class ShipSystem;
|
class ShipSystem;
|
||||||
class ScrapSystem;
|
class ScrapSystem;
|
||||||
|
|
||||||
@@ -96,6 +98,8 @@ private:
|
|||||||
std::unique_ptr<DynamicBodySystem> m_dynamicBodySystem;
|
std::unique_ptr<DynamicBodySystem> m_dynamicBodySystem;
|
||||||
std::unique_ptr<CombatSystem> m_combatSystem;
|
std::unique_ptr<CombatSystem> m_combatSystem;
|
||||||
std::unique_ptr<ScrapSystem> m_scrapSystem;
|
std::unique_ptr<ScrapSystem> m_scrapSystem;
|
||||||
|
std::unique_ptr<SalvagerSystem> m_salvagerSystem;
|
||||||
|
std::unique_ptr<RepairSystem> m_repairSystem;
|
||||||
|
|
||||||
entt::entity m_team1HqEntity;
|
entt::entity m_team1HqEntity;
|
||||||
entt::entity m_team2HqEntity;
|
entt::entity m_team2HqEntity;
|
||||||
|
|||||||
9
src/lib/ecs/component/AdvanceBehavior.h
Normal file
9
src/lib/ecs/component/AdvanceBehavior.h
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Baseline fallback behavior, present on every ship. The executor moves the ship
|
||||||
|
// toward the opposing side (direction derived from FactionComponent), so a ship
|
||||||
|
// with no better behavior keeps advancing.
|
||||||
|
struct AdvanceBehavior
|
||||||
|
{
|
||||||
|
float score = 0.0f;
|
||||||
|
};
|
||||||
13
src/lib/ecs/component/AttackBehavior.h
Normal file
13
src/lib/ecs/component/AttackBehavior.h
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
#include "entt/entity/entity.hpp"
|
||||||
|
|
||||||
|
// Combat behavior for ships with weapons (was ThreatResponseBehaviorComponent).
|
||||||
|
// The evaluator sets currentTarget; the executor pushes it to in-range weapons.
|
||||||
|
struct AttackBehavior
|
||||||
|
{
|
||||||
|
std::optional<entt::entity> currentTarget;
|
||||||
|
float score = 0.0f;
|
||||||
|
};
|
||||||
15
src/lib/ecs/component/BehaviorKind.h
Normal file
15
src/lib/ecs/component/BehaviorKind.h
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Identifies a ship behavior. Written into SelectedBehaviorComponent by the
|
||||||
|
// AiSystem selection pass so each behavior's executor can tell whether it won.
|
||||||
|
enum class BehaviorKind
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Advance,
|
||||||
|
Rally,
|
||||||
|
Retreat,
|
||||||
|
Attack,
|
||||||
|
Repair,
|
||||||
|
SalvageScrap,
|
||||||
|
DeliverScrap
|
||||||
|
};
|
||||||
22
src/lib/ecs/component/BehaviorScores.h
Normal file
22
src/lib/ecs/component/BehaviorScores.h
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Score bands for ship-behavior evaluation. The AiSystem selection pass picks
|
||||||
|
// the behavior with the highest score per ship; these constants define a single
|
||||||
|
// comparable scale so the desired priority falls out:
|
||||||
|
// Retreat > Attack > Repair / Salvage / Deliver > Rally > Advance.
|
||||||
|
// Evaluators may return kInactive when their behavior does not apply this tick.
|
||||||
|
namespace BehaviorScores
|
||||||
|
{
|
||||||
|
constexpr float kInactive = 0.0f;
|
||||||
|
constexpr float kAdvance = 0.05f; // baseline fallback; always present
|
||||||
|
constexpr float kRally = 0.20f;
|
||||||
|
constexpr float kDeliver = 0.50f; // cargo full
|
||||||
|
constexpr float kRepair = 0.55f;
|
||||||
|
constexpr float kSalvage = 0.55f; // cargo not full and scrap in range
|
||||||
|
constexpr float kAttack = 0.60f; // healthy and target in sensor range
|
||||||
|
constexpr float kRetreat = 0.90f;
|
||||||
|
|
||||||
|
// Health fraction at/below which a ship is considered "low HP" — used by the
|
||||||
|
// Attack evaluator (do not attack when low) and the Retreat evaluator.
|
||||||
|
constexpr float kLowHpFraction = 0.3f;
|
||||||
|
}
|
||||||
@@ -1,24 +1,29 @@
|
|||||||
SET(HDRS
|
SET(HDRS
|
||||||
${HDRS}
|
${HDRS}
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/AdvanceBehavior.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/AttackBehavior.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/BehaviorKind.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/BehaviorScores.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/DeliverScrapBehavior.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/DespawnAtComponent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/DespawnAtComponent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/DynamicBodyComponent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/DynamicBodyComponent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/FacingComponent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/FacingComponent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/FactionComponent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/FactionComponent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/HealthComponent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/HealthComponent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/HomeReturnBehaviorComponent.h
|
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/HqProxyComponent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/HqProxyComponent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/MovementIntentComponent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/MovementIntentComponent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/PositionComponent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/PositionComponent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/RallyBehaviorComponent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/RallyBehavior.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/RepairBehaviorComponent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/RepairBehavior.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/RepairToolComponent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/RepairToolComponent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/SalvageBehaviorComponent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/RetreatBehavior.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/SalvageCargoComponent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/SalvageCargoComponent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/SalvageScrapBehavior.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ScrapDataComponent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ScrapDataComponent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBehaviorComponent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/SensorRangeComponent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/SensorRangeComponent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipIdentityComponent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ShipIdentityComponent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/StationBodyComponent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/StationBodyComponent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ThreatResponseBehaviorComponent.h
|
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/WeaponComponent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/WeaponComponent.h
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
)
|
)
|
||||||
|
|||||||
12
src/lib/ecs/component/DeliverScrapBehavior.h
Normal file
12
src/lib/ecs/component/DeliverScrapBehavior.h
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "BuildingId.h"
|
||||||
|
|
||||||
|
// Deliver-scrap behavior (one half of the old SalvageBehaviorComponent). Scored
|
||||||
|
// high only when cargo is full. The evaluator assigns the nearest SalvageBay;
|
||||||
|
// SalvagerSystem performs the actual delivery.
|
||||||
|
struct DeliverScrapBehavior
|
||||||
|
{
|
||||||
|
BuildingId deliveryBay = kInvalidBuildingId;
|
||||||
|
float score = 0.0f;
|
||||||
|
};
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <QVector2D>
|
|
||||||
|
|
||||||
struct HomeReturnBehaviorComponent
|
|
||||||
{
|
|
||||||
float retreatHpFraction;
|
|
||||||
QVector2D homePos;
|
|
||||||
};
|
|
||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
#include <QVector2D>
|
#include <QVector2D>
|
||||||
|
|
||||||
// A ship-behavior system writes this each tick before movement runs; the
|
// The winning behavior's executor writes this each tick before movement runs.
|
||||||
// highest-priority write wins. Priority order is fixed globally — see
|
// `active` is false when no behavior set a destination (the ship brakes); the
|
||||||
// architecture.md "Movement Arbitration".
|
// score-based selection (see architecture.md "Movement Arbitration") decides
|
||||||
|
// which single executor writes here.
|
||||||
struct MovementIntentComponent
|
struct MovementIntentComponent
|
||||||
{
|
{
|
||||||
int priority;
|
bool active = false;
|
||||||
QVector2D target;
|
QVector2D target;
|
||||||
};
|
};
|
||||||
|
|||||||
11
src/lib/ecs/component/RallyBehavior.h
Normal file
11
src/lib/ecs/component/RallyBehavior.h
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QVector2D>
|
||||||
|
|
||||||
|
// Player combat ships loiter at the rally point until the departure timer
|
||||||
|
// removes this component (ShipSystem::triggerRallyDeparture).
|
||||||
|
struct RallyBehavior
|
||||||
|
{
|
||||||
|
QVector2D rallyPoint;
|
||||||
|
float score = 0.0f;
|
||||||
|
};
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <QVector2D>
|
|
||||||
|
|
||||||
struct RallyBehaviorComponent
|
|
||||||
{
|
|
||||||
QVector2D rallyPoint;
|
|
||||||
};
|
|
||||||
15
src/lib/ecs/component/RepairBehavior.h
Normal file
15
src/lib/ecs/component/RepairBehavior.h
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
#include "entt/entity/entity.hpp"
|
||||||
|
|
||||||
|
// Repair behavior for ships with repair modules. The evaluator picks the nearest
|
||||||
|
// damaged friendly as currentTarget; the executor moves toward it and assigns
|
||||||
|
// in-range repair tools. RepairSystem applies the actual healing.
|
||||||
|
struct RepairBehavior
|
||||||
|
{
|
||||||
|
std::optional<entt::entity> currentTarget;
|
||||||
|
float maxRepairRange_tiles = 0.0f;
|
||||||
|
float score = 0.0f;
|
||||||
|
};
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <optional>
|
|
||||||
|
|
||||||
#include "entt/entity/entity.hpp"
|
|
||||||
|
|
||||||
struct RepairBehaviorComponent
|
|
||||||
{
|
|
||||||
std::optional<entt::entity> currentTarget;
|
|
||||||
float maxRepairRange_tiles = 0.0f;
|
|
||||||
};
|
|
||||||
13
src/lib/ecs/component/RetreatBehavior.h
Normal file
13
src/lib/ecs/component/RetreatBehavior.h
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QVector2D>
|
||||||
|
|
||||||
|
// Player-only retreat behavior (replaces HomeReturnBehaviorComponent). Scored
|
||||||
|
// high when HP is low, or when an enemy is in sensor range and the ship cannot
|
||||||
|
// fight back. The executor moves the ship to retreatPoint (the rally point).
|
||||||
|
struct RetreatBehavior
|
||||||
|
{
|
||||||
|
float retreatHpFraction = 0.0f;
|
||||||
|
QVector2D retreatPoint;
|
||||||
|
float score = 0.0f;
|
||||||
|
};
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <optional>
|
|
||||||
|
|
||||||
#include <QVector2D>
|
|
||||||
|
|
||||||
#include "BuildingId.h"
|
|
||||||
|
|
||||||
struct SalvageBehaviorComponent
|
|
||||||
{
|
|
||||||
std::optional<QVector2D> scrapTarget;
|
|
||||||
BuildingId deliveryBay; // kInvalidBuildingId until assigned at a salvage bay
|
|
||||||
float maxCollectionRange_tiles = 0.0f;
|
|
||||||
};
|
|
||||||
14
src/lib/ecs/component/SalvageScrapBehavior.h
Normal file
14
src/lib/ecs/component/SalvageScrapBehavior.h
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
#include <QVector2D>
|
||||||
|
|
||||||
|
// Collect-scrap behavior (one half of the old SalvageBehaviorComponent). The
|
||||||
|
// evaluator finds the nearest scrap and sets scrapTarget when cargo is not full.
|
||||||
|
struct SalvageScrapBehavior
|
||||||
|
{
|
||||||
|
std::optional<QVector2D> scrapTarget;
|
||||||
|
float maxCollectionRange_tiles = 0.0f;
|
||||||
|
float score = 0.0f;
|
||||||
|
};
|
||||||
11
src/lib/ecs/component/SelectedBehaviorComponent.h
Normal file
11
src/lib/ecs/component/SelectedBehaviorComponent.h
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "BehaviorKind.h"
|
||||||
|
|
||||||
|
// Result of the AiSystem selection pass: the highest-scoring behavior for a
|
||||||
|
// ship this tick. Each behavior's executor acts only when it is the winner.
|
||||||
|
struct SelectedBehaviorComponent
|
||||||
|
{
|
||||||
|
BehaviorKind winner = BehaviorKind::None;
|
||||||
|
float bestScore = 0.0f;
|
||||||
|
};
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <optional>
|
|
||||||
|
|
||||||
#include "entt/entity/entity.hpp"
|
|
||||||
|
|
||||||
struct ThreatResponseBehaviorComponent
|
|
||||||
{
|
|
||||||
std::optional<entt::entity> currentTarget;
|
|
||||||
};
|
|
||||||
@@ -1,587 +1,82 @@
|
|||||||
#include "AiSystem.h"
|
#include "AiSystem.h"
|
||||||
|
|
||||||
#include <optional>
|
#include <limits>
|
||||||
#include <unordered_map>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include <QVector2D>
|
#include "AdvanceBehavior.h"
|
||||||
|
#include "AttackBehavior.h"
|
||||||
#include "Building.h"
|
#include "BehaviorKind.h"
|
||||||
#include "BuildingSystem.h"
|
#include "DeliverScrapBehavior.h"
|
||||||
#include "BuildingType.h"
|
|
||||||
#include "BuildingId.h"
|
|
||||||
#include "EntityAdmin.h"
|
#include "EntityAdmin.h"
|
||||||
#include "FactionComponent.h"
|
#include "RallyBehavior.h"
|
||||||
#include "HealthComponent.h"
|
#include "RepairBehavior.h"
|
||||||
#include "HomeReturnBehaviorComponent.h"
|
#include "RetreatBehavior.h"
|
||||||
#include "HqProxyComponent.h"
|
#include "SalvageScrapBehavior.h"
|
||||||
#include "ModuleOwnerComponent.h"
|
#include "SelectedBehaviorComponent.h"
|
||||||
#include "MovementIntentComponent.h"
|
|
||||||
#include "PositionComponent.h"
|
|
||||||
#include "RallyBehaviorComponent.h"
|
|
||||||
#include "RepairBehaviorComponent.h"
|
|
||||||
#include "RepairToolComponent.h"
|
|
||||||
#include "SalvageBehaviorComponent.h"
|
|
||||||
#include "SalvageCargoComponent.h"
|
|
||||||
#include "ScrapSystem.h"
|
|
||||||
#include "SensorRangeComponent.h"
|
|
||||||
#include "ShipIdentityComponent.h"
|
|
||||||
#include "StationBodyComponent.h"
|
|
||||||
#include "ThreatResponseBehaviorComponent.h"
|
|
||||||
#include "tracing.h"
|
#include "tracing.h"
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
namespace
|
||||||
// Shared helpers for repair targeting
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
struct RepairableInfo
|
|
||||||
{
|
{
|
||||||
entt::entity entity;
|
// Records a behavior's score for its owning ship, keeping the highest seen.
|
||||||
QVector2D position;
|
// Considered high-priority first, so strict '>' breaks ties toward priority.
|
||||||
bool isEnemy;
|
template <typename Behavior>
|
||||||
bool isShip;
|
void consider(EntityAdmin& admin, BehaviorKind kind)
|
||||||
float hp;
|
|
||||||
float maxHp;
|
|
||||||
};
|
|
||||||
|
|
||||||
static std::vector<RepairableInfo> buildRepairables(EntityAdmin& admin)
|
|
||||||
{
|
|
||||||
std::vector<RepairableInfo> repairables;
|
|
||||||
|
|
||||||
admin.forEach<ShipIdentityComponent, PositionComponent, FactionComponent, HealthComponent>(
|
|
||||||
[&repairables](entt::entity e, const ShipIdentityComponent& /*si*/,
|
|
||||||
const PositionComponent& pos, const FactionComponent& f,
|
|
||||||
const HealthComponent& h)
|
|
||||||
{
|
|
||||||
repairables.push_back({e, pos.value, f.isEnemy, true, h.hp, h.maxHp});
|
|
||||||
});
|
|
||||||
|
|
||||||
admin.forEach<StationBodyComponent, PositionComponent, FactionComponent, HealthComponent>(
|
|
||||||
[&repairables](entt::entity e, const StationBodyComponent& /*sb*/,
|
|
||||||
const PositionComponent& pos, const FactionComponent& f,
|
|
||||||
const HealthComponent& h)
|
|
||||||
{
|
|
||||||
repairables.push_back({e, pos.value, f.isEnemy, false, h.hp, h.maxHp});
|
|
||||||
});
|
|
||||||
|
|
||||||
return repairables;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// tickHomeReturnBehavior (priority 4)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
void AiSystem::tickHomeReturnBehavior(EntityAdmin& admin)
|
|
||||||
{
|
|
||||||
TRACE();
|
|
||||||
admin.forEach<HomeReturnBehaviorComponent, HealthComponent, MovementIntentComponent>(
|
|
||||||
[](entt::entity /*e*/, const HomeReturnBehaviorComponent& homeReturnBehavior,
|
|
||||||
const HealthComponent& h, MovementIntentComponent& intent)
|
|
||||||
{
|
|
||||||
if (h.hp / h.maxHp < homeReturnBehavior.retreatHpFraction)
|
|
||||||
{
|
|
||||||
if (4 > intent.priority)
|
|
||||||
{
|
|
||||||
intent = MovementIntentComponent{4, homeReturnBehavior.homePos};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// tickThreatResponseBehavior (priority 3)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
void AiSystem::tickThreatResponseBehavior(EntityAdmin& admin, const BuildingSystem& buildings)
|
|
||||||
{
|
|
||||||
TRACE();
|
|
||||||
// Snapshot all combatant entities for target acquisition.
|
|
||||||
struct CombatantInfo
|
|
||||||
{
|
{
|
||||||
entt::entity entity;
|
admin.forEach<Behavior, SelectedBehaviorComponent>(
|
||||||
QVector2D position;
|
[kind](entt::entity /*e*/, const Behavior& behavior,
|
||||||
bool isEnemy;
|
SelectedBehaviorComponent& selected)
|
||||||
bool isStation;
|
|
||||||
};
|
|
||||||
std::vector<CombatantInfo> combatants;
|
|
||||||
|
|
||||||
admin.forEach<PositionComponent, FactionComponent, ShipIdentityComponent>(
|
|
||||||
[&combatants](entt::entity e, const PositionComponent& pos,
|
|
||||||
const FactionComponent& f, const ShipIdentityComponent& /*si*/)
|
|
||||||
{
|
|
||||||
combatants.push_back({e, pos.value, f.isEnemy, false});
|
|
||||||
});
|
|
||||||
|
|
||||||
admin.forEach<PositionComponent, FactionComponent, StationBodyComponent>(
|
|
||||||
[&combatants](entt::entity e, const PositionComponent& pos,
|
|
||||||
const FactionComponent& f, const StationBodyComponent& /*sb*/)
|
|
||||||
{
|
|
||||||
combatants.push_back({e, pos.value, f.isEnemy, true});
|
|
||||||
});
|
|
||||||
|
|
||||||
admin.forEach<PositionComponent, FactionComponent, HqProxyComponent>(
|
|
||||||
[&combatants](entt::entity e, const PositionComponent& pos,
|
|
||||||
const FactionComponent& f, const HqProxyComponent& /*hq*/)
|
|
||||||
{
|
|
||||||
combatants.push_back({e, pos.value, f.isEnemy, true});
|
|
||||||
});
|
|
||||||
|
|
||||||
admin.forEach<ThreatResponseBehaviorComponent, PositionComponent, FactionComponent,
|
|
||||||
SensorRangeComponent, MovementIntentComponent>(
|
|
||||||
[&](entt::entity e, ThreatResponseBehaviorComponent& threatResponseBehavior,
|
|
||||||
PositionComponent& pos, FactionComponent& faction,
|
|
||||||
SensorRangeComponent& sensor, MovementIntentComponent& intent)
|
|
||||||
{
|
|
||||||
const float range = sensor.value_tiles;
|
|
||||||
|
|
||||||
// Validate current target.
|
|
||||||
bool targetValid = false;
|
|
||||||
if (threatResponseBehavior.currentTarget)
|
|
||||||
{
|
{
|
||||||
const entt::entity t = *threatResponseBehavior.currentTarget;
|
if (behavior.score > selected.bestScore)
|
||||||
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
|
|
||||||
{
|
{
|
||||||
const float dist =
|
selected.bestScore = behavior.score;
|
||||||
(admin.get<PositionComponent>(t).value - pos.value).length();
|
selected.winner = kind;
|
||||||
if (dist <= range)
|
|
||||||
{
|
|
||||||
targetValid = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
}
|
||||||
if (!targetValid)
|
|
||||||
{
|
|
||||||
threatResponseBehavior.currentTarget = std::nullopt;
|
|
||||||
float bestDist = range;
|
|
||||||
|
|
||||||
for (const CombatantInfo& c : combatants)
|
|
||||||
{
|
|
||||||
if (c.entity == e) { continue; }
|
|
||||||
|
|
||||||
bool isValidTarget = false;
|
|
||||||
if (!faction.isEnemy)
|
|
||||||
{
|
|
||||||
isValidTarget = c.isEnemy;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
isValidTarget = !c.isEnemy;
|
|
||||||
}
|
|
||||||
if (!isValidTarget) { continue; }
|
|
||||||
|
|
||||||
const float dist = (c.position - pos.value).length();
|
|
||||||
if (dist < bestDist)
|
|
||||||
{
|
|
||||||
bestDist = dist;
|
|
||||||
threatResponseBehavior.currentTarget = c.entity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (threatResponseBehavior.currentTarget)
|
|
||||||
{
|
|
||||||
const entt::entity t = *threatResponseBehavior.currentTarget;
|
|
||||||
QVector2D dest = pos.value;
|
|
||||||
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
|
|
||||||
{
|
|
||||||
dest = admin.get<PositionComponent>(t).value;
|
|
||||||
}
|
|
||||||
if (3 > intent.priority)
|
|
||||||
{
|
|
||||||
intent = MovementIntentComponent{3, dest};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (3 > intent.priority)
|
|
||||||
{
|
|
||||||
if (admin.hasAll<RallyBehaviorComponent>(e))
|
|
||||||
{
|
|
||||||
intent = MovementIntentComponent{
|
|
||||||
3, admin.get<RallyBehaviorComponent>(e).rallyPoint};
|
|
||||||
}
|
|
||||||
else if (!faction.isEnemy)
|
|
||||||
{
|
|
||||||
intent = MovementIntentComponent{
|
|
||||||
3, QVector2D(pos.value.x() + 1000.0f, pos.value.y())};
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
intent = MovementIntentComponent{
|
|
||||||
3, QVector2D(-10000.0f, pos.value.y())};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
void AiSystem::tick(EntityAdmin& admin, const BuildingSystem& buildings,
|
||||||
// tickRepairBehavior (priority 2)
|
const ScrapSystem& scraps)
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
void AiSystem::tickRepairBehavior(EntityAdmin& admin, BuildingSystem& buildings)
|
|
||||||
{
|
{
|
||||||
TRACE();
|
TRACE();
|
||||||
std::vector<RepairableInfo> repairables = buildRepairables(admin);
|
|
||||||
|
|
||||||
// Snapshot enemy ships for threat detection.
|
// Phase 1: evaluators score behaviors and set their target data.
|
||||||
struct EnemyInfo
|
m_advanceEvaluator.evaluate(admin);
|
||||||
{
|
m_rallyEvaluator.evaluate(admin);
|
||||||
QVector2D position;
|
m_retreatEvaluator.evaluate(admin);
|
||||||
};
|
m_attackEvaluator.evaluate(admin);
|
||||||
std::vector<EnemyInfo> enemies;
|
m_repairEvaluator.evaluate(admin);
|
||||||
admin.forEach<ShipIdentityComponent, PositionComponent, FactionComponent>(
|
m_salvageScrapEvaluator.evaluate(admin, scraps);
|
||||||
[&enemies](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
|
m_deliverScrapEvaluator.evaluate(admin, buildings);
|
||||||
const PositionComponent& pos, const FactionComponent& f)
|
|
||||||
{
|
|
||||||
if (f.isEnemy)
|
|
||||||
{
|
|
||||||
enemies.push_back({pos.value});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
admin.forEach<RepairBehaviorComponent, PositionComponent,
|
// Phase 2: pick the highest-scoring behavior per ship.
|
||||||
FactionComponent, SensorRangeComponent, MovementIntentComponent>(
|
selectWinningBehaviors(admin);
|
||||||
[&](entt::entity e, RepairBehaviorComponent& rb,
|
|
||||||
PositionComponent& pos, FactionComponent& /*faction*/,
|
|
||||||
SensorRangeComponent& sensor, MovementIntentComponent& intent)
|
|
||||||
{
|
|
||||||
// Flee if enemy nearby.
|
|
||||||
bool enemyNearby = false;
|
|
||||||
for (const EnemyInfo& enemy : enemies)
|
|
||||||
{
|
|
||||||
if ((enemy.position - pos.value).length() <= sensor.value_tiles)
|
|
||||||
{
|
|
||||||
enemyNearby = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (enemyNearby)
|
|
||||||
{
|
|
||||||
if (2 > intent.priority)
|
|
||||||
{
|
|
||||||
intent = MovementIntentComponent{
|
|
||||||
2, QVector2D(-10000.0f, pos.value.y())};
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate current target.
|
// Phase 3: executors run for the winning behavior.
|
||||||
bool targetValid = false;
|
m_advanceExecutor.execute(admin);
|
||||||
if (rb.currentTarget)
|
m_rallyExecutor.execute(admin);
|
||||||
{
|
m_retreatExecutor.execute(admin);
|
||||||
const entt::entity t = *rb.currentTarget;
|
m_attackExecutor.execute(admin);
|
||||||
if (admin.isValid(t) && admin.hasAll<HealthComponent>(t))
|
m_repairExecutor.execute(admin);
|
||||||
{
|
m_salvageScrapExecutor.execute(admin);
|
||||||
const HealthComponent& th = admin.get<HealthComponent>(t);
|
m_deliverScrapExecutor.execute(admin, buildings);
|
||||||
if (th.hp > 0.0f && th.hp < th.maxHp)
|
|
||||||
{
|
|
||||||
targetValid = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetValid)
|
|
||||||
{
|
|
||||||
rb.currentTarget = std::nullopt;
|
|
||||||
float bestDist = sensor.value_tiles;
|
|
||||||
|
|
||||||
for (const RepairableInfo& r : repairables)
|
|
||||||
{
|
|
||||||
if (r.entity == e) { continue; }
|
|
||||||
if (r.isEnemy) { continue; }
|
|
||||||
if (r.hp >= r.maxHp) { continue; }
|
|
||||||
const float dist = (r.position - pos.value).length();
|
|
||||||
if (dist < bestDist)
|
|
||||||
{
|
|
||||||
bestDist = dist;
|
|
||||||
rb.currentTarget = r.entity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!rb.currentTarget)
|
|
||||||
{
|
|
||||||
if (2 > intent.priority)
|
|
||||||
{
|
|
||||||
intent = MovementIntentComponent{
|
|
||||||
2, QVector2D(pos.value.x() + 1000.0f, pos.value.y())};
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entt::entity target = *rb.currentTarget;
|
|
||||||
QVector2D targetPos = pos.value;
|
|
||||||
if (admin.isValid(target) && admin.hasAll<PositionComponent>(target))
|
|
||||||
{
|
|
||||||
targetPos = admin.get<PositionComponent>(target).value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (2 > intent.priority)
|
|
||||||
{
|
|
||||||
intent = MovementIntentComponent{2, targetPos};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
void AiSystem::selectWinningBehaviors(EntityAdmin& admin)
|
||||||
// tickRepairTools
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
void AiSystem::tickRepairTools(EntityAdmin& admin)
|
|
||||||
{
|
{
|
||||||
TRACE();
|
TRACE();
|
||||||
const std::vector<RepairableInfo> repairables = buildRepairables(admin);
|
admin.forEach<SelectedBehaviorComponent>(
|
||||||
|
[](entt::entity /*e*/, SelectedBehaviorComponent& selected)
|
||||||
admin.forEach<RepairToolComponent, ModuleOwnerComponent>(
|
|
||||||
[&](entt::entity /*e*/, RepairToolComponent& rt, const ModuleOwnerComponent& owner)
|
|
||||||
{
|
{
|
||||||
if (!admin.hasAll<RepairBehaviorComponent>(owner.owner)) { return; }
|
selected.winner = BehaviorKind::None;
|
||||||
|
selected.bestScore = std::numeric_limits<float>::lowest();
|
||||||
const RepairBehaviorComponent& rb =
|
|
||||||
admin.get<RepairBehaviorComponent>(owner.owner);
|
|
||||||
const PositionComponent& ownerPos =
|
|
||||||
admin.get<PositionComponent>(owner.owner);
|
|
||||||
|
|
||||||
// Try the ship's preferred nav target first.
|
|
||||||
if (rb.currentTarget)
|
|
||||||
{
|
|
||||||
const entt::entity preferred = *rb.currentTarget;
|
|
||||||
if (admin.isValid(preferred) && admin.hasAll<HealthComponent>(preferred)
|
|
||||||
&& admin.hasAll<PositionComponent>(preferred))
|
|
||||||
{
|
|
||||||
HealthComponent& th = admin.get<HealthComponent>(preferred);
|
|
||||||
const float dist =
|
|
||||||
(admin.get<PositionComponent>(preferred).value
|
|
||||||
- ownerPos.value).length();
|
|
||||||
if (th.hp > 0.0f && th.hp < th.maxHp && dist <= rt.range_tiles)
|
|
||||||
{
|
|
||||||
rt.currentTarget = rb.currentTarget;
|
|
||||||
th.hp = std::min(th.hp + rt.ratePerTick, th.maxHp);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preferred target unavailable; scan for nearest damaged friendly in range.
|
|
||||||
rt.currentTarget = std::nullopt;
|
|
||||||
float bestDist = rt.range_tiles;
|
|
||||||
for (const RepairableInfo& r : repairables)
|
|
||||||
{
|
|
||||||
if (r.isEnemy) { continue; }
|
|
||||||
if (r.hp <= 0.0f || r.hp >= r.maxHp) { continue; }
|
|
||||||
const float dist = (r.position - ownerPos.value).length();
|
|
||||||
if (dist < bestDist)
|
|
||||||
{
|
|
||||||
bestDist = dist;
|
|
||||||
rt.currentTarget = r.entity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!rt.currentTarget) { return; }
|
|
||||||
|
|
||||||
HealthComponent& targetHealth =
|
|
||||||
admin.get<HealthComponent>(*rt.currentTarget);
|
|
||||||
targetHealth.hp = std::min(targetHealth.hp + rt.ratePerTick, targetHealth.maxHp);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// tickSalvageBehavior (priority 1)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
void AiSystem::tickSalvageBehavior(EntityAdmin& admin, ScrapSystem& scraps,
|
|
||||||
BuildingSystem& buildings)
|
|
||||||
{
|
|
||||||
TRACE();
|
|
||||||
// Snapshot enemy ships for threat detection.
|
|
||||||
struct EnemyShipPos
|
|
||||||
{
|
|
||||||
QVector2D position;
|
|
||||||
};
|
|
||||||
std::vector<EnemyShipPos> enemyShips;
|
|
||||||
admin.forEach<ShipIdentityComponent, PositionComponent, FactionComponent>(
|
|
||||||
[&enemyShips](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
|
|
||||||
const PositionComponent& pos, const FactionComponent& f)
|
|
||||||
{
|
|
||||||
if (f.isEnemy)
|
|
||||||
{
|
|
||||||
enemyShips.push_back({pos.value});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Aggregate cargo across all salvage-module children per owning ship.
|
|
||||||
struct AggregatedCargo
|
|
||||||
{
|
|
||||||
int totalCurrent = 0;
|
|
||||||
int totalCapacity = 0;
|
|
||||||
};
|
|
||||||
std::unordered_map<entt::entity, AggregatedCargo> cargoByShip;
|
|
||||||
admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
|
|
||||||
[&](entt::entity /*ce*/, const SalvageCargoComponent& c, const ModuleOwnerComponent& o)
|
|
||||||
{
|
|
||||||
AggregatedCargo& agg = cargoByShip[o.owner];
|
|
||||||
agg.totalCurrent += c.current;
|
|
||||||
agg.totalCapacity += c.capacity;
|
|
||||||
});
|
|
||||||
|
|
||||||
const std::vector<ScrapInfo> allScrap = scraps.allScrapInfo();
|
|
||||||
|
|
||||||
// Tick down per-module collection cooldowns.
|
|
||||||
admin.forEach<SalvageCargoComponent>(
|
|
||||||
[](entt::entity /*e*/, SalvageCargoComponent& c)
|
|
||||||
{
|
|
||||||
if (c.cooldownTicksRemaining > 0) { --c.cooldownTicksRemaining; }
|
|
||||||
});
|
|
||||||
|
|
||||||
admin.forEach<SalvageBehaviorComponent, PositionComponent,
|
|
||||||
SensorRangeComponent, MovementIntentComponent>(
|
|
||||||
[&](entt::entity e, SalvageBehaviorComponent& salvageBehavior,
|
|
||||||
PositionComponent& pos,
|
|
||||||
SensorRangeComponent& sensor, MovementIntentComponent& intent)
|
|
||||||
{
|
|
||||||
const float collectRange = salvageBehavior.maxCollectionRange_tiles;
|
|
||||||
const AggregatedCargo& cargoState = cargoByShip[e];
|
|
||||||
|
|
||||||
// Assign nearest SalvageBay if needed.
|
|
||||||
if (salvageBehavior.deliveryBay == kInvalidBuildingId)
|
|
||||||
{
|
|
||||||
const Building* bay = buildings.findNearestBuilding(pos.value,
|
|
||||||
BuildingType::SalvageBay);
|
|
||||||
if (bay)
|
|
||||||
{
|
|
||||||
salvageBehavior.deliveryBay = bay->id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const BuildingId bayId = salvageBehavior.deliveryBay;
|
|
||||||
|
|
||||||
QVector2D bayPos = pos.value;
|
|
||||||
if (bayId != kInvalidBuildingId)
|
|
||||||
{
|
|
||||||
const Building* bay = buildings.findBuilding(bayId);
|
|
||||||
if (bay)
|
|
||||||
{
|
|
||||||
bayPos = QVector2D(bay->anchor.x() + bay->footprint.width() / 2.0f,
|
|
||||||
bay->anchor.y() + bay->footprint.height() / 2.0f);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bool cargoFull = (cargoState.totalCurrent >= cargoState.totalCapacity
|
|
||||||
&& cargoState.totalCapacity > 0);
|
|
||||||
|
|
||||||
if (cargoFull)
|
|
||||||
{
|
|
||||||
if (1 > intent.priority)
|
|
||||||
{
|
|
||||||
intent = MovementIntentComponent{1, bayPos};
|
|
||||||
}
|
|
||||||
if (bayId != kInvalidBuildingId
|
|
||||||
&& (pos.value - bayPos).length() <= 1.0f)
|
|
||||||
{
|
|
||||||
// Decrement first non-empty salvage child.
|
|
||||||
bool delivered = false;
|
|
||||||
admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
|
|
||||||
[&](entt::entity /*ce*/, SalvageCargoComponent& c,
|
|
||||||
const ModuleOwnerComponent& o)
|
|
||||||
{
|
|
||||||
if (delivered || o.owner != e || c.current <= 0) { return; }
|
|
||||||
if (buildings.deliverScrapToSalvageBay(bayId))
|
|
||||||
{
|
|
||||||
--c.current;
|
|
||||||
delivered = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retreat if enemy near and cargo empty.
|
|
||||||
bool retreating = false;
|
|
||||||
if (cargoState.totalCurrent == 0)
|
|
||||||
{
|
|
||||||
for (const EnemyShipPos& enemy : enemyShips)
|
|
||||||
{
|
|
||||||
if ((enemy.position - pos.value).length() <= collectRange)
|
|
||||||
{
|
|
||||||
if (1 > intent.priority)
|
|
||||||
{
|
|
||||||
intent = MovementIntentComponent{
|
|
||||||
1, QVector2D(-10000.0f, pos.value.y())};
|
|
||||||
}
|
|
||||||
retreating = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (retreating) { return; }
|
|
||||||
|
|
||||||
// Per-module independent collection: each ready module collects one scrap.
|
|
||||||
bool anythingCollected = false;
|
|
||||||
admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
|
|
||||||
[&](entt::entity /*ce*/, SalvageCargoComponent& c,
|
|
||||||
const ModuleOwnerComponent& o)
|
|
||||||
{
|
|
||||||
if (o.owner != e || c.current >= c.capacity
|
|
||||||
|| c.cooldownTicksRemaining > 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const ScrapInfo& si : allScrap)
|
|
||||||
{
|
|
||||||
if ((si.position - pos.value).length() > c.collectionRange_tiles) { continue; }
|
|
||||||
if (scraps.consume(si.entity))
|
|
||||||
{
|
|
||||||
++c.current;
|
|
||||||
c.cooldownTicksRemaining = c.collectionIntervalTicks;
|
|
||||||
anythingCollected = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (anythingCollected)
|
|
||||||
{
|
|
||||||
salvageBehavior.scrapTarget = std::nullopt;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move toward scrap target or find a new one.
|
|
||||||
if (salvageBehavior.scrapTarget)
|
|
||||||
{
|
|
||||||
if (1 > intent.priority)
|
|
||||||
{
|
|
||||||
intent = MovementIntentComponent{1, *salvageBehavior.scrapTarget};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
float bestDist = sensor.value_tiles;
|
|
||||||
std::optional<QVector2D> bestPos;
|
|
||||||
for (const ScrapInfo& si : allScrap)
|
|
||||||
{
|
|
||||||
const float dist = (si.position - pos.value).length();
|
|
||||||
if (dist < bestDist)
|
|
||||||
{
|
|
||||||
bestDist = dist;
|
|
||||||
bestPos = si.position;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (bestPos)
|
|
||||||
{
|
|
||||||
salvageBehavior.scrapTarget = bestPos;
|
|
||||||
if (1 > intent.priority)
|
|
||||||
{
|
|
||||||
intent = MovementIntentComponent{1, *bestPos};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (1 > intent.priority)
|
|
||||||
{
|
|
||||||
intent = MovementIntentComponent{
|
|
||||||
1, QVector2D(pos.value.x() + 1000.0f, pos.value.y())};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Highest priority first so ties resolve toward the more urgent behavior.
|
||||||
|
consider<RetreatBehavior>(admin, BehaviorKind::Retreat);
|
||||||
|
consider<AttackBehavior>(admin, BehaviorKind::Attack);
|
||||||
|
consider<RepairBehavior>(admin, BehaviorKind::Repair);
|
||||||
|
consider<SalvageScrapBehavior>(admin, BehaviorKind::SalvageScrap);
|
||||||
|
consider<DeliverScrapBehavior>(admin, BehaviorKind::DeliverScrap);
|
||||||
|
consider<RallyBehavior>(admin, BehaviorKind::Rally);
|
||||||
|
consider<AdvanceBehavior>(admin, BehaviorKind::Advance);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,52 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include "AdvanceEvaluator.h"
|
||||||
|
#include "AdvanceExecutor.h"
|
||||||
|
#include "AttackEvaluator.h"
|
||||||
|
#include "AttackExecutor.h"
|
||||||
|
#include "DeliverScrapEvaluator.h"
|
||||||
|
#include "DeliverScrapExecutor.h"
|
||||||
|
#include "RallyEvaluator.h"
|
||||||
|
#include "RallyExecutor.h"
|
||||||
|
#include "RepairEvaluator.h"
|
||||||
|
#include "RepairExecutor.h"
|
||||||
|
#include "RetreatEvaluator.h"
|
||||||
|
#include "RetreatExecutor.h"
|
||||||
|
#include "SalvageScrapEvaluator.h"
|
||||||
|
#include "SalvageScrapExecutor.h"
|
||||||
|
|
||||||
class BuildingSystem;
|
class BuildingSystem;
|
||||||
class EntityAdmin;
|
class EntityAdmin;
|
||||||
class ScrapSystem;
|
class ScrapSystem;
|
||||||
|
|
||||||
|
// Orchestrates ship-behavior decision-making in three batched phases:
|
||||||
|
// 1. evaluators score each behavior and set its target data,
|
||||||
|
// 2. selectWinningBehaviors picks the highest-scoring behavior per ship,
|
||||||
|
// 3. executors run for the winning behavior, setting movement intent and
|
||||||
|
// preferred module targets.
|
||||||
|
// All world mutation (collection, healing, damage) is left to the module
|
||||||
|
// systems (SalvagerSystem, RepairSystem, CombatSystem).
|
||||||
class AiSystem
|
class AiSystem
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
void tickHomeReturnBehavior(EntityAdmin& admin);
|
void tick(EntityAdmin& admin, const BuildingSystem& buildings, const ScrapSystem& scraps);
|
||||||
void tickThreatResponseBehavior(EntityAdmin& admin, const BuildingSystem& buildings);
|
|
||||||
void tickRepairBehavior(EntityAdmin& admin, BuildingSystem& buildings);
|
private:
|
||||||
void tickRepairTools(EntityAdmin& admin);
|
void selectWinningBehaviors(EntityAdmin& admin);
|
||||||
void tickSalvageBehavior(EntityAdmin& admin, ScrapSystem& scraps, BuildingSystem& buildings);
|
|
||||||
|
AdvanceEvaluator m_advanceEvaluator;
|
||||||
|
RallyEvaluator m_rallyEvaluator;
|
||||||
|
RetreatEvaluator m_retreatEvaluator;
|
||||||
|
AttackEvaluator m_attackEvaluator;
|
||||||
|
RepairEvaluator m_repairEvaluator;
|
||||||
|
SalvageScrapEvaluator m_salvageScrapEvaluator;
|
||||||
|
DeliverScrapEvaluator m_deliverScrapEvaluator;
|
||||||
|
|
||||||
|
AdvanceExecutor m_advanceExecutor;
|
||||||
|
RallyExecutor m_rallyExecutor;
|
||||||
|
RetreatExecutor m_retreatExecutor;
|
||||||
|
AttackExecutor m_attackExecutor;
|
||||||
|
RepairExecutor m_repairExecutor;
|
||||||
|
SalvageScrapExecutor m_salvageScrapExecutor;
|
||||||
|
DeliverScrapExecutor m_deliverScrapExecutor;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,26 @@
|
|||||||
SET(HDRS
|
SET(HDRS
|
||||||
${HDRS}
|
${HDRS}
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/AdvanceEvaluator.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/AdvanceExecutor.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/AttackEvaluator.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/AttackExecutor.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/BehaviorTargeting.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/DeliverScrapEvaluator.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/DeliverScrapExecutor.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/RallyEvaluator.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/RallyExecutor.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/RepairEvaluator.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/RepairExecutor.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/RetreatEvaluator.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/RetreatExecutor.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/SalvageScrapEvaluator.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/SalvageScrapExecutor.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/AiSystem.h
|
${CMAKE_CURRENT_SOURCE_DIR}/AiSystem.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/CombatSystem.h
|
${CMAKE_CURRENT_SOURCE_DIR}/CombatSystem.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/DynamicBodySystem.h
|
${CMAKE_CURRENT_SOURCE_DIR}/DynamicBodySystem.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/MovementIntentSystem.h
|
${CMAKE_CURRENT_SOURCE_DIR}/MovementIntentSystem.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/RepairSystem.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/SalvagerSystem.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ScrapSystem.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ScrapSystem.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipSystem.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ShipSystem.h
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
@@ -11,10 +28,27 @@ SET(HDRS
|
|||||||
|
|
||||||
SET(SRCS
|
SET(SRCS
|
||||||
${SRCS}
|
${SRCS}
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/AdvanceEvaluator.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/AdvanceExecutor.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/AttackEvaluator.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/AttackExecutor.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/BehaviorTargeting.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/DeliverScrapEvaluator.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/DeliverScrapExecutor.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/RallyEvaluator.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/RallyExecutor.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/RepairEvaluator.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/RepairExecutor.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/RetreatEvaluator.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/RetreatExecutor.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/SalvageScrapEvaluator.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai/SalvageScrapExecutor.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/AiSystem.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/AiSystem.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/CombatSystem.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/CombatSystem.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/DynamicBodySystem.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/DynamicBodySystem.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/MovementIntentSystem.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/MovementIntentSystem.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/RepairSystem.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/SalvagerSystem.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ScrapSystem.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/ScrapSystem.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipSystem.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/ShipSystem.cpp
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
@@ -23,5 +57,6 @@ SET(SRCS
|
|||||||
set(LIB_INCLUDE_PATH
|
set(LIB_INCLUDE_PATH
|
||||||
${LIB_INCLUDE_PATH}
|
${LIB_INCLUDE_PATH}
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}
|
${CMAKE_CURRENT_SOURCE_DIR}
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ai
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
#include "PositionComponent.h"
|
#include "PositionComponent.h"
|
||||||
#include "SensorRangeComponent.h"
|
#include "SensorRangeComponent.h"
|
||||||
#include "ShipIdentityComponent.h"
|
#include "ShipIdentityComponent.h"
|
||||||
#include "ThreatResponseBehaviorComponent.h"
|
|
||||||
#include "tracing.h"
|
#include "tracing.h"
|
||||||
#include "WeaponComponent.h"
|
#include "WeaponComponent.h"
|
||||||
|
|
||||||
@@ -25,14 +24,11 @@ void CombatSystem::tick(Tick currentTick,
|
|||||||
{
|
{
|
||||||
TRACE();
|
TRACE();
|
||||||
// All weapons (ships and stations) are child entities linked via ModuleOwnerComponent.
|
// All weapons (ships and stations) are child entities linked via ModuleOwnerComponent.
|
||||||
|
// AttackExecutor has already set each weapon's preferred (in-range) target; here we
|
||||||
|
// validate it, fall back to nearest-target acquisition, and fire.
|
||||||
admin.forEach<WeaponComponent, ModuleOwnerComponent>(
|
admin.forEach<WeaponComponent, ModuleOwnerComponent>(
|
||||||
[&](entt::entity /*e*/, WeaponComponent& weapon, const ModuleOwnerComponent& owner)
|
[&](entt::entity /*e*/, WeaponComponent& weapon, const ModuleOwnerComponent& owner)
|
||||||
{
|
{
|
||||||
if (admin.hasAll<ThreatResponseBehaviorComponent>(owner.owner))
|
|
||||||
{
|
|
||||||
weapon.currentTarget =
|
|
||||||
admin.get<ThreatResponseBehaviorComponent>(owner.owner).currentTarget;
|
|
||||||
}
|
|
||||||
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, outWeaponFiredEvents);
|
resolveWeapon(owner.owner, weapon, pos, faction, currentTick, admin, outWeaponFiredEvents);
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ void MovementIntentSystem::tick(EntityAdmin& admin)
|
|||||||
[](entt::entity /*e*/, const PositionComponent& pos, const FacingComponent& facing,
|
[](entt::entity /*e*/, const PositionComponent& pos, const FacingComponent& facing,
|
||||||
DynamicBodyComponent& body, const MovementIntentComponent& intent)
|
DynamicBodyComponent& body, const MovementIntentComponent& intent)
|
||||||
{
|
{
|
||||||
if (intent.priority == 0)
|
if (!intent.active)
|
||||||
{
|
{
|
||||||
// No movement intent: brake using available thrust.
|
// No movement intent: brake using available thrust.
|
||||||
const float linearBraking = std::min(body.velocity_tpt.length(),
|
const float linearBraking = std::min(body.velocity_tpt.length(),
|
||||||
|
|||||||
70
src/lib/ecs/system/RepairSystem.cpp
Normal file
70
src/lib/ecs/system/RepairSystem.cpp
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
#include "RepairSystem.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <optional>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QVector2D>
|
||||||
|
|
||||||
|
#include "BehaviorTargeting.h"
|
||||||
|
#include "EntityAdmin.h"
|
||||||
|
#include "HealthComponent.h"
|
||||||
|
#include "ModuleOwnerComponent.h"
|
||||||
|
#include "PositionComponent.h"
|
||||||
|
#include "RepairToolComponent.h"
|
||||||
|
#include "tracing.h"
|
||||||
|
|
||||||
|
RepairSystem::RepairSystem(EntityAdmin& admin)
|
||||||
|
: m_admin(admin)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void RepairSystem::tick()
|
||||||
|
{
|
||||||
|
TRACE();
|
||||||
|
const std::vector<RepairableInfo> repairables = buildRepairables(m_admin);
|
||||||
|
|
||||||
|
m_admin.forEach<RepairToolComponent, ModuleOwnerComponent>(
|
||||||
|
[&](entt::entity /*re*/, RepairToolComponent& tool, const ModuleOwnerComponent& owner)
|
||||||
|
{
|
||||||
|
if (!m_admin.hasAll<PositionComponent>(owner.owner)) { return; }
|
||||||
|
const QVector2D ownerPos = m_admin.get<PositionComponent>(owner.owner).value;
|
||||||
|
|
||||||
|
// Honour the executor-set target if it is still valid and in range.
|
||||||
|
if (tool.currentTarget)
|
||||||
|
{
|
||||||
|
const entt::entity t = *tool.currentTarget;
|
||||||
|
if (m_admin.isValid(t) && m_admin.hasAll<HealthComponent, PositionComponent>(t))
|
||||||
|
{
|
||||||
|
HealthComponent& th = m_admin.get<HealthComponent>(t);
|
||||||
|
const float dist =
|
||||||
|
(m_admin.get<PositionComponent>(t).value - ownerPos).length();
|
||||||
|
if (th.hp > 0.0f && th.hp < th.maxHp && dist <= tool.range_tiles)
|
||||||
|
{
|
||||||
|
th.hp = std::min(th.hp + tool.ratePerTick, th.maxHp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: heal the nearest damaged friendly within tool range.
|
||||||
|
tool.currentTarget = std::nullopt;
|
||||||
|
float bestDist = tool.range_tiles;
|
||||||
|
for (const RepairableInfo& r : repairables)
|
||||||
|
{
|
||||||
|
if (r.isEnemy) { continue; }
|
||||||
|
if (r.hp <= 0.0f || r.hp >= r.maxHp) { continue; }
|
||||||
|
const float dist = (r.position - ownerPos).length();
|
||||||
|
if (dist < bestDist)
|
||||||
|
{
|
||||||
|
bestDist = dist;
|
||||||
|
tool.currentTarget = r.entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tool.currentTarget) { return; }
|
||||||
|
|
||||||
|
HealthComponent& targetHealth = m_admin.get<HealthComponent>(*tool.currentTarget);
|
||||||
|
targetHealth.hp = std::min(targetHealth.hp + tool.ratePerTick, targetHealth.maxHp);
|
||||||
|
});
|
||||||
|
}
|
||||||
17
src/lib/ecs/system/RepairSystem.h
Normal file
17
src/lib/ecs/system/RepairSystem.h
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
class EntityAdmin;
|
||||||
|
|
||||||
|
// World-mutation system for repair modules: validates each tool's target (set by
|
||||||
|
// RepairExecutor), falls back to the nearest damaged friendly in range, and
|
||||||
|
// applies healing. Runs every tick, independent of behavior selection.
|
||||||
|
class RepairSystem
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit RepairSystem(EntityAdmin& admin);
|
||||||
|
|
||||||
|
void tick();
|
||||||
|
|
||||||
|
private:
|
||||||
|
EntityAdmin& m_admin;
|
||||||
|
};
|
||||||
79
src/lib/ecs/system/SalvagerSystem.cpp
Normal file
79
src/lib/ecs/system/SalvagerSystem.cpp
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
#include "SalvagerSystem.h"
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QVector2D>
|
||||||
|
|
||||||
|
#include "Building.h"
|
||||||
|
#include "BuildingSystem.h"
|
||||||
|
#include "DeliverScrapBehavior.h"
|
||||||
|
#include "EntityAdmin.h"
|
||||||
|
#include "ModuleOwnerComponent.h"
|
||||||
|
#include "PositionComponent.h"
|
||||||
|
#include "SalvageCargoComponent.h"
|
||||||
|
#include "ScrapSystem.h"
|
||||||
|
#include "tracing.h"
|
||||||
|
|
||||||
|
SalvagerSystem::SalvagerSystem(EntityAdmin& admin)
|
||||||
|
: m_admin(admin)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void SalvagerSystem::tick(ScrapSystem& scraps, BuildingSystem& buildings)
|
||||||
|
{
|
||||||
|
TRACE();
|
||||||
|
const std::vector<ScrapInfo> allScrap = scraps.allScrapInfo();
|
||||||
|
|
||||||
|
// Tick down per-module collection cooldowns.
|
||||||
|
m_admin.forEach<SalvageCargoComponent>(
|
||||||
|
[](entt::entity /*e*/, SalvageCargoComponent& c)
|
||||||
|
{
|
||||||
|
if (c.cooldownTicksRemaining > 0) { --c.cooldownTicksRemaining; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collection: each ready, in-range module collects one scrap.
|
||||||
|
m_admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
|
||||||
|
[&](entt::entity /*ce*/, SalvageCargoComponent& c, const ModuleOwnerComponent& o)
|
||||||
|
{
|
||||||
|
if (c.current >= c.capacity || c.cooldownTicksRemaining > 0) { return; }
|
||||||
|
if (!m_admin.hasAll<PositionComponent>(o.owner)) { return; }
|
||||||
|
|
||||||
|
const QVector2D ownerPos = m_admin.get<PositionComponent>(o.owner).value;
|
||||||
|
for (const ScrapInfo& si : allScrap)
|
||||||
|
{
|
||||||
|
if ((si.position - ownerPos).length() > c.collectionRange_tiles) { continue; }
|
||||||
|
if (scraps.consume(si.entity))
|
||||||
|
{
|
||||||
|
++c.current;
|
||||||
|
c.cooldownTicksRemaining = c.collectionIntervalTicks;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delivery: a ship at its assigned bay hands over one unit of cargo per tick.
|
||||||
|
m_admin.forEach<DeliverScrapBehavior, PositionComponent>(
|
||||||
|
[&](entt::entity ship, const DeliverScrapBehavior& deliver, const PositionComponent& pos)
|
||||||
|
{
|
||||||
|
if (deliver.deliveryBay == kInvalidBuildingId) { return; }
|
||||||
|
const Building* bay = buildings.findBuilding(deliver.deliveryBay);
|
||||||
|
if (!bay) { return; }
|
||||||
|
|
||||||
|
const QVector2D bayCenter(bay->anchor.x() + bay->footprint.width() / 2.0f,
|
||||||
|
bay->anchor.y() + bay->footprint.height() / 2.0f);
|
||||||
|
if ((pos.value - bayCenter).length() > 1.0f) { return; }
|
||||||
|
|
||||||
|
// Decrement the first non-empty salvage child belonging to this ship.
|
||||||
|
bool delivered = false;
|
||||||
|
m_admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
|
||||||
|
[&](entt::entity /*ce*/, SalvageCargoComponent& c, const ModuleOwnerComponent& o)
|
||||||
|
{
|
||||||
|
if (delivered || o.owner != ship || c.current <= 0) { return; }
|
||||||
|
if (buildings.deliverScrapToSalvageBay(deliver.deliveryBay))
|
||||||
|
{
|
||||||
|
--c.current;
|
||||||
|
delivered = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
19
src/lib/ecs/system/SalvagerSystem.h
Normal file
19
src/lib/ecs/system/SalvagerSystem.h
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
class BuildingSystem;
|
||||||
|
class EntityAdmin;
|
||||||
|
class ScrapSystem;
|
||||||
|
|
||||||
|
// World-mutation system for salvage modules: collects scrap into cargo and
|
||||||
|
// delivers full cargo at a SalvageBay. Runs every tick, independent of which
|
||||||
|
// behavior the AiSystem selected.
|
||||||
|
class SalvagerSystem
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit SalvagerSystem(EntityAdmin& admin);
|
||||||
|
|
||||||
|
void tick(ScrapSystem& scraps, BuildingSystem& buildings);
|
||||||
|
|
||||||
|
private:
|
||||||
|
EntityAdmin& m_admin;
|
||||||
|
};
|
||||||
@@ -6,6 +6,10 @@
|
|||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "AdvanceBehavior.h"
|
||||||
|
#include "AttackBehavior.h"
|
||||||
|
#include "BehaviorScores.h"
|
||||||
|
#include "DeliverScrapBehavior.h"
|
||||||
#include "DynamicBodyComponent.h"
|
#include "DynamicBodyComponent.h"
|
||||||
#include "EntityAdmin.h"
|
#include "EntityAdmin.h"
|
||||||
#include "FactionComponent.h"
|
#include "FactionComponent.h"
|
||||||
@@ -13,14 +17,15 @@
|
|||||||
#include "ModuleOwnerComponent.h"
|
#include "ModuleOwnerComponent.h"
|
||||||
#include "ModulesConfig.h"
|
#include "ModulesConfig.h"
|
||||||
#include "MovementIntentComponent.h"
|
#include "MovementIntentComponent.h"
|
||||||
#include "RallyBehaviorComponent.h"
|
#include "RallyBehavior.h"
|
||||||
#include "RepairBehaviorComponent.h"
|
#include "RepairBehavior.h"
|
||||||
#include "RepairToolComponent.h"
|
#include "RepairToolComponent.h"
|
||||||
#include "SalvageBehaviorComponent.h"
|
#include "RetreatBehavior.h"
|
||||||
#include "SalvageCargoComponent.h"
|
#include "SalvageCargoComponent.h"
|
||||||
|
#include "SalvageScrapBehavior.h"
|
||||||
|
#include "SelectedBehaviorComponent.h"
|
||||||
#include "SensorRangeComponent.h"
|
#include "SensorRangeComponent.h"
|
||||||
#include "Tick.h"
|
#include "Tick.h"
|
||||||
#include "ThreatResponseBehaviorComponent.h"
|
|
||||||
#include "tracing.h"
|
#include "tracing.h"
|
||||||
#include "WeaponComponent.h"
|
#include "WeaponComponent.h"
|
||||||
|
|
||||||
@@ -321,15 +326,30 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
|
|||||||
|
|
||||||
// --- Pass 3: attach behavior components based on capability presence -----
|
// --- Pass 3: attach behavior components based on capability presence -----
|
||||||
|
|
||||||
|
// Baseline: every ship can always fall back to advancing, and needs a slot
|
||||||
|
// for the per-tick behavior selection result.
|
||||||
|
m_admin.addComponent<AdvanceBehavior>(entity, AdvanceBehavior{});
|
||||||
|
m_admin.addComponent<SelectedBehaviorComponent>(entity, SelectedBehaviorComponent{});
|
||||||
|
|
||||||
|
// Player ships retreat to the rally point when threatened or badly damaged
|
||||||
|
// (disabled by the balancing tool to keep arena fights symmetric).
|
||||||
|
if (!isEnemy && m_retreatEnabled)
|
||||||
|
{
|
||||||
|
RetreatBehavior retreat;
|
||||||
|
retreat.retreatHpFraction = BehaviorScores::kLowHpFraction;
|
||||||
|
retreat.retreatPoint = m_rallyPoint;
|
||||||
|
m_admin.addComponent<RetreatBehavior>(entity, retreat);
|
||||||
|
}
|
||||||
|
|
||||||
if (!weaponChildren.empty())
|
if (!weaponChildren.empty())
|
||||||
{
|
{
|
||||||
m_admin.addComponent<ThreatResponseBehaviorComponent>(
|
m_admin.addComponent<AttackBehavior>(entity, AttackBehavior{});
|
||||||
entity, ThreatResponseBehaviorComponent{});
|
|
||||||
|
|
||||||
if (!isEnemy)
|
if (!isEnemy)
|
||||||
{
|
{
|
||||||
m_admin.addComponent<RallyBehaviorComponent>(
|
RallyBehavior rally;
|
||||||
entity, RallyBehaviorComponent{m_rallyPoint});
|
rally.rallyPoint = m_rallyPoint;
|
||||||
|
m_admin.addComponent<RallyBehavior>(entity, rally);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,11 +362,14 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
|
|||||||
if (r > maxCollRange) { maxCollRange = r; }
|
if (r > maxCollRange) { maxCollRange = r; }
|
||||||
}
|
}
|
||||||
|
|
||||||
SalvageBehaviorComponent sb;
|
SalvageScrapBehavior salvage;
|
||||||
sb.scrapTarget = std::nullopt;
|
salvage.scrapTarget = std::nullopt;
|
||||||
sb.deliveryBay = kInvalidBuildingId;
|
salvage.maxCollectionRange_tiles = maxCollRange;
|
||||||
sb.maxCollectionRange_tiles = maxCollRange;
|
m_admin.addComponent<SalvageScrapBehavior>(entity, salvage);
|
||||||
m_admin.addComponent<SalvageBehaviorComponent>(entity, sb);
|
|
||||||
|
DeliverScrapBehavior deliver;
|
||||||
|
deliver.deliveryBay = kInvalidBuildingId;
|
||||||
|
m_admin.addComponent<DeliverScrapBehavior>(entity, deliver);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!repairChildren.empty())
|
if (!repairChildren.empty())
|
||||||
@@ -358,10 +381,10 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
|
|||||||
if (r > maxRepairRange) { maxRepairRange = r; }
|
if (r > maxRepairRange) { maxRepairRange = r; }
|
||||||
}
|
}
|
||||||
|
|
||||||
RepairBehaviorComponent rb;
|
RepairBehavior repair;
|
||||||
rb.currentTarget = std::nullopt;
|
repair.currentTarget = std::nullopt;
|
||||||
rb.maxRepairRange_tiles = maxRepairRange;
|
repair.maxRepairRange_tiles = maxRepairRange;
|
||||||
m_admin.addComponent<RepairBehaviorComponent>(entity, rb);
|
m_admin.addComponent<RepairBehavior>(entity, repair);
|
||||||
}
|
}
|
||||||
|
|
||||||
return entity;
|
return entity;
|
||||||
@@ -385,7 +408,7 @@ void ShipSystem::clearMovementIntents()
|
|||||||
m_admin.forEach<MovementIntentComponent>(
|
m_admin.forEach<MovementIntentComponent>(
|
||||||
[](entt::entity /*e*/, MovementIntentComponent& i)
|
[](entt::entity /*e*/, MovementIntentComponent& i)
|
||||||
{
|
{
|
||||||
i = MovementIntentComponent{0, QVector2D(0.0f, 0.0f)};
|
i = MovementIntentComponent{false, QVector2D(0.0f, 0.0f)};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,12 +417,17 @@ void ShipSystem::setRallyPoint(QVector2D point)
|
|||||||
m_rallyPoint = point;
|
m_rallyPoint = point;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ShipSystem::setRetreatEnabled(bool enabled)
|
||||||
|
{
|
||||||
|
m_retreatEnabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
void ShipSystem::triggerRallyDeparture()
|
void ShipSystem::triggerRallyDeparture()
|
||||||
{
|
{
|
||||||
TRACE();
|
TRACE();
|
||||||
std::vector<entt::entity> toRemove;
|
std::vector<entt::entity> toRemove;
|
||||||
m_admin.forEach<RallyBehaviorComponent, FactionComponent>(
|
m_admin.forEach<RallyBehavior, FactionComponent>(
|
||||||
[&toRemove](entt::entity e, const RallyBehaviorComponent& /*rb*/,
|
[&toRemove](entt::entity e, const RallyBehavior& /*rb*/,
|
||||||
const FactionComponent& f)
|
const FactionComponent& f)
|
||||||
{
|
{
|
||||||
if (!f.isEnemy)
|
if (!f.isEnemy)
|
||||||
@@ -409,6 +437,6 @@ void ShipSystem::triggerRallyDeparture()
|
|||||||
});
|
});
|
||||||
for (entt::entity e : toRemove)
|
for (entt::entity e : toRemove)
|
||||||
{
|
{
|
||||||
m_admin.removeComponent<RallyBehaviorComponent>(e);
|
m_admin.removeComponent<RallyBehavior>(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public:
|
|||||||
const std::map<std::string, int>& moduleLevelOverrides = {});
|
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 inactive before behavior systems run.
|
||||||
void clearMovementIntents();
|
void clearMovementIntents();
|
||||||
|
|
||||||
// Set the rally point that newly spawned player combat ships will loiter at.
|
// Set the rally point that newly spawned player combat ships will loiter at.
|
||||||
@@ -33,6 +33,11 @@ public:
|
|||||||
// Release all gathered player combat ships to advance toward the enemy.
|
// Release all gathered player combat ships to advance toward the enemy.
|
||||||
void triggerRallyDeparture();
|
void triggerRallyDeparture();
|
||||||
|
|
||||||
|
// Controls whether newly spawned player ships receive a RetreatBehavior. The
|
||||||
|
// balancing tool disables this so arena fights stay symmetric and aggressive
|
||||||
|
// (REQ-BAL-SIM-AI); the main game keeps it enabled (REQ-SHP-RETREAT).
|
||||||
|
void setRetreatEnabled(bool enabled);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
const ShipDef* findShipDef(const std::string& schematicId) const;
|
const ShipDef* findShipDef(const std::string& schematicId) const;
|
||||||
const ModuleDef* findModuleDef(const std::string& id) const;
|
const ModuleDef* findModuleDef(const std::string& id) const;
|
||||||
@@ -40,4 +45,5 @@ private:
|
|||||||
const GameConfig& m_config;
|
const GameConfig& m_config;
|
||||||
EntityAdmin& m_admin;
|
EntityAdmin& m_admin;
|
||||||
QVector2D m_rallyPoint;
|
QVector2D m_rallyPoint;
|
||||||
|
bool m_retreatEnabled = true;
|
||||||
};
|
};
|
||||||
|
|||||||
16
src/lib/ecs/system/ai/AdvanceEvaluator.cpp
Normal file
16
src/lib/ecs/system/ai/AdvanceEvaluator.cpp
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
#include "AdvanceEvaluator.h"
|
||||||
|
|
||||||
|
#include "AdvanceBehavior.h"
|
||||||
|
#include "BehaviorScores.h"
|
||||||
|
#include "EntityAdmin.h"
|
||||||
|
#include "tracing.h"
|
||||||
|
|
||||||
|
void AdvanceEvaluator::evaluate(EntityAdmin& admin)
|
||||||
|
{
|
||||||
|
TRACE();
|
||||||
|
admin.forEach<AdvanceBehavior>(
|
||||||
|
[](entt::entity /*e*/, AdvanceBehavior& advance)
|
||||||
|
{
|
||||||
|
advance.score = BehaviorScores::kAdvance;
|
||||||
|
});
|
||||||
|
}
|
||||||
11
src/lib/ecs/system/ai/AdvanceEvaluator.h
Normal file
11
src/lib/ecs/system/ai/AdvanceEvaluator.h
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
class EntityAdmin;
|
||||||
|
|
||||||
|
// Baseline fallback: gives every ship a constant low score so there is always a
|
||||||
|
// winning behavior. The actual movement direction is decided by AdvanceExecutor.
|
||||||
|
class AdvanceEvaluator
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
void evaluate(EntityAdmin& admin);
|
||||||
|
};
|
||||||
30
src/lib/ecs/system/ai/AdvanceExecutor.cpp
Normal file
30
src/lib/ecs/system/ai/AdvanceExecutor.cpp
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
#include "AdvanceExecutor.h"
|
||||||
|
|
||||||
|
#include <QVector2D>
|
||||||
|
|
||||||
|
#include "AdvanceBehavior.h"
|
||||||
|
#include "BehaviorKind.h"
|
||||||
|
#include "EntityAdmin.h"
|
||||||
|
#include "FactionComponent.h"
|
||||||
|
#include "MovementIntentComponent.h"
|
||||||
|
#include "PositionComponent.h"
|
||||||
|
#include "SelectedBehaviorComponent.h"
|
||||||
|
#include "tracing.h"
|
||||||
|
|
||||||
|
void AdvanceExecutor::execute(EntityAdmin& admin)
|
||||||
|
{
|
||||||
|
TRACE();
|
||||||
|
admin.forEach<AdvanceBehavior, SelectedBehaviorComponent, PositionComponent,
|
||||||
|
FactionComponent, MovementIntentComponent>(
|
||||||
|
[](entt::entity /*e*/, const AdvanceBehavior& /*advance*/,
|
||||||
|
const SelectedBehaviorComponent& selected, const PositionComponent& pos,
|
||||||
|
const FactionComponent& faction, MovementIntentComponent& intent)
|
||||||
|
{
|
||||||
|
if (selected.winner != BehaviorKind::Advance) { return; }
|
||||||
|
|
||||||
|
const QVector2D target = faction.isEnemy
|
||||||
|
? QVector2D(-10000.0f, pos.value.y())
|
||||||
|
: QVector2D(pos.value.x() + 1000.0f, pos.value.y());
|
||||||
|
intent = MovementIntentComponent{true, target};
|
||||||
|
});
|
||||||
|
}
|
||||||
11
src/lib/ecs/system/ai/AdvanceExecutor.h
Normal file
11
src/lib/ecs/system/ai/AdvanceExecutor.h
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
class EntityAdmin;
|
||||||
|
|
||||||
|
// Moves a ship toward the opposing side when Advance is the winning behavior:
|
||||||
|
// player ships advance toward +x (the enemy), enemy ships toward -x (the base).
|
||||||
|
class AdvanceExecutor
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
void execute(EntityAdmin& admin);
|
||||||
|
};
|
||||||
71
src/lib/ecs/system/ai/AttackEvaluator.cpp
Normal file
71
src/lib/ecs/system/ai/AttackEvaluator.cpp
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
#include "AttackEvaluator.h"
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QVector2D>
|
||||||
|
|
||||||
|
#include "AttackBehavior.h"
|
||||||
|
#include "BehaviorScores.h"
|
||||||
|
#include "BehaviorTargeting.h"
|
||||||
|
#include "EntityAdmin.h"
|
||||||
|
#include "FactionComponent.h"
|
||||||
|
#include "HealthComponent.h"
|
||||||
|
#include "PositionComponent.h"
|
||||||
|
#include "SensorRangeComponent.h"
|
||||||
|
#include "tracing.h"
|
||||||
|
|
||||||
|
void AttackEvaluator::evaluate(EntityAdmin& admin)
|
||||||
|
{
|
||||||
|
TRACE();
|
||||||
|
const std::vector<CombatantInfo> combatants = buildCombatants(admin);
|
||||||
|
|
||||||
|
admin.forEach<AttackBehavior, PositionComponent, FactionComponent,
|
||||||
|
SensorRangeComponent, HealthComponent>(
|
||||||
|
[&](entt::entity e, AttackBehavior& attack, const PositionComponent& pos,
|
||||||
|
const FactionComponent& faction, const SensorRangeComponent& sensor,
|
||||||
|
const HealthComponent& health)
|
||||||
|
{
|
||||||
|
const float range = sensor.value_tiles;
|
||||||
|
|
||||||
|
// Validate current target: still valid, still in range.
|
||||||
|
bool targetValid = false;
|
||||||
|
if (attack.currentTarget)
|
||||||
|
{
|
||||||
|
const entt::entity t = *attack.currentTarget;
|
||||||
|
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
|
||||||
|
{
|
||||||
|
const float dist =
|
||||||
|
(admin.get<PositionComponent>(t).value - pos.value).length();
|
||||||
|
if (dist <= range) { targetValid = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire nearest valid target if needed.
|
||||||
|
if (!targetValid)
|
||||||
|
{
|
||||||
|
attack.currentTarget = std::nullopt;
|
||||||
|
float bestDist = range;
|
||||||
|
for (const CombatantInfo& c : combatants)
|
||||||
|
{
|
||||||
|
if (c.entity == e) { continue; }
|
||||||
|
const bool isValidTarget =
|
||||||
|
faction.isEnemy ? !c.isEnemy : c.isEnemy;
|
||||||
|
if (!isValidTarget) { continue; }
|
||||||
|
|
||||||
|
const float dist = (c.position - pos.value).length();
|
||||||
|
if (dist < bestDist)
|
||||||
|
{
|
||||||
|
bestDist = dist;
|
||||||
|
attack.currentTarget = c.entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool healthy =
|
||||||
|
(health.maxHp > 0.0f)
|
||||||
|
&& (health.hp / health.maxHp >= BehaviorScores::kLowHpFraction);
|
||||||
|
attack.score = (healthy && attack.currentTarget)
|
||||||
|
? BehaviorScores::kAttack
|
||||||
|
: BehaviorScores::kInactive;
|
||||||
|
});
|
||||||
|
}
|
||||||
11
src/lib/ecs/system/ai/AttackEvaluator.h
Normal file
11
src/lib/ecs/system/ai/AttackEvaluator.h
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
class EntityAdmin;
|
||||||
|
|
||||||
|
// Acquires/validates a combat target for ships with weapons. Scores high only
|
||||||
|
// when the ship's health is not low and a valid target is within sensor range.
|
||||||
|
class AttackEvaluator
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
void evaluate(EntityAdmin& admin);
|
||||||
|
};
|
||||||
61
src/lib/ecs/system/ai/AttackExecutor.cpp
Normal file
61
src/lib/ecs/system/ai/AttackExecutor.cpp
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
#include "AttackExecutor.h"
|
||||||
|
|
||||||
|
#include "AttackBehavior.h"
|
||||||
|
#include "BehaviorKind.h"
|
||||||
|
#include "EntityAdmin.h"
|
||||||
|
#include "ModuleOwnerComponent.h"
|
||||||
|
#include "MovementIntentComponent.h"
|
||||||
|
#include "PositionComponent.h"
|
||||||
|
#include "SelectedBehaviorComponent.h"
|
||||||
|
#include "tracing.h"
|
||||||
|
#include "WeaponComponent.h"
|
||||||
|
|
||||||
|
void AttackExecutor::execute(EntityAdmin& admin)
|
||||||
|
{
|
||||||
|
TRACE();
|
||||||
|
|
||||||
|
// Ships: move toward the behavior target.
|
||||||
|
admin.forEach<AttackBehavior, SelectedBehaviorComponent, PositionComponent,
|
||||||
|
MovementIntentComponent>(
|
||||||
|
[&](entt::entity /*e*/, const AttackBehavior& attack,
|
||||||
|
const SelectedBehaviorComponent& selected, const PositionComponent& pos,
|
||||||
|
MovementIntentComponent& intent)
|
||||||
|
{
|
||||||
|
if (selected.winner != BehaviorKind::Attack) { return; }
|
||||||
|
if (!attack.currentTarget) { return; }
|
||||||
|
|
||||||
|
const entt::entity t = *attack.currentTarget;
|
||||||
|
QVector2D dest = pos.value;
|
||||||
|
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
|
||||||
|
{
|
||||||
|
dest = admin.get<PositionComponent>(t).value;
|
||||||
|
}
|
||||||
|
intent = MovementIntentComponent{true, dest};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Weapons: assign the behavior target only if it is within this weapon's range.
|
||||||
|
admin.forEach<WeaponComponent, ModuleOwnerComponent>(
|
||||||
|
[&](entt::entity /*we*/, WeaponComponent& weapon, const ModuleOwnerComponent& owner)
|
||||||
|
{
|
||||||
|
if (!admin.hasAll<AttackBehavior, SelectedBehaviorComponent>(owner.owner))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const SelectedBehaviorComponent& selected =
|
||||||
|
admin.get<SelectedBehaviorComponent>(owner.owner);
|
||||||
|
if (selected.winner != BehaviorKind::Attack) { return; }
|
||||||
|
|
||||||
|
const AttackBehavior& attack = admin.get<AttackBehavior>(owner.owner);
|
||||||
|
if (!attack.currentTarget) { return; }
|
||||||
|
|
||||||
|
const entt::entity t = *attack.currentTarget;
|
||||||
|
if (!admin.isValid(t) || !admin.hasAll<PositionComponent>(t)) { return; }
|
||||||
|
|
||||||
|
const QVector2D ownerPos = admin.get<PositionComponent>(owner.owner).value;
|
||||||
|
const float dist = (admin.get<PositionComponent>(t).value - ownerPos).length();
|
||||||
|
if (dist <= weapon.range_tiles)
|
||||||
|
{
|
||||||
|
weapon.currentTarget = t;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
12
src/lib/ecs/system/ai/AttackExecutor.h
Normal file
12
src/lib/ecs/system/ai/AttackExecutor.h
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
class EntityAdmin;
|
||||||
|
|
||||||
|
// When Attack wins, moves the ship toward its target and assigns that target to
|
||||||
|
// each weapon that has it in range. Weapons whose range excludes the target are
|
||||||
|
// left untouched so CombatSystem can keep/acquire a closer target (no thrash).
|
||||||
|
class AttackExecutor
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
void execute(EntityAdmin& admin);
|
||||||
|
};
|
||||||
81
src/lib/ecs/system/ai/BehaviorTargeting.cpp
Normal file
81
src/lib/ecs/system/ai/BehaviorTargeting.cpp
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
#include "BehaviorTargeting.h"
|
||||||
|
|
||||||
|
#include "EntityAdmin.h"
|
||||||
|
#include "FactionComponent.h"
|
||||||
|
#include "HealthComponent.h"
|
||||||
|
#include "HqProxyComponent.h"
|
||||||
|
#include "ModuleOwnerComponent.h"
|
||||||
|
#include "PositionComponent.h"
|
||||||
|
#include "SalvageCargoComponent.h"
|
||||||
|
#include "ShipIdentityComponent.h"
|
||||||
|
#include "StationBodyComponent.h"
|
||||||
|
|
||||||
|
std::vector<RepairableInfo> buildRepairables(EntityAdmin& admin)
|
||||||
|
{
|
||||||
|
std::vector<RepairableInfo> repairables;
|
||||||
|
|
||||||
|
admin.forEach<ShipIdentityComponent, PositionComponent, FactionComponent, HealthComponent>(
|
||||||
|
[&repairables](entt::entity e, const ShipIdentityComponent& /*si*/,
|
||||||
|
const PositionComponent& pos, const FactionComponent& f,
|
||||||
|
const HealthComponent& h)
|
||||||
|
{
|
||||||
|
repairables.push_back({e, pos.value, f.isEnemy, true, h.hp, h.maxHp});
|
||||||
|
});
|
||||||
|
|
||||||
|
admin.forEach<StationBodyComponent, PositionComponent, FactionComponent, HealthComponent>(
|
||||||
|
[&repairables](entt::entity e, const StationBodyComponent& /*sb*/,
|
||||||
|
const PositionComponent& pos, const FactionComponent& f,
|
||||||
|
const HealthComponent& h)
|
||||||
|
{
|
||||||
|
repairables.push_back({e, pos.value, f.isEnemy, false, h.hp, h.maxHp});
|
||||||
|
});
|
||||||
|
|
||||||
|
return repairables;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<CombatantInfo> buildCombatants(EntityAdmin& admin)
|
||||||
|
{
|
||||||
|
std::vector<CombatantInfo> combatants;
|
||||||
|
|
||||||
|
admin.forEach<PositionComponent, FactionComponent, ShipIdentityComponent>(
|
||||||
|
[&combatants](entt::entity e, const PositionComponent& pos,
|
||||||
|
const FactionComponent& f, const ShipIdentityComponent& /*si*/)
|
||||||
|
{
|
||||||
|
combatants.push_back({e, pos.value, f.isEnemy, false});
|
||||||
|
});
|
||||||
|
|
||||||
|
admin.forEach<PositionComponent, FactionComponent, StationBodyComponent>(
|
||||||
|
[&combatants](entt::entity e, const PositionComponent& pos,
|
||||||
|
const FactionComponent& f, const StationBodyComponent& /*sb*/)
|
||||||
|
{
|
||||||
|
combatants.push_back({e, pos.value, f.isEnemy, true});
|
||||||
|
});
|
||||||
|
|
||||||
|
admin.forEach<PositionComponent, FactionComponent, HqProxyComponent>(
|
||||||
|
[&combatants](entt::entity e, const PositionComponent& pos,
|
||||||
|
const FactionComponent& f, const HqProxyComponent& /*hq*/)
|
||||||
|
{
|
||||||
|
combatants.push_back({e, pos.value, f.isEnemy, true});
|
||||||
|
});
|
||||||
|
|
||||||
|
return combatants;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unordered_map<entt::entity, CargoState> buildCargoByShip(EntityAdmin& admin)
|
||||||
|
{
|
||||||
|
std::unordered_map<entt::entity, CargoState> cargoByShip;
|
||||||
|
admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
|
||||||
|
[&cargoByShip](entt::entity /*ce*/, const SalvageCargoComponent& c,
|
||||||
|
const ModuleOwnerComponent& o)
|
||||||
|
{
|
||||||
|
CargoState& agg = cargoByShip[o.owner];
|
||||||
|
agg.current += c.current;
|
||||||
|
agg.capacity += c.capacity;
|
||||||
|
});
|
||||||
|
return cargoByShip;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isCargoFull(const CargoState& cargo)
|
||||||
|
{
|
||||||
|
return cargo.capacity > 0 && cargo.current >= cargo.capacity;
|
||||||
|
}
|
||||||
49
src/lib/ecs/system/ai/BehaviorTargeting.h
Normal file
49
src/lib/ecs/system/ai/BehaviorTargeting.h
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QVector2D>
|
||||||
|
|
||||||
|
#include "entt/entity/entity.hpp"
|
||||||
|
|
||||||
|
class EntityAdmin;
|
||||||
|
|
||||||
|
// Shared, per-call target snapshots used by behavior evaluators and the repair
|
||||||
|
// system. Each caller builds its own snapshot (no cross-system caching).
|
||||||
|
|
||||||
|
struct RepairableInfo
|
||||||
|
{
|
||||||
|
entt::entity entity;
|
||||||
|
QVector2D position;
|
||||||
|
bool isEnemy;
|
||||||
|
bool isShip;
|
||||||
|
float hp;
|
||||||
|
float maxHp;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CombatantInfo
|
||||||
|
{
|
||||||
|
entt::entity entity;
|
||||||
|
QVector2D position;
|
||||||
|
bool isEnemy;
|
||||||
|
bool isStation;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CargoState
|
||||||
|
{
|
||||||
|
int current = 0;
|
||||||
|
int capacity = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// All ships and stations with health — candidates for repair targeting.
|
||||||
|
std::vector<RepairableInfo> buildRepairables(EntityAdmin& admin);
|
||||||
|
|
||||||
|
// All ships, stations, and the HQ proxy — candidates for attack targeting.
|
||||||
|
std::vector<CombatantInfo> buildCombatants(EntityAdmin& admin);
|
||||||
|
|
||||||
|
// Aggregated salvage cargo per owning ship, summed across its salvage modules.
|
||||||
|
std::unordered_map<entt::entity, CargoState> buildCargoByShip(EntityAdmin& admin);
|
||||||
|
|
||||||
|
// True when the ship's aggregated cargo is at capacity (and it has any capacity).
|
||||||
|
bool isCargoFull(const CargoState& cargo);
|
||||||
43
src/lib/ecs/system/ai/DeliverScrapEvaluator.cpp
Normal file
43
src/lib/ecs/system/ai/DeliverScrapEvaluator.cpp
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
#include "DeliverScrapEvaluator.h"
|
||||||
|
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
#include "BehaviorScores.h"
|
||||||
|
#include "BehaviorTargeting.h"
|
||||||
|
#include "Building.h"
|
||||||
|
#include "BuildingSystem.h"
|
||||||
|
#include "BuildingType.h"
|
||||||
|
#include "DeliverScrapBehavior.h"
|
||||||
|
#include "EntityAdmin.h"
|
||||||
|
#include "PositionComponent.h"
|
||||||
|
#include "tracing.h"
|
||||||
|
|
||||||
|
void DeliverScrapEvaluator::evaluate(EntityAdmin& admin, const BuildingSystem& buildings)
|
||||||
|
{
|
||||||
|
TRACE();
|
||||||
|
const std::unordered_map<entt::entity, CargoState> cargoByShip = buildCargoByShip(admin);
|
||||||
|
|
||||||
|
admin.forEach<DeliverScrapBehavior, PositionComponent>(
|
||||||
|
[&](entt::entity e, DeliverScrapBehavior& deliver, const PositionComponent& pos)
|
||||||
|
{
|
||||||
|
const std::unordered_map<entt::entity, CargoState>::const_iterator it =
|
||||||
|
cargoByShip.find(e);
|
||||||
|
const bool cargoFull = (it != cargoByShip.end()) && isCargoFull(it->second);
|
||||||
|
|
||||||
|
if (!cargoFull)
|
||||||
|
{
|
||||||
|
deliver.score = BehaviorScores::kInactive;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign nearest SalvageBay if not yet assigned.
|
||||||
|
if (deliver.deliveryBay == kInvalidBuildingId)
|
||||||
|
{
|
||||||
|
const Building* bay =
|
||||||
|
buildings.findNearestBuilding(pos.value, BuildingType::SalvageBay);
|
||||||
|
if (bay) { deliver.deliveryBay = bay->id; }
|
||||||
|
}
|
||||||
|
|
||||||
|
deliver.score = BehaviorScores::kDeliver;
|
||||||
|
});
|
||||||
|
}
|
||||||
12
src/lib/ecs/system/ai/DeliverScrapEvaluator.h
Normal file
12
src/lib/ecs/system/ai/DeliverScrapEvaluator.h
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
class EntityAdmin;
|
||||||
|
class BuildingSystem;
|
||||||
|
|
||||||
|
// Scores high only when the ship's cargo is full, and assigns the nearest
|
||||||
|
// SalvageBay as the delivery destination.
|
||||||
|
class DeliverScrapEvaluator
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
void evaluate(EntityAdmin& admin, const BuildingSystem& buildings);
|
||||||
|
};
|
||||||
38
src/lib/ecs/system/ai/DeliverScrapExecutor.cpp
Normal file
38
src/lib/ecs/system/ai/DeliverScrapExecutor.cpp
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
#include "DeliverScrapExecutor.h"
|
||||||
|
|
||||||
|
#include <QVector2D>
|
||||||
|
|
||||||
|
#include "BehaviorKind.h"
|
||||||
|
#include "Building.h"
|
||||||
|
#include "BuildingSystem.h"
|
||||||
|
#include "DeliverScrapBehavior.h"
|
||||||
|
#include "EntityAdmin.h"
|
||||||
|
#include "MovementIntentComponent.h"
|
||||||
|
#include "PositionComponent.h"
|
||||||
|
#include "SelectedBehaviorComponent.h"
|
||||||
|
#include "tracing.h"
|
||||||
|
|
||||||
|
void DeliverScrapExecutor::execute(EntityAdmin& admin, const BuildingSystem& buildings)
|
||||||
|
{
|
||||||
|
TRACE();
|
||||||
|
admin.forEach<DeliverScrapBehavior, SelectedBehaviorComponent, PositionComponent,
|
||||||
|
MovementIntentComponent>(
|
||||||
|
[&](entt::entity /*e*/, const DeliverScrapBehavior& deliver,
|
||||||
|
const SelectedBehaviorComponent& selected, const PositionComponent& pos,
|
||||||
|
MovementIntentComponent& intent)
|
||||||
|
{
|
||||||
|
if (selected.winner != BehaviorKind::DeliverScrap) { return; }
|
||||||
|
|
||||||
|
QVector2D dest = pos.value;
|
||||||
|
if (deliver.deliveryBay != kInvalidBuildingId)
|
||||||
|
{
|
||||||
|
const Building* bay = buildings.findBuilding(deliver.deliveryBay);
|
||||||
|
if (bay)
|
||||||
|
{
|
||||||
|
dest = QVector2D(bay->anchor.x() + bay->footprint.width() / 2.0f,
|
||||||
|
bay->anchor.y() + bay->footprint.height() / 2.0f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
intent = MovementIntentComponent{true, dest};
|
||||||
|
});
|
||||||
|
}
|
||||||
12
src/lib/ecs/system/ai/DeliverScrapExecutor.h
Normal file
12
src/lib/ecs/system/ai/DeliverScrapExecutor.h
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
class EntityAdmin;
|
||||||
|
class BuildingSystem;
|
||||||
|
|
||||||
|
// Moves a ship toward its delivery bay when DeliverScrap is the winning
|
||||||
|
// behavior. Never decrements cargo — SalvagerSystem performs the delivery.
|
||||||
|
class DeliverScrapExecutor
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
void execute(EntityAdmin& admin, const BuildingSystem& buildings);
|
||||||
|
};
|
||||||
16
src/lib/ecs/system/ai/RallyEvaluator.cpp
Normal file
16
src/lib/ecs/system/ai/RallyEvaluator.cpp
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
#include "RallyEvaluator.h"
|
||||||
|
|
||||||
|
#include "BehaviorScores.h"
|
||||||
|
#include "EntityAdmin.h"
|
||||||
|
#include "RallyBehavior.h"
|
||||||
|
#include "tracing.h"
|
||||||
|
|
||||||
|
void RallyEvaluator::evaluate(EntityAdmin& admin)
|
||||||
|
{
|
||||||
|
TRACE();
|
||||||
|
admin.forEach<RallyBehavior>(
|
||||||
|
[](entt::entity /*e*/, RallyBehavior& rally)
|
||||||
|
{
|
||||||
|
rally.score = BehaviorScores::kRally;
|
||||||
|
});
|
||||||
|
}
|
||||||
12
src/lib/ecs/system/ai/RallyEvaluator.h
Normal file
12
src/lib/ecs/system/ai/RallyEvaluator.h
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
class EntityAdmin;
|
||||||
|
|
||||||
|
// Scores the rally behavior so player combat ships gather at the rally point
|
||||||
|
// until an enemy appears (Attack outscores it) or the departure timer removes
|
||||||
|
// the RallyBehavior component.
|
||||||
|
class RallyEvaluator
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
void evaluate(EntityAdmin& admin);
|
||||||
|
};
|
||||||
20
src/lib/ecs/system/ai/RallyExecutor.cpp
Normal file
20
src/lib/ecs/system/ai/RallyExecutor.cpp
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#include "RallyExecutor.h"
|
||||||
|
|
||||||
|
#include "BehaviorKind.h"
|
||||||
|
#include "EntityAdmin.h"
|
||||||
|
#include "MovementIntentComponent.h"
|
||||||
|
#include "RallyBehavior.h"
|
||||||
|
#include "SelectedBehaviorComponent.h"
|
||||||
|
#include "tracing.h"
|
||||||
|
|
||||||
|
void RallyExecutor::execute(EntityAdmin& admin)
|
||||||
|
{
|
||||||
|
TRACE();
|
||||||
|
admin.forEach<RallyBehavior, SelectedBehaviorComponent, MovementIntentComponent>(
|
||||||
|
[](entt::entity /*e*/, const RallyBehavior& rally,
|
||||||
|
const SelectedBehaviorComponent& selected, MovementIntentComponent& intent)
|
||||||
|
{
|
||||||
|
if (selected.winner != BehaviorKind::Rally) { return; }
|
||||||
|
intent = MovementIntentComponent{true, rally.rallyPoint};
|
||||||
|
});
|
||||||
|
}
|
||||||
10
src/lib/ecs/system/ai/RallyExecutor.h
Normal file
10
src/lib/ecs/system/ai/RallyExecutor.h
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
class EntityAdmin;
|
||||||
|
|
||||||
|
// Moves a ship to its rally point when Rally is the winning behavior.
|
||||||
|
class RallyExecutor
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
void execute(EntityAdmin& admin);
|
||||||
|
};
|
||||||
58
src/lib/ecs/system/ai/RepairEvaluator.cpp
Normal file
58
src/lib/ecs/system/ai/RepairEvaluator.cpp
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
#include "RepairEvaluator.h"
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "BehaviorScores.h"
|
||||||
|
#include "BehaviorTargeting.h"
|
||||||
|
#include "EntityAdmin.h"
|
||||||
|
#include "HealthComponent.h"
|
||||||
|
#include "PositionComponent.h"
|
||||||
|
#include "RepairBehavior.h"
|
||||||
|
#include "SensorRangeComponent.h"
|
||||||
|
#include "tracing.h"
|
||||||
|
|
||||||
|
void RepairEvaluator::evaluate(EntityAdmin& admin)
|
||||||
|
{
|
||||||
|
TRACE();
|
||||||
|
const std::vector<RepairableInfo> repairables = buildRepairables(admin);
|
||||||
|
|
||||||
|
admin.forEach<RepairBehavior, PositionComponent, SensorRangeComponent>(
|
||||||
|
[&](entt::entity e, RepairBehavior& repair, const PositionComponent& pos,
|
||||||
|
const SensorRangeComponent& sensor)
|
||||||
|
{
|
||||||
|
// Validate current target: alive and still damaged.
|
||||||
|
bool targetValid = false;
|
||||||
|
if (repair.currentTarget)
|
||||||
|
{
|
||||||
|
const entt::entity t = *repair.currentTarget;
|
||||||
|
if (admin.isValid(t) && admin.hasAll<HealthComponent>(t))
|
||||||
|
{
|
||||||
|
const HealthComponent& th = admin.get<HealthComponent>(t);
|
||||||
|
if (th.hp > 0.0f && th.hp < th.maxHp) { targetValid = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire nearest damaged friendly within sensor range.
|
||||||
|
if (!targetValid)
|
||||||
|
{
|
||||||
|
repair.currentTarget = std::nullopt;
|
||||||
|
float bestDist = sensor.value_tiles;
|
||||||
|
for (const RepairableInfo& r : repairables)
|
||||||
|
{
|
||||||
|
if (r.entity == e) { continue; }
|
||||||
|
if (r.isEnemy) { continue; }
|
||||||
|
if (r.hp <= 0.0f || r.hp >= r.maxHp) { continue; }
|
||||||
|
const float dist = (r.position - pos.value).length();
|
||||||
|
if (dist < bestDist)
|
||||||
|
{
|
||||||
|
bestDist = dist;
|
||||||
|
repair.currentTarget = r.entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repair.score = repair.currentTarget
|
||||||
|
? BehaviorScores::kRepair
|
||||||
|
: BehaviorScores::kInactive;
|
||||||
|
});
|
||||||
|
}
|
||||||
11
src/lib/ecs/system/ai/RepairEvaluator.h
Normal file
11
src/lib/ecs/system/ai/RepairEvaluator.h
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
class EntityAdmin;
|
||||||
|
|
||||||
|
// Picks the nearest damaged friendly within sensor range as the repair target.
|
||||||
|
// Scores high when such a target exists.
|
||||||
|
class RepairEvaluator
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
void evaluate(EntityAdmin& admin);
|
||||||
|
};
|
||||||
61
src/lib/ecs/system/ai/RepairExecutor.cpp
Normal file
61
src/lib/ecs/system/ai/RepairExecutor.cpp
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
#include "RepairExecutor.h"
|
||||||
|
|
||||||
|
#include "BehaviorKind.h"
|
||||||
|
#include "EntityAdmin.h"
|
||||||
|
#include "ModuleOwnerComponent.h"
|
||||||
|
#include "MovementIntentComponent.h"
|
||||||
|
#include "PositionComponent.h"
|
||||||
|
#include "RepairBehavior.h"
|
||||||
|
#include "RepairToolComponent.h"
|
||||||
|
#include "SelectedBehaviorComponent.h"
|
||||||
|
#include "tracing.h"
|
||||||
|
|
||||||
|
void RepairExecutor::execute(EntityAdmin& admin)
|
||||||
|
{
|
||||||
|
TRACE();
|
||||||
|
|
||||||
|
// Ships: move toward the repair target.
|
||||||
|
admin.forEach<RepairBehavior, SelectedBehaviorComponent, PositionComponent,
|
||||||
|
MovementIntentComponent>(
|
||||||
|
[&](entt::entity /*e*/, const RepairBehavior& repair,
|
||||||
|
const SelectedBehaviorComponent& selected, const PositionComponent& pos,
|
||||||
|
MovementIntentComponent& intent)
|
||||||
|
{
|
||||||
|
if (selected.winner != BehaviorKind::Repair) { return; }
|
||||||
|
if (!repair.currentTarget) { return; }
|
||||||
|
|
||||||
|
const entt::entity t = *repair.currentTarget;
|
||||||
|
QVector2D dest = pos.value;
|
||||||
|
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
|
||||||
|
{
|
||||||
|
dest = admin.get<PositionComponent>(t).value;
|
||||||
|
}
|
||||||
|
intent = MovementIntentComponent{true, dest};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Repair tools: prefer the behavior target if it is within tool range.
|
||||||
|
admin.forEach<RepairToolComponent, ModuleOwnerComponent>(
|
||||||
|
[&](entt::entity /*re*/, RepairToolComponent& tool, const ModuleOwnerComponent& owner)
|
||||||
|
{
|
||||||
|
if (!admin.hasAll<RepairBehavior, SelectedBehaviorComponent>(owner.owner))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const SelectedBehaviorComponent& selected =
|
||||||
|
admin.get<SelectedBehaviorComponent>(owner.owner);
|
||||||
|
if (selected.winner != BehaviorKind::Repair) { return; }
|
||||||
|
|
||||||
|
const RepairBehavior& repair = admin.get<RepairBehavior>(owner.owner);
|
||||||
|
if (!repair.currentTarget) { return; }
|
||||||
|
|
||||||
|
const entt::entity t = *repair.currentTarget;
|
||||||
|
if (!admin.isValid(t) || !admin.hasAll<PositionComponent>(t)) { return; }
|
||||||
|
|
||||||
|
const QVector2D ownerPos = admin.get<PositionComponent>(owner.owner).value;
|
||||||
|
const float dist = (admin.get<PositionComponent>(t).value - ownerPos).length();
|
||||||
|
if (dist <= tool.range_tiles)
|
||||||
|
{
|
||||||
|
tool.currentTarget = t;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
12
src/lib/ecs/system/ai/RepairExecutor.h
Normal file
12
src/lib/ecs/system/ai/RepairExecutor.h
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
class EntityAdmin;
|
||||||
|
|
||||||
|
// When Repair wins, moves the ship toward its target and assigns that target to
|
||||||
|
// each repair tool that has it in range. RepairSystem applies the healing and
|
||||||
|
// does fallback acquisition for tools whose preferred target is out of range.
|
||||||
|
class RepairExecutor
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
void execute(EntityAdmin& admin);
|
||||||
|
};
|
||||||
56
src/lib/ecs/system/ai/RetreatEvaluator.cpp
Normal file
56
src/lib/ecs/system/ai/RetreatEvaluator.cpp
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
#include "RetreatEvaluator.h"
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QVector2D>
|
||||||
|
|
||||||
|
#include "AttackBehavior.h"
|
||||||
|
#include "BehaviorScores.h"
|
||||||
|
#include "EntityAdmin.h"
|
||||||
|
#include "FactionComponent.h"
|
||||||
|
#include "HealthComponent.h"
|
||||||
|
#include "PositionComponent.h"
|
||||||
|
#include "RetreatBehavior.h"
|
||||||
|
#include "SensorRangeComponent.h"
|
||||||
|
#include "ShipIdentityComponent.h"
|
||||||
|
#include "tracing.h"
|
||||||
|
|
||||||
|
void RetreatEvaluator::evaluate(EntityAdmin& admin)
|
||||||
|
{
|
||||||
|
TRACE();
|
||||||
|
|
||||||
|
// Snapshot enemy ship positions for threat detection.
|
||||||
|
std::vector<QVector2D> enemyShips;
|
||||||
|
admin.forEach<ShipIdentityComponent, PositionComponent, FactionComponent>(
|
||||||
|
[&enemyShips](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
|
||||||
|
const PositionComponent& pos, const FactionComponent& f)
|
||||||
|
{
|
||||||
|
if (f.isEnemy) { enemyShips.push_back(pos.value); }
|
||||||
|
});
|
||||||
|
|
||||||
|
admin.forEach<RetreatBehavior, PositionComponent, HealthComponent, SensorRangeComponent>(
|
||||||
|
[&](entt::entity e, RetreatBehavior& retreat, const PositionComponent& pos,
|
||||||
|
const HealthComponent& health, const SensorRangeComponent& sensor)
|
||||||
|
{
|
||||||
|
const bool lowHp = (health.maxHp > 0.0f)
|
||||||
|
&& (health.hp / health.maxHp < retreat.retreatHpFraction);
|
||||||
|
|
||||||
|
bool threatened = false;
|
||||||
|
const bool hasWeapons = admin.hasAll<AttackBehavior>(e);
|
||||||
|
if (!hasWeapons)
|
||||||
|
{
|
||||||
|
for (const QVector2D& enemy : enemyShips)
|
||||||
|
{
|
||||||
|
if ((enemy - pos.value).length() <= sensor.value_tiles)
|
||||||
|
{
|
||||||
|
threatened = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
retreat.score = (lowHp || threatened)
|
||||||
|
? BehaviorScores::kRetreat
|
||||||
|
: BehaviorScores::kInactive;
|
||||||
|
});
|
||||||
|
}
|
||||||
12
src/lib/ecs/system/ai/RetreatEvaluator.h
Normal file
12
src/lib/ecs/system/ai/RetreatEvaluator.h
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
class EntityAdmin;
|
||||||
|
|
||||||
|
// Scores high (above all task behaviors) when the ship's health is below its
|
||||||
|
// retreat threshold, or when an enemy ship is within sensor range and the ship
|
||||||
|
// has no weapons to fight back with.
|
||||||
|
class RetreatEvaluator
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
void evaluate(EntityAdmin& admin);
|
||||||
|
};
|
||||||
20
src/lib/ecs/system/ai/RetreatExecutor.cpp
Normal file
20
src/lib/ecs/system/ai/RetreatExecutor.cpp
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#include "RetreatExecutor.h"
|
||||||
|
|
||||||
|
#include "BehaviorKind.h"
|
||||||
|
#include "EntityAdmin.h"
|
||||||
|
#include "MovementIntentComponent.h"
|
||||||
|
#include "RetreatBehavior.h"
|
||||||
|
#include "SelectedBehaviorComponent.h"
|
||||||
|
#include "tracing.h"
|
||||||
|
|
||||||
|
void RetreatExecutor::execute(EntityAdmin& admin)
|
||||||
|
{
|
||||||
|
TRACE();
|
||||||
|
admin.forEach<RetreatBehavior, SelectedBehaviorComponent, MovementIntentComponent>(
|
||||||
|
[](entt::entity /*e*/, const RetreatBehavior& retreat,
|
||||||
|
const SelectedBehaviorComponent& selected, MovementIntentComponent& intent)
|
||||||
|
{
|
||||||
|
if (selected.winner != BehaviorKind::Retreat) { return; }
|
||||||
|
intent = MovementIntentComponent{true, retreat.retreatPoint};
|
||||||
|
});
|
||||||
|
}
|
||||||
10
src/lib/ecs/system/ai/RetreatExecutor.h
Normal file
10
src/lib/ecs/system/ai/RetreatExecutor.h
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
class EntityAdmin;
|
||||||
|
|
||||||
|
// Moves a ship to its retreat point (the rally point) when Retreat wins.
|
||||||
|
class RetreatExecutor
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
void execute(EntityAdmin& admin);
|
||||||
|
};
|
||||||
55
src/lib/ecs/system/ai/SalvageScrapEvaluator.cpp
Normal file
55
src/lib/ecs/system/ai/SalvageScrapEvaluator.cpp
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
#include "SalvageScrapEvaluator.h"
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QVector2D>
|
||||||
|
|
||||||
|
#include "BehaviorScores.h"
|
||||||
|
#include "BehaviorTargeting.h"
|
||||||
|
#include "EntityAdmin.h"
|
||||||
|
#include "PositionComponent.h"
|
||||||
|
#include "SalvageScrapBehavior.h"
|
||||||
|
#include "ScrapSystem.h"
|
||||||
|
#include "SensorRangeComponent.h"
|
||||||
|
#include "tracing.h"
|
||||||
|
|
||||||
|
void SalvageScrapEvaluator::evaluate(EntityAdmin& admin, const ScrapSystem& scraps)
|
||||||
|
{
|
||||||
|
TRACE();
|
||||||
|
const std::unordered_map<entt::entity, CargoState> cargoByShip = buildCargoByShip(admin);
|
||||||
|
const std::vector<ScrapInfo> allScrap = scraps.allScrapInfo();
|
||||||
|
|
||||||
|
admin.forEach<SalvageScrapBehavior, PositionComponent, SensorRangeComponent>(
|
||||||
|
[&](entt::entity e, SalvageScrapBehavior& salvage, const PositionComponent& pos,
|
||||||
|
const SensorRangeComponent& sensor)
|
||||||
|
{
|
||||||
|
const std::unordered_map<entt::entity, CargoState>::const_iterator it =
|
||||||
|
cargoByShip.find(e);
|
||||||
|
const bool cargoFull = (it != cargoByShip.end()) && isCargoFull(it->second);
|
||||||
|
|
||||||
|
if (cargoFull)
|
||||||
|
{
|
||||||
|
salvage.scrapTarget = std::nullopt;
|
||||||
|
salvage.score = BehaviorScores::kInactive;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find nearest scrap within sensor range.
|
||||||
|
float bestDist = sensor.value_tiles;
|
||||||
|
std::optional<QVector2D> bestPos;
|
||||||
|
for (const ScrapInfo& si : allScrap)
|
||||||
|
{
|
||||||
|
const float dist = (si.position - pos.value).length();
|
||||||
|
if (dist < bestDist)
|
||||||
|
{
|
||||||
|
bestDist = dist;
|
||||||
|
bestPos = si.position;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
salvage.scrapTarget = bestPos;
|
||||||
|
salvage.score = bestPos ? BehaviorScores::kSalvage : BehaviorScores::kInactive;
|
||||||
|
});
|
||||||
|
}
|
||||||
13
src/lib/ecs/system/ai/SalvageScrapEvaluator.h
Normal file
13
src/lib/ecs/system/ai/SalvageScrapEvaluator.h
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
class EntityAdmin;
|
||||||
|
class ScrapSystem;
|
||||||
|
|
||||||
|
// When cargo is not full, finds the nearest scrap within sensor range and sets
|
||||||
|
// it as the target, scoring high. Scores inactive when cargo is full or no scrap
|
||||||
|
// is in range (Advance then handles roaming).
|
||||||
|
class SalvageScrapEvaluator
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
void evaluate(EntityAdmin& admin, const ScrapSystem& scraps);
|
||||||
|
};
|
||||||
21
src/lib/ecs/system/ai/SalvageScrapExecutor.cpp
Normal file
21
src/lib/ecs/system/ai/SalvageScrapExecutor.cpp
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#include "SalvageScrapExecutor.h"
|
||||||
|
|
||||||
|
#include "BehaviorKind.h"
|
||||||
|
#include "EntityAdmin.h"
|
||||||
|
#include "MovementIntentComponent.h"
|
||||||
|
#include "SalvageScrapBehavior.h"
|
||||||
|
#include "SelectedBehaviorComponent.h"
|
||||||
|
#include "tracing.h"
|
||||||
|
|
||||||
|
void SalvageScrapExecutor::execute(EntityAdmin& admin)
|
||||||
|
{
|
||||||
|
TRACE();
|
||||||
|
admin.forEach<SalvageScrapBehavior, SelectedBehaviorComponent, MovementIntentComponent>(
|
||||||
|
[](entt::entity /*e*/, const SalvageScrapBehavior& salvage,
|
||||||
|
const SelectedBehaviorComponent& selected, MovementIntentComponent& intent)
|
||||||
|
{
|
||||||
|
if (selected.winner != BehaviorKind::SalvageScrap) { return; }
|
||||||
|
if (!salvage.scrapTarget) { return; }
|
||||||
|
intent = MovementIntentComponent{true, *salvage.scrapTarget};
|
||||||
|
});
|
||||||
|
}
|
||||||
10
src/lib/ecs/system/ai/SalvageScrapExecutor.h
Normal file
10
src/lib/ecs/system/ai/SalvageScrapExecutor.h
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
class EntityAdmin;
|
||||||
|
|
||||||
|
// Moves a ship toward its scrap target when SalvageScrap is the winning behavior.
|
||||||
|
class SalvageScrapExecutor
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
void execute(EntityAdmin& admin);
|
||||||
|
};
|
||||||
@@ -720,11 +720,18 @@ void BeltSystem::routeSplitterItems()
|
|||||||
|
|
||||||
bool routed = false;
|
bool routed = false;
|
||||||
|
|
||||||
|
// A front slot holds only one item, so an item entering at progress 0.0
|
||||||
|
// would have to traverse the whole tile before the next could enter,
|
||||||
|
// throttling that output below belt speed and leaving large gaps. Entering
|
||||||
|
// near the output edge lets the slot clear roughly every quarter tile, so
|
||||||
|
// the output stays packed (fixes the half-blocked / single-output gap bug).
|
||||||
|
constexpr double frontEntryProgress = 0.75;
|
||||||
|
|
||||||
if (matchesA && !matchesB)
|
if (matchesA && !matchesB)
|
||||||
{
|
{
|
||||||
if (!st.frontA)
|
if (!st.frontA)
|
||||||
{
|
{
|
||||||
st.frontA = BeltItemSlot{item, 0.0};
|
st.frontA = BeltItemSlot{item, frontEntryProgress};
|
||||||
routed = true;
|
routed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -732,7 +739,7 @@ void BeltSystem::routeSplitterItems()
|
|||||||
{
|
{
|
||||||
if (!st.frontB)
|
if (!st.frontB)
|
||||||
{
|
{
|
||||||
st.frontB = BeltItemSlot{item, 0.0};
|
st.frontB = BeltItemSlot{item, frontEntryProgress};
|
||||||
routed = true;
|
routed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -743,26 +750,26 @@ void BeltSystem::routeSplitterItems()
|
|||||||
|
|
||||||
if (preferA && !st.frontA)
|
if (preferA && !st.frontA)
|
||||||
{
|
{
|
||||||
st.frontA = BeltItemSlot{item, 0.0};
|
st.frontA = BeltItemSlot{item, frontEntryProgress};
|
||||||
st.nextOutputIsA = false;
|
st.nextOutputIsA = false;
|
||||||
routed = true;
|
routed = true;
|
||||||
}
|
}
|
||||||
else if (!preferA && !st.frontB)
|
else if (!preferA && !st.frontB)
|
||||||
{
|
{
|
||||||
st.frontB = BeltItemSlot{item, 0.0};
|
st.frontB = BeltItemSlot{item, frontEntryProgress};
|
||||||
st.nextOutputIsA = true;
|
st.nextOutputIsA = true;
|
||||||
routed = true;
|
routed = true;
|
||||||
}
|
}
|
||||||
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.75};
|
st.frontB = BeltItemSlot{item, frontEntryProgress};
|
||||||
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.75};
|
st.frontA = BeltItemSlot{item, frontEntryProgress};
|
||||||
routed = true;
|
routed = true;
|
||||||
}
|
}
|
||||||
// else both fronts occupied — back stays.
|
// else both fronts occupied — back stays.
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
#include "ModuleOwnerComponent.h"
|
#include "ModuleOwnerComponent.h"
|
||||||
#include "MovementIntentSystem.h"
|
#include "MovementIntentSystem.h"
|
||||||
#include "PositionComponent.h"
|
#include "PositionComponent.h"
|
||||||
|
#include "RepairSystem.h"
|
||||||
|
#include "SalvagerSystem.h"
|
||||||
#include "ScrapSystem.h"
|
#include "ScrapSystem.h"
|
||||||
#include "ShipIdentityComponent.h"
|
#include "ShipIdentityComponent.h"
|
||||||
#include "ShipSystem.h"
|
#include "ShipSystem.h"
|
||||||
@@ -68,6 +70,8 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
|
|||||||
m_movementIntentSystem = std::make_unique<MovementIntentSystem>();
|
m_movementIntentSystem = std::make_unique<MovementIntentSystem>();
|
||||||
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
|
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
|
||||||
m_scrapSystem = std::make_unique<ScrapSystem>(m_admin);
|
m_scrapSystem = std::make_unique<ScrapSystem>(m_admin);
|
||||||
|
m_salvagerSystem = std::make_unique<SalvagerSystem>(m_admin);
|
||||||
|
m_repairSystem = std::make_unique<RepairSystem>(m_admin);
|
||||||
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);
|
||||||
|
|
||||||
@@ -169,6 +173,8 @@ void Simulation::reset(unsigned int seed)
|
|||||||
m_movementIntentSystem = std::make_unique<MovementIntentSystem>();
|
m_movementIntentSystem = std::make_unique<MovementIntentSystem>();
|
||||||
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
|
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
|
||||||
m_scrapSystem = std::make_unique<ScrapSystem>(m_admin);
|
m_scrapSystem = std::make_unique<ScrapSystem>(m_admin);
|
||||||
|
m_salvagerSystem = std::make_unique<SalvagerSystem>(m_admin);
|
||||||
|
m_repairSystem = std::make_unique<RepairSystem>(m_admin);
|
||||||
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);
|
||||||
|
|
||||||
@@ -238,11 +244,12 @@ void Simulation::tick()
|
|||||||
}
|
}
|
||||||
|
|
||||||
m_shipSystem->clearMovementIntents();
|
m_shipSystem->clearMovementIntents();
|
||||||
m_aiSystem->tickHomeReturnBehavior(m_admin); // priority 4
|
// Score-based behavior selection: evaluate, select winner, execute (sets
|
||||||
m_aiSystem->tickThreatResponseBehavior(m_admin, *m_buildingSystem); // priority 3
|
// movement intent + preferred module targets only — no world mutation).
|
||||||
m_aiSystem->tickRepairBehavior(m_admin, *m_buildingSystem); // priority 2
|
m_aiSystem->tick(m_admin, *m_buildingSystem, *m_scrapSystem);
|
||||||
m_aiSystem->tickRepairTools(m_admin);
|
// Module systems perform the world mutation (collection/delivery, healing).
|
||||||
m_aiSystem->tickSalvageBehavior(m_admin, *m_scrapSystem, *m_buildingSystem); // priority 1
|
m_salvagerSystem->tick(*m_scrapSystem, *m_buildingSystem);
|
||||||
|
m_repairSystem->tick();
|
||||||
|
|
||||||
// Step 8: combat resolution
|
// Step 8: combat resolution
|
||||||
m_combatSystem->tick(m_currentTick, m_admin,
|
m_combatSystem->tick(m_currentTick, m_admin,
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ class BuildingSystem;
|
|||||||
class CombatSystem;
|
class CombatSystem;
|
||||||
class DynamicBodySystem;
|
class DynamicBodySystem;
|
||||||
class MovementIntentSystem;
|
class MovementIntentSystem;
|
||||||
|
class RepairSystem;
|
||||||
|
class SalvagerSystem;
|
||||||
class ShipSystem;
|
class ShipSystem;
|
||||||
class ScrapSystem;
|
class ScrapSystem;
|
||||||
class WaveSystem;
|
class WaveSystem;
|
||||||
@@ -185,6 +187,8 @@ private:
|
|||||||
std::unique_ptr<MovementIntentSystem> m_movementIntentSystem;
|
std::unique_ptr<MovementIntentSystem> m_movementIntentSystem;
|
||||||
std::unique_ptr<DynamicBodySystem> m_dynamicBodySystem;
|
std::unique_ptr<DynamicBodySystem> m_dynamicBodySystem;
|
||||||
std::unique_ptr<ScrapSystem> m_scrapSystem;
|
std::unique_ptr<ScrapSystem> m_scrapSystem;
|
||||||
|
std::unique_ptr<SalvagerSystem> m_salvagerSystem;
|
||||||
|
std::unique_ptr<RepairSystem> m_repairSystem;
|
||||||
std::unique_ptr<WaveSystem> m_waveSystem;
|
std::unique_ptr<WaveSystem> m_waveSystem;
|
||||||
std::unique_ptr<CombatSystem> m_combatSystem;
|
std::unique_ptr<CombatSystem> m_combatSystem;
|
||||||
|
|
||||||
|
|||||||
@@ -5,35 +5,40 @@
|
|||||||
#include <QPoint>
|
#include <QPoint>
|
||||||
#include <QVector2D>
|
#include <QVector2D>
|
||||||
|
|
||||||
|
#include "AdvanceBehavior.h"
|
||||||
#include "AiSystem.h"
|
#include "AiSystem.h"
|
||||||
|
#include "AttackBehavior.h"
|
||||||
|
#include "BehaviorKind.h"
|
||||||
#include "BeltSystem.h"
|
#include "BeltSystem.h"
|
||||||
#include "ModuleOwnerComponent.h"
|
|
||||||
#include "ShipLayout.h"
|
|
||||||
#include "Building.h"
|
#include "Building.h"
|
||||||
#include "BuildingSystem.h"
|
#include "BuildingSystem.h"
|
||||||
#include "BuildingType.h"
|
#include "BuildingType.h"
|
||||||
#include "ConfigLoader.h"
|
#include "ConfigLoader.h"
|
||||||
|
#include "DeliverScrapBehavior.h"
|
||||||
#include "DynamicBodyComponent.h"
|
#include "DynamicBodyComponent.h"
|
||||||
#include "DynamicBodySystem.h"
|
#include "DynamicBodySystem.h"
|
||||||
#include "EntityAdmin.h"
|
#include "EntityAdmin.h"
|
||||||
#include "FactionComponent.h"
|
#include "FactionComponent.h"
|
||||||
#include "HealthComponent.h"
|
#include "HealthComponent.h"
|
||||||
#include "HomeReturnBehaviorComponent.h"
|
#include "ModuleOwnerComponent.h"
|
||||||
#include "MovementIntentComponent.h"
|
#include "MovementIntentComponent.h"
|
||||||
#include "MovementIntentSystem.h"
|
#include "MovementIntentSystem.h"
|
||||||
#include "PositionComponent.h"
|
#include "PositionComponent.h"
|
||||||
#include "RallyBehaviorComponent.h"
|
#include "RepairBehavior.h"
|
||||||
#include "RepairBehaviorComponent.h"
|
#include "RepairSystem.h"
|
||||||
#include "RepairToolComponent.h"
|
#include "RepairToolComponent.h"
|
||||||
|
#include "RetreatBehavior.h"
|
||||||
#include "Rotation.h"
|
#include "Rotation.h"
|
||||||
#include "SalvageBehaviorComponent.h"
|
|
||||||
#include "SalvageCargoComponent.h"
|
#include "SalvageCargoComponent.h"
|
||||||
|
#include "SalvageScrapBehavior.h"
|
||||||
|
#include "SalvagerSystem.h"
|
||||||
#include "ScrapSystem.h"
|
#include "ScrapSystem.h"
|
||||||
|
#include "SelectedBehaviorComponent.h"
|
||||||
#include "SensorRangeComponent.h"
|
#include "SensorRangeComponent.h"
|
||||||
#include "ShipIdentityComponent.h"
|
#include "ShipIdentityComponent.h"
|
||||||
|
#include "ShipLayout.h"
|
||||||
#include "ShipSystem.h"
|
#include "ShipSystem.h"
|
||||||
#include "Tick.h"
|
#include "Tick.h"
|
||||||
#include "ThreatResponseBehaviorComponent.h"
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Fixture
|
// Fixture
|
||||||
@@ -55,6 +60,8 @@ struct Fixture
|
|||||||
BuildingSystem buildings;
|
BuildingSystem buildings;
|
||||||
ShipSystem ships;
|
ShipSystem ships;
|
||||||
AiSystem ai;
|
AiSystem ai;
|
||||||
|
SalvagerSystem salvager;
|
||||||
|
RepairSystem repair;
|
||||||
MovementIntentSystem movementIntent;
|
MovementIntentSystem movementIntent;
|
||||||
DynamicBodySystem dynamicBody;
|
DynamicBodySystem dynamicBody;
|
||||||
ScrapSystem scraps;
|
ScrapSystem scraps;
|
||||||
@@ -73,20 +80,32 @@ struct Fixture
|
|||||||
[](const std::string&) -> bool { return true; },
|
[](const std::string&) -> bool { return true; },
|
||||||
rng)
|
rng)
|
||||||
, ships(cfg, admin)
|
, ships(cfg, admin)
|
||||||
|
, salvager(admin)
|
||||||
|
, repair(admin)
|
||||||
, scraps(admin)
|
, scraps(admin)
|
||||||
, tick(0)
|
, tick(0)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 1-3: clear intents, evaluate behaviors, select winners, execute.
|
||||||
|
void decide()
|
||||||
|
{
|
||||||
|
ships.clearMovementIntents();
|
||||||
|
ai.tick(admin, buildings, scraps);
|
||||||
|
}
|
||||||
|
|
||||||
|
// World mutation: collection/delivery and healing.
|
||||||
|
void runModules()
|
||||||
|
{
|
||||||
|
salvager.tick(scraps, buildings);
|
||||||
|
repair.tick();
|
||||||
|
}
|
||||||
|
|
||||||
// Run one full behavior+movement tick (steps 7 and 10).
|
// Run one full behavior+movement tick (steps 7 and 10).
|
||||||
void runBehaviorTick()
|
void runBehaviorTick()
|
||||||
{
|
{
|
||||||
ships.clearMovementIntents();
|
decide();
|
||||||
ai.tickHomeReturnBehavior(admin);
|
runModules();
|
||||||
ai.tickThreatResponseBehavior(admin, buildings);
|
|
||||||
ai.tickRepairBehavior(admin, buildings);
|
|
||||||
ai.tickRepairTools(admin);
|
|
||||||
ai.tickSalvageBehavior(admin, scraps, buildings);
|
|
||||||
movementIntent.tick(admin);
|
movementIntent.tick(admin);
|
||||||
dynamicBody.tick(admin);
|
dynamicBody.tick(admin);
|
||||||
++tick;
|
++tick;
|
||||||
@@ -131,7 +150,6 @@ static entt::entity firstSalvageChild(EntityAdmin& admin, entt::entity ship)
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helpers to read ECS data for a ship entity.
|
|
||||||
static entt::entity firstRepairChild(EntityAdmin& admin, entt::entity ship)
|
static entt::entity firstRepairChild(EntityAdmin& admin, entt::entity ship)
|
||||||
{
|
{
|
||||||
entt::entity result = entt::null;
|
entt::entity result = entt::null;
|
||||||
@@ -159,6 +177,11 @@ static const MovementIntentComponent& intent(EntityAdmin& a, entt::entity e)
|
|||||||
return a.get<MovementIntentComponent>(e);
|
return a.get<MovementIntentComponent>(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static BehaviorKind winnerOf(EntityAdmin& a, entt::entity e)
|
||||||
|
{
|
||||||
|
return a.get<SelectedBehaviorComponent>(e).winner;
|
||||||
|
}
|
||||||
|
|
||||||
static const HealthComponent& health(EntityAdmin& a, entt::entity e)
|
static const HealthComponent& health(EntityAdmin& a, entt::entity e)
|
||||||
{
|
{
|
||||||
return a.get<HealthComponent>(e);
|
return a.get<HealthComponent>(e);
|
||||||
@@ -173,16 +196,16 @@ static const PositionComponent& pos(EntityAdmin& a, entt::entity e)
|
|||||||
// clearMovementIntents
|
// clearMovementIntents
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: clearMovementIntents resets all ships to priority 0",
|
TEST_CASE("BehaviorSystem: clearMovementIntents resets all ships to inactive",
|
||||||
"[behavior]")
|
"[behavior]")
|
||||||
{
|
{
|
||||||
Fixture f;
|
Fixture f;
|
||||||
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||||
|
|
||||||
f.admin.get<MovementIntentComponent>(e) = MovementIntentComponent{3, QVector2D(10.0f, 0.0f)};
|
f.admin.get<MovementIntentComponent>(e) = MovementIntentComponent{true, QVector2D(10.0f, 0.0f)};
|
||||||
f.ships.clearMovementIntents();
|
f.ships.clearMovementIntents();
|
||||||
|
|
||||||
REQUIRE(intent(f.admin, e).priority == 0);
|
REQUIRE_FALSE(intent(f.admin, e).active);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -196,7 +219,7 @@ TEST_CASE("BehaviorSystem: tickMovement advances ship by maxSpeed_tpt toward tar
|
|||||||
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||||
|
|
||||||
const float speed = f.admin.get<DynamicBodyComponent>(e).maxSpeed_tpt;
|
const float speed = f.admin.get<DynamicBodyComponent>(e).maxSpeed_tpt;
|
||||||
f.admin.get<MovementIntentComponent>(e) = MovementIntentComponent{1, QVector2D(100.0f, 0.0f)};
|
f.admin.get<MovementIntentComponent>(e) = MovementIntentComponent{true, QVector2D(100.0f, 0.0f)};
|
||||||
f.movementIntent.tick(f.admin);
|
f.movementIntent.tick(f.admin);
|
||||||
f.dynamicBody.tick(f.admin);
|
f.dynamicBody.tick(f.admin);
|
||||||
|
|
||||||
@@ -212,7 +235,7 @@ TEST_CASE("BehaviorSystem: tickMovement stops exactly at target without overshoo
|
|||||||
|
|
||||||
const float speed = f.admin.get<DynamicBodyComponent>(e).maxSpeed_tpt;
|
const float speed = f.admin.get<DynamicBodyComponent>(e).maxSpeed_tpt;
|
||||||
const QVector2D target(speed * 0.5f, 0.0f);
|
const QVector2D target(speed * 0.5f, 0.0f);
|
||||||
f.admin.get<MovementIntentComponent>(e) = MovementIntentComponent{1, target};
|
f.admin.get<MovementIntentComponent>(e) = MovementIntentComponent{true, target};
|
||||||
f.movementIntent.tick(f.admin);
|
f.movementIntent.tick(f.admin);
|
||||||
f.dynamicBody.tick(f.admin);
|
f.dynamicBody.tick(f.admin);
|
||||||
|
|
||||||
@@ -221,60 +244,65 @@ TEST_CASE("BehaviorSystem: tickMovement stops exactly at target without overshoo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// tickHomeReturnBehavior
|
// RetreatBehavior
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: tickHomeReturnBehavior does nothing when HP is above threshold",
|
TEST_CASE("BehaviorSystem: healthy player ship does not retreat", "[behavior]")
|
||||||
"[behavior]")
|
|
||||||
{
|
{
|
||||||
Fixture f;
|
Fixture f;
|
||||||
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||||
f.admin.addComponent<HomeReturnBehaviorComponent>(e, HomeReturnBehaviorComponent{0.3f, QVector2D(-10.0f, 0.0f)});
|
|
||||||
f.admin.get<HealthComponent>(e).hp = f.admin.get<HealthComponent>(e).maxHp; // full HP
|
f.admin.get<HealthComponent>(e).hp = f.admin.get<HealthComponent>(e).maxHp; // full HP
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.decide();
|
||||||
f.ai.tickHomeReturnBehavior(f.admin);
|
|
||||||
|
|
||||||
REQUIRE(intent(f.admin, e).priority == 0);
|
REQUIRE(winnerOf(f.admin, e) != BehaviorKind::Retreat);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: tickHomeReturnBehavior writes priority-4 intent toward homePos when HP is low",
|
TEST_CASE("BehaviorSystem: low-HP player ship retreats toward the rally point", "[behavior]")
|
||||||
"[behavior]")
|
|
||||||
{
|
{
|
||||||
Fixture f;
|
Fixture f;
|
||||||
|
const QVector2D rallyPoint(-50.0f, 0.0f);
|
||||||
|
f.ships.setRallyPoint(rallyPoint);
|
||||||
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||||
const QVector2D homePos(-10.0f, 0.0f);
|
|
||||||
f.admin.addComponent<HomeReturnBehaviorComponent>(e, HomeReturnBehaviorComponent{0.5f, homePos});
|
|
||||||
f.admin.get<HealthComponent>(e).hp = f.admin.get<HealthComponent>(e).maxHp * 0.2f; // below threshold
|
f.admin.get<HealthComponent>(e).hp = f.admin.get<HealthComponent>(e).maxHp * 0.2f; // below threshold
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.decide();
|
||||||
f.ai.tickHomeReturnBehavior(f.admin);
|
|
||||||
|
|
||||||
REQUIRE(intent(f.admin, e).priority == 4);
|
REQUIRE(winnerOf(f.admin, e) == BehaviorKind::Retreat);
|
||||||
REQUIRE(intent(f.admin, e).target.x() == Approx(homePos.x()));
|
REQUIRE(intent(f.admin, e).active);
|
||||||
|
REQUIRE(intent(f.admin, e).target.x() == Approx(rallyPoint.x()));
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: tickHomeReturnBehavior priority-4 beats tickThreatResponseBehavior priority-3",
|
TEST_CASE("BehaviorSystem: low-HP retreat outranks attacking a nearby enemy", "[behavior]")
|
||||||
"[behavior]")
|
|
||||||
{
|
{
|
||||||
Fixture f;
|
Fixture f;
|
||||||
|
const QVector2D rallyPoint(-50.0f, 0.0f);
|
||||||
|
f.ships.setRallyPoint(rallyPoint);
|
||||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||||
f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f), /*isEnemy=*/true);
|
f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f), /*isEnemy=*/true);
|
||||||
|
|
||||||
const QVector2D homePos(-50.0f, 0.0f);
|
|
||||||
f.admin.addComponent<HomeReturnBehaviorComponent>(player, HomeReturnBehaviorComponent{0.5f, homePos});
|
|
||||||
f.admin.get<HealthComponent>(player).hp = f.admin.get<HealthComponent>(player).maxHp * 0.1f;
|
f.admin.get<HealthComponent>(player).hp = f.admin.get<HealthComponent>(player).maxHp * 0.1f;
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.decide();
|
||||||
f.ai.tickHomeReturnBehavior(f.admin);
|
|
||||||
f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
|
|
||||||
|
|
||||||
REQUIRE(intent(f.admin, player).priority == 4);
|
REQUIRE(winnerOf(f.admin, player) == BehaviorKind::Retreat);
|
||||||
REQUIRE(intent(f.admin, player).target.x() == Approx(homePos.x()));
|
REQUIRE(intent(f.admin, player).target.x() == Approx(rallyPoint.x()));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("BehaviorSystem: enemy ships never retreat even at low HP", "[behavior]")
|
||||||
|
{
|
||||||
|
Fixture f;
|
||||||
|
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f),
|
||||||
|
/*isEnemy=*/true);
|
||||||
|
f.admin.get<HealthComponent>(enemy).hp = f.admin.get<HealthComponent>(enemy).maxHp * 0.05f;
|
||||||
|
|
||||||
|
f.decide();
|
||||||
|
|
||||||
|
REQUIRE_FALSE(f.admin.hasAll<RetreatBehavior>(enemy));
|
||||||
|
REQUIRE(winnerOf(f.admin, enemy) != BehaviorKind::Retreat);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// tickThreatResponseBehavior — player ships
|
// AttackBehavior — player ships
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: player combat ship acquires nearest enemy ship in range",
|
TEST_CASE("BehaviorSystem: player combat ship acquires nearest enemy ship in range",
|
||||||
@@ -285,13 +313,13 @@ TEST_CASE("BehaviorSystem: player combat ship acquires nearest enemy ship in ran
|
|||||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f),
|
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f),
|
||||||
/*isEnemy=*/true);
|
/*isEnemy=*/true);
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.decide();
|
||||||
f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
|
|
||||||
|
|
||||||
REQUIRE(f.admin.hasAll<ThreatResponseBehaviorComponent>(player));
|
REQUIRE(f.admin.hasAll<AttackBehavior>(player));
|
||||||
const ThreatResponseBehaviorComponent& threatResponseBehavior = f.admin.get<ThreatResponseBehaviorComponent>(player);
|
const AttackBehavior& attack = f.admin.get<AttackBehavior>(player);
|
||||||
REQUIRE(threatResponseBehavior.currentTarget.has_value());
|
REQUIRE(attack.currentTarget.has_value());
|
||||||
REQUIRE(*threatResponseBehavior.currentTarget == enemy);
|
REQUIRE(*attack.currentTarget == enemy);
|
||||||
|
REQUIRE(winnerOf(f.admin, player) == BehaviorKind::Attack);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: player combat ship does not target friendly ships",
|
TEST_CASE("BehaviorSystem: player combat ship does not target friendly ships",
|
||||||
@@ -301,11 +329,11 @@ TEST_CASE("BehaviorSystem: player combat ship does not target friendly ships",
|
|||||||
const entt::entity e1 = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
const entt::entity e1 = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||||
f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f)); // also player
|
f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f)); // also player
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.decide();
|
||||||
f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
|
|
||||||
|
|
||||||
REQUIRE(f.admin.hasAll<ThreatResponseBehaviorComponent>(e1));
|
REQUIRE(f.admin.hasAll<AttackBehavior>(e1));
|
||||||
REQUIRE_FALSE(f.admin.get<ThreatResponseBehaviorComponent>(e1).currentTarget.has_value());
|
REQUIRE_FALSE(f.admin.get<AttackBehavior>(e1).currentTarget.has_value());
|
||||||
|
REQUIRE(winnerOf(f.admin, e1) != BehaviorKind::Attack);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: player combat ship ignores enemy beyond engagement range",
|
TEST_CASE("BehaviorSystem: player combat ship ignores enemy beyond engagement range",
|
||||||
@@ -315,14 +343,13 @@ TEST_CASE("BehaviorSystem: player combat ship ignores enemy beyond engagement ra
|
|||||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||||
f.ships.spawn("interceptor", 1, QVector2D(500.0f, 0.0f), /*isEnemy=*/true);
|
f.ships.spawn("interceptor", 1, QVector2D(500.0f, 0.0f), /*isEnemy=*/true);
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.decide();
|
||||||
f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
|
|
||||||
|
|
||||||
REQUIRE_FALSE(f.admin.get<ThreatResponseBehaviorComponent>(player).currentTarget.has_value());
|
REQUIRE_FALSE(f.admin.get<AttackBehavior>(player).currentTarget.has_value());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// tickThreatResponseBehavior — enemy ships
|
// AttackBehavior — enemy ships
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: enemy ship acquires nearest player ship in range",
|
TEST_CASE("BehaviorSystem: enemy ship acquires nearest player ship in range",
|
||||||
@@ -333,34 +360,34 @@ TEST_CASE("BehaviorSystem: enemy ship acquires nearest player ship in range",
|
|||||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f),
|
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f),
|
||||||
/*isEnemy=*/true);
|
/*isEnemy=*/true);
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.decide();
|
||||||
f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
|
|
||||||
|
|
||||||
REQUIRE(f.admin.hasAll<ThreatResponseBehaviorComponent>(enemy));
|
REQUIRE(f.admin.hasAll<AttackBehavior>(enemy));
|
||||||
const ThreatResponseBehaviorComponent& threatResponseBehavior = f.admin.get<ThreatResponseBehaviorComponent>(enemy);
|
const AttackBehavior& attack = f.admin.get<AttackBehavior>(enemy);
|
||||||
REQUIRE(threatResponseBehavior.currentTarget.has_value());
|
REQUIRE(attack.currentTarget.has_value());
|
||||||
REQUIRE(*threatResponseBehavior.currentTarget == player);
|
REQUIRE(*attack.currentTarget == player);
|
||||||
|
REQUIRE(winnerOf(f.admin, enemy) == BehaviorKind::Attack);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: enemy ship with no target writes leftward movement intent",
|
TEST_CASE("BehaviorSystem: enemy ship with no target advances leftward",
|
||||||
"[behavior]")
|
"[behavior]")
|
||||||
{
|
{
|
||||||
Fixture f;
|
Fixture f;
|
||||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(100.0f, 0.0f),
|
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(100.0f, 0.0f),
|
||||||
/*isEnemy=*/true);
|
/*isEnemy=*/true);
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.decide();
|
||||||
f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
|
|
||||||
|
|
||||||
REQUIRE(intent(f.admin, enemy).priority == 3);
|
REQUIRE(winnerOf(f.admin, enemy) == BehaviorKind::Advance);
|
||||||
|
REQUIRE(intent(f.admin, enemy).active);
|
||||||
REQUIRE(intent(f.admin, enemy).target.x() < 0.0f);
|
REQUIRE(intent(f.admin, enemy).target.x() < 0.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// tickRepairBehavior
|
// RepairBehavior
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: repair ship writes intent toward damaged friendly ship",
|
TEST_CASE("BehaviorSystem: repair ship moves toward damaged friendly ship",
|
||||||
"[behavior]")
|
"[behavior]")
|
||||||
{
|
{
|
||||||
Fixture f;
|
Fixture f;
|
||||||
@@ -371,10 +398,10 @@ TEST_CASE("BehaviorSystem: repair ship writes intent toward damaged friendly shi
|
|||||||
|
|
||||||
f.admin.get<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
|
f.admin.get<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.decide();
|
||||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
|
||||||
|
|
||||||
REQUIRE(intent(f.admin, repairShip).priority == 2);
|
REQUIRE(winnerOf(f.admin, repairShip) == BehaviorKind::Repair);
|
||||||
|
REQUIRE(intent(f.admin, repairShip).active);
|
||||||
REQUIRE(intent(f.admin, repairShip).target.x() == Approx(5.0f));
|
REQUIRE(intent(f.admin, repairShip).target.x() == Approx(5.0f));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,16 +410,14 @@ TEST_CASE("BehaviorSystem: repair ship heals damaged ally within repair range",
|
|||||||
{
|
{
|
||||||
Fixture f;
|
Fixture f;
|
||||||
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
||||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, repairLayout);
|
||||||
false, repairLayout);
|
|
||||||
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f));
|
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f));
|
||||||
|
|
||||||
const float initialHp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
|
const float initialHp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
|
||||||
f.admin.get<HealthComponent>(friendly).hp = initialHp;
|
f.admin.get<HealthComponent>(friendly).hp = initialHp;
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.decide();
|
||||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
f.runModules();
|
||||||
f.ai.tickRepairTools(f.admin);
|
|
||||||
|
|
||||||
REQUIRE(health(f.admin, friendly).hp > initialHp);
|
REQUIRE(health(f.admin, friendly).hp > initialHp);
|
||||||
}
|
}
|
||||||
@@ -408,9 +433,8 @@ TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]")
|
|||||||
|
|
||||||
for (int i = 0; i < 5; ++i)
|
for (int i = 0; i < 5; ++i)
|
||||||
{
|
{
|
||||||
f.ships.clearMovementIntents();
|
f.decide();
|
||||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
f.runModules();
|
||||||
f.ai.tickRepairTools(f.admin);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const HealthComponent& h = health(f.admin, friendly);
|
const HealthComponent& h = health(f.admin, friendly);
|
||||||
@@ -419,10 +443,10 @@ TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]")
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// tickRepairTools — per-module targeting
|
// RepairSystem — per-module targeting
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: rt.currentTarget is set to preferred target when in range and damaged",
|
TEST_CASE("RepairSystem: tool heals the in-range damaged target chosen by the executor",
|
||||||
"[behavior]")
|
"[behavior]")
|
||||||
{
|
{
|
||||||
Fixture f;
|
Fixture f;
|
||||||
@@ -431,118 +455,117 @@ TEST_CASE("BehaviorSystem: rt.currentTarget is set to preferred target when in r
|
|||||||
false, repairLayout);
|
false, repairLayout);
|
||||||
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
||||||
|
|
||||||
f.admin.get<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
|
const float initHp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
|
||||||
|
f.admin.get<HealthComponent>(friendly).hp = initHp;
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.decide();
|
||||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
f.runModules();
|
||||||
f.ai.tickRepairTools(f.admin);
|
|
||||||
|
|
||||||
const entt::entity rc = firstRepairChild(f.admin, repairShip);
|
const entt::entity rc = firstRepairChild(f.admin, repairShip);
|
||||||
REQUIRE(f.admin.isValid(rc));
|
REQUIRE(f.admin.isValid(rc));
|
||||||
REQUIRE(f.admin.get<RepairToolComponent>(rc).currentTarget.has_value());
|
REQUIRE(f.admin.get<RepairToolComponent>(rc).currentTarget.has_value());
|
||||||
REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == friendly);
|
REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == friendly);
|
||||||
REQUIRE(health(f.admin, friendly).hp > f.admin.get<HealthComponent>(friendly).maxHp * 0.5f);
|
REQUIRE(health(f.admin, friendly).hp > initHp);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: repair module falls back to in-range target when preferred is out of repair range",
|
TEST_CASE("RepairSystem: tool falls back to in-range target when its target is out of repair range",
|
||||||
"[behavior]")
|
"[behavior]")
|
||||||
{
|
{
|
||||||
Fixture f;
|
Fixture f;
|
||||||
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
||||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
||||||
false, repairLayout);
|
false, repairLayout);
|
||||||
// preferred: within sensor range (200) but beyond repair range (80)
|
// out of repair range (80) but in sensor range (200)
|
||||||
const entt::entity preferred = f.ships.spawn("interceptor", 1, QVector2D(90.0f, 0.0f));
|
const entt::entity outOfRange = f.ships.spawn("interceptor", 1, QVector2D(90.0f, 0.0f));
|
||||||
// fallback: within repair range
|
// within repair range
|
||||||
const entt::entity fallback = f.ships.spawn("interceptor", 1, QVector2D(20.0f, 0.0f));
|
const entt::entity fallback = f.ships.spawn("interceptor", 1, QVector2D(20.0f, 0.0f));
|
||||||
|
|
||||||
const float preferredInitHp = f.admin.get<HealthComponent>(preferred).maxHp * 0.5f;
|
const float outInitHp = f.admin.get<HealthComponent>(outOfRange).maxHp * 0.5f;
|
||||||
const float fallbackInitHp = f.admin.get<HealthComponent>(fallback).maxHp * 0.5f;
|
const float fallbackInitHp = f.admin.get<HealthComponent>(fallback).maxHp * 0.5f;
|
||||||
f.admin.get<HealthComponent>(preferred).hp = preferredInitHp;
|
f.admin.get<HealthComponent>(outOfRange).hp = outInitHp;
|
||||||
f.admin.get<HealthComponent>(fallback).hp = fallbackInitHp;
|
f.admin.get<HealthComponent>(fallback).hp = fallbackInitHp;
|
||||||
|
|
||||||
// Force preferred as nav target without running full behavior tick.
|
|
||||||
f.admin.get<RepairBehaviorComponent>(repairShip).currentTarget = preferred;
|
|
||||||
|
|
||||||
f.ai.tickRepairTools(f.admin);
|
|
||||||
|
|
||||||
|
// Seed the tool with an out-of-range target; RepairSystem must reacquire.
|
||||||
const entt::entity rc = firstRepairChild(f.admin, repairShip);
|
const entt::entity rc = firstRepairChild(f.admin, repairShip);
|
||||||
|
f.admin.get<RepairToolComponent>(rc).currentTarget = outOfRange;
|
||||||
|
|
||||||
|
f.repair.tick();
|
||||||
|
|
||||||
REQUIRE(f.admin.get<RepairToolComponent>(rc).currentTarget.has_value());
|
REQUIRE(f.admin.get<RepairToolComponent>(rc).currentTarget.has_value());
|
||||||
REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == fallback);
|
REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == fallback);
|
||||||
REQUIRE(health(f.admin, fallback).hp > fallbackInitHp);
|
REQUIRE(health(f.admin, fallback).hp > fallbackInitHp);
|
||||||
REQUIRE(health(f.admin, preferred).hp == Approx(preferredInitHp));
|
REQUIRE(health(f.admin, outOfRange).hp == Approx(outInitHp));
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: repair module falls back when preferred target is fully healed",
|
TEST_CASE("RepairSystem: tool falls back when its target is fully healed",
|
||||||
"[behavior]")
|
"[behavior]")
|
||||||
{
|
{
|
||||||
Fixture f;
|
Fixture f;
|
||||||
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
||||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
||||||
false, repairLayout);
|
false, repairLayout);
|
||||||
const entt::entity preferred = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
const entt::entity healed = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
||||||
const entt::entity fallback = f.ships.spawn("interceptor", 1, QVector2D(15.0f, 0.0f));
|
const entt::entity fallback = f.ships.spawn("interceptor", 1, QVector2D(15.0f, 0.0f));
|
||||||
|
|
||||||
// preferred is at full HP; only fallback needs repair
|
f.admin.get<HealthComponent>(healed).hp = f.admin.get<HealthComponent>(healed).maxHp;
|
||||||
f.admin.get<HealthComponent>(preferred).hp = f.admin.get<HealthComponent>(preferred).maxHp;
|
|
||||||
const float fallbackInitHp = f.admin.get<HealthComponent>(fallback).maxHp * 0.5f;
|
const float fallbackInitHp = f.admin.get<HealthComponent>(fallback).maxHp * 0.5f;
|
||||||
f.admin.get<HealthComponent>(fallback).hp = fallbackInitHp;
|
f.admin.get<HealthComponent>(fallback).hp = fallbackInitHp;
|
||||||
|
|
||||||
f.admin.get<RepairBehaviorComponent>(repairShip).currentTarget = preferred;
|
|
||||||
|
|
||||||
f.ai.tickRepairTools(f.admin);
|
|
||||||
|
|
||||||
const entt::entity rc = firstRepairChild(f.admin, repairShip);
|
const entt::entity rc = firstRepairChild(f.admin, repairShip);
|
||||||
|
f.admin.get<RepairToolComponent>(rc).currentTarget = healed;
|
||||||
|
|
||||||
|
f.repair.tick();
|
||||||
|
|
||||||
REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == fallback);
|
REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == fallback);
|
||||||
REQUIRE(health(f.admin, fallback).hp > fallbackInitHp);
|
REQUIRE(health(f.admin, fallback).hp > fallbackInitHp);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: repair module falls back when preferred target is destroyed",
|
TEST_CASE("RepairSystem: tool falls back when its target is destroyed",
|
||||||
"[behavior]")
|
"[behavior]")
|
||||||
{
|
{
|
||||||
Fixture f;
|
Fixture f;
|
||||||
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
||||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
||||||
false, repairLayout);
|
false, repairLayout);
|
||||||
const entt::entity preferred = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
const entt::entity gone = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
||||||
const entt::entity fallback = f.ships.spawn("interceptor", 1, QVector2D(15.0f, 0.0f));
|
const entt::entity fallback = f.ships.spawn("interceptor", 1, QVector2D(15.0f, 0.0f));
|
||||||
|
|
||||||
const float fallbackInitHp = f.admin.get<HealthComponent>(fallback).maxHp * 0.5f;
|
const float fallbackInitHp = f.admin.get<HealthComponent>(fallback).maxHp * 0.5f;
|
||||||
f.admin.get<HealthComponent>(fallback).hp = fallbackInitHp;
|
f.admin.get<HealthComponent>(fallback).hp = fallbackInitHp;
|
||||||
|
|
||||||
f.admin.get<RepairBehaviorComponent>(repairShip).currentTarget = preferred;
|
|
||||||
f.ships.despawn(preferred);
|
|
||||||
|
|
||||||
f.ai.tickRepairTools(f.admin);
|
|
||||||
|
|
||||||
const entt::entity rc = firstRepairChild(f.admin, repairShip);
|
const entt::entity rc = firstRepairChild(f.admin, repairShip);
|
||||||
|
f.admin.get<RepairToolComponent>(rc).currentTarget = gone;
|
||||||
|
f.ships.despawn(gone);
|
||||||
|
|
||||||
|
f.repair.tick();
|
||||||
|
|
||||||
REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == fallback);
|
REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == fallback);
|
||||||
REQUIRE(health(f.admin, fallback).hp > fallbackInitHp);
|
REQUIRE(health(f.admin, fallback).hp > fallbackInitHp);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: rt.currentTarget is cleared when no repairable target is in range",
|
TEST_CASE("RepairSystem: tool target is cleared when no repairable target is in range",
|
||||||
"[behavior]")
|
"[behavior]")
|
||||||
{
|
{
|
||||||
Fixture f;
|
Fixture f;
|
||||||
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
||||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
||||||
false, repairLayout);
|
false, repairLayout);
|
||||||
// friendly is beyond repair range (80) but within sensor range (200)
|
// damaged but beyond repair range (80)
|
||||||
const entt::entity outOfRange = f.ships.spawn("interceptor", 1, QVector2D(150.0f, 0.0f));
|
const entt::entity outOfRange = f.ships.spawn("interceptor", 1, QVector2D(150.0f, 0.0f));
|
||||||
|
|
||||||
const float initHp = f.admin.get<HealthComponent>(outOfRange).maxHp * 0.5f;
|
const float initHp = f.admin.get<HealthComponent>(outOfRange).maxHp * 0.5f;
|
||||||
f.admin.get<HealthComponent>(outOfRange).hp = initHp;
|
f.admin.get<HealthComponent>(outOfRange).hp = initHp;
|
||||||
|
|
||||||
f.admin.get<RepairBehaviorComponent>(repairShip).currentTarget = outOfRange;
|
|
||||||
|
|
||||||
f.ai.tickRepairTools(f.admin);
|
|
||||||
|
|
||||||
const entt::entity rc = firstRepairChild(f.admin, repairShip);
|
const entt::entity rc = firstRepairChild(f.admin, repairShip);
|
||||||
|
f.admin.get<RepairToolComponent>(rc).currentTarget = outOfRange;
|
||||||
|
|
||||||
|
f.repair.tick();
|
||||||
|
|
||||||
REQUIRE_FALSE(f.admin.get<RepairToolComponent>(rc).currentTarget.has_value());
|
REQUIRE_FALSE(f.admin.get<RepairToolComponent>(rc).currentTarget.has_value());
|
||||||
REQUIRE(health(f.admin, outOfRange).hp == Approx(initHp));
|
REQUIRE(health(f.admin, outOfRange).hp == Approx(initHp));
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: two repair modules both heal preferred target additively",
|
TEST_CASE("RepairSystem: two repair modules both heal the chosen target additively",
|
||||||
"[behavior]")
|
"[behavior]")
|
||||||
{
|
{
|
||||||
Fixture f;
|
Fixture f;
|
||||||
@@ -554,9 +577,8 @@ TEST_CASE("BehaviorSystem: two repair modules both heal preferred target additiv
|
|||||||
const float initHp = f.admin.get<HealthComponent>(targetA).maxHp * 0.5f;
|
const float initHp = f.admin.get<HealthComponent>(targetA).maxHp * 0.5f;
|
||||||
f.admin.get<HealthComponent>(targetA).hp = initHp;
|
f.admin.get<HealthComponent>(targetA).hp = initHp;
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.decide();
|
||||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
f.runModules();
|
||||||
f.ai.tickRepairTools(f.admin);
|
|
||||||
|
|
||||||
// Both modules should have healed targetA — total increase is 2 * ratePerTick.
|
// Both modules should have healed targetA — total increase is 2 * ratePerTick.
|
||||||
const float ratePerTick = (5.0f + 1.0f) / static_cast<float>(kTickRateHz);
|
const float ratePerTick = (5.0f + 1.0f) / static_cast<float>(kTickRateHz);
|
||||||
@@ -570,24 +592,27 @@ TEST_CASE("BehaviorSystem: two repair modules both heal preferred target additiv
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: two repair modules both fall back and heal same target when preferred is fully healed",
|
TEST_CASE("RepairSystem: two modules both fall back and heal the same target",
|
||||||
"[behavior]")
|
"[behavior]")
|
||||||
{
|
{
|
||||||
Fixture f;
|
Fixture f;
|
||||||
const ShipLayoutConfig repairLayout = makeTwoModuleLayout("repair_tool");
|
const ShipLayoutConfig repairLayout = makeTwoModuleLayout("repair_tool");
|
||||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
||||||
false, repairLayout);
|
false, repairLayout);
|
||||||
const entt::entity preferred = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
const entt::entity healed = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
||||||
const entt::entity targetB = f.ships.spawn("interceptor", 1, QVector2D(20.0f, 0.0f));
|
const entt::entity targetB = f.ships.spawn("interceptor", 1, QVector2D(20.0f, 0.0f));
|
||||||
|
|
||||||
// preferred is at full HP so both modules must fall back
|
f.admin.get<HealthComponent>(healed).hp = f.admin.get<HealthComponent>(healed).maxHp;
|
||||||
f.admin.get<HealthComponent>(preferred).hp = f.admin.get<HealthComponent>(preferred).maxHp;
|
|
||||||
const float initHp = f.admin.get<HealthComponent>(targetB).maxHp * 0.5f;
|
const float initHp = f.admin.get<HealthComponent>(targetB).maxHp * 0.5f;
|
||||||
f.admin.get<HealthComponent>(targetB).hp = initHp;
|
f.admin.get<HealthComponent>(targetB).hp = initHp;
|
||||||
|
|
||||||
f.admin.get<RepairBehaviorComponent>(repairShip).currentTarget = preferred;
|
// Seed both tools with the (fully-healed) target; they must reacquire targetB.
|
||||||
|
for (const entt::entity child : allRepairChildren(f.admin, repairShip))
|
||||||
|
{
|
||||||
|
f.admin.get<RepairToolComponent>(child).currentTarget = healed;
|
||||||
|
}
|
||||||
|
|
||||||
f.ai.tickRepairTools(f.admin);
|
f.repair.tick();
|
||||||
|
|
||||||
const float ratePerTick = (5.0f + 1.0f) / static_cast<float>(kTickRateHz);
|
const float ratePerTick = (5.0f + 1.0f) / static_cast<float>(kTickRateHz);
|
||||||
REQUIRE(health(f.admin, targetB).hp == Approx(initHp + 2.0f * ratePerTick));
|
REQUIRE(health(f.admin, targetB).hp == Approx(initHp + 2.0f * ratePerTick));
|
||||||
@@ -600,13 +625,12 @@ TEST_CASE("BehaviorSystem: two repair modules both fall back and heal same targe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: tickRepairTools does not crash when owner lacks RepairBehaviorComponent",
|
TEST_CASE("RepairSystem: does not crash when a tool's owner is not a repair ship",
|
||||||
"[behavior]")
|
"[behavior]")
|
||||||
{
|
{
|
||||||
Fixture f;
|
Fixture f;
|
||||||
|
|
||||||
// Bare child entity: has RepairToolComponent and ModuleOwnerComponent but owner has no
|
// Bare child entity: RepairToolComponent + ModuleOwnerComponent, owner is a combat ship.
|
||||||
// RepairBehaviorComponent.
|
|
||||||
const entt::entity ownerShip = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
const entt::entity ownerShip = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||||
const entt::entity moduleEntity = f.admin.createModuleEntity();
|
const entt::entity moduleEntity = f.admin.createModuleEntity();
|
||||||
RepairToolComponent rt;
|
RepairToolComponent rt;
|
||||||
@@ -616,17 +640,17 @@ TEST_CASE("BehaviorSystem: tickRepairTools does not crash when owner lacks Repai
|
|||||||
f.admin.addComponent<RepairToolComponent>(moduleEntity, rt);
|
f.admin.addComponent<RepairToolComponent>(moduleEntity, rt);
|
||||||
f.admin.addComponent<ModuleOwnerComponent>(moduleEntity, ModuleOwnerComponent{ownerShip});
|
f.admin.addComponent<ModuleOwnerComponent>(moduleEntity, ModuleOwnerComponent{ownerShip});
|
||||||
|
|
||||||
// Must not crash.
|
// Must not crash; no damaged friendly in range, so no target is set.
|
||||||
f.ai.tickRepairTools(f.admin);
|
f.repair.tick();
|
||||||
|
|
||||||
REQUIRE_FALSE(f.admin.get<RepairToolComponent>(moduleEntity).currentTarget.has_value());
|
REQUIRE_FALSE(f.admin.get<RepairToolComponent>(moduleEntity).currentTarget.has_value());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// tickSalvageBehavior
|
// SalvageScrapBehavior / DeliverScrapBehavior
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: salvage ship writes intent toward nearest scrap", "[behavior]")
|
TEST_CASE("BehaviorSystem: salvage ship moves toward nearest scrap", "[behavior]")
|
||||||
{
|
{
|
||||||
Fixture f;
|
Fixture f;
|
||||||
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
|
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
|
||||||
@@ -636,10 +660,10 @@ TEST_CASE("BehaviorSystem: salvage ship writes intent toward nearest scrap", "[b
|
|||||||
const QVector2D scrapPos(100.0f, 0.0f);
|
const QVector2D scrapPos(100.0f, 0.0f);
|
||||||
f.scraps.spawn(scrapPos, 1, 100000);
|
f.scraps.spawn(scrapPos, 1, 100000);
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.decide();
|
||||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
|
||||||
|
|
||||||
REQUIRE(intent(f.admin, ship).priority == 1);
|
REQUIRE(winnerOf(f.admin, ship) == BehaviorKind::SalvageScrap);
|
||||||
|
REQUIRE(intent(f.admin, ship).active);
|
||||||
REQUIRE(intent(f.admin, ship).target.x() == Approx(scrapPos.x()));
|
REQUIRE(intent(f.admin, ship).target.x() == Approx(scrapPos.x()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,8 +675,7 @@ TEST_CASE("BehaviorSystem: salvage ship collects scrap on arrival", "[behavior]"
|
|||||||
false, salvageLayout);
|
false, salvageLayout);
|
||||||
const entt::entity scrapEntity = f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
const entt::entity scrapEntity = f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.salvager.tick(f.scraps, f.buildings);
|
||||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
|
||||||
|
|
||||||
const entt::entity sc = firstSalvageChild(f.admin, ship);
|
const entt::entity sc = firstSalvageChild(f.admin, ship);
|
||||||
REQUIRE(f.admin.isValid(sc));
|
REQUIRE(f.admin.isValid(sc));
|
||||||
@@ -687,11 +710,12 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b
|
|||||||
cargo.current = cargo.capacity; // full cargo
|
cargo.current = cargo.capacity; // full cargo
|
||||||
}
|
}
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.decide();
|
||||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
|
||||||
|
|
||||||
|
REQUIRE(winnerOf(f.admin, ship) == BehaviorKind::DeliverScrap);
|
||||||
|
REQUIRE(f.admin.get<DeliverScrapBehavior>(ship).deliveryBay == bayId);
|
||||||
const MovementIntentComponent& i = intent(f.admin, ship);
|
const MovementIntentComponent& i = intent(f.admin, ship);
|
||||||
REQUIRE(i.priority == 1);
|
REQUIRE(i.active);
|
||||||
REQUIRE(i.target.x() < pos(f.admin, ship).value.x());
|
REQUIRE(i.target.x() < pos(f.admin, ship).value.x());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -710,7 +734,7 @@ static int totalSalvageCurrent(EntityAdmin& admin, entt::entity ship)
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: salvage module does not collect scrap beyond its collection range",
|
TEST_CASE("SalvagerSystem: module does not collect scrap beyond its collection range",
|
||||||
"[behavior]")
|
"[behavior]")
|
||||||
{
|
{
|
||||||
// collection_range_m_formula = "50"; scrap at distance 55 must not be collected.
|
// collection_range_m_formula = "50"; scrap at distance 55 must not be collected.
|
||||||
@@ -720,13 +744,12 @@ TEST_CASE("BehaviorSystem: salvage module does not collect scrap beyond its coll
|
|||||||
false, salvageLayout);
|
false, salvageLayout);
|
||||||
f.scraps.spawn(QVector2D(55.0f, 0.0f), 1, 100000);
|
f.scraps.spawn(QVector2D(55.0f, 0.0f), 1, 100000);
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.salvager.tick(f.scraps, f.buildings);
|
||||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
|
||||||
|
|
||||||
REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 0);
|
REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: salvage module collects scrap within its collection range",
|
TEST_CASE("SalvagerSystem: module collects scrap within its collection range",
|
||||||
"[behavior]")
|
"[behavior]")
|
||||||
{
|
{
|
||||||
// collection_range_m_formula = "50"; scrap at distance 45 must be collected.
|
// collection_range_m_formula = "50"; scrap at distance 45 must be collected.
|
||||||
@@ -736,8 +759,7 @@ TEST_CASE("BehaviorSystem: salvage module collects scrap within its collection r
|
|||||||
false, salvageLayout);
|
false, salvageLayout);
|
||||||
f.scraps.spawn(QVector2D(45.0f, 0.0f), 1, 100000);
|
f.scraps.spawn(QVector2D(45.0f, 0.0f), 1, 100000);
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.salvager.tick(f.scraps, f.buildings);
|
||||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
|
||||||
|
|
||||||
REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 1);
|
REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 1);
|
||||||
}
|
}
|
||||||
@@ -746,7 +768,7 @@ TEST_CASE("BehaviorSystem: salvage module collects scrap within its collection r
|
|||||||
// Collection rate (per-module cooldown)
|
// Collection rate (per-module cooldown)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: salvage collection sets cooldown on module", "[behavior]")
|
TEST_CASE("SalvagerSystem: collection sets cooldown on module", "[behavior]")
|
||||||
{
|
{
|
||||||
Fixture f;
|
Fixture f;
|
||||||
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
|
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
|
||||||
@@ -754,8 +776,7 @@ TEST_CASE("BehaviorSystem: salvage collection sets cooldown on module", "[behavi
|
|||||||
false, salvageLayout);
|
false, salvageLayout);
|
||||||
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.salvager.tick(f.scraps, f.buildings);
|
||||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
|
||||||
|
|
||||||
const SalvageCargoComponent& cargo =
|
const SalvageCargoComponent& cargo =
|
||||||
f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship));
|
f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship));
|
||||||
@@ -763,7 +784,7 @@ TEST_CASE("BehaviorSystem: salvage collection sets cooldown on module", "[behavi
|
|||||||
REQUIRE(cargo.cooldownTicksRemaining == cargo.collectionIntervalTicks);
|
REQUIRE(cargo.cooldownTicksRemaining == cargo.collectionIntervalTicks);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: salvage module on cooldown does not collect scrap", "[behavior]")
|
TEST_CASE("SalvagerSystem: module on cooldown does not collect scrap", "[behavior]")
|
||||||
{
|
{
|
||||||
Fixture f;
|
Fixture f;
|
||||||
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
|
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
|
||||||
@@ -773,13 +794,12 @@ TEST_CASE("BehaviorSystem: salvage module on cooldown does not collect scrap", "
|
|||||||
|
|
||||||
f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).cooldownTicksRemaining = 10;
|
f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).cooldownTicksRemaining = 10;
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.salvager.tick(f.scraps, f.buildings);
|
||||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
|
||||||
|
|
||||||
REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 0);
|
REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: salvage module collects again after cooldown expires", "[behavior]")
|
TEST_CASE("SalvagerSystem: module collects again after cooldown expires", "[behavior]")
|
||||||
{
|
{
|
||||||
Fixture f;
|
Fixture f;
|
||||||
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
|
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
|
||||||
@@ -788,8 +808,7 @@ TEST_CASE("BehaviorSystem: salvage module collects again after cooldown expires"
|
|||||||
const entt::entity sc = firstSalvageChild(f.admin, ship);
|
const entt::entity sc = firstSalvageChild(f.admin, ship);
|
||||||
|
|
||||||
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||||
f.ships.clearMovementIntents();
|
f.salvager.tick(f.scraps, f.buildings);
|
||||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
|
||||||
REQUIRE(f.admin.get<SalvageCargoComponent>(sc).current == 1);
|
REQUIRE(f.admin.get<SalvageCargoComponent>(sc).current == 1);
|
||||||
|
|
||||||
// Shorten cooldown to 1 tick and place a second scrap.
|
// Shorten cooldown to 1 tick and place a second scrap.
|
||||||
@@ -797,8 +816,7 @@ TEST_CASE("BehaviorSystem: salvage module collects again after cooldown expires"
|
|||||||
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||||
|
|
||||||
// Next tick: cooldown decrements to 0, module collects the second scrap.
|
// Next tick: cooldown decrements to 0, module collects the second scrap.
|
||||||
f.ships.clearMovementIntents();
|
f.salvager.tick(f.scraps, f.buildings);
|
||||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
|
||||||
|
|
||||||
REQUIRE(f.admin.get<SalvageCargoComponent>(sc).current == 2);
|
REQUIRE(f.admin.get<SalvageCargoComponent>(sc).current == 2);
|
||||||
}
|
}
|
||||||
@@ -807,7 +825,7 @@ TEST_CASE("BehaviorSystem: salvage module collects again after cooldown expires"
|
|||||||
// Multiple salvage modules
|
// Multiple salvage modules
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: two salvage modules collect independently in same tick", "[behavior]")
|
TEST_CASE("SalvagerSystem: two salvage modules collect independently in same tick", "[behavior]")
|
||||||
{
|
{
|
||||||
Fixture f;
|
Fixture f;
|
||||||
const ShipLayoutConfig salvageLayout = makeTwoModuleLayout("salvager");
|
const ShipLayoutConfig salvageLayout = makeTwoModuleLayout("salvager");
|
||||||
@@ -817,13 +835,12 @@ TEST_CASE("BehaviorSystem: two salvage modules collect independently in same tic
|
|||||||
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||||
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.salvager.tick(f.scraps, f.buildings);
|
||||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
|
||||||
|
|
||||||
REQUIRE(totalSalvageCurrent(f.admin, ship) == 2);
|
REQUIRE(totalSalvageCurrent(f.admin, ship) == 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("BehaviorSystem: second salvage module does not collect when first module is on cooldown",
|
TEST_CASE("SalvagerSystem: second salvage module does not collect when first is on cooldown",
|
||||||
"[behavior]")
|
"[behavior]")
|
||||||
{
|
{
|
||||||
// One module on cooldown, one ready: only the ready module collects.
|
// One module on cooldown, one ready: only the ready module collects.
|
||||||
@@ -847,8 +864,7 @@ TEST_CASE("BehaviorSystem: second salvage module does not collect when first mod
|
|||||||
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||||
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.salvager.tick(f.scraps, f.buildings);
|
||||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
|
||||||
|
|
||||||
// Only one module was ready, so only one scrap is collected.
|
// Only one module was ready, so only one scrap is collected.
|
||||||
REQUIRE(totalSalvageCurrent(f.admin, ship) == 1);
|
REQUIRE(totalSalvageCurrent(f.admin, ship) == 1);
|
||||||
@@ -866,7 +882,7 @@ TEST_CASE("SensorRange: sensorRange is populated from config formula at spawn",
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Sensor range — tickThreatResponseBehavior
|
// Sensor range — AttackBehavior
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
TEST_CASE("SensorRange: player combat ship acquires enemy just inside sensor range", "[sensor]")
|
TEST_CASE("SensorRange: player combat ship acquires enemy just inside sensor range", "[sensor]")
|
||||||
@@ -876,10 +892,9 @@ TEST_CASE("SensorRange: player combat ship acquires enemy just inside sensor ran
|
|||||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(190.0f, 0.0f),
|
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(190.0f, 0.0f),
|
||||||
/*isEnemy=*/true);
|
/*isEnemy=*/true);
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.decide();
|
||||||
f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
|
|
||||||
|
|
||||||
REQUIRE(f.admin.get<ThreatResponseBehaviorComponent>(player).currentTarget == enemy);
|
REQUIRE(f.admin.get<AttackBehavior>(player).currentTarget == enemy);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("SensorRange: player combat ship ignores enemy just outside sensor range", "[sensor]")
|
TEST_CASE("SensorRange: player combat ship ignores enemy just outside sensor range", "[sensor]")
|
||||||
@@ -888,10 +903,9 @@ TEST_CASE("SensorRange: player combat ship ignores enemy just outside sensor ran
|
|||||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||||
f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f), /*isEnemy=*/true);
|
f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f), /*isEnemy=*/true);
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.decide();
|
||||||
f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
|
|
||||||
|
|
||||||
REQUIRE_FALSE(f.admin.get<ThreatResponseBehaviorComponent>(player).currentTarget.has_value());
|
REQUIRE_FALSE(f.admin.get<AttackBehavior>(player).currentTarget.has_value());
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("SensorRange: enemy ship ignores player just outside sensor range", "[sensor]")
|
TEST_CASE("SensorRange: enemy ship ignores player just outside sensor range", "[sensor]")
|
||||||
@@ -901,29 +915,29 @@ TEST_CASE("SensorRange: enemy ship ignores player just outside sensor range", "[
|
|||||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f),
|
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f),
|
||||||
/*isEnemy=*/true);
|
/*isEnemy=*/true);
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.decide();
|
||||||
f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
|
|
||||||
|
|
||||||
REQUIRE_FALSE(f.admin.get<ThreatResponseBehaviorComponent>(enemy).currentTarget.has_value());
|
REQUIRE_FALSE(f.admin.get<AttackBehavior>(enemy).currentTarget.has_value());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Sensor range — tickRepairBehavior
|
// Sensor range — RetreatBehavior (unarmed ships flee threats)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
TEST_CASE("SensorRange: repair ship retreats from enemy within sensor range", "[sensor]")
|
TEST_CASE("SensorRange: repair ship retreats from enemy within sensor range", "[sensor]")
|
||||||
{
|
{
|
||||||
Fixture f;
|
Fixture f;
|
||||||
|
const QVector2D rallyPoint(-100.0f, 0.0f);
|
||||||
|
f.ships.setRallyPoint(rallyPoint);
|
||||||
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
||||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
||||||
false, repairLayout);
|
false, repairLayout);
|
||||||
f.ships.spawn("interceptor", 1, QVector2D(200.0f, 0.0f), /*isEnemy=*/true);
|
f.ships.spawn("interceptor", 1, QVector2D(200.0f, 0.0f), /*isEnemy=*/true);
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.decide();
|
||||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
|
||||||
|
|
||||||
REQUIRE(intent(f.admin, repairShip).priority == 2);
|
REQUIRE(winnerOf(f.admin, repairShip) == BehaviorKind::Retreat);
|
||||||
REQUIRE(intent(f.admin, repairShip).target.x() < 0.0f);
|
REQUIRE(intent(f.admin, repairShip).target.x() == Approx(rallyPoint.x()));
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("SensorRange: repair ship does not retreat from enemy beyond sensor range", "[sensor]")
|
TEST_CASE("SensorRange: repair ship does not retreat from enemy beyond sensor range", "[sensor]")
|
||||||
@@ -934,9 +948,9 @@ TEST_CASE("SensorRange: repair ship does not retreat from enemy beyond sensor ra
|
|||||||
false, repairLayout);
|
false, repairLayout);
|
||||||
f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f), /*isEnemy=*/true);
|
f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f), /*isEnemy=*/true);
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.decide();
|
||||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
|
||||||
|
|
||||||
|
REQUIRE(winnerOf(f.admin, repairShip) != BehaviorKind::Retreat);
|
||||||
REQUIRE(intent(f.admin, repairShip).target.x() > pos(f.admin, repairShip).value.x());
|
REQUIRE(intent(f.admin, repairShip).target.x() > pos(f.admin, repairShip).value.x());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -949,14 +963,13 @@ TEST_CASE("SensorRange: repair ship does not acquire damaged ally beyond sensor
|
|||||||
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f));
|
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f));
|
||||||
f.admin.get<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
|
f.admin.get<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.decide();
|
||||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
|
||||||
|
|
||||||
REQUIRE_FALSE(f.admin.get<RepairBehaviorComponent>(repairShip).currentTarget.has_value());
|
REQUIRE_FALSE(f.admin.get<RepairBehavior>(repairShip).currentTarget.has_value());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Sensor range — tickSalvageBehavior
|
// Sensor range — SalvageScrapBehavior
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
TEST_CASE("SensorRange: salvage ship ignores scrap beyond sensor range", "[sensor]")
|
TEST_CASE("SensorRange: salvage ship ignores scrap beyond sensor range", "[sensor]")
|
||||||
@@ -967,9 +980,8 @@ TEST_CASE("SensorRange: salvage ship ignores scrap beyond sensor range", "[senso
|
|||||||
false, salvageLayout);
|
false, salvageLayout);
|
||||||
f.scraps.spawn(QVector2D(300.0f, 0.0f), 1, 100000);
|
f.scraps.spawn(QVector2D(300.0f, 0.0f), 1, 100000);
|
||||||
|
|
||||||
f.ships.clearMovementIntents();
|
f.decide();
|
||||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
|
||||||
|
|
||||||
REQUIRE_FALSE(f.admin.get<SalvageBehaviorComponent>(ship).scrapTarget.has_value());
|
REQUIRE_FALSE(f.admin.get<SalvageScrapBehavior>(ship).scrapTarget.has_value());
|
||||||
REQUIRE(intent(f.admin, ship).target.x() > pos(f.admin, ship).value.x());
|
REQUIRE(intent(f.admin, ship).target.x() > pos(f.admin, ship).value.x());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -593,17 +593,18 @@ TEST_CASE("BeltSystem: splitter fallback enters the open output at progress 0.75
|
|||||||
// (North has no downstream tile, so it can never move out).
|
// (North has no downstream tile, so it can never move out).
|
||||||
bs.tryPutItem(tileSpl, makeItem("blockA"));
|
bs.tryPutItem(tileSpl, makeItem("blockA"));
|
||||||
bs.tick(); // back: 0.25
|
bs.tick(); // back: 0.25
|
||||||
bs.tick(); // back: 0.5 -> frontA at 0.0 (preferred A), nextOutputIsA = false
|
bs.tick(); // back: 0.5 -> frontA at 0.75 (preferred A), nextOutputIsA = false
|
||||||
bs.tick(); bs.tick(); bs.tick(); bs.tick(); // frontA: 0.25 -> 0.5 -> 0.75 -> 1.0 (stuck)
|
bs.tick(); bs.tick(); // frontA: 0.75 -> 1.0 (stuck, no North downstream)
|
||||||
|
|
||||||
// Item routed to B as the *preferred* output enters at progress 0.0.
|
// Cycle one item through B as the *preferred* output (also enters at 0.75) to
|
||||||
|
// flip nextOutputIsA back to true and free frontB for the fallback case below.
|
||||||
bs.tryPutItem(tileSpl, makeItem("toB_pref"));
|
bs.tryPutItem(tileSpl, makeItem("toB_pref"));
|
||||||
bs.tick(); // back: 0.25
|
bs.tick(); // back: 0.25
|
||||||
bs.tick(); // back: 0.5 -> frontB at 0.0 (preferred B), nextOutputIsA = true
|
bs.tick(); // back: 0.5 -> frontB at 0.75 (preferred B), nextOutputIsA = true
|
||||||
REQUIRE(southProgressOf("toB_pref") == Approx(0.0));
|
REQUIRE(southProgressOf("toB_pref") == Approx(0.75));
|
||||||
|
|
||||||
// Let it traverse and hand off to the downstream belt, freeing frontB.
|
// One tick reaches the edge and hands off to tileB; the rest just clear frontB.
|
||||||
bs.tick(); bs.tick(); bs.tick(); bs.tick(); // frontB: 0.25 -> 0.5 -> 0.75 -> 1.0 -> tileB
|
bs.tick(); bs.tick(); // frontB: 0.75 -> 1.0 -> tileB, then empty
|
||||||
|
|
||||||
// Next item prefers A again (nextOutputIsA == true), but A is still blocked,
|
// Next item prefers A again (nextOutputIsA == true), but A is still blocked,
|
||||||
// so it falls back to B — and must enter near the edge at progress 0.75.
|
// so it falls back to B — and must enter near the edge at progress 0.75.
|
||||||
@@ -613,6 +614,96 @@ TEST_CASE("BeltSystem: splitter fallback enters the open output at progress 0.75
|
|||||||
REQUIRE(southProgressOf("toB_fallback") == Approx(0.75));
|
REQUIRE(southProgressOf("toB_fallback") == Approx(0.75));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("BeltSystem: splitter with an exclusive filter enters its only output at progress 0.75", "[belt]")
|
||||||
|
{
|
||||||
|
// An item that matches only one filter has a single eligible output. Like the
|
||||||
|
// blocked-fallback case, it must enter near the edge (progress 0.75) so the
|
||||||
|
// one-item-wide front does not throttle that output and open large gaps.
|
||||||
|
const double quarterSpeed = 0.25 * static_cast<double>(kTickRateHz);
|
||||||
|
BeltSystem bs(quarterSpeed);
|
||||||
|
|
||||||
|
const QPoint tileSpl(1, 0);
|
||||||
|
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South);
|
||||||
|
bs.setSplitterFilters(tileSpl, {ItemType{"iron_ore"}}, {ItemType{"copper_ore"}});
|
||||||
|
|
||||||
|
// Inverts slotWorldPos to recover a named item's progress along the given output.
|
||||||
|
auto progressOf = [&bs, tileSpl](const std::string& id, Rotation dir) -> std::optional<double>
|
||||||
|
{
|
||||||
|
std::optional<double> progress;
|
||||||
|
bs.forEachVisualItem(QRect(-5, -5, 20, 20), [&](VisualItem vi)
|
||||||
|
{
|
||||||
|
if (vi.type.id != id)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (dir)
|
||||||
|
{
|
||||||
|
case Rotation::North: progress = (tileSpl.y() + 1.0) - vi.worldPos.y(); break;
|
||||||
|
case Rotation::South: progress = vi.worldPos.y() - tileSpl.y(); break;
|
||||||
|
case Rotation::East: progress = vi.worldPos.x() - tileSpl.x(); break;
|
||||||
|
case Rotation::West: progress = (tileSpl.x() + 1.0) - vi.worldPos.x(); break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return progress;
|
||||||
|
};
|
||||||
|
|
||||||
|
// iron_ore matches filterA only -> sole eligible output A.
|
||||||
|
bs.tryPutItem(tileSpl, makeItem("iron_ore"));
|
||||||
|
bs.tick(); // back: 0.25
|
||||||
|
bs.tick(); // back: 0.5 -> routes to frontA at 0.75
|
||||||
|
REQUIRE(progressOf("iron_ore", Rotation::North) == Approx(0.75));
|
||||||
|
|
||||||
|
// copper_ore matches filterB only -> sole eligible output B.
|
||||||
|
bs.tryPutItem(tileSpl, makeItem("copper_ore"));
|
||||||
|
bs.tick(); // back: 0.25
|
||||||
|
bs.tick(); // back: 0.5 -> routes to frontB at 0.75
|
||||||
|
REQUIRE(progressOf("copper_ore", Rotation::South) == Approx(0.75));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("BeltSystem: splitter alternation enters the preferred output at progress 0.75", "[belt]")
|
||||||
|
{
|
||||||
|
// With both outputs eligible and free, the preferred output uses the same
|
||||||
|
// near-edge entry as the diverted paths, so an evenly-split splitter keeps
|
||||||
|
// each side packed instead of throttling it to one in-flight item per tile.
|
||||||
|
const double quarterSpeed = 0.25 * static_cast<double>(kTickRateHz);
|
||||||
|
BeltSystem bs(quarterSpeed);
|
||||||
|
|
||||||
|
const QPoint tileSpl(1, 0);
|
||||||
|
bs.placeSplitter(tileSpl, Rotation::North, Rotation::South); // no filters: both match
|
||||||
|
|
||||||
|
auto progressOf = [&bs, tileSpl](const std::string& id, Rotation dir) -> std::optional<double>
|
||||||
|
{
|
||||||
|
std::optional<double> progress;
|
||||||
|
bs.forEachVisualItem(QRect(-5, -5, 20, 20), [&](VisualItem vi)
|
||||||
|
{
|
||||||
|
if (vi.type.id != id)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (dir)
|
||||||
|
{
|
||||||
|
case Rotation::North: progress = (tileSpl.y() + 1.0) - vi.worldPos.y(); break;
|
||||||
|
case Rotation::South: progress = vi.worldPos.y() - tileSpl.y(); break;
|
||||||
|
case Rotation::East: progress = vi.worldPos.x() - tileSpl.x(); break;
|
||||||
|
case Rotation::West: progress = (tileSpl.x() + 1.0) - vi.worldPos.x(); break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return progress;
|
||||||
|
};
|
||||||
|
|
||||||
|
// First item: preferred A (nextOutputIsA starts true) -> frontA at 0.75.
|
||||||
|
bs.tryPutItem(tileSpl, makeItem("first"));
|
||||||
|
bs.tick(); // back: 0.25
|
||||||
|
bs.tick(); // back: 0.5 -> routes to preferred frontA at 0.75, nextOutputIsA = false
|
||||||
|
REQUIRE(progressOf("first", Rotation::North) == Approx(0.75));
|
||||||
|
|
||||||
|
// Second item: preference flipped, B is free -> frontB at 0.75.
|
||||||
|
bs.tryPutItem(tileSpl, makeItem("second"));
|
||||||
|
bs.tick(); // back: 0.25 (first sticks at North 1.0, no downstream)
|
||||||
|
bs.tick(); // back: 0.5 -> routes to preferred frontB at 0.75
|
||||||
|
REQUIRE(progressOf("second", Rotation::South) == Approx(0.75));
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Splitter — direct building input (no output belts)
|
// Splitter — direct building input (no output belts)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -17,9 +17,9 @@
|
|||||||
#include "ScrapSystem.h"
|
#include "ScrapSystem.h"
|
||||||
#include "ShipSystem.h"
|
#include "ShipSystem.h"
|
||||||
#include "Simulation.h"
|
#include "Simulation.h"
|
||||||
|
#include "AttackBehavior.h"
|
||||||
#include "StationBodyComponent.h"
|
#include "StationBodyComponent.h"
|
||||||
#include "Tick.h"
|
#include "Tick.h"
|
||||||
#include "ThreatResponseBehaviorComponent.h"
|
|
||||||
#include "WeaponComponent.h"
|
#include "WeaponComponent.h"
|
||||||
|
|
||||||
static GameConfig loadConfig()
|
static GameConfig loadConfig()
|
||||||
@@ -80,17 +80,18 @@ struct CombatFixture
|
|||||||
|
|
||||||
void wireEnemyTarget(entt::entity enemy, entt::entity playerTarget)
|
void wireEnemyTarget(entt::entity enemy, entt::entity playerTarget)
|
||||||
{
|
{
|
||||||
// Set target on weapon child entity (CombatSystem syncs from ThreatResponse each tick,
|
// Set the target directly on the weapon child entity. CombatSystem now
|
||||||
// but also setting directly ensures the first tick fires without waiting for sync).
|
// fires at whatever target a weapon already has (AttackExecutor would set
|
||||||
|
// it in a full tick); setting it here drives CombatSystem in isolation.
|
||||||
const entt::entity wc = findWeaponChild(admin, enemy);
|
const entt::entity wc = findWeaponChild(admin, enemy);
|
||||||
if (wc != entt::null)
|
if (wc != entt::null)
|
||||||
{
|
{
|
||||||
admin.get<WeaponComponent>(wc).currentTarget = playerTarget;
|
admin.get<WeaponComponent>(wc).currentTarget = playerTarget;
|
||||||
admin.get<WeaponComponent>(wc).cooldownTicks = 0.0f;
|
admin.get<WeaponComponent>(wc).cooldownTicks = 0.0f;
|
||||||
}
|
}
|
||||||
if (admin.hasAll<ThreatResponseBehaviorComponent>(enemy))
|
if (admin.hasAll<AttackBehavior>(enemy))
|
||||||
{
|
{
|
||||||
admin.get<ThreatResponseBehaviorComponent>(enemy).currentTarget = playerTarget;
|
admin.get<AttackBehavior>(enemy).currentTarget = playerTarget;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,22 +6,27 @@
|
|||||||
#include <QPoint>
|
#include <QPoint>
|
||||||
#include <QVector2D>
|
#include <QVector2D>
|
||||||
|
|
||||||
|
#include "AdvanceBehavior.h"
|
||||||
|
#include "AttackBehavior.h"
|
||||||
#include "BuildingId.h"
|
#include "BuildingId.h"
|
||||||
#include "ConfigLoader.h"
|
#include "ConfigLoader.h"
|
||||||
|
#include "DeliverScrapBehavior.h"
|
||||||
#include "DynamicBodyComponent.h"
|
#include "DynamicBodyComponent.h"
|
||||||
#include "EntityAdmin.h"
|
#include "EntityAdmin.h"
|
||||||
#include "HealthComponent.h"
|
#include "HealthComponent.h"
|
||||||
#include "ModuleOwnerComponent.h"
|
#include "ModuleOwnerComponent.h"
|
||||||
#include "RepairBehaviorComponent.h"
|
#include "RallyBehavior.h"
|
||||||
|
#include "RepairBehavior.h"
|
||||||
#include "RepairToolComponent.h"
|
#include "RepairToolComponent.h"
|
||||||
|
#include "RetreatBehavior.h"
|
||||||
#include "Rotation.h"
|
#include "Rotation.h"
|
||||||
#include "SalvageBehaviorComponent.h"
|
|
||||||
#include "SalvageCargoComponent.h"
|
#include "SalvageCargoComponent.h"
|
||||||
|
#include "SalvageScrapBehavior.h"
|
||||||
|
#include "SelectedBehaviorComponent.h"
|
||||||
#include "SensorRangeComponent.h"
|
#include "SensorRangeComponent.h"
|
||||||
#include "ShipLayout.h"
|
#include "ShipLayout.h"
|
||||||
#include "ShipSystem.h"
|
#include "ShipSystem.h"
|
||||||
#include "Tick.h"
|
#include "Tick.h"
|
||||||
#include "ThreatResponseBehaviorComponent.h"
|
|
||||||
#include "WeaponComponent.h"
|
#include "WeaponComponent.h"
|
||||||
|
|
||||||
static GameConfig loadConfig()
|
static GameConfig loadConfig()
|
||||||
@@ -81,7 +86,7 @@ static ShipLayoutConfig makeSingleModuleLayout(const std::string& moduleId)
|
|||||||
// Combat ship (interceptor has default_modules = [laser_cannon])
|
// Combat ship (interceptor has default_modules = [laser_cannon])
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
TEST_CASE("ShipSystem: interceptor spawn has weapon child and threatResponse, no cargo or repair",
|
TEST_CASE("ShipSystem: interceptor spawn has weapon child and attack behavior, no cargo or repair",
|
||||||
"[ship]")
|
"[ship]")
|
||||||
{
|
{
|
||||||
EntityAdmin admin;
|
EntityAdmin admin;
|
||||||
@@ -92,11 +97,47 @@ TEST_CASE("ShipSystem: interceptor spawn has weapon child and threatResponse, no
|
|||||||
|
|
||||||
REQUIRE(admin.isValid(e));
|
REQUIRE(admin.isValid(e));
|
||||||
REQUIRE(admin.isValid(firstWeaponChild(admin, e)));
|
REQUIRE(admin.isValid(firstWeaponChild(admin, e)));
|
||||||
REQUIRE(admin.hasAll<ThreatResponseBehaviorComponent>(e));
|
REQUIRE(admin.hasAll<AttackBehavior>(e));
|
||||||
|
// Every ship gets the baseline behaviors; a player combat ship also rallies
|
||||||
|
// and can retreat.
|
||||||
|
REQUIRE(admin.hasAll<AdvanceBehavior>(e));
|
||||||
|
REQUIRE(admin.hasAll<SelectedBehaviorComponent>(e));
|
||||||
|
REQUIRE(admin.hasAll<RallyBehavior>(e));
|
||||||
|
REQUIRE(admin.hasAll<RetreatBehavior>(e));
|
||||||
REQUIRE_FALSE(admin.isValid(firstSalvageChild(admin, e)));
|
REQUIRE_FALSE(admin.isValid(firstSalvageChild(admin, e)));
|
||||||
REQUIRE_FALSE(admin.isValid(firstRepairChild(admin, e)));
|
REQUIRE_FALSE(admin.isValid(firstRepairChild(admin, e)));
|
||||||
REQUIRE_FALSE(admin.hasAll<RepairBehaviorComponent>(e));
|
REQUIRE_FALSE(admin.hasAll<RepairBehavior>(e));
|
||||||
REQUIRE_FALSE(admin.hasAll<SalvageBehaviorComponent>(e));
|
REQUIRE_FALSE(admin.hasAll<SalvageScrapBehavior>(e));
|
||||||
|
REQUIRE_FALSE(admin.hasAll<DeliverScrapBehavior>(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ShipSystem: enemy combat ship has no rally or retreat behavior", "[ship]")
|
||||||
|
{
|
||||||
|
EntityAdmin admin;
|
||||||
|
const GameConfig cfg = loadConfig();
|
||||||
|
ShipSystem ss(cfg, admin);
|
||||||
|
|
||||||
|
const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f), /*isEnemy=*/true);
|
||||||
|
|
||||||
|
REQUIRE(admin.hasAll<AttackBehavior>(e));
|
||||||
|
REQUIRE(admin.hasAll<AdvanceBehavior>(e));
|
||||||
|
REQUIRE_FALSE(admin.hasAll<RallyBehavior>(e));
|
||||||
|
REQUIRE_FALSE(admin.hasAll<RetreatBehavior>(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ShipSystem: setRetreatEnabled(false) suppresses player retreat behavior", "[ship]")
|
||||||
|
{
|
||||||
|
EntityAdmin admin;
|
||||||
|
const GameConfig cfg = loadConfig();
|
||||||
|
ShipSystem ss(cfg, admin);
|
||||||
|
ss.setRetreatEnabled(false);
|
||||||
|
|
||||||
|
const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||||
|
|
||||||
|
// Other player behaviors are unaffected; only retreat is suppressed.
|
||||||
|
REQUIRE(admin.hasAll<AttackBehavior>(e));
|
||||||
|
REQUIRE(admin.hasAll<RallyBehavior>(e));
|
||||||
|
REQUIRE_FALSE(admin.hasAll<RetreatBehavior>(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("ShipSystem: interceptor level 1 stats match config formulas", "[ship]")
|
TEST_CASE("ShipSystem: interceptor level 1 stats match config formulas", "[ship]")
|
||||||
@@ -161,7 +202,8 @@ TEST_CASE("ShipSystem: salvage_ship spawn with salvage module has cargo child an
|
|||||||
const entt::entity e = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), false, layout);
|
const entt::entity e = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), false, layout);
|
||||||
|
|
||||||
REQUIRE(admin.isValid(firstSalvageChild(admin, e)));
|
REQUIRE(admin.isValid(firstSalvageChild(admin, e)));
|
||||||
REQUIRE(admin.hasAll<SalvageBehaviorComponent>(e));
|
REQUIRE(admin.hasAll<SalvageScrapBehavior>(e));
|
||||||
|
REQUIRE(admin.hasAll<DeliverScrapBehavior>(e));
|
||||||
REQUIRE_FALSE(admin.isValid(firstWeaponChild(admin, e)));
|
REQUIRE_FALSE(admin.isValid(firstWeaponChild(admin, e)));
|
||||||
REQUIRE_FALSE(admin.isValid(firstRepairChild(admin, e)));
|
REQUIRE_FALSE(admin.isValid(firstRepairChild(admin, e)));
|
||||||
}
|
}
|
||||||
@@ -180,9 +222,9 @@ TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]")
|
|||||||
REQUIRE(admin.isValid(sc));
|
REQUIRE(admin.isValid(sc));
|
||||||
REQUIRE(admin.get<SalvageCargoComponent>(sc).capacity == 10);
|
REQUIRE(admin.get<SalvageCargoComponent>(sc).capacity == 10);
|
||||||
REQUIRE(admin.get<SalvageCargoComponent>(sc).current == 0);
|
REQUIRE(admin.get<SalvageCargoComponent>(sc).current == 0);
|
||||||
REQUIRE(admin.get<SalvageBehaviorComponent>(e).deliveryBay == kInvalidBuildingId);
|
REQUIRE(admin.get<DeliverScrapBehavior>(e).deliveryBay == kInvalidBuildingId);
|
||||||
REQUIRE_FALSE(admin.get<SalvageBehaviorComponent>(e).scrapTarget.has_value());
|
REQUIRE_FALSE(admin.get<SalvageScrapBehavior>(e).scrapTarget.has_value());
|
||||||
REQUIRE(admin.get<SalvageBehaviorComponent>(e).maxCollectionRange_tiles == Approx(50.0f));
|
REQUIRE(admin.get<SalvageScrapBehavior>(e).maxCollectionRange_tiles == Approx(50.0f));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -200,7 +242,7 @@ TEST_CASE("ShipSystem: repair_ship spawn with repair module has repair child and
|
|||||||
const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, layout);
|
const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, layout);
|
||||||
|
|
||||||
REQUIRE(admin.isValid(firstRepairChild(admin, e)));
|
REQUIRE(admin.isValid(firstRepairChild(admin, e)));
|
||||||
REQUIRE(admin.hasAll<RepairBehaviorComponent>(e));
|
REQUIRE(admin.hasAll<RepairBehavior>(e));
|
||||||
REQUIRE_FALSE(admin.isValid(firstWeaponChild(admin, e)));
|
REQUIRE_FALSE(admin.isValid(firstWeaponChild(admin, e)));
|
||||||
REQUIRE_FALSE(admin.isValid(firstSalvageChild(admin, e)));
|
REQUIRE_FALSE(admin.isValid(firstSalvageChild(admin, e)));
|
||||||
}
|
}
|
||||||
@@ -221,7 +263,7 @@ TEST_CASE("ShipSystem: repair_ship level 1 repair stats match config formulas",
|
|||||||
REQUIRE(admin.get<RepairToolComponent>(rc).ratePerTick == Approx(expectedRate));
|
REQUIRE(admin.get<RepairToolComponent>(rc).ratePerTick == Approx(expectedRate));
|
||||||
// repair_range_m_formula = "800" m → 800/10 = 80 tiles
|
// repair_range_m_formula = "800" m → 800/10 = 80 tiles
|
||||||
REQUIRE(admin.get<RepairToolComponent>(rc).range_tiles == Approx(80.0f));
|
REQUIRE(admin.get<RepairToolComponent>(rc).range_tiles == Approx(80.0f));
|
||||||
REQUIRE(admin.get<RepairBehaviorComponent>(e).maxRepairRange_tiles == Approx(80.0f));
|
REQUIRE(admin.get<RepairBehavior>(e).maxRepairRange_tiles == Approx(80.0f));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
170
tools/verify_layouts.py
Normal file
170
tools/verify_layouts.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Verify which module surface masks fit which ship layout grids.
|
||||||
|
|
||||||
|
Reads ships.toml and modules.toml, then checks every module footprint
|
||||||
|
against every ship layout the same way the game does (all four mask
|
||||||
|
rotations, every placement position). Prints three reports:
|
||||||
|
|
||||||
|
1. Fit matrix — can the footprint be placed on the hull at all?
|
||||||
|
2. Max simultaneous — how many disjoint copies of a footprint fit at
|
||||||
|
once (only computed for footprints of 4+ cells;
|
||||||
|
smaller ones would be slow and uninteresting).
|
||||||
|
3. Cell counts — buildable cells per hull.
|
||||||
|
|
||||||
|
Use it after editing layout grids or surface masks to confirm the
|
||||||
|
footprint-gating rules in docs/content_design.md still hold, e.g. that
|
||||||
|
no 2x2 area exists on s-class hulls or that the drone hangar fits the
|
||||||
|
carrier only.
|
||||||
|
|
||||||
|
Usage (from the repository root or anywhere else):
|
||||||
|
|
||||||
|
python dota_factory/tools/verify_layouts.py
|
||||||
|
python dota_factory/tools/verify_layouts.py --config-dir path/to/config
|
||||||
|
|
||||||
|
By default the config directory is resolved relative to this script
|
||||||
|
(../bin/app/data/config). Requires the 'toml' package on Python < 3.11
|
||||||
|
(pip install --user toml); on 3.11+ the standard tomllib is used.
|
||||||
|
|
||||||
|
The script is informational only — it always exits 0. Read the matrix
|
||||||
|
and compare it against the intended gating in docs/content_design.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def load_toml(path):
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
return tomllib.load(fh)
|
||||||
|
except ImportError:
|
||||||
|
import toml
|
||||||
|
return toml.load(path)
|
||||||
|
|
||||||
|
|
||||||
|
def grid_cells(rows):
|
||||||
|
"""Set of (x, y) for every 'O' cell in a list of layout/mask strings."""
|
||||||
|
return {(x, y)
|
||||||
|
for y, row in enumerate(rows)
|
||||||
|
for x, ch in enumerate(row)
|
||||||
|
if ch == "O"}
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_cw(shape):
|
||||||
|
height = max(y for x, y in shape) + 1
|
||||||
|
return {(height - 1 - y, x) for x, y in shape}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(shape):
|
||||||
|
min_x = min(x for x, y in shape)
|
||||||
|
min_y = min(y for x, y in shape)
|
||||||
|
return frozenset((x - min_x, y - min_y) for x, y in shape)
|
||||||
|
|
||||||
|
|
||||||
|
def orientations(shape):
|
||||||
|
"""All distinct 90-degree rotations of a shape, normalized to (0, 0)."""
|
||||||
|
result = []
|
||||||
|
current = shape
|
||||||
|
for _ in range(4):
|
||||||
|
norm = normalize(current)
|
||||||
|
if norm not in result:
|
||||||
|
result.append(norm)
|
||||||
|
current = rotate_cw(current)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def placements(layout, shape):
|
||||||
|
"""Every position (in any rotation) where shape fits fully on layout."""
|
||||||
|
found = []
|
||||||
|
layout_w = max(x for x, y in layout) + 1
|
||||||
|
layout_h = max(y for x, y in layout) + 1
|
||||||
|
for orient in orientations(shape):
|
||||||
|
shape_w = max(x for x, y in orient) + 1
|
||||||
|
shape_h = max(y for x, y in orient) + 1
|
||||||
|
for off_x in range(layout_w - shape_w + 1):
|
||||||
|
for off_y in range(layout_h - shape_h + 1):
|
||||||
|
cells = frozenset((x + off_x, y + off_y) for x, y in orient)
|
||||||
|
if cells <= layout and cells not in found:
|
||||||
|
found.append(cells)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def max_disjoint(layout, shape):
|
||||||
|
"""Maximum number of non-overlapping placements of shape on layout."""
|
||||||
|
options = placements(layout, shape)
|
||||||
|
best = [0]
|
||||||
|
|
||||||
|
def recurse(start_index, used, count):
|
||||||
|
if count > best[0]:
|
||||||
|
best[0] = count
|
||||||
|
for i in range(start_index, len(options)):
|
||||||
|
if not (options[i] & used):
|
||||||
|
recurse(i + 1, used | options[i], count + 1)
|
||||||
|
|
||||||
|
recurse(0, frozenset(), 0)
|
||||||
|
return best[0]
|
||||||
|
|
||||||
|
|
||||||
|
def shape_label(shape):
|
||||||
|
width = max(x for x, y in shape) + 1
|
||||||
|
height = max(y for x, y in shape) + 1
|
||||||
|
if len(shape) == width * height:
|
||||||
|
return "{}x{}".format(width, height)
|
||||||
|
return "{}x{}-{}c".format(width, height, len(shape))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
default_dir = os.path.normpath(os.path.join(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
"..", "bin", "app", "data", "config"))
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Check module surface masks against ship layout grids.")
|
||||||
|
parser.add_argument("--config-dir", default=default_dir,
|
||||||
|
help="directory containing ships.toml and modules.toml"
|
||||||
|
" (default: %(default)s)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
ships = load_toml(os.path.join(args.config_dir, "ships.toml"))["ship"]
|
||||||
|
modules = load_toml(os.path.join(args.config_dir, "modules.toml"))["module"]
|
||||||
|
|
||||||
|
layouts = [(s["id"], grid_cells(s["layout"])) for s in ships]
|
||||||
|
|
||||||
|
# Group modules that share the same footprint (up to rotation).
|
||||||
|
footprint_modules = {} # canonical shape -> [module ids]
|
||||||
|
for module in modules:
|
||||||
|
shape = grid_cells(module["surface_mask"])
|
||||||
|
canonical = min(orientations(shape), key=sorted)
|
||||||
|
footprint_modules.setdefault(canonical, []).append(module["id"])
|
||||||
|
footprints = sorted(footprint_modules.items(), key=lambda e: len(e[0]))
|
||||||
|
|
||||||
|
column_header = "".join("{:>8}".format(ship_id[:7])
|
||||||
|
for ship_id, _ in layouts)
|
||||||
|
|
||||||
|
print("Fit matrix (YES = at least one placement exists)")
|
||||||
|
print("{:48}{}".format("footprint (modules)", column_header))
|
||||||
|
for shape, module_ids in footprints:
|
||||||
|
name = "{:8}{}".format(shape_label(shape), ", ".join(module_ids))
|
||||||
|
row = "".join("{:>8}".format("YES" if placements(layout, shape) else "-")
|
||||||
|
for _, layout in layouts)
|
||||||
|
print("{:48}{}".format(name[:47], row))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Max simultaneous (disjoint) placements, footprints of 4+ cells")
|
||||||
|
print("{:48}{}".format("footprint (modules)", column_header))
|
||||||
|
for shape, module_ids in footprints:
|
||||||
|
if len(shape) < 4:
|
||||||
|
continue
|
||||||
|
name = "{:8}{}".format(shape_label(shape), ", ".join(module_ids))
|
||||||
|
row = "".join("{:>8}".format(max_disjoint(layout, shape))
|
||||||
|
for _, layout in layouts)
|
||||||
|
print("{:48}{}".format(name[:47], row))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Buildable cells per hull")
|
||||||
|
for ship_id, layout in layouts:
|
||||||
|
print("{:16}{}".format(ship_id, len(layout)))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
132
tools/verify_recipes.py
Normal file
132
tools/verify_recipes.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Verify the recipe tree is closed and consistent.
|
||||||
|
|
||||||
|
Reads recipes.toml, ships.toml, modules.toml, and visuals.toml, then checks:
|
||||||
|
|
||||||
|
1. Producers — every item consumed anywhere (recipe inputs, ship hull
|
||||||
|
materials, module materials) is produced by some recipe. 'scrap' is
|
||||||
|
exempt: it drops from destroyed ships.
|
||||||
|
2. Visuals — every item that exists in the economy has an [items.*]
|
||||||
|
entry in visuals.toml, and visuals.toml has no entries for items
|
||||||
|
that no longer exist.
|
||||||
|
3. Orphans — items that are produced but never consumed (warning only;
|
||||||
|
'building_block' is exempt: the HQ consumes it).
|
||||||
|
|
||||||
|
It also prints which items are obtainable ONLY through reprocessing —
|
||||||
|
the combat-gated materials — so changes to that gate are visible.
|
||||||
|
|
||||||
|
Usage (from the repository root or anywhere else):
|
||||||
|
|
||||||
|
python dota_factory/tools/verify_recipes.py
|
||||||
|
python dota_factory/tools/verify_recipes.py --config-dir path/to/config
|
||||||
|
|
||||||
|
By default the config directory is resolved relative to this script
|
||||||
|
(../bin/app/data/config). Requires the 'toml' package on Python < 3.11
|
||||||
|
(pip install --user toml); on 3.11+ the standard tomllib is used.
|
||||||
|
|
||||||
|
Exits 1 if a producer or visuals check fails, 0 otherwise (warnings do
|
||||||
|
not affect the exit code).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
WORLD_SOURCED_ITEMS = {"scrap"} # dropped by destroyed ships
|
||||||
|
IMPLICITLY_CONSUMED_ITEMS = {"building_block"} # consumed by the HQ
|
||||||
|
|
||||||
|
|
||||||
|
def load_toml(path):
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
return tomllib.load(fh)
|
||||||
|
except ImportError:
|
||||||
|
import toml
|
||||||
|
return toml.load(path)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
default_dir = os.path.normpath(os.path.join(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
"..", "bin", "app", "data", "config"))
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Check recipe tree consistency across the config files.")
|
||||||
|
parser.add_argument("--config-dir", default=default_dir,
|
||||||
|
help="directory containing the config toml files"
|
||||||
|
" (default: %(default)s)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
recipes = load_toml(os.path.join(args.config_dir, "recipes.toml"))["recipe"]
|
||||||
|
ships = load_toml(os.path.join(args.config_dir, "ships.toml"))["ship"]
|
||||||
|
modules = load_toml(os.path.join(args.config_dir, "modules.toml"))["module"]
|
||||||
|
visuals = load_toml(os.path.join(args.config_dir, "visuals.toml"))
|
||||||
|
|
||||||
|
produced = {} # item id -> [producer descriptions]
|
||||||
|
consumed = {} # item id -> [consumer descriptions]
|
||||||
|
|
||||||
|
for recipe in recipes:
|
||||||
|
for output in recipe.get("outputs", []):
|
||||||
|
produced.setdefault(output["item"], []).append(
|
||||||
|
"recipe '{}'".format(recipe["id"]))
|
||||||
|
for inp in recipe.get("inputs", []):
|
||||||
|
consumed.setdefault(inp["item"], []).append(
|
||||||
|
"recipe '{}'".format(recipe["id"]))
|
||||||
|
|
||||||
|
for ship in ships:
|
||||||
|
for material in ship["schematic"]["materials"]:
|
||||||
|
consumed.setdefault(material["item"], []).append(
|
||||||
|
"ship '{}'".format(ship["id"]))
|
||||||
|
|
||||||
|
for module in modules:
|
||||||
|
for material in module["materials"]:
|
||||||
|
consumed.setdefault(material["item"], []).append(
|
||||||
|
"module '{}'".format(module["id"]))
|
||||||
|
|
||||||
|
all_items = set(produced) | set(consumed) | WORLD_SOURCED_ITEMS
|
||||||
|
visual_items = set(visuals.get("items", {}))
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
for item in sorted(consumed):
|
||||||
|
if item not in produced and item not in WORLD_SOURCED_ITEMS:
|
||||||
|
errors.append("no producer for '{}' (consumed by {})".format(
|
||||||
|
item, ", ".join(sorted(set(consumed[item])))))
|
||||||
|
|
||||||
|
for item in sorted(all_items - visual_items):
|
||||||
|
errors.append("no [items.{}] entry in visuals.toml".format(item))
|
||||||
|
for item in sorted(visual_items - all_items):
|
||||||
|
warnings.append("visuals.toml entry [items.{}] matches no known item"
|
||||||
|
.format(item))
|
||||||
|
|
||||||
|
for item in sorted(produced):
|
||||||
|
if item not in consumed and item not in IMPLICITLY_CONSUMED_ITEMS:
|
||||||
|
warnings.append("'{}' is produced but never consumed (by {})"
|
||||||
|
.format(item, ", ".join(sorted(set(produced[item])))))
|
||||||
|
|
||||||
|
reprocessing_only = sorted(
|
||||||
|
item for item, producers in produced.items()
|
||||||
|
if all("reprocessing" in p for p in producers))
|
||||||
|
|
||||||
|
print("{} items, {} recipes, {} ships, {} modules".format(
|
||||||
|
len(all_items), len(recipes), len(ships), len(modules)))
|
||||||
|
print("obtainable only via reprocessing: {}".format(
|
||||||
|
", ".join(reprocessing_only) if reprocessing_only else "(none)"))
|
||||||
|
print()
|
||||||
|
|
||||||
|
for warning in warnings:
|
||||||
|
print("WARNING: {}".format(warning))
|
||||||
|
for error in errors:
|
||||||
|
print("ERROR: {}".format(error))
|
||||||
|
if not errors and not warnings:
|
||||||
|
print("all checks passed")
|
||||||
|
elif not errors:
|
||||||
|
print("no errors")
|
||||||
|
|
||||||
|
return 1 if errors else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user