Compare commits
23 Commits
0.1.0
...
9ddd622870
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ddd622870 | |||
| df9f0d5ccd | |||
| db7a03dc1f | |||
| 67054f432f | |||
| 7a9a7a35ad | |||
| f822047b4e | |||
| d9e7dd0fe8 | |||
| 4d5b218fec | |||
| 97bafc95c3 | |||
| 5aa0544cf2 | |||
| 38fd2e4e89 | |||
| d889b79658 | |||
| b9e70ba83a | |||
| 3aefc05118 | |||
| fe7703d5c3 | |||
| 72b38df0dd | |||
| c7a168f4a9 | |||
| a6451f2bdf | |||
| 493208d230 | |||
| 4098e40c8c | |||
| 730cd3bdf3 | |||
| 437ac97355 | |||
| 41081620f0 |
@@ -1,26 +1,29 @@
|
||||
# modules.toml
|
||||
#
|
||||
# First real-content iteration: module ids and surface masks are the designed
|
||||
# content; stats, materials, and threat costs are placeholders until the
|
||||
# recipe and balancing passes.
|
||||
# Production tree v2: all weapons are railguns for now — the implementation
|
||||
# (instant damage, no projectile, no ammunition) stays as-is and the beam
|
||||
# visual reads as a tracer round. Lasers are reserved for a future distinct
|
||||
# weapon type (see docs/content_design.md, "Production tree v2 — Weapons").
|
||||
# Combat stats are placeholders until the arena balancing pass;
|
||||
# production_time_seconds values come from the numbers pass.
|
||||
#
|
||||
# Unlock progression is mostly disabled (unlock_at_station_level = -1) pending
|
||||
# the balancing pass. The laser cannons are the exception: laser_cannon_m is
|
||||
# gated to station level 1, and laser_cannon_l requires laser_cannon_m to be
|
||||
# unlocked first (unlock_requires) — a demonstration of the prerequisite chain.
|
||||
# the pacing pass. The railguns are the exception: railgun_m is gated to
|
||||
# station level 1, and railgun_l requires railgun_m to be unlocked first
|
||||
# (unlock_requires) — a demonstration of the prerequisite chain.
|
||||
#
|
||||
# Surface mask footprint ladder — footprints gate which hulls can mount a
|
||||
# module, purely through geometry (see ships.toml for the matching hull
|
||||
# grids):
|
||||
#
|
||||
# 1x1 laser_cannon_s, salvager, repair_tool fits every hull, incl. drones
|
||||
# 1x1 railgun_s, salvager, repair_tool fits every hull, incl. drones
|
||||
# 1x2 maneuvering_thrusters, sensor_booster,
|
||||
# armor_plates frigate and up
|
||||
# 1x3 afterburner frigate and up (eats most of a frigate)
|
||||
# L-shape weapon_stabilizer, weapon_primer,
|
||||
# weapon_upgrade frigate and up
|
||||
# 2x2 laser_cannon_m, drone_bay cruiser and up (no 2x2 area on s hulls)
|
||||
# 3x3 laser_cannon_l battleship and up (no 3x3 area on m hulls)
|
||||
# 2x2 railgun_m, drone_bay cruiser and up (no 2x2 area on s hulls)
|
||||
# 3x3 railgun_l battleship and up (no 3x3 area on m hulls)
|
||||
# 2x6 drone_hangar carrier only
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -28,13 +31,13 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
[[module]]
|
||||
id = "laser_cannon_s"
|
||||
id = "railgun_s"
|
||||
unlock_at_station_level = -1
|
||||
surface_mask = ["O"]
|
||||
materials = [{item = "laser_cannon_s_module", amount = 1}]
|
||||
production_time_seconds = 0.5
|
||||
materials = [{item = "railgun_s_module", amount = 1}]
|
||||
production_time_seconds = 1
|
||||
fill_color = "#FF8040"
|
||||
glyph = "Ls"
|
||||
glyph = "Rs"
|
||||
|
||||
[module.weapon]
|
||||
damage = 2
|
||||
@@ -43,37 +46,37 @@ attack_rate_hz = 2.0
|
||||
|
||||
|
||||
[[module]]
|
||||
id = "laser_cannon_m"
|
||||
id = "railgun_m"
|
||||
unlock_at_station_level = 1
|
||||
surface_mask = [
|
||||
"OO",
|
||||
"OO"]
|
||||
materials = [{item = "laser_cannon_m_module", amount = 1}]
|
||||
production_time_seconds = 2
|
||||
materials = [{item = "railgun_m_module", amount = 1}]
|
||||
production_time_seconds = 3
|
||||
fill_color = "#FF8040"
|
||||
glyph = "Lm"
|
||||
glyph = "Rm"
|
||||
|
||||
[module.weapon]
|
||||
damage = 10
|
||||
damage = 14
|
||||
attack_range_m = 70
|
||||
attack_rate_hz = 1.5
|
||||
|
||||
|
||||
[[module]]
|
||||
id = "laser_cannon_l"
|
||||
id = "railgun_l"
|
||||
unlock_at_station_level = 1
|
||||
unlock_requires = ["laser_cannon_m"]
|
||||
unlock_requires = ["railgun_m"]
|
||||
surface_mask = [
|
||||
"OOO",
|
||||
"OOO",
|
||||
"OOO"]
|
||||
materials = [{item = "laser_cannon_l_module", amount = 1}]
|
||||
production_time_seconds = 8
|
||||
materials = [{item = "railgun_l_module", amount = 1}]
|
||||
production_time_seconds = 4
|
||||
fill_color = "#FF8040"
|
||||
glyph = "Ll"
|
||||
glyph = "Rl"
|
||||
|
||||
[module.weapon]
|
||||
damage = 40
|
||||
damage = 52
|
||||
attack_range_m = 100
|
||||
attack_rate_hz = 0.8
|
||||
|
||||
@@ -86,13 +89,13 @@ id = "salvager"
|
||||
unlock_at_station_level = -1
|
||||
surface_mask = ["O"]
|
||||
materials = [{item = "salvager_module", amount = 1}]
|
||||
production_time_seconds = 2
|
||||
production_time_seconds = 1
|
||||
fill_color = "#AACC44"
|
||||
glyph = "Sv"
|
||||
|
||||
[module.salvage]
|
||||
collection_range_m = 500
|
||||
cargo_capacity = 10
|
||||
collection_range_m = 60
|
||||
cargo_capacity = 20
|
||||
collection_rate_hz = 0.5
|
||||
|
||||
|
||||
@@ -101,13 +104,13 @@ id = "repair_tool"
|
||||
unlock_at_station_level = -1
|
||||
surface_mask = ["O"]
|
||||
materials = [{item = "repair_tool_module", amount = 1}]
|
||||
production_time_seconds = 2
|
||||
production_time_seconds = 1
|
||||
fill_color = "#66CCFF"
|
||||
glyph = "Rp"
|
||||
|
||||
[module.repair]
|
||||
repair_rate_hz = 1
|
||||
repair_amount_hp = 6
|
||||
repair_amount_hp = 12
|
||||
repair_range_m = 80
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -119,7 +122,7 @@ id = "afterburner"
|
||||
unlock_at_station_level = -1
|
||||
surface_mask = ["OOO"]
|
||||
materials = [{item = "afterburner_module", amount = 1}]
|
||||
production_time_seconds = 2
|
||||
production_time_seconds = 1
|
||||
fill_color = "#40A0FF"
|
||||
glyph = "Ab"
|
||||
|
||||
@@ -133,7 +136,7 @@ id = "maneuvering_thrusters"
|
||||
unlock_at_station_level = -1
|
||||
surface_mask = ["OO"]
|
||||
materials = [{item = "maneuvering_thrusters_module", amount = 1}]
|
||||
production_time_seconds = 2
|
||||
production_time_seconds = 1
|
||||
fill_color = "#40A0FF"
|
||||
glyph = "Mt"
|
||||
|
||||
@@ -150,12 +153,12 @@ id = "armor_plates"
|
||||
unlock_at_station_level = -1
|
||||
surface_mask = ["OO"]
|
||||
materials = [{item = "armor_plates_module", amount = 1}]
|
||||
production_time_seconds = 3
|
||||
production_time_seconds = 1
|
||||
fill_color = "#808080"
|
||||
glyph = "A"
|
||||
|
||||
[module.health]
|
||||
added_hp = 40
|
||||
added_hp = 1200
|
||||
|
||||
|
||||
[[module]]
|
||||
@@ -163,7 +166,7 @@ id = "sensor_booster"
|
||||
unlock_at_station_level = -1
|
||||
surface_mask = ["OO"]
|
||||
materials = [{item = "sensor_booster_module", amount = 1}]
|
||||
production_time_seconds = 2
|
||||
production_time_seconds = 1
|
||||
fill_color = "#40A0FF"
|
||||
glyph = "S"
|
||||
|
||||
@@ -182,7 +185,7 @@ surface_mask = [
|
||||
"OX",
|
||||
]
|
||||
materials = [{item = "weapon_upgrade_module", amount = 1}]
|
||||
production_time_seconds = 4
|
||||
production_time_seconds = 2
|
||||
fill_color = "#FF4040"
|
||||
glyph = "Wu"
|
||||
|
||||
@@ -198,7 +201,7 @@ surface_mask = [
|
||||
"OX",
|
||||
]
|
||||
materials = [{item = "weapon_primer_module", amount = 1}]
|
||||
production_time_seconds = 4
|
||||
production_time_seconds = 2
|
||||
fill_color = "#FF4040"
|
||||
glyph = "Wp"
|
||||
|
||||
@@ -214,12 +217,12 @@ surface_mask = [
|
||||
"OX",
|
||||
]
|
||||
materials = [{item = "weapon_stabilizer_module", amount = 1}]
|
||||
production_time_seconds = 4
|
||||
production_time_seconds = 1
|
||||
fill_color = "#FF4040"
|
||||
glyph = "Ws"
|
||||
|
||||
[module.weapon]
|
||||
multiplied_attack_range_m = 1.5
|
||||
multiplied_attack_range_m = 1.3
|
||||
multiplied_attack_rate_hz = 0.8
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -236,7 +239,7 @@ surface_mask = [
|
||||
"OO",
|
||||
"OO"]
|
||||
materials = [{item = "drone_bay_module", amount = 1}]
|
||||
production_time_seconds = 5
|
||||
production_time_seconds = 3
|
||||
fill_color = "#CC66FF"
|
||||
glyph = "Db"
|
||||
|
||||
@@ -248,6 +251,6 @@ surface_mask = [
|
||||
"OOOOOO",
|
||||
"OOOOOO"]
|
||||
materials = [{item = "drone_hangar_module", amount = 1}]
|
||||
production_time_seconds = 20
|
||||
production_time_seconds = 6
|
||||
fill_color = "#9933CC"
|
||||
glyph = "Dh"
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
# recipes.toml
|
||||
#
|
||||
# First real-content iteration of the production tree. Quantities and
|
||||
# durations are a first guess; the balancing pass will tune them and assign
|
||||
# real unlock_at_station_level values. Almost everything is unlocked for now so
|
||||
# the full tree is testable; the only gated recipe schematics are the medium and
|
||||
# large laser-cannon item recipes, which demonstrate the unlock_requires
|
||||
# prerequisite chain (large requires medium — see modules.toml / ships.toml).
|
||||
# Production tree v2 (see docs/content_design.md, "Production tree v2" and
|
||||
# "Numbers — first pass"). Quantities and durations are tuned so that every
|
||||
# fitted ship lands on the threat-cost ladder and the ratio curve is
|
||||
# realized: tier 1 ratios are 1:1, tier 2 ratios are 2:3, tier 3+ ratios
|
||||
# are deliberately strange.
|
||||
#
|
||||
# Input chain per game phase — each phase adds exactly one new base input:
|
||||
# Input chain per game phase — each phase transition adds exactly one new
|
||||
# base input:
|
||||
#
|
||||
# early iron_ore + copper_ore -> ingots -> copper_wire, steel_plate,
|
||||
# circuit_board
|
||||
# mid + titanium_ore -> titanium_frame; assembler-made
|
||||
# mechanical_parts, targeting_unit,
|
||||
# drive_unit
|
||||
# late + advanced_alloy -> reinforced_plating, capital_core.
|
||||
# advanced_alloy CANNOT be mined; it only
|
||||
# comes from reprocessing salvaged scrap,
|
||||
# so capital production requires combat.
|
||||
# early iron_ore + copper_ore minable on every asteroid tile (the
|
||||
# asteroid is an M-type body — its bulk
|
||||
# rock IS ore)
|
||||
# mid + quartz geode deposits in expansion territory
|
||||
# (deposit gating pending — see action
|
||||
# item 5 in docs/progression_design.md;
|
||||
# until then quartz mines anywhere)
|
||||
# late + voidsteel battle-forged: ONLY from reprocessing
|
||||
# salvaged scrap, so capital production
|
||||
# requires combat
|
||||
#
|
||||
# Run tools/verify_recipes.py after editing to check that every consumed
|
||||
# item has a producer and every item has a visuals.toml entry.
|
||||
# Run tools/verify_recipes.py and tools/threat_report.py after editing.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Mining (tier 0)
|
||||
@@ -38,405 +38,406 @@ id = "mine_copper_ore"
|
||||
building = "miner"
|
||||
inputs = []
|
||||
outputs = [{item = "copper_ore", amount = 1}]
|
||||
duration_seconds = 1.5
|
||||
duration_seconds = 1.0
|
||||
|
||||
# Titanium is the midgame ore: mined three times slower than iron.
|
||||
[[recipe]]
|
||||
id = "mine_titanium_ore"
|
||||
id = "mine_quartz"
|
||||
building = "miner"
|
||||
inputs = []
|
||||
outputs = [{item = "titanium_ore", amount = 1}]
|
||||
duration_seconds = 3.0
|
||||
outputs = [{item = "quartz", amount = 1}]
|
||||
duration_seconds = 2.0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Smelting (tier 1)
|
||||
# Smelting (tier 1) — one recipe per input item; ratios are 1:1 with miners.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
[[recipe]]
|
||||
id = "iron_ingot"
|
||||
building = "smelter"
|
||||
inputs = [{item = "iron_ore", amount = 2}]
|
||||
inputs = [{item = "iron_ore", amount = 1}]
|
||||
outputs = [{item = "iron_ingot", amount = 1}]
|
||||
duration_seconds = 2.0
|
||||
duration_seconds = 1.0
|
||||
|
||||
[[recipe]]
|
||||
id = "copper_ingot"
|
||||
building = "smelter"
|
||||
inputs = [{item = "copper_ore", amount = 2}]
|
||||
inputs = [{item = "copper_ore", amount = 1}]
|
||||
outputs = [{item = "copper_ingot", amount = 1}]
|
||||
duration_seconds = 2.5
|
||||
duration_seconds = 1.0
|
||||
|
||||
[[recipe]]
|
||||
id = "titanium_ingot"
|
||||
id = "silicon"
|
||||
building = "smelter"
|
||||
inputs = [{item = "titanium_ore", amount = 3}]
|
||||
outputs = [{item = "titanium_ingot", amount = 1}]
|
||||
duration_seconds = 4.0
|
||||
inputs = [{item = "quartz", amount = 1}]
|
||||
outputs = [{item = "silicon", amount = 1}]
|
||||
duration_seconds = 2.0
|
||||
|
||||
# Scrap smelting: the safe, boring sink. Deliberately value-losing (4 threat
|
||||
# of scrap becomes a 2-threat ingot) — reprocessing is the value-preserving
|
||||
# path.
|
||||
[[recipe]]
|
||||
id = "scrap_smelting"
|
||||
building = "smelter"
|
||||
inputs = [{item = "scrap", amount = 1}]
|
||||
outputs = [{item = "iron_ingot", amount = 1}]
|
||||
duration_seconds = 1.0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Reprocessing
|
||||
#
|
||||
# The only source of advanced_alloy: salvaged scrap from destroyed ships.
|
||||
# Reprocessing — the only source of voidsteel (battle-forged; formed when
|
||||
# weapon plasma anneals hull metal in the violence of ship destruction).
|
||||
# Weights are authored for the fully unlocked pool state; the pool
|
||||
# renormalizes over implicitly unlocked items early game.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
[[recipe]]
|
||||
id = "reprocessing_cycle"
|
||||
building = "reprocessing_plant"
|
||||
inputs = [{item = "scrap", amount = 5}]
|
||||
duration_seconds = 3.0
|
||||
inputs = [{item = "scrap", amount = 4}]
|
||||
duration_seconds = 4.0
|
||||
|
||||
[[recipe.outputs]]
|
||||
item = "iron_ingot"
|
||||
amount = 2
|
||||
probability = 0.45
|
||||
amount = 1
|
||||
probability = 0.3
|
||||
|
||||
[[recipe.outputs]]
|
||||
item = "copper_ingot"
|
||||
amount = 1
|
||||
probability = 0.25
|
||||
probability = 0.3
|
||||
|
||||
[[recipe.outputs]]
|
||||
item = "titanium_ingot"
|
||||
item = "silicon"
|
||||
amount = 1
|
||||
probability = 0.15
|
||||
probability = 0.2
|
||||
|
||||
[[recipe.outputs]]
|
||||
item = "advanced_alloy"
|
||||
item = "voidsteel"
|
||||
amount = 1
|
||||
probability = 0.15
|
||||
probability = 0.2
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Basic components (tier 2, early game)
|
||||
# Tier 2 — early intermediates (clean ratios, ~2:3)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
[[recipe]]
|
||||
id = "copper_wire"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [{item = "copper_ingot", amount = 1}]
|
||||
outputs = [{item = "copper_wire", amount = 2}]
|
||||
duration_seconds = 1.5
|
||||
|
||||
[[recipe]]
|
||||
id = "steel_plate"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [{item = "iron_ingot", amount = 2}]
|
||||
outputs = [{item = "steel_plate", amount = 1}]
|
||||
duration_seconds = 3.0
|
||||
|
||||
[[recipe]]
|
||||
id = "copper_wire"
|
||||
building = "assembler"
|
||||
inputs = [{item = "copper_ingot", amount = 1}]
|
||||
outputs = [{item = "copper_wire", amount = 2}]
|
||||
duration_seconds = 1.0
|
||||
|
||||
[[recipe]]
|
||||
id = "copper_coil"
|
||||
building = "assembler"
|
||||
inputs = [{item = "copper_wire", amount = 2}]
|
||||
outputs = [{item = "copper_coil", amount = 1}]
|
||||
duration_seconds = 1.5
|
||||
|
||||
# Depth-3 chain (ore -> ingot -> plate -> block) is the factory's
|
||||
# doubling-time knob; see the block economy rules in progression_design.md.
|
||||
[[recipe]]
|
||||
id = "building_block"
|
||||
building = "assembler"
|
||||
inputs = [{item = "steel_plate", amount = 2}]
|
||||
outputs = [{item = "building_block", amount = 4}]
|
||||
duration_seconds = 2.0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tier 3 — mid intermediates (strange ratios begin; need quartz)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
[[recipe]]
|
||||
id = "control_chip"
|
||||
building = "assembler"
|
||||
inputs = [{item = "silicon", amount = 1}, {item = "copper_wire", amount = 2}]
|
||||
outputs = [{item = "control_chip", amount = 1}]
|
||||
duration_seconds = 5.0
|
||||
|
||||
[[recipe]]
|
||||
id = "capacitor_bank"
|
||||
building = "assembler"
|
||||
inputs = [{item = "copper_coil", amount = 2}, {item = "silicon", amount = 1}]
|
||||
outputs = [{item = "capacitor_bank", amount = 1}]
|
||||
duration_seconds = 5.0
|
||||
|
||||
# The quality gate for m+ hulls: a deliberately long-running recipe
|
||||
# (time-heavy archetype).
|
||||
[[recipe]]
|
||||
id = "hardened_steel"
|
||||
building = "assembler"
|
||||
inputs = [{item = "steel_plate", amount = 3}]
|
||||
outputs = [{item = "hardened_steel", amount = 1}]
|
||||
duration_seconds = 12.0
|
||||
|
||||
[[recipe]]
|
||||
id = "ceramic_plate"
|
||||
building = "assembler"
|
||||
inputs = [{item = "quartz", amount = 2}]
|
||||
outputs = [{item = "ceramic_plate", amount = 1}]
|
||||
duration_seconds = 4.0
|
||||
|
||||
[[recipe]]
|
||||
id = "drive_unit"
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "steel_plate", amount = 2},
|
||||
{item = "copper_coil", amount = 2},
|
||||
{item = "control_chip", amount = 1},
|
||||
]
|
||||
outputs = [{item = "drive_unit", amount = 1}]
|
||||
duration_seconds = 8.0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tier 4 — late intermediates (need voidsteel)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
[[recipe]]
|
||||
id = "voidsteel_plate"
|
||||
building = "assembler"
|
||||
inputs = [{item = "voidsteel", amount = 1}, {item = "hardened_steel", amount = 1}]
|
||||
outputs = [{item = "voidsteel_plate", amount = 1}]
|
||||
duration_seconds = 8.0
|
||||
|
||||
[[recipe]]
|
||||
id = "capital_core"
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "voidsteel", amount = 2},
|
||||
{item = "capacitor_bank", amount = 1},
|
||||
{item = "control_chip", amount = 1},
|
||||
]
|
||||
outputs = [{item = "capital_core", amount = 1}]
|
||||
duration_seconds = 10.0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Shortcut recipes — drop-only assembler recipe schematics
|
||||
# (unlock_at_station_level >= 0). Pure rewards: item threat stays defined by
|
||||
# the base (expensive) path via the max rule, so shortcuts give real factory
|
||||
# efficiency without shifting any balance.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
[[recipe]]
|
||||
id = "shortcut_steel_plate"
|
||||
unlock_at_station_level = 1
|
||||
building = "assembler"
|
||||
inputs = [{item = "iron_ore", amount = 3}]
|
||||
outputs = [{item = "steel_plate", amount = 1}]
|
||||
duration_seconds = 2.0
|
||||
|
||||
[[recipe]]
|
||||
id = "circuit_board"
|
||||
unlock_at_station_level = -1
|
||||
id = "shortcut_control_chip"
|
||||
unlock_at_station_level = 2
|
||||
building = "assembler"
|
||||
inputs = [{item = "iron_ingot", amount = 1}, {item = "copper_wire", amount = 2}]
|
||||
outputs = [{item = "circuit_board", amount = 1}]
|
||||
duration_seconds = 2.0
|
||||
inputs = [{item = "quartz", amount = 2}]
|
||||
outputs = [{item = "control_chip", amount = 1}]
|
||||
duration_seconds = 4.0
|
||||
|
||||
[[recipe]]
|
||||
id = "building_blocks"
|
||||
unlock_at_station_level = -1
|
||||
id = "shortcut_hardened_steel"
|
||||
unlock_at_station_level = 2
|
||||
building = "assembler"
|
||||
inputs = [{item = "iron_ingot", amount = 4}]
|
||||
outputs = [{item = "building_block", amount = 10}]
|
||||
duration_seconds = 4.0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Advanced components (tier 3, midgame)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
[[recipe]]
|
||||
id = "mechanical_parts"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [{item = "steel_plate", amount = 1}, {item = "iron_ingot", amount = 1}]
|
||||
outputs = [{item = "mechanical_parts", amount = 2}]
|
||||
duration_seconds = 2.5
|
||||
|
||||
[[recipe]]
|
||||
id = "targeting_unit"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [{item = "circuit_board", amount = 2}, {item = "copper_wire", amount = 1}]
|
||||
outputs = [{item = "targeting_unit", amount = 1}]
|
||||
duration_seconds = 3.0
|
||||
|
||||
[[recipe]]
|
||||
id = "drive_unit"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "steel_plate", amount = 1},
|
||||
{item = "mechanical_parts", amount = 1},
|
||||
{item = "circuit_board", amount = 1},
|
||||
]
|
||||
outputs = [{item = "drive_unit", amount = 1}]
|
||||
duration_seconds = 4.0
|
||||
|
||||
[[recipe]]
|
||||
id = "titanium_frame"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [{item = "titanium_ingot", amount = 2}, {item = "steel_plate", amount = 1}]
|
||||
outputs = [{item = "titanium_frame", amount = 1}]
|
||||
duration_seconds = 4.0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Capital components (tier 4, lategame — gated on advanced_alloy)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
[[recipe]]
|
||||
id = "reinforced_plating"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [{item = "steel_plate", amount = 2}, {item = "advanced_alloy", amount = 1}]
|
||||
outputs = [{item = "reinforced_plating", amount = 1}]
|
||||
duration_seconds = 5.0
|
||||
|
||||
[[recipe]]
|
||||
id = "capital_core"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "targeting_unit", amount = 1},
|
||||
{item = "drive_unit", amount = 1},
|
||||
{item = "advanced_alloy", amount = 2},
|
||||
]
|
||||
outputs = [{item = "capital_core", amount = 1}]
|
||||
outputs = [{item = "hardened_steel", amount = 1}]
|
||||
duration_seconds = 8.0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Module items — early game
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
[[recipe]]
|
||||
id = "laser_cannon_s_module"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [{item = "iron_ingot", amount = 2}, {item = "circuit_board", amount = 1}]
|
||||
outputs = [{item = "laser_cannon_s_module", amount = 1}]
|
||||
duration_seconds = 3.0
|
||||
|
||||
[[recipe]]
|
||||
id = "salvager_module"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [{item = "steel_plate", amount = 1}, {item = "circuit_board", amount = 1}]
|
||||
outputs = [{item = "salvager_module", amount = 1}]
|
||||
duration_seconds = 4.0
|
||||
|
||||
[[recipe]]
|
||||
id = "repair_tool_module"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [{item = "circuit_board", amount = 2}, {item = "copper_wire", amount = 1}]
|
||||
outputs = [{item = "repair_tool_module", amount = 1}]
|
||||
duration_seconds = 4.0
|
||||
|
||||
[[recipe]]
|
||||
id = "armor_plates_module"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [{item = "steel_plate", amount = 2}]
|
||||
outputs = [{item = "armor_plates_module", amount = 1}]
|
||||
duration_seconds = 3.0
|
||||
|
||||
[[recipe]]
|
||||
id = "sensor_booster_module"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [{item = "circuit_board", amount = 1}, {item = "copper_wire", amount = 2}]
|
||||
outputs = [{item = "sensor_booster_module", amount = 1}]
|
||||
duration_seconds = 3.0
|
||||
|
||||
[[recipe]]
|
||||
id = "maneuvering_thrusters_module"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [{item = "mechanical_parts", amount = 1}, {item = "copper_wire", amount = 1}]
|
||||
outputs = [{item = "maneuvering_thrusters_module", amount = 1}]
|
||||
duration_seconds = 3.0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Module items — midgame
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
[[recipe]]
|
||||
id = "afterburner_module"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [{item = "drive_unit", amount = 1}, {item = "steel_plate", amount = 1}]
|
||||
outputs = [{item = "afterburner_module", amount = 1}]
|
||||
duration_seconds = 4.0
|
||||
|
||||
[[recipe]]
|
||||
id = "weapon_upgrade_module"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [{item = "targeting_unit", amount = 1}, {item = "steel_plate", amount = 1}]
|
||||
outputs = [{item = "weapon_upgrade_module", amount = 1}]
|
||||
duration_seconds = 4.0
|
||||
|
||||
[[recipe]]
|
||||
id = "weapon_primer_module"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [{item = "targeting_unit", amount = 1}, {item = "copper_wire", amount = 2}]
|
||||
outputs = [{item = "weapon_primer_module", amount = 1}]
|
||||
duration_seconds = 4.0
|
||||
|
||||
[[recipe]]
|
||||
id = "weapon_stabilizer_module"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [{item = "targeting_unit", amount = 1}, {item = "mechanical_parts", amount = 1}]
|
||||
outputs = [{item = "weapon_stabilizer_module", amount = 1}]
|
||||
duration_seconds = 4.0
|
||||
|
||||
[[recipe]]
|
||||
id = "laser_cannon_m_module"
|
||||
unlock_at_station_level = 1
|
||||
building = "assembler"
|
||||
inputs = [{item = "targeting_unit", amount = 1}, {item = "titanium_frame", amount = 1}]
|
||||
outputs = [{item = "laser_cannon_m_module", amount = 1}]
|
||||
duration_seconds = 6.0
|
||||
|
||||
[[recipe]]
|
||||
id = "drone_bay_module"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "titanium_frame", amount = 1},
|
||||
{item = "mechanical_parts", amount = 1},
|
||||
{item = "circuit_board", amount = 1},
|
||||
]
|
||||
outputs = [{item = "drone_bay_module", amount = 1}]
|
||||
duration_seconds = 6.0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Module items — lategame
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
[[recipe]]
|
||||
id = "laser_cannon_l_module"
|
||||
unlock_at_station_level = 1
|
||||
unlock_requires = ["laser_cannon_m_module"]
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "targeting_unit", amount = 2},
|
||||
{item = "reinforced_plating", amount = 2},
|
||||
{item = "titanium_frame", amount = 1},
|
||||
]
|
||||
outputs = [{item = "laser_cannon_l_module", amount = 1}]
|
||||
duration_seconds = 12.0
|
||||
|
||||
[[recipe]]
|
||||
id = "drone_hangar_module"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "capital_core", amount = 1},
|
||||
{item = "titanium_frame", amount = 2},
|
||||
{item = "reinforced_plating", amount = 2},
|
||||
]
|
||||
outputs = [{item = "drone_hangar_module", amount = 1}]
|
||||
duration_seconds = 20.0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Ship hulls
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
[[recipe]]
|
||||
id = "drone_hull"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [{item = "iron_ingot", amount = 5}, {item = "circuit_board", amount = 1}]
|
||||
inputs = [{item = "iron_ingot", amount = 1}]
|
||||
outputs = [{item = "drone_hull", amount = 1}]
|
||||
duration_seconds = 4.0
|
||||
duration_seconds = 1.0
|
||||
|
||||
[[recipe]]
|
||||
id = "frigate_hull"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "steel_plate", amount = 3},
|
||||
{item = "mechanical_parts", amount = 1},
|
||||
{item = "circuit_board", amount = 1},
|
||||
]
|
||||
inputs = [{item = "steel_plate", amount = 2}, {item = "copper_wire", amount = 1}]
|
||||
outputs = [{item = "frigate_hull", amount = 1}]
|
||||
duration_seconds = 8.0
|
||||
duration_seconds = 2.0
|
||||
|
||||
[[recipe]]
|
||||
id = "destroyer_hull"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "steel_plate", amount = 5},
|
||||
{item = "mechanical_parts", amount = 2},
|
||||
{item = "circuit_board", amount = 1},
|
||||
]
|
||||
inputs = [{item = "steel_plate", amount = 3}, {item = "copper_coil", amount = 2}]
|
||||
outputs = [{item = "destroyer_hull", amount = 1}]
|
||||
duration_seconds = 10.0
|
||||
duration_seconds = 4.0
|
||||
|
||||
[[recipe]]
|
||||
id = "cruiser_hull"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "titanium_frame", amount = 2},
|
||||
{item = "steel_plate", amount = 4},
|
||||
{item = "drive_unit", amount = 1},
|
||||
]
|
||||
inputs = [{item = "hardened_steel", amount = 2}, {item = "control_chip", amount = 2}]
|
||||
outputs = [{item = "cruiser_hull", amount = 1}]
|
||||
duration_seconds = 15.0
|
||||
duration_seconds = 6.0
|
||||
|
||||
[[recipe]]
|
||||
id = "battlecruiser_hull"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "titanium_frame", amount = 3},
|
||||
{item = "steel_plate", amount = 6},
|
||||
{item = "hardened_steel", amount = 3},
|
||||
{item = "control_chip", amount = 2},
|
||||
{item = "drive_unit", amount = 1},
|
||||
{item = "targeting_unit", amount = 1},
|
||||
]
|
||||
outputs = [{item = "battlecruiser_hull", amount = 1}]
|
||||
duration_seconds = 20.0
|
||||
duration_seconds = 8.0
|
||||
|
||||
[[recipe]]
|
||||
id = "battleship_hull"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "titanium_frame", amount = 4},
|
||||
{item = "reinforced_plating", amount = 2},
|
||||
{item = "drive_unit", amount = 2},
|
||||
{item = "voidsteel_plate", amount = 3},
|
||||
{item = "drive_unit", amount = 1},
|
||||
{item = "control_chip", amount = 2},
|
||||
]
|
||||
outputs = [{item = "battleship_hull", amount = 1}]
|
||||
duration_seconds = 30.0
|
||||
duration_seconds = 10.0
|
||||
|
||||
[[recipe]]
|
||||
id = "dreadnought_hull"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "voidsteel_plate", amount = 5},
|
||||
{item = "capital_core", amount = 1},
|
||||
{item = "titanium_frame", amount = 6},
|
||||
{item = "reinforced_plating", amount = 4},
|
||||
{item = "drive_unit", amount = 2},
|
||||
]
|
||||
outputs = [{item = "dreadnought_hull", amount = 1}]
|
||||
duration_seconds = 60.0
|
||||
duration_seconds = 12.0
|
||||
|
||||
[[recipe]]
|
||||
id = "carrier_hull"
|
||||
unlock_at_station_level = -1
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "voidsteel_plate", amount = 5},
|
||||
{item = "capital_core", amount = 1},
|
||||
{item = "titanium_frame", amount = 5},
|
||||
{item = "reinforced_plating", amount = 3},
|
||||
{item = "drive_unit", amount = 2},
|
||||
]
|
||||
outputs = [{item = "carrier_hull", amount = 1}]
|
||||
duration_seconds = 60.0
|
||||
duration_seconds = 12.0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Module prefabs
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
[[recipe]]
|
||||
id = "railgun_s_module"
|
||||
building = "assembler"
|
||||
inputs = [{item = "copper_coil", amount = 1}]
|
||||
outputs = [{item = "railgun_s_module", amount = 1}]
|
||||
duration_seconds = 1.0
|
||||
|
||||
[[recipe]]
|
||||
id = "salvager_module"
|
||||
building = "assembler"
|
||||
inputs = [{item = "steel_plate", amount = 1}, {item = "copper_wire", amount = 2}]
|
||||
outputs = [{item = "salvager_module", amount = 1}]
|
||||
duration_seconds = 2.0
|
||||
|
||||
[[recipe]]
|
||||
id = "repair_tool_module"
|
||||
building = "assembler"
|
||||
inputs = [{item = "steel_plate", amount = 1}, {item = "copper_wire", amount = 2}]
|
||||
outputs = [{item = "repair_tool_module", amount = 1}]
|
||||
duration_seconds = 2.0
|
||||
|
||||
# Material-heavy, fast: the armor archetype.
|
||||
[[recipe]]
|
||||
id = "armor_plates_module"
|
||||
building = "assembler"
|
||||
inputs = [{item = "steel_plate", amount = 4}]
|
||||
outputs = [{item = "armor_plates_module", amount = 1}]
|
||||
duration_seconds = 3.0
|
||||
|
||||
[[recipe]]
|
||||
id = "maneuvering_thrusters_module"
|
||||
building = "assembler"
|
||||
inputs = [{item = "steel_plate", amount = 1}, {item = "copper_coil", amount = 1}]
|
||||
outputs = [{item = "maneuvering_thrusters_module", amount = 1}]
|
||||
duration_seconds = 2.0
|
||||
|
||||
[[recipe]]
|
||||
id = "sensor_booster_module"
|
||||
building = "assembler"
|
||||
inputs = [{item = "copper_wire", amount = 2}, {item = "copper_coil", amount = 1}]
|
||||
outputs = [{item = "sensor_booster_module", amount = 1}]
|
||||
duration_seconds = 2.0
|
||||
|
||||
[[recipe]]
|
||||
id = "afterburner_module"
|
||||
building = "assembler"
|
||||
inputs = [{item = "copper_coil", amount = 2}, {item = "steel_plate", amount = 1}]
|
||||
outputs = [{item = "afterburner_module", amount = 1}]
|
||||
duration_seconds = 3.0
|
||||
|
||||
[[recipe]]
|
||||
id = "weapon_stabilizer_module"
|
||||
building = "assembler"
|
||||
inputs = [{item = "steel_plate", amount = 1}, {item = "copper_coil", amount = 1}]
|
||||
outputs = [{item = "weapon_stabilizer_module", amount = 1}]
|
||||
duration_seconds = 2.0
|
||||
|
||||
[[recipe]]
|
||||
id = "weapon_primer_module"
|
||||
building = "assembler"
|
||||
inputs = [{item = "capacitor_bank", amount = 1}, {item = "copper_coil", amount = 1}]
|
||||
outputs = [{item = "weapon_primer_module", amount = 1}]
|
||||
duration_seconds = 4.0
|
||||
|
||||
[[recipe]]
|
||||
id = "weapon_upgrade_module"
|
||||
building = "assembler"
|
||||
inputs = [{item = "control_chip", amount = 1}, {item = "copper_coil", amount = 1}]
|
||||
outputs = [{item = "weapon_upgrade_module", amount = 1}]
|
||||
duration_seconds = 4.0
|
||||
|
||||
[[recipe]]
|
||||
id = "railgun_m_module"
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "capacitor_bank", amount = 1},
|
||||
{item = "steel_plate", amount = 2},
|
||||
{item = "copper_coil", amount = 1},
|
||||
]
|
||||
outputs = [{item = "railgun_m_module", amount = 1}]
|
||||
duration_seconds = 4.0
|
||||
|
||||
[[recipe]]
|
||||
id = "drone_bay_module"
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "control_chip", amount = 1},
|
||||
{item = "steel_plate", amount = 2},
|
||||
{item = "copper_coil", amount = 1},
|
||||
]
|
||||
outputs = [{item = "drone_bay_module", amount = 1}]
|
||||
duration_seconds = 4.0
|
||||
|
||||
[[recipe]]
|
||||
id = "railgun_l_module"
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "capacitor_bank", amount = 1},
|
||||
{item = "hardened_steel", amount = 2},
|
||||
{item = "ceramic_plate", amount = 1},
|
||||
]
|
||||
outputs = [{item = "railgun_l_module", amount = 1}]
|
||||
duration_seconds = 6.0
|
||||
|
||||
[[recipe]]
|
||||
id = "drone_hangar_module"
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "voidsteel_plate", amount = 1},
|
||||
{item = "control_chip", amount = 2},
|
||||
{item = "drive_unit", amount = 1},
|
||||
]
|
||||
outputs = [{item = "drone_hangar_module", amount = 1}]
|
||||
duration_seconds = 10.0
|
||||
|
||||
@@ -24,21 +24,21 @@
|
||||
id = "drone"
|
||||
unlock_at_station_level = -1
|
||||
layout = ["O"]
|
||||
default_modules = [{type = "laser_cannon_s", x = 0, y = 0, rotation = "east"}]
|
||||
default_modules = [{type = "railgun_s", x = 0, y = 0, rotation = "east"}]
|
||||
|
||||
[ship.schematic]
|
||||
materials = [{item = "iron_ore", amount = 1}]
|
||||
production_time_seconds = 5
|
||||
materials = [{item = "drone_hull", amount = 1}]
|
||||
production_time_seconds = 1
|
||||
|
||||
[ship.health]
|
||||
hp = 3
|
||||
hp = 60
|
||||
|
||||
[ship.movement]
|
||||
speed_mps = 40
|
||||
main_acceleration_mpss = 2
|
||||
maneuvering_acceleration_mpss = 1
|
||||
angular_acceleration_radpss = 12.56
|
||||
max_rotation_speed_radps = 6.28
|
||||
speed_mps = 45
|
||||
main_acceleration_mpss = 60
|
||||
maneuvering_acceleration_mpss = 30
|
||||
angular_acceleration_radpss = 12
|
||||
max_rotation_speed_radps = 6
|
||||
|
||||
[ship.sensor]
|
||||
sensor_range_m = 150
|
||||
@@ -55,18 +55,23 @@ layout = [
|
||||
"OOO",
|
||||
"XOX",
|
||||
]
|
||||
default_modules = [
|
||||
{type = "railgun_s", x = 1, y = 0, rotation = "east"},
|
||||
{type = "railgun_s", x = 2, y = 1, rotation = "east"},
|
||||
{type = "maneuvering_thrusters", x = 0, y = 1, rotation = "east"},
|
||||
]
|
||||
|
||||
[ship.schematic]
|
||||
materials = [{item = "frigate_hull", amount = 1}]
|
||||
production_time_seconds = 10
|
||||
production_time_seconds = 2
|
||||
|
||||
[ship.health]
|
||||
hp = 30
|
||||
hp = 300
|
||||
|
||||
[ship.movement]
|
||||
speed_mps = 30
|
||||
main_acceleration_mpss = 50
|
||||
maneuvering_acceleration_mpss = 25
|
||||
speed_mps = 35
|
||||
main_acceleration_mpss = 45
|
||||
maneuvering_acceleration_mpss = 22
|
||||
angular_acceleration_radpss = 8
|
||||
max_rotation_speed_radps = 4
|
||||
|
||||
@@ -84,18 +89,25 @@ layout = [
|
||||
"OXOXO",
|
||||
"OOOOO",
|
||||
]
|
||||
default_modules = [
|
||||
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
|
||||
{type = "railgun_s", x = 2, y = 0, rotation = "east"},
|
||||
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
|
||||
{type = "armor_plates", x = 0, y = 1, rotation = "east"},
|
||||
{type = "sensor_booster", x = 3, y = 1, rotation = "east"},
|
||||
]
|
||||
|
||||
[ship.schematic]
|
||||
materials = [{item = "destroyer_hull", amount = 1}]
|
||||
production_time_seconds = 15
|
||||
production_time_seconds = 3
|
||||
|
||||
[ship.health]
|
||||
hp = 50
|
||||
hp = 550
|
||||
|
||||
[ship.movement]
|
||||
speed_mps = 25
|
||||
main_acceleration_mpss = 40
|
||||
maneuvering_acceleration_mpss = 20
|
||||
speed_mps = 30
|
||||
main_acceleration_mpss = 35
|
||||
maneuvering_acceleration_mpss = 18
|
||||
angular_acceleration_radpss = 6
|
||||
max_rotation_speed_radps = 3
|
||||
|
||||
@@ -115,18 +127,24 @@ layout = [
|
||||
"OOOO",
|
||||
"XOOX",
|
||||
]
|
||||
default_modules = [
|
||||
{type = "railgun_m", x = 0, y = 1, rotation = "east"},
|
||||
{type = "railgun_m", x = 2, y = 1, rotation = "east"},
|
||||
{type = "armor_plates", x = 1, y = 0, rotation = "east"},
|
||||
{type = "maneuvering_thrusters", x = 1, y = 3, rotation = "east"},
|
||||
]
|
||||
|
||||
[ship.schematic]
|
||||
materials = [{item = "cruiser_hull", amount = 1}]
|
||||
production_time_seconds = 25
|
||||
production_time_seconds = 4
|
||||
|
||||
[ship.health]
|
||||
hp = 120
|
||||
hp = 1500
|
||||
|
||||
[ship.movement]
|
||||
speed_mps = 20
|
||||
main_acceleration_mpss = 30
|
||||
maneuvering_acceleration_mpss = 15
|
||||
speed_mps = 24
|
||||
main_acceleration_mpss = 25
|
||||
maneuvering_acceleration_mpss = 12
|
||||
angular_acceleration_radpss = 4
|
||||
max_rotation_speed_radps = 2
|
||||
|
||||
@@ -147,18 +165,26 @@ layout = [
|
||||
"XOOOOX",
|
||||
"XXOOXX",
|
||||
]
|
||||
default_modules = [
|
||||
{type = "railgun_m", x = 0, y = 0, rotation = "east"},
|
||||
{type = "railgun_m", x = 4, y = 0, rotation = "east"},
|
||||
{type = "railgun_m", x = 2, y = 1, rotation = "east"},
|
||||
{type = "armor_plates", x = 2, y = 3, rotation = "east"},
|
||||
{type = "railgun_s", x = 1, y = 2, rotation = "east"},
|
||||
{type = "railgun_s", x = 4, y = 2, rotation = "east"},
|
||||
]
|
||||
|
||||
[ship.schematic]
|
||||
materials = [{item = "battlecruiser_hull", amount = 1}]
|
||||
production_time_seconds = 35
|
||||
production_time_seconds = 5
|
||||
|
||||
[ship.health]
|
||||
hp = 180
|
||||
hp = 2200
|
||||
|
||||
[ship.movement]
|
||||
speed_mps = 18
|
||||
main_acceleration_mpss = 25
|
||||
maneuvering_acceleration_mpss = 12
|
||||
speed_mps = 20
|
||||
main_acceleration_mpss = 20
|
||||
maneuvering_acceleration_mpss = 10
|
||||
angular_acceleration_radpss = 3
|
||||
max_rotation_speed_radps = 1.5
|
||||
|
||||
@@ -182,18 +208,26 @@ layout = [
|
||||
"OOOOOO",
|
||||
"XOOOOX",
|
||||
]
|
||||
default_modules = [
|
||||
{type = "railgun_l", x = 1, y = 0, rotation = "east"},
|
||||
{type = "railgun_m", x = 1, y = 3, rotation = "east"},
|
||||
{type = "railgun_m", x = 3, y = 3, rotation = "east"},
|
||||
{type = "weapon_stabilizer", x = 4, y = 1, rotation = "east"},
|
||||
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
|
||||
{type = "railgun_s", x = 0, y = 1, rotation = "east"},
|
||||
]
|
||||
|
||||
[ship.schematic]
|
||||
materials = [{item = "battleship_hull", amount = 1}]
|
||||
production_time_seconds = 60
|
||||
production_time_seconds = 6
|
||||
|
||||
[ship.health]
|
||||
hp = 350
|
||||
hp = 6300
|
||||
|
||||
[ship.movement]
|
||||
speed_mps = 14
|
||||
main_acceleration_mpss = 18
|
||||
maneuvering_acceleration_mpss = 8
|
||||
speed_mps = 15
|
||||
main_acceleration_mpss = 14
|
||||
maneuvering_acceleration_mpss = 7
|
||||
angular_acceleration_radpss = 2
|
||||
max_rotation_speed_radps = 1
|
||||
|
||||
@@ -217,18 +251,28 @@ layout = [
|
||||
"OOOXOOOXOOO",
|
||||
"XXOOXXXOOXX",
|
||||
]
|
||||
default_modules = [
|
||||
{type = "railgun_l", x = 0, y = 1, rotation = "east"},
|
||||
{type = "railgun_l", x = 4, y = 1, rotation = "east"},
|
||||
{type = "railgun_l", x = 8, y = 1, rotation = "east"},
|
||||
{type = "armor_plates", x = 3, y = 0, rotation = "east"},
|
||||
{type = "armor_plates", x = 5, y = 0, rotation = "east"},
|
||||
{type = "armor_plates", x = 2, y = 4, rotation = "east"},
|
||||
{type = "armor_plates", x = 7, y = 4, rotation = "east"},
|
||||
{type = "railgun_s", x = 7, y = 0, rotation = "east"},
|
||||
]
|
||||
|
||||
[ship.schematic]
|
||||
materials = [{item = "dreadnought_hull", amount = 1}]
|
||||
production_time_seconds = 120
|
||||
production_time_seconds = 8
|
||||
|
||||
[ship.health]
|
||||
hp = 800
|
||||
hp = 24000
|
||||
|
||||
[ship.movement]
|
||||
speed_mps = 8
|
||||
main_acceleration_mpss = 10
|
||||
maneuvering_acceleration_mpss = 5
|
||||
speed_mps = 10
|
||||
main_acceleration_mpss = 8
|
||||
maneuvering_acceleration_mpss = 4
|
||||
angular_acceleration_radpss = 1
|
||||
max_rotation_speed_radps = 0.5
|
||||
|
||||
@@ -250,18 +294,26 @@ layout = [
|
||||
"XOOOOOOOOX",
|
||||
"XXXOOOOXXX",
|
||||
]
|
||||
default_modules = [
|
||||
{type = "drone_hangar", x = 2, y = 0, rotation = "east"},
|
||||
{type = "railgun_m", x = 3, y = 2, rotation = "east"},
|
||||
{type = "railgun_m", x = 6, y = 2, rotation = "east"},
|
||||
{type = "armor_plates", x = 0, y = 1, rotation = "east"},
|
||||
{type = "armor_plates", x = 8, y = 1, rotation = "east"},
|
||||
{type = "sensor_booster", x = 3, y = 4, rotation = "east"},
|
||||
]
|
||||
|
||||
[ship.schematic]
|
||||
materials = [{item = "carrier_hull", amount = 1}]
|
||||
production_time_seconds = 120
|
||||
production_time_seconds = 8
|
||||
|
||||
[ship.health]
|
||||
hp = 700
|
||||
hp = 24000
|
||||
|
||||
[ship.movement]
|
||||
speed_mps = 9
|
||||
main_acceleration_mpss = 10
|
||||
maneuvering_acceleration_mpss = 5
|
||||
speed_mps = 10
|
||||
main_acceleration_mpss = 8
|
||||
maneuvering_acceleration_mpss = 4
|
||||
angular_acceleration_radpss = 1
|
||||
max_rotation_speed_radps = 0.5
|
||||
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
# stations.toml
|
||||
#
|
||||
# Combat-pass anchors (see docs/content_design.md, "Combat stat anchors"):
|
||||
# a fresh player defence station holds one early parity wave unaided; the
|
||||
# enemy station at level 0 matches the player station exactly and scales
|
||||
# with the push level x. Station scrap drops stay authored (pushing rewards
|
||||
# are tuned independently of ship production costs, REQ-RES-SCRAP-DROP).
|
||||
|
||||
[hq]
|
||||
surface_mask = [
|
||||
"AAA",
|
||||
"AAA",
|
||||
"AAA",
|
||||
]
|
||||
hp_formula = "1000"
|
||||
hp_formula = "5000"
|
||||
|
||||
[player_station]
|
||||
surface_mask = [
|
||||
@@ -12,19 +20,19 @@ surface_mask = [
|
||||
"SS",
|
||||
]
|
||||
level = 1
|
||||
hp_formula = "300"
|
||||
damage_formula = "5"
|
||||
range_m_formula = "200"
|
||||
hp_formula = "3000"
|
||||
damage_formula = "25"
|
||||
range_m_formula = "120"
|
||||
fire_rate_hz_formula = "1"
|
||||
scrap_drop_formula = "10"
|
||||
scrap_drop_formula = "40"
|
||||
|
||||
[enemy_station]
|
||||
surface_mask = [
|
||||
"SS",
|
||||
"SS",
|
||||
]
|
||||
hp_formula = "300 + 150*x"
|
||||
damage_formula = "2 + 1*x"
|
||||
range_m_formula = "200"
|
||||
fire_rate_hz_formula = "1.0 + 0.2*x"
|
||||
scrap_drop_formula = "10 + 5*x"
|
||||
hp_formula = "3000 + 1500*x"
|
||||
damage_formula = "25 + 12*x"
|
||||
range_m_formula = "120"
|
||||
fire_rate_hz_formula = "1.0 + 0.1*x"
|
||||
scrap_drop_formula = "40 + 30*x"
|
||||
|
||||
@@ -116,11 +116,11 @@ outline = "#201010"
|
||||
fill = "#c47a3a"
|
||||
outline = "#3a1a0a"
|
||||
|
||||
[items.titanium_ore]
|
||||
fill = "#9aa3ad"
|
||||
outline = "#2a2e33"
|
||||
[items.quartz]
|
||||
fill = "#e0d4f0"
|
||||
outline = "#40345a"
|
||||
|
||||
# --- ingots ---
|
||||
# --- smelted basics ---
|
||||
|
||||
[items.iron_ingot]
|
||||
fill = "#b0b0b8"
|
||||
@@ -130,9 +130,9 @@ outline = "#202028"
|
||||
fill = "#d48a4a"
|
||||
outline = "#402010"
|
||||
|
||||
[items.titanium_ingot]
|
||||
fill = "#c8d2dc"
|
||||
outline = "#3a4048"
|
||||
[items.silicon]
|
||||
fill = "#33415e"
|
||||
outline = "#0e1420"
|
||||
|
||||
# --- salvage loop ---
|
||||
|
||||
@@ -140,9 +140,9 @@ outline = "#3a4048"
|
||||
fill = "#7a7268"
|
||||
outline = "#201a14"
|
||||
|
||||
[items.advanced_alloy]
|
||||
fill = "#a06acc"
|
||||
outline = "#201030"
|
||||
[items.voidsteel]
|
||||
fill = "#4a3a6a"
|
||||
outline = "#151020"
|
||||
|
||||
# --- basic components ---
|
||||
|
||||
@@ -154,9 +154,9 @@ outline = "#3a2008"
|
||||
fill = "#8a92a0"
|
||||
outline = "#22262c"
|
||||
|
||||
[items.circuit_board]
|
||||
fill = "#2ea35a"
|
||||
outline = "#0a2a14"
|
||||
[items.copper_coil]
|
||||
fill = "#d07030"
|
||||
outline = "#381808"
|
||||
|
||||
[items.building_block]
|
||||
fill = "#c8b070"
|
||||
@@ -164,26 +164,30 @@ outline = "#302810"
|
||||
|
||||
# --- advanced components ---
|
||||
|
||||
[items.mechanical_parts]
|
||||
fill = "#6f7a66"
|
||||
outline = "#1c2018"
|
||||
[items.control_chip]
|
||||
fill = "#2ea35a"
|
||||
outline = "#0a2a14"
|
||||
|
||||
[items.targeting_unit]
|
||||
fill = "#3a9e8c"
|
||||
outline = "#0c2824"
|
||||
[items.capacitor_bank]
|
||||
fill = "#d0a030"
|
||||
outline = "#302408"
|
||||
|
||||
[items.hardened_steel]
|
||||
fill = "#6a7280"
|
||||
outline = "#181c22"
|
||||
|
||||
[items.ceramic_plate]
|
||||
fill = "#e0d8c8"
|
||||
outline = "#3a3428"
|
||||
|
||||
[items.drive_unit]
|
||||
fill = "#4a6ad0"
|
||||
outline = "#101a38"
|
||||
|
||||
[items.titanium_frame]
|
||||
fill = "#b8c4d4"
|
||||
outline = "#343c48"
|
||||
|
||||
# --- capital components ---
|
||||
|
||||
[items.reinforced_plating]
|
||||
fill = "#8a6ad0"
|
||||
[items.voidsteel_plate]
|
||||
fill = "#7a5aaa"
|
||||
outline = "#1c1038"
|
||||
|
||||
[items.capital_core]
|
||||
@@ -192,15 +196,15 @@ outline = "#280c30"
|
||||
|
||||
# --- module items ---
|
||||
|
||||
[items.laser_cannon_s_module]
|
||||
[items.railgun_s_module]
|
||||
fill = "#691313"
|
||||
outline = "#f3ff4f"
|
||||
|
||||
[items.laser_cannon_m_module]
|
||||
[items.railgun_m_module]
|
||||
fill = "#892020"
|
||||
outline = "#f3ff4f"
|
||||
|
||||
[items.laser_cannon_l_module]
|
||||
[items.railgun_l_module]
|
||||
fill = "#a92d2d"
|
||||
outline = "#f3ff4f"
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
height_tiles = 40
|
||||
refund_percentage = 100
|
||||
starting_building_blocks = 1000
|
||||
scrap_despawn_seconds = 30
|
||||
scrap_per_threat = 0.01
|
||||
scrap_despawn_seconds = 120
|
||||
scrap_per_threat = 0.25
|
||||
tile_size_m = 10
|
||||
belt_speed_mps = 20
|
||||
tunnel_max_distance_tiles = 10
|
||||
|
||||
@@ -1,68 +1,397 @@
|
||||
# balancing.toml — canonical arena suite for the combat stats pass.
|
||||
#
|
||||
# Ship counts are chosen so both teams have (near-)equal total threat,
|
||||
# using the verified fitted threat values from tools/threat_report.py:
|
||||
# drone 10.5, frigate 47, destroyer 99, cruiser 233.5,
|
||||
# battlecruiser 354.5, battleship 722.5, dreadnought 1491.5,
|
||||
# carrier 1436.5, glass destroyer (8 small guns) 92, repair drone 17.
|
||||
# Module arrays mirror the ships' default_modules loadouts unless a
|
||||
# doctrine variant is the point of the arena.
|
||||
#
|
||||
# Expectations: mirror matches and equal-threat cross-tier matchups should
|
||||
# be near-draws (power-per-threat rule); the two-to-one arena must be a
|
||||
# decisive win for the larger team. "Cruisers vs carrier" is EXPECTED to be
|
||||
# a loss for the carrier until the drone-launching capability exists — the
|
||||
# hangar is 224 threat of dead weight.
|
||||
|
||||
# --- mirrors (sanity: symmetric outcomes, fight duration in the 30-60 s band) ---
|
||||
|
||||
[[arena]]
|
||||
name = "Fighters vs Sniper"
|
||||
height_tiles = 20
|
||||
name = "Mirror: drones 20v20"
|
||||
height_tiles = 10
|
||||
player_buffer_width_tiles = 10
|
||||
contest_zone_width_tiles = 60
|
||||
contest_zone_width_tiles = 50
|
||||
enemy_buffer_width_tiles = 10
|
||||
|
||||
[[arena.team]]
|
||||
name = "Alpha"
|
||||
[[arena.team.ship]]
|
||||
schematic = "drone"
|
||||
count = 5
|
||||
count = 20
|
||||
modules = [
|
||||
{type = "laser_cannon_s", x = 1, y = 1, rotation = "east"},
|
||||
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena.team]]
|
||||
name = "Beta"
|
||||
[[arena.team.ship]]
|
||||
schematic = "drone"
|
||||
count = 2
|
||||
count = 20
|
||||
modules = [
|
||||
{type = "laser_cannon_s", x = 1, y = 1, rotation = "east"},
|
||||
{type = "weapon_stabilizer", x = 1, y = 1, rotation = "east"},
|
||||
{type = "weapon_stabilizer", x = 1, y = 1, rotation = "east"},
|
||||
{type = "weapon_upgrade", x = 1, y = 1, rotation = "east"},
|
||||
{type = "sensor_booster", x = 1, y = 1, rotation = "east"},
|
||||
{type = "sensor_booster", x = 1, y = 1, rotation = "east"},
|
||||
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena]]
|
||||
name = "Fighters vs Supported"
|
||||
height_tiles = 20
|
||||
name = "Mirror: cruisers 6v6"
|
||||
height_tiles = 10
|
||||
player_buffer_width_tiles = 10
|
||||
contest_zone_width_tiles = 60
|
||||
contest_zone_width_tiles = 50
|
||||
enemy_buffer_width_tiles = 10
|
||||
|
||||
[[arena.team]]
|
||||
name = "Fighters"
|
||||
name = "Alpha"
|
||||
[[arena.team.ship]]
|
||||
schematic = "drone"
|
||||
count = 5
|
||||
schematic = "cruiser"
|
||||
count = 6
|
||||
modules = [
|
||||
{type = "laser_cannon_s", x = 1, y = 1, rotation = "east"},
|
||||
{type = "railgun_m", x = 0, y = 1, rotation = "east"},
|
||||
{type = "railgun_m", x = 2, y = 1, rotation = "east"},
|
||||
{type = "armor_plates", x = 1, y = 0, rotation = "east"},
|
||||
{type = "maneuvering_thrusters", x = 1, y = 3, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena.team]]
|
||||
name = "Supported"
|
||||
name = "Beta"
|
||||
[[arena.team.ship]]
|
||||
schematic = "drone"
|
||||
count = 3
|
||||
schematic = "cruiser"
|
||||
count = 6
|
||||
modules = [
|
||||
{type = "laser_cannon_s", x = 1, y = 1, rotation = "east"},
|
||||
{type = "railgun_m", x = 0, y = 1, rotation = "east"},
|
||||
{type = "railgun_m", x = 2, y = 1, rotation = "east"},
|
||||
{type = "armor_plates", x = 1, y = 0, rotation = "east"},
|
||||
{type = "maneuvering_thrusters", x = 1, y = 3, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena]]
|
||||
name = "Mirror: battleships 2v2"
|
||||
height_tiles = 10
|
||||
player_buffer_width_tiles = 10
|
||||
contest_zone_width_tiles = 50
|
||||
enemy_buffer_width_tiles = 10
|
||||
|
||||
[[arena.team]]
|
||||
name = "Alpha"
|
||||
[[arena.team.ship]]
|
||||
schematic = "battleship"
|
||||
count = 2
|
||||
modules = [
|
||||
{type = "railgun_l", x = 1, y = 0, rotation = "east"},
|
||||
{type = "railgun_m", x = 1, y = 3, rotation = "east"},
|
||||
{type = "railgun_m", x = 3, y = 3, rotation = "east"},
|
||||
{type = "weapon_stabilizer", x = 4, y = 1, rotation = "east"},
|
||||
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
|
||||
{type = "railgun_s", x = 0, y = 1, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena.team]]
|
||||
name = "Beta"
|
||||
[[arena.team.ship]]
|
||||
schematic = "battleship"
|
||||
count = 2
|
||||
modules = [
|
||||
{type = "railgun_l", x = 1, y = 0, rotation = "east"},
|
||||
{type = "railgun_m", x = 1, y = 3, rotation = "east"},
|
||||
{type = "railgun_m", x = 3, y = 3, rotation = "east"},
|
||||
{type = "weapon_stabilizer", x = 4, y = 1, rotation = "east"},
|
||||
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
|
||||
{type = "railgun_s", x = 0, y = 1, rotation = "east"},
|
||||
]
|
||||
|
||||
# --- equal-threat cross-tier matchups (power-per-threat: expect near-draws) ---
|
||||
|
||||
[[arena]]
|
||||
name = "Drone swarm vs cruisers (462 vs 467)"
|
||||
height_tiles = 10
|
||||
player_buffer_width_tiles = 10
|
||||
contest_zone_width_tiles = 50
|
||||
enemy_buffer_width_tiles = 10
|
||||
|
||||
[[arena.team]]
|
||||
name = "Swarm"
|
||||
[[arena.team.ship]]
|
||||
schematic = "drone"
|
||||
count = 44
|
||||
modules = [
|
||||
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena.team]]
|
||||
name = "Cruisers"
|
||||
[[arena.team.ship]]
|
||||
schematic = "cruiser"
|
||||
count = 2
|
||||
modules = [
|
||||
{type = "railgun_m", x = 0, y = 1, rotation = "east"},
|
||||
{type = "railgun_m", x = 2, y = 1, rotation = "east"},
|
||||
{type = "armor_plates", x = 1, y = 0, rotation = "east"},
|
||||
{type = "maneuvering_thrusters", x = 1, y = 3, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena]]
|
||||
name = "Frigates vs battleship (705 vs 723)"
|
||||
height_tiles = 10
|
||||
player_buffer_width_tiles = 10
|
||||
contest_zone_width_tiles = 50
|
||||
enemy_buffer_width_tiles = 10
|
||||
|
||||
[[arena.team]]
|
||||
name = "Frigates"
|
||||
[[arena.team.ship]]
|
||||
schematic = "frigate"
|
||||
count = 15
|
||||
modules = [
|
||||
{type = "railgun_s", x = 1, y = 0, rotation = "east"},
|
||||
{type = "railgun_s", x = 2, y = 1, rotation = "east"},
|
||||
{type = "maneuvering_thrusters", x = 0, y = 1, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena.team]]
|
||||
name = "Battleship"
|
||||
[[arena.team.ship]]
|
||||
schematic = "battleship"
|
||||
count = 1
|
||||
modules = [
|
||||
{type = "railgun_l", x = 1, y = 0, rotation = "east"},
|
||||
{type = "railgun_m", x = 1, y = 3, rotation = "east"},
|
||||
{type = "railgun_m", x = 3, y = 3, rotation = "east"},
|
||||
{type = "weapon_stabilizer", x = 4, y = 1, rotation = "east"},
|
||||
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
|
||||
{type = "railgun_s", x = 0, y = 1, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena]]
|
||||
name = "Destroyers vs dreadnought (1485 vs 1492)"
|
||||
height_tiles = 10
|
||||
player_buffer_width_tiles = 10
|
||||
contest_zone_width_tiles = 50
|
||||
enemy_buffer_width_tiles = 10
|
||||
|
||||
[[arena.team]]
|
||||
name = "Destroyers"
|
||||
[[arena.team.ship]]
|
||||
schematic = "destroyer"
|
||||
count = 15
|
||||
modules = [
|
||||
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
|
||||
{type = "railgun_s", x = 2, y = 0, rotation = "east"},
|
||||
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
|
||||
{type = "armor_plates", x = 0, y = 1, rotation = "east"},
|
||||
{type = "sensor_booster", x = 3, y = 1, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena.team]]
|
||||
name = "Dreadnought"
|
||||
[[arena.team.ship]]
|
||||
schematic = "dreadnought"
|
||||
count = 1
|
||||
modules = [
|
||||
{type = "railgun_l", x = 0, y = 1, rotation = "east"},
|
||||
{type = "railgun_l", x = 4, y = 1, rotation = "east"},
|
||||
{type = "railgun_l", x = 8, y = 1, rotation = "east"},
|
||||
{type = "armor_plates", x = 3, y = 0, rotation = "east"},
|
||||
{type = "armor_plates", x = 5, y = 0, rotation = "east"},
|
||||
{type = "armor_plates", x = 2, y = 4, rotation = "east"},
|
||||
{type = "armor_plates", x = 7, y = 4, rotation = "east"},
|
||||
{type = "railgun_s", x = 7, y = 0, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena]]
|
||||
name = "Cruisers vs carrier (1401 vs 1437, carrier expected to lose)"
|
||||
height_tiles = 10
|
||||
player_buffer_width_tiles = 10
|
||||
contest_zone_width_tiles = 50
|
||||
enemy_buffer_width_tiles = 10
|
||||
|
||||
[[arena.team]]
|
||||
name = "Cruisers"
|
||||
[[arena.team.ship]]
|
||||
schematic = "cruiser"
|
||||
count = 6
|
||||
modules = [
|
||||
{type = "railgun_m", x = 0, y = 1, rotation = "east"},
|
||||
{type = "railgun_m", x = 2, y = 1, rotation = "east"},
|
||||
{type = "armor_plates", x = 1, y = 0, rotation = "east"},
|
||||
{type = "maneuvering_thrusters", x = 1, y = 3, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena.team]]
|
||||
name = "Carrier"
|
||||
[[arena.team.ship]]
|
||||
schematic = "carrier"
|
||||
count = 1
|
||||
modules = [
|
||||
{type = "drone_hangar", x = 2, y = 0, rotation = "east"},
|
||||
{type = "railgun_m", x = 3, y = 2, rotation = "east"},
|
||||
{type = "railgun_m", x = 6, y = 2, rotation = "east"},
|
||||
{type = "armor_plates", x = 0, y = 1, rotation = "east"},
|
||||
{type = "armor_plates", x = 8, y = 1, rotation = "east"},
|
||||
{type = "sensor_booster", x = 3, y = 4, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena]]
|
||||
name = "Mixed mid vs battlecruisers (1394 vs 1418)"
|
||||
height_tiles = 10
|
||||
player_buffer_width_tiles = 10
|
||||
contest_zone_width_tiles = 50
|
||||
enemy_buffer_width_tiles = 10
|
||||
|
||||
[[arena.team]]
|
||||
name = "Mixed"
|
||||
[[arena.team.ship]]
|
||||
schematic = "destroyer"
|
||||
count = 7
|
||||
modules = [
|
||||
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
|
||||
{type = "railgun_s", x = 2, y = 0, rotation = "east"},
|
||||
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
|
||||
{type = "armor_plates", x = 0, y = 1, rotation = "east"},
|
||||
{type = "sensor_booster", x = 3, y = 1, rotation = "east"},
|
||||
]
|
||||
[[arena.team.ship]]
|
||||
schematic = "cruiser"
|
||||
count = 3
|
||||
modules = [
|
||||
{type = "railgun_m", x = 0, y = 1, rotation = "east"},
|
||||
{type = "railgun_m", x = 2, y = 1, rotation = "east"},
|
||||
{type = "armor_plates", x = 1, y = 0, rotation = "east"},
|
||||
{type = "maneuvering_thrusters", x = 1, y = 3, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena.team]]
|
||||
name = "Battlecruisers"
|
||||
[[arena.team.ship]]
|
||||
schematic = "battlecruiser"
|
||||
count = 4
|
||||
modules = [
|
||||
{type = "railgun_m", x = 0, y = 0, rotation = "east"},
|
||||
{type = "railgun_m", x = 4, y = 0, rotation = "east"},
|
||||
{type = "railgun_m", x = 2, y = 1, rotation = "east"},
|
||||
{type = "armor_plates", x = 2, y = 3, rotation = "east"},
|
||||
{type = "railgun_s", x = 1, y = 2, rotation = "east"},
|
||||
{type = "railgun_s", x = 4, y = 2, rotation = "east"},
|
||||
]
|
||||
|
||||
# --- asymmetric checks ---
|
||||
|
||||
[[arena]]
|
||||
name = "Two to one (must be decisive)"
|
||||
height_tiles = 10
|
||||
player_buffer_width_tiles = 10
|
||||
contest_zone_width_tiles = 50
|
||||
enemy_buffer_width_tiles = 10
|
||||
|
||||
[[arena.team]]
|
||||
name = "Six"
|
||||
[[arena.team.ship]]
|
||||
schematic = "frigate"
|
||||
count = 6
|
||||
modules = [
|
||||
{type = "railgun_s", x = 1, y = 0, rotation = "east"},
|
||||
{type = "railgun_s", x = 2, y = 1, rotation = "east"},
|
||||
{type = "maneuvering_thrusters", x = 0, y = 1, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena.team]]
|
||||
name = "Three"
|
||||
[[arena.team.ship]]
|
||||
schematic = "frigate"
|
||||
count = 3
|
||||
modules = [
|
||||
{type = "railgun_s", x = 1, y = 0, rotation = "east"},
|
||||
{type = "railgun_s", x = 2, y = 1, rotation = "east"},
|
||||
{type = "maneuvering_thrusters", x = 0, y = 1, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena]]
|
||||
name = "Armored vs glass destroyers (1188 vs 1196)"
|
||||
height_tiles = 10
|
||||
player_buffer_width_tiles = 10
|
||||
contest_zone_width_tiles = 50
|
||||
enemy_buffer_width_tiles = 10
|
||||
|
||||
[[arena.team]]
|
||||
name = "Armored"
|
||||
[[arena.team.ship]]
|
||||
schematic = "destroyer"
|
||||
count = 12
|
||||
modules = [
|
||||
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
|
||||
{type = "railgun_s", x = 2, y = 0, rotation = "east"},
|
||||
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
|
||||
{type = "armor_plates", x = 0, y = 1, rotation = "east"},
|
||||
{type = "sensor_booster", x = 3, y = 1, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena.team]]
|
||||
name = "Glass"
|
||||
[[arena.team.ship]]
|
||||
schematic = "destroyer"
|
||||
count = 13
|
||||
modules = [
|
||||
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
|
||||
{type = "railgun_s", x = 2, y = 0, rotation = "east"},
|
||||
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
|
||||
{type = "railgun_s", x = 0, y = 1, rotation = "east"},
|
||||
{type = "railgun_s", x = 1, y = 1, rotation = "east"},
|
||||
{type = "railgun_s", x = 2, y = 1, rotation = "east"},
|
||||
{type = "railgun_s", x = 3, y = 1, rotation = "east"},
|
||||
{type = "railgun_s", x = 4, y = 1, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena]]
|
||||
name = "Repair escort vs raw numbers (444 vs 444)"
|
||||
height_tiles = 10
|
||||
player_buffer_width_tiles = 10
|
||||
contest_zone_width_tiles = 50
|
||||
enemy_buffer_width_tiles = 10
|
||||
|
||||
[[arena.team]]
|
||||
name = "Escorted"
|
||||
[[arena.team.ship]]
|
||||
schematic = "frigate"
|
||||
count = 8
|
||||
modules = [
|
||||
{type = "railgun_s", x = 1, y = 0, rotation = "east"},
|
||||
{type = "railgun_s", x = 2, y = 1, rotation = "east"},
|
||||
{type = "maneuvering_thrusters", x = 0, y = 1, rotation = "east"},
|
||||
]
|
||||
[[arena.team.ship]]
|
||||
schematic = "drone"
|
||||
count = 4
|
||||
modules = [
|
||||
{type = "repair_tool", x = 0, y = 0, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena.team]]
|
||||
name = "Raw"
|
||||
[[arena.team.ship]]
|
||||
schematic = "frigate"
|
||||
count = 9
|
||||
modules = [
|
||||
{type = "railgun_s", x = 1, y = 0, rotation = "east"},
|
||||
{type = "railgun_s", x = 2, y = 1, rotation = "east"},
|
||||
{type = "maneuvering_thrusters", x = 0, y = 1, rotation = "east"},
|
||||
]
|
||||
[[arena.team.ship]]
|
||||
schematic = "drone"
|
||||
count = 2
|
||||
modules = [
|
||||
{type = "repair_tool", x = 1, y = 1, rotation = "east"},
|
||||
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
|
||||
]
|
||||
|
||||
[[arena]]
|
||||
name = "Stations and Ships"
|
||||
height_tiles = 60
|
||||
name = "Station assault (2 stations + 105 vs 315)"
|
||||
height_tiles = 10
|
||||
player_buffer_width_tiles = 15
|
||||
contest_zone_width_tiles = 40
|
||||
enemy_buffer_width_tiles = 15
|
||||
@@ -71,9 +400,9 @@ enemy_buffer_width_tiles = 15
|
||||
name = "Fortified"
|
||||
[[arena.team.ship]]
|
||||
schematic = "drone"
|
||||
count = 3
|
||||
count = 10
|
||||
modules = [
|
||||
{type = "laser_cannon_s", x = 1, y = 1, rotation = "east"},
|
||||
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
|
||||
]
|
||||
[[arena.team.station]]
|
||||
type = "player_station"
|
||||
@@ -90,7 +419,7 @@ enemy_buffer_width_tiles = 15
|
||||
name = "Swarm"
|
||||
[[arena.team.ship]]
|
||||
schematic = "drone"
|
||||
count = 8
|
||||
count = 30
|
||||
modules = [
|
||||
{type = "laser_cannon_s", x = 1, y = 1, rotation = "east"},
|
||||
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
|
||||
]
|
||||
|
||||
@@ -92,3 +92,60 @@ duration_seconds = 3.0
|
||||
item = "advanced_alloy"
|
||||
amount = 1
|
||||
probability = 0.1
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Extra recipes for ThreatCostCalculator unit tests (fixes 6-9)
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
# Fix 6: scrap-consuming smelter recipe for iron_ingot. Because iron_ingot
|
||||
# already has a scrap-free smelter recipe above, this recipe must be excluded
|
||||
# from iron_ingot's threat computation.
|
||||
[[recipe]]
|
||||
id = "scrap_iron"
|
||||
building = "smelter"
|
||||
inputs = [{item = "scrap", amount = 1}]
|
||||
outputs = [{item = "iron_ingot", amount = 1}]
|
||||
duration_seconds = 1.0
|
||||
|
||||
# Fix 7: a recipe that produces 2 items per cycle. Per-unit threat must
|
||||
# divide by the output amount.
|
||||
# dual_wire: (duration=3.0 + iron_ore(1.0)*1) / 2 = 4.0 / 2 = 2.0 per unit.
|
||||
[[recipe]]
|
||||
id = "dual_wire"
|
||||
building = "assembler"
|
||||
inputs = [{item = "iron_ore", amount = 1}]
|
||||
outputs = [{item = "dual_wire", amount = 2}]
|
||||
duration_seconds = 3.0
|
||||
|
||||
# Fix 8: an item downstream of a reprocessing-only item (advanced_alloy).
|
||||
# advanced_alloy is resolved only by the reprocessing pass; downstream_product
|
||||
# can only resolve in a non-reprocessing pass that runs AFTER the reprocessing
|
||||
# pass, requiring proper fixpoint iteration.
|
||||
# downstream_product: 2.0 + advanced_alloy(80.0)*1 = 82.0
|
||||
[[recipe]]
|
||||
id = "downstream_product"
|
||||
building = "assembler"
|
||||
inputs = [{item = "advanced_alloy", amount = 1}]
|
||||
outputs = [{item = "downstream_product", amount = 1}]
|
||||
duration_seconds = 2.0
|
||||
|
||||
# Fix 9: two recipes producing the same staggered_item. The cheap recipe
|
||||
# resolves before circuit_board is known; the expensive one requires
|
||||
# circuit_board. The item must be committed only once BOTH are computable,
|
||||
# so the result is max(cheap, expensive).
|
||||
# staggered_item_cheap: 1.0 + iron_ore(1.0)*1 = 2.0 (resolves early)
|
||||
# staggered_item_expensive: 1.0 + circuit_board(28.0)*1 = 29.0 (resolves later)
|
||||
# expected: max = 29.0
|
||||
[[recipe]]
|
||||
id = "staggered_item_cheap"
|
||||
building = "assembler"
|
||||
inputs = [{item = "iron_ore", amount = 1}]
|
||||
outputs = [{item = "staggered_item", amount = 1}]
|
||||
duration_seconds = 1.0
|
||||
|
||||
[[recipe]]
|
||||
id = "staggered_item_expensive"
|
||||
building = "assembler"
|
||||
inputs = [{item = "circuit_board", amount = 1}]
|
||||
outputs = [{item = "staggered_item", amount = 1}]
|
||||
duration_seconds = 1.0
|
||||
|
||||
@@ -129,7 +129,12 @@ Maximum simultaneous (disjoint) placements: m guns — cruiser 2,
|
||||
battlecruiser 3, battleship 4; l guns — battleship 1, dreadnought 3;
|
||||
drone hangar — carrier 1.
|
||||
|
||||
## Production tree
|
||||
## Production tree (first pass — superseded)
|
||||
|
||||
**Superseded (July 2026):** this first-pass tree predates the rules in
|
||||
`docs/progression_design.md` and will be replaced. The decisions for the
|
||||
redesign are recorded in "Production tree v2 — decisions" below; the text
|
||||
of this section is kept for reference until the new tree lands.
|
||||
|
||||
Design principle: each game phase adds exactly one new base input chain, so
|
||||
factory complexity ramps alongside ship size.
|
||||
@@ -171,6 +176,444 @@ It verifies every consumed item has a producer, every item has a visuals
|
||||
entry, flags orphaned items, and prints which items are reprocessing-only
|
||||
(currently exactly advanced_alloy).
|
||||
|
||||
## Production tree v2 — decisions (July 2026)
|
||||
|
||||
The old tree is discarded; the new one is designed against the rules in
|
||||
`docs/progression_design.md` (ratio curve, cost ladder, cost archetypes,
|
||||
refactorability). Decisions fixed so far:
|
||||
|
||||
### Base inputs (4) and fiction
|
||||
|
||||
- **iron_ore, copper_ore** — from the start, minable on every asteroid
|
||||
tile. Fiction: the asteroid is an M-type (metal) body — its bulk rock
|
||||
*is* ore, which is why the shipyard operation was built here at all.
|
||||
- **quartz** — mid-game, minable only on geode deposit patches in
|
||||
expansion territory (see the Resource deposits rules in
|
||||
`progression_design.md`). Fiction: ordinary silicate dust is everywhere
|
||||
and worthless; chips and optics need rare, pocket-bound optical-grade
|
||||
crystal.
|
||||
- **voidsteel** — late-game, obtained only by reprocessing scrap.
|
||||
Fiction: battle-forged — formed when weapon plasma anneals hull metal
|
||||
in the violence of ship destruction. Any wreck yields it, including the
|
||||
player's own; no foundry can replicate it.
|
||||
- **titanium is dropped.** Its hull-gating role moves to quartz-era
|
||||
control systems ("you can smelt all the steel you want, but you cannot
|
||||
steer a battlecruiser without electronics") and possibly a *quality*
|
||||
steel step — e.g. a long-running hardened-steel recipe (time-heavy
|
||||
archetype). Open question for the tree draft: quality step vs.
|
||||
electronics-only gating; explicitly **not** sheer steel quantity alone.
|
||||
|
||||
### Material palette (fingerprints per family)
|
||||
|
||||
- **iron/steel** — structure.
|
||||
- **copper** — conduction and heat: wiring, coils, heat sinks.
|
||||
- **silicon family** (all derived from quartz): silicon (logic,
|
||||
sensors), glass/optics (lenses, focusing crystals), ceramics (heat
|
||||
shielding, insulators). Carries the non-metal variety without extra
|
||||
base inputs.
|
||||
- **voidsteel** — capital-tier structure and exotics.
|
||||
- Deliberately skipped: carbon (mostly redundant with copper/ceramics),
|
||||
plastics (drags in Factorio-style chemical chains; ceramics read more
|
||||
sci-fi anyway), volatiles/ice (materials are build costs only — no
|
||||
consumption mechanic to justify fuel).
|
||||
|
||||
### Weapons
|
||||
|
||||
- All current lasers are renamed to **railguns** (`laser_cannon_s/m/l` →
|
||||
`railgun_s/m/l`); footprints and the gating matrix are unchanged.
|
||||
Implementation stays as-is (instant damage application, no projectile,
|
||||
no ammunition) — the beam visual reads as a tracer round. Materials:
|
||||
iron slugs, copper coils, steel rails — the starting-metal fingerprint.
|
||||
- **Lasers are reserved for later** as a genuinely distinct weapon type
|
||||
(e.g. once projectile/ammunition mechanics exist for other families),
|
||||
arriving with quartz optics. More weapon types are planned; railguns
|
||||
are simply the baseline tech that ships with v1.
|
||||
|
||||
### Tree structure — draft
|
||||
|
||||
Recipes are sketched as input lists only; quantities and durations come
|
||||
in the numbers pass, tuned so every chain sums to its threat-ladder
|
||||
value and follows the ratio curve (t1 nice → t4 strange). Glass/optics
|
||||
are cut from v1 — their only consumers would be lasers, which are
|
||||
deferred; the silicon family ships as silicon + ceramics.
|
||||
|
||||
**Mined (miner):** `iron_ore`, `copper_ore` (every tile), `quartz`
|
||||
(geode deposits in expansion territory).
|
||||
|
||||
**Smelted (smelter — exactly one recipe per input item):**
|
||||
|
||||
| output | input | ratio class |
|
||||
|---|---|---|
|
||||
| iron_ingot | iron_ore | nice (1:1 or 1:2) |
|
||||
| copper_ingot | copper_ore | nice |
|
||||
| silicon | quartz | mid entry |
|
||||
| iron_ingot | scrap | the safe, boring scrap sink |
|
||||
|
||||
**Reprocessing pool (scrap):** `iron_ingot`, `copper_ingot`, `silicon`,
|
||||
`voidsteel` — the only source of voidsteel. Weights authored for the
|
||||
fully unlocked pool state.
|
||||
|
||||
**Tier 2 — early intermediates (clean ratios, ~2:3):**
|
||||
|
||||
| item | inputs | role |
|
||||
|---|---|---|
|
||||
| steel_plate | iron_ingot | structure backbone, highest volume |
|
||||
| copper_wire | copper_ingot | conductors |
|
||||
| copper_coil | copper_wire | electromagnets: railguns, thrusters |
|
||||
| building_block | steel_plate | depth-3 chain = the doubling-time knob |
|
||||
|
||||
**Tier 3 — mid intermediates (strange ratios begin, need quartz):**
|
||||
|
||||
| item | inputs | role |
|
||||
|---|---|---|
|
||||
| control_chip | silicon + copper_wire | electronics gate for m+ hulls |
|
||||
| capacitor_bank | copper_coil + silicon | power for railgun m/l |
|
||||
| hardened_steel | steel_plate (long cycle) | quality gate for m+ hulls; time-heavy |
|
||||
| ceramic_plate | quartz | heat shielding: drives, l guns, capitals |
|
||||
| drive_unit | steel_plate + copper_coil + control_chip | propulsion for m+ hulls |
|
||||
|
||||
**Tier 4 — late intermediates (need voidsteel):**
|
||||
|
||||
| item | inputs | role |
|
||||
|---|---|---|
|
||||
| voidsteel_plate | voidsteel + hardened_steel | capital structure |
|
||||
| capital_core | voidsteel + capacitor_bank + control_chip | capital heart |
|
||||
|
||||
**Hull items** (`<ship>_hull`, assembler-made; the shipyard consumes the
|
||||
hull item plus module materials). The m+ hull gate is resolved as
|
||||
**both** of the open-question options: hardened_steel (quality steel, a
|
||||
deliberately long-running recipe — the time-heavy step) *and*
|
||||
control_chip (electronics):
|
||||
|
||||
| hull | inputs |
|
||||
|---|---|
|
||||
| drone_hull | steel_plate |
|
||||
| frigate_hull | steel_plate + copper_wire |
|
||||
| destroyer_hull | steel_plate + copper_coil |
|
||||
| cruiser_hull | hardened_steel + control_chip |
|
||||
| battlecruiser_hull | hardened_steel + control_chip + drive_unit |
|
||||
| battleship_hull | voidsteel_plate + drive_unit + control_chip |
|
||||
| dreadnought_hull | voidsteel_plate + capital_core + drive_unit |
|
||||
| carrier_hull | voidsteel_plate + capital_core + drive_unit |
|
||||
|
||||
**Module items** (`<module>_module`, assembler-made prefabs — kept as
|
||||
items so shipyard belt inputs stay simple and module production can be
|
||||
stockpiled):
|
||||
|
||||
| module | inputs | archetype |
|
||||
|---|---|---|
|
||||
| railgun_s | steel_plate + copper_coil | balanced |
|
||||
| salvager | steel_plate + copper_wire | balanced |
|
||||
| repair_tool | steel_plate + copper_wire | balanced |
|
||||
| armor_plates | steel_plate (many) | material-heavy, fast |
|
||||
| maneuvering_thrusters | steel_plate + copper_coil | balanced |
|
||||
| sensor_booster | copper_wire + copper_coil | lean (an antenna, no chip) |
|
||||
| afterburner | copper_coil + steel_plate | balanced |
|
||||
| weapon_stabilizer | steel_plate + copper_coil | balanced |
|
||||
| weapon_primer | capacitor_bank + copper_coil | mid; time-heavy |
|
||||
| weapon_upgrade | control_chip + copper_coil | mid; time-heavy |
|
||||
| railgun_m | capacitor_bank + steel_plate + copper_coil | mid |
|
||||
| drone_bay | control_chip + steel_plate + copper_coil | mid |
|
||||
| railgun_l | capacitor_bank + hardened_steel + ceramic_plate | late |
|
||||
| drone_hangar | voidsteel_plate + control_chip + drive_unit | late (carrier only) |
|
||||
|
||||
Refactorability check (the default technique holds): railgun_s → m
|
||||
introduces capacitor_bank, built from a subset of the small gun's
|
||||
inputs (copper_coil) plus the new base resource (silicon); the m gun
|
||||
otherwise reuses the small gun's inputs. Hulls likewise: cruiser adds
|
||||
hardening (fed by the existing steel line) and chips (fed by the new
|
||||
quartz territory) without touching the iron/copper core.
|
||||
|
||||
**Shortcut recipe candidates** (drop-only assembler schematics; not
|
||||
every strange chain gets one):
|
||||
|
||||
- `iron_ore → steel_plate` — skips the ingot step on the highest-volume
|
||||
chain in the game.
|
||||
- `quartz → control_chip` — skips silicon on the electronics chain.
|
||||
- `iron_ingot → hardened_steel` — a nicer-ratio route past the
|
||||
deliberately awkward hardening step.
|
||||
|
||||
Item count: 3 mined + scrap + 3 smelted + 4 t2 + 5 t3 + 3 t4 (incl.
|
||||
voidsteel) + 8 hulls + 14 modules ≈ 41 item types — same scale as the
|
||||
first-pass tree. `verify_recipes.py` must be re-run once this lands in
|
||||
recipes.toml; the numbers pass should add a threat-report tool that
|
||||
prints per-item threat values and producer:consumer ratio tables.
|
||||
|
||||
### Numbers — first pass
|
||||
|
||||
These numbers are live in the config files and verified by
|
||||
`tools/threat_report.py`; quantities and durations are tuned so fitted
|
||||
ships land on the threat-cost ladder and the ratio curve is realized.
|
||||
The typical loadouts are geometry-validated against the hull grids and
|
||||
configured as each ship's `default_modules`: the L-shaped weapon
|
||||
modifiers turned out not to fit alongside the full gun complement on
|
||||
the battlecruiser and dreadnought, so their loadouts use armor and
|
||||
small-railgun fillers instead — which moved all three heavy ships
|
||||
closer to their ladder targets.
|
||||
|
||||
**Economy constants:** `scrap_per_threat = 0.25` (1 scrap per 4 threat
|
||||
destroyed — a cruiser kill drops ~59 scrap). Reprocessing: 4 scrap per
|
||||
cycle, 4 s, full-pool weights iron_ingot 30 / copper_ingot 30 /
|
||||
silicon 20 / voidsteel 20 → threat(voidsteel) = (4·4 + 4)/0.2 = 100.
|
||||
Scrap smelting: 1 scrap → 1 iron_ingot, 1 s — deliberately
|
||||
value-losing (4 threat of scrap becomes a 2-threat ingot); reprocessing
|
||||
is the value-preserving path.
|
||||
|
||||
**Recipes** (dur in seconds; threat is per output item):
|
||||
|
||||
| item | recipe | dur | out | threat |
|
||||
|---|---|---|---|---|
|
||||
| iron_ore / copper_ore | miner | 1 | 1 | 1 |
|
||||
| quartz | miner (deposit) | 2 | 1 | 2 |
|
||||
| iron_ingot | 1 iron_ore | 1 | 1 | 2 |
|
||||
| copper_ingot | 1 copper_ore | 1 | 1 | 2 |
|
||||
| silicon | 1 quartz | 2 | 1 | 4 |
|
||||
| steel_plate | 2 iron_ingot | 3 | 1 | 7 |
|
||||
| copper_wire | 1 copper_ingot | 1 | 2 | 1.5 |
|
||||
| copper_coil | 2 copper_wire | 1.5 | 1 | 4.5 |
|
||||
| building_block | 2 steel_plate | 2 | 4 | 4 |
|
||||
| control_chip | 1 silicon + 2 copper_wire | 5 | 1 | 12 |
|
||||
| capacitor_bank | 2 copper_coil + 1 silicon | 5 | 1 | 18 |
|
||||
| hardened_steel | 3 steel_plate | 12 | 1 | 33 |
|
||||
| ceramic_plate | 2 quartz | 4 | 1 | 8 |
|
||||
| drive_unit | 2 steel_plate + 2 copper_coil + 1 control_chip | 8 | 1 | 43 |
|
||||
| voidsteel_plate | 1 voidsteel + 1 hardened_steel | 8 | 1 | 141 |
|
||||
| capital_core | 2 voidsteel + 1 capacitor_bank + 1 control_chip | 10 | 1 | 240 |
|
||||
|
||||
**Module prefabs** (contribution = item threat + module production
|
||||
time):
|
||||
|
||||
| module | recipe | dur | mod. time | contribution |
|
||||
|---|---|---|---|---|
|
||||
| railgun_s | 1 copper_coil | 1 | 1 | 6.5 |
|
||||
| salvager | 1 steel_plate + 2 copper_wire | 2 | 1 | 13 |
|
||||
| repair_tool | 1 steel_plate + 2 copper_wire | 2 | 1 | 13 |
|
||||
| armor_plates | 4 steel_plate | 3 | 1 | 32 |
|
||||
| maneuvering_thrusters | 1 steel_plate + 1 copper_coil | 2 | 1 | 14.5 |
|
||||
| sensor_booster | 2 copper_wire + 1 copper_coil | 2 | 1 | 10.5 |
|
||||
| afterburner | 2 copper_coil + 1 steel_plate | 3 | 1 | 20 |
|
||||
| weapon_stabilizer | 1 steel_plate + 1 copper_coil | 2 | 1 | 14.5 |
|
||||
| weapon_primer | 1 capacitor_bank + 1 copper_coil | 4 | 2 | 28.5 |
|
||||
| weapon_upgrade | 1 control_chip + 1 copper_coil | 4 | 2 | 22.5 |
|
||||
| railgun_m | 1 capacitor_bank + 2 steel_plate + 1 copper_coil | 4 | 3 | 43.5 |
|
||||
| drone_bay | 1 control_chip + 2 steel_plate + 1 copper_coil | 4 | 3 | 37.5 |
|
||||
| railgun_l | 1 capacitor_bank + 2 hardened_steel + 1 ceramic_plate | 6 | 4 | 102 |
|
||||
| drone_hangar | 1 voidsteel_plate + 2 control_chip + 1 drive_unit | 10 | 6 | 224 |
|
||||
|
||||
**Ships** (fitted = hull item + ship base time + typical loadout; the
|
||||
typical loadouts double as the `default_modules` for enemy waves):
|
||||
|
||||
| ship | hull recipe | dur | base | typical loadout | fitted (target) |
|
||||
|---|---|---|---|---|---|
|
||||
| drone | 1 iron_ingot | 1 | 1 | railgun_s | 10 (10) |
|
||||
| frigate | 2 steel_plate + 1 copper_wire | 2 | 2 | 2× railgun_s, maneuvering_thrusters | 47 (40) |
|
||||
| destroyer | 3 steel_plate + 2 copper_coil | 4 | 3 | 3× railgun_s, armor_plates, sensor_booster | 99 (80) |
|
||||
| cruiser | 2 hardened_steel + 2 control_chip | 6 | 4 | 2× railgun_m, armor_plates, maneuvering_thrusters | 234 (200) |
|
||||
| battlecruiser | 3 hardened_steel + 2 control_chip + 1 drive_unit | 8 | 5 | 3× railgun_m, armor_plates, 2× railgun_s | 355 (350) |
|
||||
| battleship | 3 voidsteel_plate + 1 drive_unit + 2 control_chip | 10 | 6 | railgun_l, 2× railgun_m, weapon_stabilizer, 2× railgun_s | 723 (700) |
|
||||
| dreadnought | 5 voidsteel_plate + 1 capital_core + 2 drive_unit | 12 | 8 | 3× railgun_l, 4× armor_plates, railgun_s | 1492 (1500) |
|
||||
| carrier | 5 voidsteel_plate + 1 capital_core + 2 drive_unit | 12 | 8 | drone_hangar, 2× railgun_m, 2× armor_plates, sensor_booster | 1436 (1500) |
|
||||
|
||||
**Checks:**
|
||||
|
||||
- *Ratio curve realized:* t1 all 1:1 (miner:smelter); t2 clean 2:3
|
||||
(ingot→plate, wire→coil); t3 strange — 2:5 (silicon→chip), 3:5
|
||||
(coil→capacitor), 3:4 (plate→hardened, plate→drive); t4 inverted 3:2
|
||||
(hardened→voidsteel_plate).
|
||||
- *Belt feasibility:* worst input demand is 1.33 items/s (wire→coil,
|
||||
plate→armor) — under the ~2/s single-belt cap everywhere; no
|
||||
accidental multi-belt recipes.
|
||||
- *Block economy:* building_block = 4 threat ⇒ for the 4-minute
|
||||
doubling at 30% capacity, the average building must cost ≈ 18 blocks
|
||||
(0.3 × 240 / 4). buildings.toml costs should be set around that mean
|
||||
(belts cheap, producers ~20–30).
|
||||
- *Small-end deviation:* frigate–cruiser land 6–24% hot because the
|
||||
fixed chain overhead dominates small hulls. Recommendation: accept
|
||||
and adjust the ladder targets to the achieved values (the ~×2-per-
|
||||
class curve shape is preserved) rather than thinning the early
|
||||
chains below readability.
|
||||
- *Rule bug discovered:* the scrap→iron_ingot smelter recipe combined
|
||||
with REQ-THREAT-ITEM's max-across-recipes rule would set
|
||||
threat(iron_ingot) to the scrap path (1 + 4 = 5, or more at other
|
||||
scrap values) instead of 2, inflating every downstream item. Fix:
|
||||
scrap-consuming recipes count toward an item's threat only when no
|
||||
scrap-free recipe produces that item, mirroring the reprocessing-path
|
||||
rule (see progression_design.md action items).
|
||||
|
||||
### Combat stat anchors — first pass
|
||||
|
||||
All combat stats derive from a few anchors so that power-per-threat holds
|
||||
by construction; the arena suite in `bin/balancing/data/balancing.toml`
|
||||
verifies it empirically. Change the anchors first, re-derive stats second.
|
||||
|
||||
- **Weapon DPS per threat pays a concentration tax that grows with gun
|
||||
size**: railgun_s 2 dmg × 2.0 Hz = 4.0 DPS at 6.5 threat (0.62),
|
||||
range 50; railgun_m 14 × 1.5 = 21 at 43.5 (0.48), range 70;
|
||||
railgun_l 52 × 0.8 = 41.6 at 102 (0.41), range 100. Arena round 1
|
||||
(see below) showed that at equal threat, concentrated fleets win
|
||||
flawlessly with a flat-ish DPS curve — concentration itself (focus
|
||||
survivability, range, no losses-to-attrition of own DPS) is worth
|
||||
paying for, so bigger guns get less raw DPS per threat.
|
||||
- **Hull HP = 15 per threat of hull contribution** (incl. ship base
|
||||
time) as the *prior*; per-hull HP is the empirical trim knob for
|
||||
arena results (guns are shared across many hulls, so gun changes move
|
||||
many matchups at once — hull HP moves exactly one). Current values
|
||||
after round-4 trims: drone 60, frigate 300, destroyer 550,
|
||||
cruiser 1500, battlecruiser 2200, battleship 6300,
|
||||
dreadnought/carrier 24000. (The capital hulls sit well above the
|
||||
15/threat prior while their guns sit below the DPS prior — the arena
|
||||
consistently prices big ships as tanks with taxed guns.)
|
||||
- **Armor HP ≈ 37 per threat** — a strong premium over hull HP (15)
|
||||
because armor is pure HP with no capability, and fights snowball:
|
||||
killing enemies removes their DPS, while surviving merely delays —
|
||||
so a point of HP must be cheaper than a point of DPS.
|
||||
armor_plates adds 1200 HP at 32 threat (raised from 640 → 1000 →
|
||||
1200: the all-guns destroyer beat the armored one in three
|
||||
consecutive arena rounds).
|
||||
- **TTK check:** a mirror cruiser duel (51 DPS vs 2140 EHP) lasts ~42 s —
|
||||
inside the 30–60 s parity-fight band implied by the 25-ship fleet
|
||||
target. Capital mirrors run longer (~90 s), deliberately.
|
||||
- **Repair ≈ 1 HP/s per threat:** repair_tool heals 12 HP × 1 Hz at 13
|
||||
threat (arena round 1: at 2 HP/s-per-threat the escorted team won
|
||||
flawlessly — in-combat sustain effectively removes enemy DPS and must
|
||||
be priced like DPS, not like HP). Salvager: collection range 60 (was
|
||||
a nonsensical 500, beyond any sensor), cargo 20, 0.5 collections/s.
|
||||
`scrap_despawn_seconds` raised 30 → 120: a capital kill drops
|
||||
hundreds of scrap collected one per cycle, so 30 s despawned nearly
|
||||
everything.
|
||||
- **Mobility ladder is monotone in size** — small = fast and nimble
|
||||
(drone 45 m/s, 60 accel) down to capitals (10 m/s, 8 accel); the old
|
||||
placeholder had the drone as the least agile ship. Sensor ranges keep
|
||||
the existing 150→350 ladder; all weapon ranges stay below sensor
|
||||
ranges.
|
||||
- **Weapon modifiers are capital economy:** ×1.2 damage at 22.5 threat
|
||||
beats adding a gun once a ship carries more than ~68 threat of weapons
|
||||
— so modifiers pay off on gun-heavy big hulls and are a waste on small
|
||||
ones. Damage/rate kept at ×1.2; the stabilizer's range multiplier was
|
||||
cut ×1.5 → ×1.3 after round 2 — range is the strongest stat in the
|
||||
orbit-AI's hands (free approach fire), and the stabilized battleship
|
||||
was the one loadout still overperforming.
|
||||
- **Stations:** a fresh player station (3000 HP, 25 dmg × 1 Hz,
|
||||
range 120) holds one early parity wave unaided; the enemy station at
|
||||
level 0 matches it exactly and scales per push level
|
||||
(+1500 HP, +12 dmg, +0.1 Hz per level). HQ 5000 HP. Range reduced
|
||||
from 200 in round 1: at 200 (4× a small gun's range) two stations
|
||||
annihilated a 3× threat swarm through approach fire alone — range,
|
||||
not HP, is the station dominance lever.
|
||||
- **Known imbalance, accepted:** the carrier loses its equal-threat
|
||||
arena until the drone-launching capability exists — the hangar is 224
|
||||
threat of dead weight. Do not compensate with stats; fix by
|
||||
implementing drones.
|
||||
|
||||
**Arena round 1 results (2026-07-04):** mirrors snowball as expected
|
||||
(40–50% of the winner survives) but team 1 won all three mirrors —
|
||||
possible sim-side bias, under investigation (progression_design.md
|
||||
action items 6–7). All four equal-threat cross-tier matchups were
|
||||
flawless wins for the concentrated side; glass destroyers beat armored;
|
||||
repair escort beat raw numbers flawlessly; two stations shrugged off a
|
||||
3× threat swarm. Round-2 knob changes: concentration tax on m/l gun
|
||||
DPS (17→14, 70→52 damage), armor 640→1000, repair 25→12, station range
|
||||
200→120. Gun ranges deliberately untouched — if concentrated fleets
|
||||
still win flawlessly in round 2, the range ladder (kiting) is the next
|
||||
suspect, ahead of further DPS cuts.
|
||||
|
||||
**Arena round 4 results (2026-07-04) and convergence policy:** mirrors
|
||||
at 0–3%; swarm vs cruisers +7%; glass vs armored resolved at +3% for
|
||||
armored (armor 1200 confirmed after four rounds of signal). Noise
|
||||
floor established: the battleship held +23% straight through a −10%
|
||||
EHP cut, and repair drifted 14→24% with no repair changes — single-run
|
||||
margins move ~±10% on a re-roll. Policy from here: only act on signals
|
||||
that persist two rounds; treat anything inside ±20% as converged for
|
||||
v1 (real-game playtests refine further). Armor-coupling overshoots
|
||||
corrected in round 5: battlecruiser 2000→2200, dreadnought/carrier
|
||||
22500→24000 (two-round persistent deficit vs destroyers). **Accepted,
|
||||
not chased:** the stabilized battleship beats a pure frigate fleet by
|
||||
~23% — the edge survived a stat cut, so it is mechanical (range), and
|
||||
"the extreme smallest-ship fleet modestly loses to a range-fitted
|
||||
capital" is desirable doctrine texture; the fair anti-capital answer
|
||||
is the mixed fleet, whose arena is balanced.
|
||||
|
||||
**Arena round 3 results (2026-07-04, narrow lanes):** arenas were
|
||||
narrowed (height 10) so fleets can no longer pass each other — arena
|
||||
geometry is part of the fixture from here on; margins are only
|
||||
comparable within the same geometry. Results: mirrors healthy (1–7%);
|
||||
dreadnought closed to −11%; battleship improved to +22% (still over);
|
||||
drone swarm now beats cruisers by 14% (full engagement in the narrow
|
||||
lane favors numbers); mixed vs battlecruisers +18%; glass beat armored
|
||||
for the third consecutive round (+12%); repair 14%, two-to-one 78%,
|
||||
station set cracked at 51% by the 3× swarm (geometry changed under the
|
||||
stations — watch before touching stats). Round-4 knob changes:
|
||||
armor_plates 1000→1200 (the persistent glass signal; also lifts the
|
||||
armor-carrying cruiser/BC/destroyer/DN loadouts), battlecruiser hull
|
||||
2500→2000 (net −300 EHP after its armor gain), battleship 7000→6300
|
||||
(no armor in its loadout, clean −10%), dreadnought/carrier
|
||||
19000→22500 (the opposing destroyers gain armor too, so the DN needs
|
||||
the larger step).
|
||||
|
||||
**Arena round 2 results (2026-07-04, with EHP-margin logging):** the
|
||||
mirror "bias" was noise — repeated runs split randomly, and winners
|
||||
finish at 5–7% EHP (near-mutual annihilation, healthy). Drones vs
|
||||
cruisers 10% and mixed vs battlecruisers 14% — near parity. The two
|
||||
capital fights diverged: battleship beat frigates at +33% while the
|
||||
dreadnought lost to destroyers at −37% — same guns, so the difference
|
||||
is the destroyers carrying the buffed armor plus the battleship's
|
||||
stabilizer putting all its guns at 75–150 m against 50 m opponents.
|
||||
Glass vs armored narrowed to 11% (watch; if it persists, armor
|
||||
1000→1200). Repair escort 11% — fair. Two-to-one decisive at 80%;
|
||||
station assault cracked by the 3× swarm at 13% (stations strong but
|
||||
breakable). Round-3 knob changes: stabilizer range ×1.5→×1.3, and
|
||||
per-hull HP trims — battlecruiser 2700→2500, battleship 7500→7000,
|
||||
dreadnought/carrier 15500→19000.
|
||||
|
||||
## Balancing targets (first pass, July 2026)
|
||||
|
||||
The six root numbers for the balancing pass. Every derived value (threat
|
||||
rate, recipe quantities, block costs, scrap rates, unlock ladder) is tuned
|
||||
to hit these; when rebalancing later, change these first and re-derive,
|
||||
never the other way around. The rules they follow live in
|
||||
`docs/progression_design.md`.
|
||||
|
||||
All targets are in **game time**. The player can pause and accelerate, so
|
||||
real session length differs; playtests should measure both. The time unit
|
||||
is the boss cycle (`world.toml boss_countdown_seconds`, 300 s). Destroying
|
||||
a station set advances the boss countdown by `boss_advance_seconds`
|
||||
(60 s), so cycles run shorter than nominal when pushing actively — these
|
||||
targets deliberately ignore that; playtesting will show real run lengths.
|
||||
|
||||
1. **Run length** — a winning run takes up to 2 hours of game time: win
|
||||
around boss cycle 20–24. Losing runs end earlier.
|
||||
2. **Phase boundaries** — early = cycles 1–5 (iron/copper, small hulls),
|
||||
mid = cycles 6–14 (quartz, medium hulls), late = cycles 15+
|
||||
(voidsteel, capitals). Push cadence roughly one station set per
|
||||
cycle from mid onward. This fixes the `unlock_at_station_level`
|
||||
ladder and, with the artifact win count, the `artifact_chance_formula`
|
||||
pacing.
|
||||
3. **Factory size curve** — producing buildings over time; when
|
||||
saturated, output threat/s equals this count, so this curve IS the
|
||||
player power curve. Targets: ~25 when the starting asteroid is full
|
||||
(end of cycle 2), ~60 at the start of mid (cycle 6), ~120 at the
|
||||
start of late (cycle 15), ~150 near the win. `threat_rate_formula`
|
||||
must remain a fraction of this curve, and buildings plus belts must
|
||||
physically fit the asteroid plus affordable expansions.
|
||||
4. **Threat-cost ladder** — total production-seconds per *fitted* hull
|
||||
(including a typical module loadout): drone ~10, frigate ~40,
|
||||
destroyer ~80, cruiser ~200, battlecruiser ~350, battleship ~700,
|
||||
dreadnought/carrier ~1500. Every production chain must sum to its
|
||||
ladder value. Cross-check against (3): fitted-ship cadence =
|
||||
ladder value / (factory threat/s devoted to military) — e.g. a mid
|
||||
factory spending half of 60 threat/s on ships fields a fitted
|
||||
cruiser roughly every 7 s.
|
||||
5. **Fleet size** — swarm-leaning: ~25 player combat ships as the
|
||||
standing mid-game fleet. Standing fleet = build cadence (4) × average
|
||||
ship lifetime, so this target drives time-to-kill and therefore the
|
||||
combat stat magnitudes tuned in the arena pass.
|
||||
6. **Block economy roots** — bootstrap complete (starting asteroid full)
|
||||
by the end of cycle 2; a factory spending ~30% of its capacity on
|
||||
blocks doubles in ~4 minutes early game; one expansion affordable per
|
||||
cycle at ~1/3 of block income, with escalating costs that eventually
|
||||
outrun any income (see the growth curve rules in
|
||||
`progression_design.md`).
|
||||
|
||||
## Deliberate placeholders / open questions for later passes
|
||||
|
||||
- All new hulls have `threat.cost_formula = "0"` so enemy waves do not spawn
|
||||
|
||||
381
docs/progression_design.md
Normal file
381
docs/progression_design.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# Progression & Balancing Design
|
||||
|
||||
Rules and principles that govern the production tree, progression pacing,
|
||||
and balancing. This document contains **rules only** — concrete content
|
||||
(item lists, recipes, unlock levels, stat numbers) lives in the config files
|
||||
and `content_design.md`; those numbers must follow the rules stated here.
|
||||
|
||||
## Player-experience goals
|
||||
|
||||
What each phase of a run should feel like:
|
||||
|
||||
- **Early:** learning belts and ratios with forgiving chains. The building
|
||||
block economy is the main constraint; the player bootstraps a
|
||||
self-sustaining factory from the starting stock.
|
||||
- **Mid:** deeper chains, the first real ratio puzzles, and the first
|
||||
meaningful drop decisions (which schematic, when to push).
|
||||
- **Late:** combat feeds the factory — capital production requires salvage.
|
||||
Progress means extending and refactoring the existing factory, not
|
||||
rebuilding it. Strange ratios are deliberate optimization puzzles.
|
||||
|
||||
Overarching: an experienced player gains efficiency through **knowledge** —
|
||||
layout foresight, understanding chains, exploiting shortcut recipes — never
|
||||
through hidden mechanics. An inexperienced setup should not cost much more
|
||||
than an experienced one; experience pays off in how easily the factory
|
||||
adapts later (see Refactorability).
|
||||
|
||||
## Resource phases
|
||||
|
||||
- A run has exactly **four base inputs**:
|
||||
1. Two mined resources available from the start, minable on **every**
|
||||
asteroid tile.
|
||||
2. A third mined resource unlocked mid-game, minable **only on deposit
|
||||
patches** found in expansion territory (see Resource deposits).
|
||||
3. A fourth input unlocked late-game, obtainable **only** from
|
||||
reprocessing salvaged scrap.
|
||||
- The fourth input is the core loop hook: capital ship production requires
|
||||
fighting (salvaging and reprocessing), not just mining.
|
||||
- Every gating has a fictional reason (concrete fiction in
|
||||
`content_design.md`): the asteroid is a metal-rich body, so its bulk
|
||||
rock is minable anywhere; the mid resource sits in rare pockets; the
|
||||
late input is battle-forged — created only in the violence of ship
|
||||
destruction, which is why any wreck (including the player's own)
|
||||
yields it and no foundry can make it.
|
||||
- The mid resource is **dual-gated**: schematics (knowledge, via drops)
|
||||
and territory (deposits, via expansions). Tuning must guarantee the
|
||||
deposit-bearing expansion is comfortably affordable by the time the
|
||||
first mid-tier schematics drop, or those drops are dead picks.
|
||||
- There is no direct "resource unlock" mechanism. Miner recipes unlock
|
||||
**implicitly** (REQ-LOCK-IMPLICIT) when some unlocked schematic's material
|
||||
chain reaches that resource. Resource pacing is therefore controlled
|
||||
through the `unlock_at_station_level` values of ships, modules, and
|
||||
assembler recipe schematics — and the content must guarantee that the
|
||||
chains actually connect (a mid-game schematic must require an item whose
|
||||
chain reaches the mid resource, or it never unlocks).
|
||||
|
||||
### Resource deposits
|
||||
|
||||
- **Rule: freedom first, geography later.** The starting resources are
|
||||
minable everywhere, so the player has full layout freedom while
|
||||
learning. Later mined resources are bound to deposit patches — fixed
|
||||
geography as a layout puzzle, introduced once the player is competent.
|
||||
- **Rule: deposits exist only in expansion territory.** Expansions buy
|
||||
space *and* access to resource tiers — the second leg of the growth
|
||||
curve (see Building block economy).
|
||||
- **Rule: patch area is the throughput cap.** Deposits never deplete but
|
||||
are finite in area; the number of deposit tiles caps how many miners
|
||||
the chain supports. Buying deeper expansions raises the throughput
|
||||
ceiling of high-tier chains.
|
||||
- **Rule: no empty expansions.** Deposit content per expansion is
|
||||
deterministic and config-defined; only the placement within the new
|
||||
columns is randomized. Buying an expansion never rolls "nothing".
|
||||
- **Rule: mining is binary.** A miner whose footprint overlaps at least
|
||||
one deposit tile of a resource can select that resource's recipe; no
|
||||
partial-coverage rate scaling.
|
||||
- Deposits arrive at the periphery (expansions add columns on the left),
|
||||
so each new chain starts in fresh space — supporting the
|
||||
refactorability property — and high-tier chains have the longest belt
|
||||
runs to the shipyards, escalating the logistics puzzle with tier.
|
||||
|
||||
## Production tree rules
|
||||
|
||||
### Structure
|
||||
|
||||
- **Each phase transition adds exactly one new base input chain.** A base
|
||||
input is a bottom-level resource entering the factory from outside — a
|
||||
mined resource or the scrap-only input. The early game starts with two
|
||||
ores as the baseline; the transition to mid adds one (the deposit-bound
|
||||
mid resource), the transition to late adds one (the scrap-only input).
|
||||
No transition ever introduces more than one unfamiliar bottom-level
|
||||
chain, so the factory grows in one direction at a time.
|
||||
- **Intermediates are generic shared parts.** Keep the item count low —
|
||||
modules and hulls of a tier draw from a shared pool of that tier's and
|
||||
lower tiers' intermediates rather than each having bespoke inputs.
|
||||
- **Thematic naming over thematic items.** Inputs should be plausible for
|
||||
what the recipe produces (crystals for lasers, heat sinks for bigger
|
||||
lasers). Achieve this through naming and chain membership, not by adding
|
||||
item types: rename a generic part, don't add a parallel one.
|
||||
|
||||
### Ratios
|
||||
|
||||
- **Ratio "niceness" degrades with tier.** The producer:consumer ratios
|
||||
needed for 100% throughput follow a curve:
|
||||
- Tier 1 (ore → basic material): trivially nice (e.g. 1:1 or 1:2
|
||||
miner:smelter).
|
||||
- Tier 2: slightly complex but still clean (e.g. 2:3).
|
||||
- Higher tiers: increasingly strange ratios, as deliberate optimization
|
||||
puzzles.
|
||||
- Exceptions in both directions are allowed when there is a reason — a
|
||||
clean late chain as a breather, an odd early chain as a teaser — but the
|
||||
curve is the default.
|
||||
|
||||
### Shortcut recipes
|
||||
|
||||
- Some strange chains get a **shortcut recipe**: an explicitly unlockable
|
||||
assembler recipe schematic (`unlock_at_station_level ≥ 0`, drop-only per
|
||||
REQ-LOCK-EXPLICIT) that skips a step (e.g. t1 → t3 directly) and yields
|
||||
nice ratios for a chain whose base path is strange.
|
||||
- **Not every strange chain gets a shortcut.** Some strangeness is
|
||||
permanent; the absence of a fix is a valid design choice.
|
||||
- **Shortcuts drop only for known chains.** A shortcut recipe enters the
|
||||
drop pool only when both its input items and its output item are
|
||||
already unlocked (in addition to the station level check). The player
|
||||
is never offered a shortcut for a chain they have not built yet. The
|
||||
output-item half of this check already exists in
|
||||
REQ-DEF-SCHEMATIC-DROP; the input half is new (see Action items).
|
||||
- **Shortcuts are pure rewards, never balance factors.** An item's threat
|
||||
value is the *maximum* across its producing recipes (REQ-THREAT-ITEM), so
|
||||
unlocking a cheaper recipe does not lower the item's threat accounting —
|
||||
the player gains real factory efficiency without their ships being
|
||||
valued cheaper and without enemy wave budgets shifting. Consequently:
|
||||
**balance every chain around its base (expensive) path**; the shortcut's
|
||||
savings define the size of the reward.
|
||||
|
||||
### Refactorability
|
||||
|
||||
- **Rule (the property):** unlocking the next tier or size of a thing must
|
||||
be a *local edit* of the existing production line — adding assemblers
|
||||
and belts, or replacing a machine or two in place — never a rebuild of
|
||||
the line.
|
||||
- **What this buys the player:** foresight pays off in space, not blocks.
|
||||
An experienced player leaves a little slack in the middle of a line,
|
||||
knowing the next size or tier upgrade means tearing out one assembler
|
||||
and a few belts there and inserting the new step — plus maybe swapping
|
||||
a recipe or two elsewhere — while the rest of the line keeps running
|
||||
untouched.
|
||||
- **Default technique:** the bigger version introduces one new intermediate
|
||||
that is produced from a subset of the smaller version's inputs (possibly
|
||||
plus one additional low-tier material), and otherwise reuses the smaller
|
||||
version's inputs. Existing lines keep running and feed the new
|
||||
intermediate's assemblers.
|
||||
- The property is the rule; the technique is only the default. It may be
|
||||
broken where it fights thematic plausibility, as long as the property
|
||||
still holds.
|
||||
|
||||
## Cost archetypes
|
||||
|
||||
Every item has two cost knobs: **material quantity** and **cycle time**.
|
||||
Both feed the threat value identically (threat = recursive
|
||||
production-seconds, REQ-MOD-THREAT), so the split between them does not
|
||||
change what an item is *worth* — it changes what kind of **factory
|
||||
pressure** it creates:
|
||||
|
||||
- **Material-heavy, fast** (e.g. armor plates): simple items; stress belt
|
||||
throughput, splitter logistics, and miner/smelter counts.
|
||||
- **Time-heavy, lean** (e.g. shield modules): technically complex items;
|
||||
few inputs — possibly higher-tier ones — but long cycles; stress
|
||||
assembler counts and parallelization.
|
||||
|
||||
**Rule:** each module family commits to a clear archetype, so factories
|
||||
supporting different fleet doctrines feel structurally different to build.
|
||||
|
||||
## Threat model (balancing backbone)
|
||||
|
||||
- Threat cost = total recursive production-seconds (REQ-MOD-THREAT). One
|
||||
factory-second equals one threat; player output and enemy wave budgets
|
||||
are denominated in the same currency.
|
||||
- **Rule: combat power per threat is roughly constant** across all ships,
|
||||
modules, and tiers. Higher tiers are better per *ship* and per *module
|
||||
slot*, not per invested factory-second — their advantage is
|
||||
concentration (fewer, bigger things; slot geometry per
|
||||
`content_design.md`) and qualitative capabilities, not a better exchange
|
||||
rate. Deviations from this rule are deliberate and documented.
|
||||
- **Difficulty race:** the enemy threat rate (`threat_rate_formula`) is
|
||||
tuned against the factory output (threat/s) achievable by a competent
|
||||
player — slightly below it early, crossing above it eventually. The game
|
||||
is endless; enemy scaling must ultimately outpace any factory, and
|
||||
player skill shifts *when*, not *whether*.
|
||||
- **All time scaling lives in the threat rate** — waves get bigger, ships
|
||||
of a given schematic never get individually stronger. There is no ship
|
||||
level dimension: stat formulas are plain values, and per-ship level
|
||||
scaling does not exist. Push
|
||||
scaling on enemy defence stations is the separate, player-triggered
|
||||
difficulty axis and keeps its level formulas.
|
||||
|
||||
## Unlock & drop pacing
|
||||
|
||||
- **Starting set rule:** the schematics unlocked at game start
|
||||
(`unlock_at_station_level = -1`) must be exactly enough to reach the
|
||||
first push unaided — a functioning block loop, small hulls, a basic
|
||||
weapon, and the salvage loop. Nothing more.
|
||||
- The `unlock_at_station_level` ladder mirrors the resource phases:
|
||||
mid-tier hulls/modules/recipes at low station levels, capital content at
|
||||
higher levels. A schematic must not become available before the chains
|
||||
its materials need can be unlocked alongside it.
|
||||
- **Schematics can require other schematics.** Beyond the station-level
|
||||
gate, a schematic (ship, module, or assembler recipe) may list
|
||||
prerequisite schematics (`unlock_requires`, REQ-LOCK-PREREQ) that must
|
||||
already be unlocked before it enters the drop pool — e.g. the medium
|
||||
laser requires the small laser; a future Mk2 requires its base version.
|
||||
Station level gates the earliest
|
||||
*when*; prerequisites gate the *order*, keeping drop offers coherent
|
||||
with what the player already owns.
|
||||
- **No duplicate drops.** Ship and module schematics leave the drop pool
|
||||
once owned, exactly as assembler recipe schematics already do. There are
|
||||
no schematic level-ups; player power grows through unlock breadth and
|
||||
factory scale only, which keeps power-per-threat exact on both sides.
|
||||
The pool therefore shrinks over a run and late pushes increasingly offer
|
||||
artifacts — intended: the late game is a race for the win condition.
|
||||
Per-item progression may return later as Mk2 upgrade recipes (see Open
|
||||
tasks), never as free level-ups.
|
||||
- **Artifacts trade power for progress.** Artifact options compete with
|
||||
schematic picks in the same choice dialog; the artifact chance must be
|
||||
tuned so that taking one is a real decision (giving up an unlock), not
|
||||
automatic in either direction.
|
||||
|
||||
## Scrap & reprocessing economy
|
||||
|
||||
- Scrap is the bridge from combat back into the factory, with two sinks:
|
||||
**smelting** (same basic materials as ore — the safe, boring option) and
|
||||
**reprocessing** (probabilistic higher intermediates, including the
|
||||
late-game input — the gamble that eventually becomes mandatory).
|
||||
- The reprocessing output pool renormalizes over implicitly unlocked items
|
||||
(REQ-LOCK-REPROCESSING-POOL), so its output quality improves
|
||||
automatically as the run progresses. **Rule:** weights are authored for
|
||||
the *fully unlocked* pool state; early-game behavior falls out of
|
||||
renormalization for free and needs no separate staging.
|
||||
- **Rule: ship scrap drops are derived, never authored.** A destroyed ship
|
||||
drops `threat cost × scrap_per_threat` (a `world.toml` key), with the
|
||||
threat cost computed from its actual hull plus installed modules
|
||||
(REQ-MOD-THREAT) — a kitted-out ship drops more scrap than a bare hull
|
||||
automatically. `ships.toml` carries no scrap value. Defence stations are
|
||||
the exception: they keep authored `scrap_drop_formula`s, because pushing
|
||||
rewards are tuned independently of ship production costs.
|
||||
- Consequence: the threat value of scrap is the constant
|
||||
`1 / scrap_per_threat` (REQ-THREAT-SCRAP). The former min-`scrap_drop`
|
||||
schematic derivation and its potential circularity are gone.
|
||||
- **Rule:** the late-game input's income rate meaningfully gates capital
|
||||
production — unlocking a capital hull must not mean spamming it; the
|
||||
input trickles in slowly enough that every capital ship is a noticeable
|
||||
investment. The tuning target is relative, not absolute: assume a
|
||||
reference player who destroys and salvages roughly the threat the game
|
||||
spawns ("fighting at parity"), and tune `scrap_per_threat`, the
|
||||
reprocessing weights, and capital material costs so that this player
|
||||
affords roughly N capital ships per boss cycle. An absolute income rate
|
||||
would be meaningless (income depends entirely on how much the player
|
||||
fights) and would not self-scale; per boss cycle, the target tracks the
|
||||
threat rate as it steps up.
|
||||
|
||||
## Building block economy
|
||||
|
||||
- Building blocks are the only global currency and the early game's
|
||||
central constraint. The early game is a bootstrap problem: convert the
|
||||
starting stock into a self-sustaining block loop before the first waves
|
||||
bite.
|
||||
- **Rule:** the starting stock suffices for a minimal block loop plus the
|
||||
first shipyard — with a little slack for beginner mistakes, but not
|
||||
enough to skip the loop entirely.
|
||||
- **Rule: the growth curve lives here.** A saturated building produces
|
||||
exactly 1 threat/s, so the player's output curve *is* their
|
||||
building-count curve — shaping growth over a run means shaping the
|
||||
block and space economy, there is nowhere else it can live. Intended
|
||||
shape: exponential bootstrap (block-limited) → ramp
|
||||
(expansion-limited) → asymptotic squeeze as expansion costs outrun
|
||||
income, racing the enemy threat rate throughout.
|
||||
- **Rule: escalating expansion costs.** Expansion cost is a formula of
|
||||
the number of expansions already purchased, rising steeply enough that
|
||||
expansions eventually outrun any block income. The starting asteroid
|
||||
is deliberately small — filled within the first boss cycle or two, so
|
||||
the early exponential burst is a satisfying ramp, not a balance hole —
|
||||
and from then on the output curve is the expansion curve. Blocks keep
|
||||
a meaningful sink for the entire run, and "grow vs. army" stays a live
|
||||
decision at every moment.
|
||||
- **Rule: designed doubling time.** Block production is a positive
|
||||
feedback loop (blocks buy assemblers, assemblers make blocks); its
|
||||
time constant is a designed quantity, never an accident of quantity
|
||||
choice. The block chain's depth and the per-building costs are tuned
|
||||
against a stated target of the form: "a factory spending X% of its
|
||||
capacity on blocks doubles in ~T minutes."
|
||||
- **Rule: growth is limited by economy, never by waiting.** Construction
|
||||
times stay short; the serial build queue must not be used as a growth
|
||||
brake. Waiting for placed buildings to become operational — especially
|
||||
at the start of a run — is frustration, not gameplay. All growth
|
||||
limiting comes from block income and expansion pricing.
|
||||
- Note: block income has a structural ceiling — blocks enter the stock
|
||||
through the HQ's single belt port, so income is capped at belt
|
||||
throughput regardless of assembler count. Per-building costs should be
|
||||
high enough that this cap can bind late-game (see the condensed-block
|
||||
idea under Open tasks).
|
||||
|
||||
## Numeric guardrails
|
||||
|
||||
Constraints that every recipe must respect, independent of tuning:
|
||||
|
||||
- **Belt throughput:** belt speed and per-tile capacity cap how fast a
|
||||
single belt can feed an input. A recipe whose per-cycle inputs cannot be
|
||||
sustained by one belt per input at 100% duty cycle is a *deliberate*
|
||||
design (forcing parallel belts/splitters as part of a high-tier puzzle)
|
||||
— never an accident of quantity choice.
|
||||
- **Buffer burstiness:** input buffers hold 2× the per-cycle amount
|
||||
(REQ-MAT-INPUT-BUFFER), so large per-cycle quantities create bursty belt
|
||||
demand. Low tiers prefer small quantities with short cycles; big-batch
|
||||
recipes are reserved for high tiers where burstiness is part of the
|
||||
puzzle.
|
||||
- **Cycle times scale with tier** monotonically — a higher-tier item never
|
||||
has a shorter total chain time than a lower-tier item of the same role.
|
||||
|
||||
## Open tasks / future work
|
||||
|
||||
- **Rework `recipes.toml`** once the rules in this document are fixed: the
|
||||
current tree feels too close to Factorio; apply the thematic-naming rule
|
||||
and the ratio curve to it (renames and quantity changes, not new items).
|
||||
- **Balancing pass** (see placeholders in `content_design.md`): set the
|
||||
`unlock_at_station_level` ladder, real threat costs and
|
||||
`default_modules`, reprocessing weights, and the threat-rate formulas
|
||||
according to the rules above.
|
||||
- **Condensed building blocks** — a drop-unlockable shortcut-style
|
||||
recipe that packs several blocks' worth of value into one belt item,
|
||||
relieving the HQ intake ceiling (see Building block economy) as a
|
||||
late-game reward. The ceiling is the puzzle, the drop is the fix —
|
||||
same philosophy as shortcut recipes.
|
||||
- **Mk2 upgrade recipes** — the deferred design for per-item progression,
|
||||
to revisit once the config has stabilized. A duplicate-style drop
|
||||
unlocks a distinct `*_mk2` item whose recipe consumes the Mk1 item plus
|
||||
higher-tier parts. This preserves power-per-threat (the extra power is
|
||||
paid in real production-seconds, since threat is recursive), satisfies
|
||||
the refactorability rule (the Mk1 line keeps running and feeds one new
|
||||
assembler), and keeps balancing one-dimensional (no level variable
|
||||
anywhere). Enemy-side progression happens via `default_modules`
|
||||
variants per era instead of a level formula.
|
||||
|
||||
## Action items — changes beyond this document
|
||||
|
||||
Agreed changes that require edits to `requirements.md`, the code, and the
|
||||
configs. Completed items are removed from this list (their outcomes live
|
||||
in `requirements.md` and the git history). Still open:
|
||||
|
||||
1. **Fill unfillable schematic slots with artifacts.** With duplicates
|
||||
removed, the schematic drop pool can run dry — previously unreachable.
|
||||
Decision: every slot in the choice dialog that cannot be filled with a
|
||||
schematic because the eligible pool is exhausted is filled with an
|
||||
artifact option instead (in addition to any artifact option granted by
|
||||
the regular artifact roll). A push therefore always awards a full
|
||||
dialog. Update REQ-DEF-SCHEMATIC-DROP.
|
||||
2. **Confirm wave scaling carries alone.** With per-ship level scaling
|
||||
removed, `threat_rate_formula` is the only time-scaling axis; verify
|
||||
the current `world.toml` values still produce the intended difficulty
|
||||
curve (likely a review, not a retune, since the config already ran
|
||||
with a flat ship level before the removal).
|
||||
3. **Gate shortcut-recipe drops on their inputs.** Extend the assembler
|
||||
recipe schematic pool eligibility in REQ-DEF-SCHEMATIC-DROP: in
|
||||
addition to the existing station-level and output-item checks, all of
|
||||
the recipe's input item types must be implicitly unlocked as well.
|
||||
4. **Expansion cost formula.** Replace the flat
|
||||
`world.toml [expansion].cost_building_blocks` with a formula of the
|
||||
number of expansions already purchased (x = expansions bought so
|
||||
far), so expansion costs can escalate per the block economy rules.
|
||||
Update REQ-EXP-COST and wherever the UI displays the expansion cost.
|
||||
5. **Resource deposits.** Add a terrain deposit layer per the Resource
|
||||
deposits rules: deposit patches generated in expansion columns
|
||||
(deterministic content per expansion, randomized placement within the
|
||||
new columns), deposit rendering, and a miner condition (a resource
|
||||
recipe is selectable only if the miner's footprint overlaps at least
|
||||
one matching deposit tile). Touches REQ-BLD-MINER ("every asteroid
|
||||
tile is equivalent" no longer holds), REQ-GW-ASTEROID-EXPAND /
|
||||
REQ-EXP-*, `world.toml`, and `visuals.toml`.
|
||||
6. **Verify the balancing arena end condition.** Arena round 1's
|
||||
station-assault log showed the arena "(running)" with every ship on
|
||||
both sides destroyed — per REQ-BAL-SIM-END the station-less team
|
||||
should have lost the moment its last ship died. Round 2 ended
|
||||
correctly, so it may only be a log-snapshot artifact; confirm, and
|
||||
fix the end condition if it can genuinely idle in that state.
|
||||
@@ -224,12 +224,13 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
2. The `production_time_seconds` of every module instance in the configured layout.
|
||||
3. For every material required (the union of the ship's base materials and all module instance materials, with quantities summed per item type): the recursive production time of that material multiplied by the required quantity (see REQ-THREAT-ITEM).
|
||||
|
||||
- REQ-THREAT-ITEM: The threat value of an item type (in seconds) is determined by the recipe that produces it:
|
||||
- **Miner recipe**: the recipe's `duration_seconds`.
|
||||
- **Smelter recipe**: the recipe's `duration_seconds` plus the sum of each input's threat value multiplied by that input's required quantity.
|
||||
- **Assembler recipe**: the recipe's `duration_seconds` plus the sum of each input's threat value multiplied by that input's required quantity.
|
||||
- **Reprocessing-only item** (an item type that has no miner, smelter, or assembler recipe producing it, and is only obtainable via reprocessing): `(scrap_threat × scrap_per_cycle + duration_seconds) / probability`, where `scrap_threat` is the threat value of scrap (see REQ-THREAT-SCRAP), `scrap_per_cycle` is the number of scrap consumed per reprocessing cycle, `duration_seconds` is the reprocessing cycle time, and `probability` is the normalized weight of that item in the reprocessing output pool.
|
||||
- **Multiple recipes**: if an item type can be produced by more than one non-reprocessing recipe (miner, smelter, or assembler), its threat value is the **maximum** across all such recipes. The reprocessing path is only used when no other recipe exists.
|
||||
- REQ-THREAT-ITEM: The threat value of an item type (in production-seconds **per unit**) is determined by the recipe that produces it:
|
||||
- **Miner recipe**: `duration_seconds / output_amount`, where `output_amount` is the number of units produced per cycle.
|
||||
- **Smelter recipe**: `(duration_seconds + Σ (input_threat × input_amount)) / output_amount`, where the sum is over all inputs.
|
||||
- **Assembler recipe**: `(duration_seconds + Σ (input_threat × input_amount)) / output_amount`, where the sum is over all inputs.
|
||||
- **Reprocessing-only item** (an item type that has no miner, smelter, or assembler recipe producing it, and is only obtainable via reprocessing): `(scrap_threat × scrap_per_cycle + duration_seconds) / probability`, where `scrap_threat` is the threat value of scrap (see REQ-THREAT-SCRAP), `scrap_per_cycle` is the number of scrap consumed per reprocessing cycle, `duration_seconds` is the reprocessing cycle time, and `probability` is the normalized weight of that item in the reprocessing output pool. (Reprocessing output amounts are 1 in practice, so per-unit division is already implicit in the formula.)
|
||||
- **Multiple recipes**: if an item type can be produced by more than one non-reprocessing recipe (miner, smelter, or assembler), its threat value is the **maximum** across **all** such eligible recipes, and the threat is committed only once every eligible recipe is computable (so a shallow shortcut recipe that resolves earlier than a deeper base recipe cannot lower the item's threat). The reprocessing path is only used when no other recipe exists. If recipe cycles prevent full resolution, the max over the currently computable subset is used as a fallback.
|
||||
- **Scrap-consuming recipe fallback**: a non-reprocessing recipe that takes `scrap` as an input participates in an item's threat computation only if no scrap-free recipe (miner, smelter, or assembler) produces that item. This mirrors the reprocessing fallback rule and prevents the scrap-to-ingot smelter recipe from inflating basic material threats via the max rule.
|
||||
|
||||
- REQ-THREAT-SCRAP: The threat value of scrap is the constant `1 / world.toml [world].scrap_per_threat`. This is the exact inverse of the scrap-drop conversion in REQ-RES-SCRAP-DROP, so a destroyed ship drops scrap worth precisely its own threat cost. Because scrap threat is now a fixed constant, it no longer depends on any ship's threat cost, removing the potential circularity with REQ-MOD-THREAT for ships built from reprocessing-only materials.
|
||||
- REQ-MOD-STAT-CALC: For each stat (on the ship hull or on a capability module instance), the final value is computed as: `final = base × total_multiplier + total_additive`, where:
|
||||
@@ -511,13 +512,17 @@ A separate executable target (`balancing`) that links against `lib` but contains
|
||||
|
||||
### UI
|
||||
|
||||
- REQ-BAL-UI-WINDOW: On startup the tool displays a window containing a "Reload Config" button and a "Start All" button at the top (in that order, left to right), followed by a scrollable vertical list of arena widgets, one per arena defined in `balancing.toml`. Simulations do not start automatically on startup. All buttons and controls in the main window are disabled while an arena is being inspected (REQ-BAL-UI-INSPECT).
|
||||
- REQ-BAL-UI-WINDOW: On startup the tool displays a window containing a "Reload Config" button, a "Start All" button, and a "Log" button at the top (in that order, left to right), followed by a scrollable vertical list of arena widgets, one per arena defined in `balancing.toml`. Simulations do not start automatically on startup. All buttons and controls in the main window are disabled while an arena is being inspected (REQ-BAL-UI-INSPECT).
|
||||
- REQ-BAL-UI-RELOAD: The "Reload Config" button reloads all config files from disk (`balancing.toml`, `ships.toml`, `stations.toml`), stops any running simulations, and replaces the arena widget list with freshly created widgets from the reloaded config. The button is disabled while any arena simulation is currently running.
|
||||
- REQ-BAL-UI-START-ALL: The "Start All" button is placed above the scrollable arena list, to the right of the "Reload Config" button. Clicking it starts (or restarts) the simulation for every arena that is not currently running. The button is disabled when all arenas are currently running.
|
||||
- REQ-BAL-UI-WIDGET: Each arena widget displays the arena name, an "Inspect" button (to the right of the arena name), and two columns (one per team). Each column shows the team name as a header, then directly below the header the team's **accumulated threat level** — the sum, across the team's configured ship entries, of each entry's `count` multiplied by the threat cost (REQ-MOD-THREAT) of one ship of that entry computed from its level-independent module layout. Only ships contribute; the HQ and defence stations are excluded. This value is static: it is computed once from the full configured roster and does not change as ships are destroyed during the fight. Below the threat level, the column shows a list of entries. The HQ is always the first entry in each column. Below the HQ, ship types are listed, followed by defence stations (if any). Each entry uses the format `surviving/total TypeName` for ship entries and `surviving/total TypeName Llevel` for the HQ and defence station entries — for example `2/3 Fighter`, `1/1 HQ L1`, or `2/2 Enemy Station L3`. The surviving count updates live as the simulation progresses. When the fight ends, the winning team's name header is prefixed with `[WON]`.
|
||||
- REQ-BAL-UI-LOG: The "Log" button is placed in the header to the right of the "Start All" button. It is enabled whenever the main window's controls are enabled — including while simulations are running (it captures a live snapshot) — and, like all main window controls, is disabled only while an arena is being inspected (REQ-BAL-UI-WINDOW). Clicking it writes the current state of every arena to a file named `balancing_log.md` in the tool's current working directory, replacing (overwriting) any previous content of that file. The log captures, for every arena in `balancing.toml` order, exactly the information shown in that arena's widget (REQ-BAL-UI-WIDGET) at the moment the button is clicked. Each arena is written as its own section:
|
||||
- A heading with the arena name followed by the arena's current state — `not started`, `running`, or `ended` (corresponding to the widget border colors of REQ-BAL-UI-WIDGET-BORDER). For an `ended` arena, the heading also includes the battle duration (REQ-BAL-UI-WIDGET), for example `ended, 42.3 s`.
|
||||
- A markdown table with one column per team (team 1 left, team 2 right). Each team's column header shows the team name — prefixed with `[WON]` when that team won, matching REQ-BAL-UI-WIDGET — the team's accumulated threat level, and the team's remaining EHP percentage (REQ-BAL-UI-WIDGET).
|
||||
- Below the header, each table row holds one of that team's entries, in the same order and text format as the widget (REQ-BAL-UI-WIDGET): the HQ first, then ship types, then defence stations, formatted `surviving/total TypeName` for ship entries and `surviving/total TypeName Llevel` for the HQ and defence station entries. When the two teams have different numbers of entries, the shorter column's remaining cells are left blank.
|
||||
- REQ-BAL-UI-WIDGET: Each arena widget displays the arena name, an "Inspect" button (to the right of the arena name), and two columns (one per team). Each column shows the team name as a header, then directly below the header the team's **accumulated threat level** — the sum, across the team's configured ship entries, of each entry's `count` multiplied by the threat cost (REQ-MOD-THREAT) of one ship of that entry computed from its level-independent module layout. Only ships contribute; the HQ and defence stations are excluded. This value is static: it is computed once from the full configured roster and does not change as ships are destroyed during the fight. Directly below the threat level, the column shows the team's **remaining EHP percentage** — the sum of the current HP of all of the team's ships and defence stations, divided by the sum of their maximum HP, expressed as a percentage rounded to a whole number. Maximum HP is the final per-entity maximum (REQ-MOD-STAT-CALC), so module HP bonuses such as armor plates are included. (The game has no damage mitigation, so effective HP equals raw HP.) The HQ is excluded from both sums. A destroyed ship or station contributes 0 to the numerator and its maximum HP to the denominator, so the value measures how much of the team's fielded durability remains: it starts at 100% and decreases as units take damage or are destroyed. Unlike the static threat level, this value updates live as the simulation progresses. If the team has neither ships nor defence stations (the denominator is 0), the percentage is shown as `n/a`. Below the EHP percentage, the column shows a list of entries. The HQ is always the first entry in each column. Below the HQ, ship types are listed, followed by defence stations (if any). Each entry uses the format `surviving/total TypeName` for ship entries and `surviving/total TypeName Llevel` for the HQ and defence station entries — for example `2/3 Fighter`, `1/1 HQ L1`, or `2/2 Enemy Station L3`. The surviving count updates live as the simulation progresses. When the fight ends, the winning team's name header is prefixed with `[WON]`. When the fight has ended, the widget also displays the arena's **battle duration** — an arena-level value (not per team) giving the game time the fight lasted, computed as the number of simulated ticks at completion multiplied by the simulation's fixed tick duration (the same tick-based simulation as the main game, REQ-BAL-SIM-ENV). This is game time, not wall-clock time, so it is independent of how fast the arena was simulated (non-inspected arenas run at maximum tick rate, REQ-BAL-SIM-SPEED). It is shown in seconds with one decimal place, for example `Duration: 42.3 s`. The battle duration is shown only for completed (ended) runs; it is not shown while the arena is not started or running.
|
||||
- REQ-BAL-UI-WIDGET-START: Each arena widget contains a "Start" button that starts the simulation for that arena. The button is disabled while the arena's simulation is running. When a finished arena's Start button is clicked, a fresh simulation is created and started (the widget resets to initial unit counts, the border returns to blue, and the previous results are replaced).
|
||||
- REQ-BAL-UI-WIDGET-BORDER: Each arena widget has a colored border indicating its state: grey when not yet started, blue while its simulation is running, and green when the fight has ended.
|
||||
- REQ-BAL-UI-INSPECT: Clicking an arena widget's "Inspect" button opens a new inspect window for that arena. Any previously open inspect window is closed first (its arena's simulation is aborted and its widget border returns to grey). The inspected arena is restarted with a fresh simulation that runs at controllable game speed with full rendering (REQ-BAL-SIM-SPEED). The arena widget updates live during inspection (surviving counts, border color, `[WON]` prefix) as it does for non-inspected arenas. Only one inspect window may be open at a time.
|
||||
- REQ-BAL-UI-INSPECT-WINDOW: The inspect window consists of three sections, top to bottom: a title bar area containing the arena name and game speed controls (same buttons as the main game: 0×, 0.5×, 1×, 2×, 4×, with Space to toggle pause — see REQ-UI-SPEED and REQ-UI-HOTKEYS), the arena view in the center, and an info panel at the bottom displaying the same team columns and entry format as the arena widget in the main window (REQ-BAL-UI-WIDGET), updated live.
|
||||
- REQ-BAL-UI-INSPECT-WINDOW: The inspect window consists of three sections, top to bottom: a title bar area containing the arena name and game speed controls (same buttons as the main game: 0×, 0.5×, 1×, 2×, 4×, with Space to toggle pause — see REQ-UI-SPEED and REQ-UI-HOTKEYS), the arena view in the center, and an info panel at the bottom displaying the same team columns and entry format as the arena widget in the main window (REQ-BAL-UI-WIDGET), updated live, including the arena's battle duration once the fight has ended (REQ-BAL-UI-WIDGET).
|
||||
- REQ-BAL-UI-INSPECT-VIEW: The arena view renders all tiles of the arena and displays ships, HQs, defence stations, and laser beams using the same visual elements and `visuals.toml` colors as the main game. Team 1 uses player visual styles; team 2 uses enemy visual styles. The view has a fixed zoom level — no zoom or scroll is possible. The tile size is derived so that the full arena (all tiles) fits within the view.
|
||||
- REQ-BAL-UI-INSPECT-CLOSE: Closing the inspect window (via the window's close button) aborts the inspected arena's simulation. The arena widget's border returns to grey and its surviving counts are left as they were at the moment of closing. All main window buttons and controls are re-enabled.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
@@ -86,12 +88,49 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
|
||||
|
||||
placeStructures();
|
||||
spawnShips();
|
||||
computeTeamMaxEhp();
|
||||
|
||||
m_shipSystem->triggerRallyDeparture();
|
||||
|
||||
updateStatus();
|
||||
}
|
||||
|
||||
std::string ArenaStatus::TeamStatus::ehpPercentText() const
|
||||
{
|
||||
if (maxEhp <= 0.0)
|
||||
{
|
||||
return "n/a";
|
||||
}
|
||||
const int percent = static_cast<int>(std::lround(100.0 * currentEhp / maxEhp));
|
||||
return std::to_string(percent) + "%";
|
||||
}
|
||||
|
||||
void ArenaSimulation::computeTeamMaxEhp()
|
||||
{
|
||||
m_teamMaxEhp[0] = 0.0;
|
||||
m_teamMaxEhp[1] = 0.0;
|
||||
|
||||
// Ships contribute their full max HP.
|
||||
m_admin.forEach<ShipIdentityComponent, FactionComponent, HealthComponent>(
|
||||
[this](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
|
||||
const FactionComponent& f, const HealthComponent& h)
|
||||
{
|
||||
m_teamMaxEhp[f.isEnemy ? 1 : 0] += static_cast<double>(h.maxHp);
|
||||
});
|
||||
|
||||
// Defence stations contribute their full max HP; the HQ is excluded.
|
||||
m_admin.forEach<StationBodyComponent, FactionComponent, HealthComponent>(
|
||||
[this](entt::entity e, const StationBodyComponent& /*sb*/,
|
||||
const FactionComponent& f, const HealthComponent& h)
|
||||
{
|
||||
if (m_admin.hasAll<HqProxyComponent>(e))
|
||||
{
|
||||
return;
|
||||
}
|
||||
m_teamMaxEhp[f.isEnemy ? 1 : 0] += static_cast<double>(h.maxHp);
|
||||
});
|
||||
}
|
||||
|
||||
ArenaSimulation::~ArenaSimulation() = default;
|
||||
|
||||
BuildingId ArenaSimulation::allocateBuildingId()
|
||||
@@ -394,10 +433,13 @@ void ArenaSimulation::tickDeaths()
|
||||
});
|
||||
|
||||
m_admin.forEach<StationBodyComponent, FactionComponent>(
|
||||
[&team1HasUnits, &team2HasUnits](entt::entity /*e*/,
|
||||
[this, &team1HasUnits, &team2HasUnits](entt::entity e,
|
||||
const StationBodyComponent& /*sb*/,
|
||||
const FactionComponent& f)
|
||||
{
|
||||
// The HQ carries a StationBodyComponent but is not a defence station;
|
||||
// its destruction is a separate end condition (REQ-BAL-SIM-END).
|
||||
if (m_admin.hasAll<HqProxyComponent>(e)) { return; }
|
||||
if (f.isEnemy) { team2HasUnits = true; }
|
||||
else { team1HasUnits = true; }
|
||||
});
|
||||
@@ -476,12 +518,41 @@ void ArenaSimulation::updateStatus()
|
||||
ArenaStatus newStatus;
|
||||
newStatus.finished = m_finished;
|
||||
newStatus.winnerTeam = m_winnerTeam;
|
||||
newStatus.durationSeconds = ticksToSeconds(m_currentTick);
|
||||
|
||||
// Live remaining HP of each team's ships and defence stations (HQ excluded);
|
||||
// the EHP-percentage numerator (denominator is the fixed m_teamMaxEhp).
|
||||
double currentEhp[2] = {0.0, 0.0};
|
||||
m_admin.forEach<ShipIdentityComponent, FactionComponent, HealthComponent>(
|
||||
[¤tEhp](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
|
||||
const FactionComponent& f, const HealthComponent& h)
|
||||
{
|
||||
if (h.hp > 0.0f)
|
||||
{
|
||||
currentEhp[f.isEnemy ? 1 : 0] += static_cast<double>(h.hp);
|
||||
}
|
||||
});
|
||||
m_admin.forEach<StationBodyComponent, FactionComponent, HealthComponent>(
|
||||
[this, ¤tEhp](entt::entity e, const StationBodyComponent& /*sb*/,
|
||||
const FactionComponent& f, const HealthComponent& h)
|
||||
{
|
||||
if (m_admin.hasAll<HqProxyComponent>(e))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (h.hp > 0.0f)
|
||||
{
|
||||
currentEhp[f.isEnemy ? 1 : 0] += static_cast<double>(h.hp);
|
||||
}
|
||||
});
|
||||
|
||||
for (int ti = 0; ti < 2; ++ti)
|
||||
{
|
||||
ArenaStatus::TeamStatus& teamStatus = newStatus.teams[ti];
|
||||
teamStatus.name = m_arenaConfig.teams[ti].name;
|
||||
teamStatus.threatLevel = m_teamThreat[ti];
|
||||
teamStatus.currentEhp = currentEhp[ti];
|
||||
teamStatus.maxEhp = m_teamMaxEhp[ti];
|
||||
|
||||
// HQ entry (always first).
|
||||
{
|
||||
|
||||
@@ -44,12 +44,24 @@ struct ArenaStatus
|
||||
{
|
||||
std::string name;
|
||||
double threatLevel = 0.0; // accumulated threat of the team's configured ships
|
||||
// Remaining durability of the team's ships and defence stations (HQ
|
||||
// excluded). currentEhp is summed live; maxEhp is the fixed full-HP
|
||||
// baseline. See ehpPercentText() for the displayed value.
|
||||
double currentEhp = 0.0;
|
||||
double maxEhp = 0.0;
|
||||
std::vector<Entry> entries; // HQ first, then ships, then stations
|
||||
|
||||
// Remaining EHP as a whole-number percentage ("NN%"), or "n/a" when the
|
||||
// team has no ships or stations (maxEhp == 0).
|
||||
std::string ehpPercentText() const;
|
||||
};
|
||||
|
||||
TeamStatus teams[2];
|
||||
bool finished = false;
|
||||
int winnerTeam = -1; // 0 or 1 when finished; -1 while running
|
||||
// Game time the fight has lasted (simulated ticks * fixed tick duration).
|
||||
// Meaningful once finished; the battle duration shown for completed runs.
|
||||
double durationSeconds = 0.0;
|
||||
};
|
||||
|
||||
class ArenaSimulation
|
||||
@@ -82,6 +94,7 @@ private:
|
||||
BuildingId allocateBuildingId();
|
||||
void placeStructures();
|
||||
void spawnShips();
|
||||
void computeTeamMaxEhp();
|
||||
void tick();
|
||||
void tickDeaths();
|
||||
void updateStatus();
|
||||
@@ -115,6 +128,10 @@ private:
|
||||
// Static accumulated threat per team, computed once from the configured roster.
|
||||
double m_teamThreat[2] = {0.0, 0.0};
|
||||
|
||||
// Full-HP baseline per team (ships + defence stations, HQ excluded), computed
|
||||
// once after spawning; the EHP-percentage denominator.
|
||||
double m_teamMaxEhp[2] = {0.0, 0.0};
|
||||
|
||||
std::vector<BeamFiredEvent> m_beamFiredEvents;
|
||||
|
||||
mutable std::mutex m_statusMutex;
|
||||
|
||||
@@ -33,6 +33,9 @@ void ArenaWidget::buildLayout(const std::string& arenaName)
|
||||
m_titleLabel->setFont(titleFont);
|
||||
titleRow->addWidget(m_titleLabel);
|
||||
|
||||
m_durationLabel = new QLabel(this);
|
||||
titleRow->addWidget(m_durationLabel);
|
||||
|
||||
titleRow->addStretch();
|
||||
|
||||
m_inspectButton = new QPushButton(tr("Inspect"), this);
|
||||
@@ -63,6 +66,8 @@ void ArenaWidget::buildLayout(const std::string& arenaName)
|
||||
team1Layout->addWidget(m_team1Header);
|
||||
m_team1Threat = new QLabel(this);
|
||||
team1Layout->addWidget(m_team1Threat);
|
||||
m_team1Ehp = new QLabel(this);
|
||||
team1Layout->addWidget(m_team1Ehp);
|
||||
m_team1Content = new QLabel(this);
|
||||
team1Layout->addWidget(m_team1Content);
|
||||
team1Layout->addStretch();
|
||||
@@ -75,6 +80,8 @@ void ArenaWidget::buildLayout(const std::string& arenaName)
|
||||
team2Layout->addWidget(m_team2Header);
|
||||
m_team2Threat = new QLabel(this);
|
||||
team2Layout->addWidget(m_team2Threat);
|
||||
m_team2Ehp = new QLabel(this);
|
||||
team2Layout->addWidget(m_team2Ehp);
|
||||
m_team2Content = new QLabel(this);
|
||||
team2Layout->addWidget(m_team2Content);
|
||||
team2Layout->addStretch();
|
||||
@@ -99,13 +106,29 @@ void ArenaWidget::resetToGrey()
|
||||
setStyleSheet("ArenaWidget { border: 2px solid #999999; padding: 8px; }");
|
||||
}
|
||||
|
||||
ArenaWidget::State ArenaWidget::getState() const
|
||||
{
|
||||
if (m_wasFinished)
|
||||
{
|
||||
return State::Ended;
|
||||
}
|
||||
if (m_running)
|
||||
{
|
||||
return State::Running;
|
||||
}
|
||||
return State::NotStarted;
|
||||
}
|
||||
|
||||
void ArenaWidget::updateStatus(const ArenaStatus& status)
|
||||
{
|
||||
m_lastStatus = status;
|
||||
|
||||
for (int ti = 0; ti < 2; ++ti)
|
||||
{
|
||||
const ArenaStatus::TeamStatus& team = status.teams[ti];
|
||||
QLabel* header = (ti == 0) ? m_team1Header : m_team2Header;
|
||||
QLabel* threat = (ti == 0) ? m_team1Threat : m_team2Threat;
|
||||
QLabel* ehp = (ti == 0) ? m_team1Ehp : m_team2Ehp;
|
||||
QLabel* content = (ti == 0) ? m_team1Content : m_team2Content;
|
||||
|
||||
if (status.finished && status.winnerTeam == ti)
|
||||
@@ -118,6 +141,7 @@ void ArenaWidget::updateStatus(const ArenaStatus& status)
|
||||
}
|
||||
|
||||
threat->setText(tr("Threat: %1").arg(QString::number(team.threatLevel, 'f', 0)));
|
||||
ehp->setText(tr("EHP: %1").arg(QString::fromStdString(team.ehpPercentText())));
|
||||
|
||||
QString lines;
|
||||
for (const ArenaStatus::Entry& entry : team.entries)
|
||||
@@ -138,6 +162,10 @@ void ArenaWidget::updateStatus(const ArenaStatus& status)
|
||||
content->setText(lines);
|
||||
}
|
||||
|
||||
m_durationLabel->setText(status.finished
|
||||
? tr("Duration: %1 s").arg(QString::number(status.durationSeconds, 'f', 1))
|
||||
: QString());
|
||||
|
||||
if (status.finished && !m_wasFinished)
|
||||
{
|
||||
m_wasFinished = true;
|
||||
|
||||
@@ -14,21 +14,35 @@ class ArenaWidget : public QFrame
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
enum class State
|
||||
{
|
||||
NotStarted,
|
||||
Running,
|
||||
Ended
|
||||
};
|
||||
|
||||
ArenaWidget(int arenaIndex, const std::string& arenaName, QWidget* parent = nullptr);
|
||||
|
||||
void updateStatus(const ArenaStatus& status);
|
||||
void startSimulation();
|
||||
void resetToGrey();
|
||||
|
||||
const ArenaStatus& getLastStatus() const { return m_lastStatus; }
|
||||
State getState() const;
|
||||
|
||||
private:
|
||||
void buildLayout(const std::string& arenaName);
|
||||
|
||||
int m_arenaIndex;
|
||||
ArenaStatus m_lastStatus;
|
||||
QLabel* m_titleLabel;
|
||||
QLabel* m_durationLabel;
|
||||
QLabel* m_team1Header;
|
||||
QLabel* m_team2Header;
|
||||
QLabel* m_team1Threat;
|
||||
QLabel* m_team2Threat;
|
||||
QLabel* m_team1Ehp;
|
||||
QLabel* m_team2Ehp;
|
||||
QLabel* m_team1Content;
|
||||
QLabel* m_team2Content;
|
||||
QPushButton* m_inspectButton;
|
||||
|
||||
@@ -1,13 +1,70 @@
|
||||
#include "BalancingWindow.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include <QFile>
|
||||
#include <QHBoxLayout>
|
||||
#include <QMessageBox>
|
||||
#include <QTextStream>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "ConfigLoader.h"
|
||||
#include "InspectWindow.h"
|
||||
#include "VisualsLoader.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
// Escapes characters that would break a markdown table cell.
|
||||
QString escapeCell(const QString& text)
|
||||
{
|
||||
QString escaped = text;
|
||||
escaped.replace("|", "\\|");
|
||||
return escaped;
|
||||
}
|
||||
|
||||
QString stateText(ArenaWidget::State state)
|
||||
{
|
||||
switch (state)
|
||||
{
|
||||
case ArenaWidget::State::NotStarted:
|
||||
return QStringLiteral("not started");
|
||||
case ArenaWidget::State::Running:
|
||||
return QStringLiteral("running");
|
||||
case ArenaWidget::State::Ended:
|
||||
return QStringLiteral("ended");
|
||||
}
|
||||
return QString();
|
||||
}
|
||||
|
||||
// Team column header: "[WON] Name — threat N", matching the arena widget.
|
||||
QString teamHeaderCell(const ArenaStatus& status, int teamIndex)
|
||||
{
|
||||
const ArenaStatus::TeamStatus& team = status.teams[teamIndex];
|
||||
QString header = QString::fromStdString(team.name);
|
||||
if (status.finished && status.winnerTeam == teamIndex)
|
||||
{
|
||||
header = QStringLiteral("[WON] ") + header;
|
||||
}
|
||||
header += QStringLiteral(" - threat %1").arg(QString::number(team.threatLevel, 'f', 0));
|
||||
header += QStringLiteral(" - EHP %1").arg(QString::fromStdString(team.ehpPercentText()));
|
||||
return escapeCell(header);
|
||||
}
|
||||
|
||||
// Single entry line: "surviving/total DisplayName [L<level>]", matching the arena widget.
|
||||
QString entryCell(const ArenaStatus::Entry& entry)
|
||||
{
|
||||
QString cell = QString("%1/%2 %3")
|
||||
.arg(entry.surviving)
|
||||
.arg(entry.total)
|
||||
.arg(QString::fromStdString(entry.displayName));
|
||||
if (entry.level.has_value())
|
||||
{
|
||||
cell += QStringLiteral(" L%1").arg(entry.level.value());
|
||||
}
|
||||
return escapeCell(cell);
|
||||
}
|
||||
}
|
||||
|
||||
BalancingWindow::BalancingWindow(const BalancingConfig& balancingConfig,
|
||||
GameConfig gameConfig,
|
||||
const std::string& configDir,
|
||||
@@ -31,13 +88,16 @@ BalancingWindow::BalancingWindow(const BalancingConfig& balancingConfig,
|
||||
QHBoxLayout* buttonRow = new QHBoxLayout();
|
||||
m_reloadButton = new QPushButton(tr("Reload Config"), this);
|
||||
m_startAllButton = new QPushButton(tr("Start All"), this);
|
||||
m_logButton = new QPushButton(tr("Log"), this);
|
||||
buttonRow->addWidget(m_reloadButton);
|
||||
buttonRow->addWidget(m_startAllButton);
|
||||
buttonRow->addWidget(m_logButton);
|
||||
buttonRow->addStretch();
|
||||
mainLayout->addLayout(buttonRow);
|
||||
|
||||
connect(m_reloadButton, &QPushButton::clicked, this, &BalancingWindow::reloadConfig);
|
||||
connect(m_startAllButton, &QPushButton::clicked, this, &BalancingWindow::startAll);
|
||||
connect(m_logButton, &QPushButton::clicked, this, &BalancingWindow::writeLog);
|
||||
|
||||
m_scrollArea = new QScrollArea(this);
|
||||
m_scrollArea->setWidgetResizable(true);
|
||||
@@ -255,6 +315,7 @@ void BalancingWindow::setMainControlsEnabled(bool enabled)
|
||||
{
|
||||
m_reloadButton->setEnabled(enabled);
|
||||
m_startAllButton->setEnabled(enabled);
|
||||
m_logButton->setEnabled(enabled);
|
||||
for (ArenaEntry& entry : m_arenas)
|
||||
{
|
||||
for (QPushButton* btn : entry.widget->findChildren<QPushButton*>())
|
||||
@@ -291,4 +352,58 @@ void BalancingWindow::updateButtons()
|
||||
|
||||
m_reloadButton->setEnabled(!anyRunning);
|
||||
m_startAllButton->setEnabled(!allRunning);
|
||||
m_logButton->setEnabled(true);
|
||||
}
|
||||
|
||||
void BalancingWindow::writeLog()
|
||||
{
|
||||
QFile file(QStringLiteral("balancing_log.md"));
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text))
|
||||
{
|
||||
QMessageBox::warning(this, tr("Log Failed"),
|
||||
tr("Could not open balancing_log.md for writing."));
|
||||
return;
|
||||
}
|
||||
|
||||
QTextStream out(&file);
|
||||
out.setCodec("UTF-8");
|
||||
|
||||
out << "# Balancing Log\n";
|
||||
|
||||
for (const ArenaEntry& entry : m_arenas)
|
||||
{
|
||||
const ArenaStatus& status = entry.widget->getLastStatus();
|
||||
const ArenaWidget::State state = entry.widget->getState();
|
||||
|
||||
QString stateSuffix = stateText(state);
|
||||
if (state == ArenaWidget::State::Ended)
|
||||
{
|
||||
stateSuffix += QStringLiteral(", %1 s")
|
||||
.arg(QString::number(status.durationSeconds, 'f', 1));
|
||||
}
|
||||
|
||||
out << "\n## Arena: " << QString::fromStdString(entry.config.name)
|
||||
<< " (" << stateSuffix << ")\n\n";
|
||||
|
||||
out << "| " << teamHeaderCell(status, 0) << " | "
|
||||
<< teamHeaderCell(status, 1) << " |\n";
|
||||
out << "|---|---|\n";
|
||||
|
||||
const std::size_t rowCount = std::max(status.teams[0].entries.size(),
|
||||
status.teams[1].entries.size());
|
||||
for (std::size_t row = 0; row < rowCount; ++row)
|
||||
{
|
||||
QString leftCell;
|
||||
if (row < status.teams[0].entries.size())
|
||||
{
|
||||
leftCell = entryCell(status.teams[0].entries[row]);
|
||||
}
|
||||
QString rightCell;
|
||||
if (row < status.teams[1].entries.size())
|
||||
{
|
||||
rightCell = entryCell(status.teams[1].entries[row]);
|
||||
}
|
||||
out << "| " << leftCell << " | " << rightCell << " |\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ private slots:
|
||||
void pollStatuses();
|
||||
void reloadConfig();
|
||||
void startAll();
|
||||
void writeLog();
|
||||
|
||||
private:
|
||||
void startArena(int index);
|
||||
@@ -72,6 +73,7 @@ private:
|
||||
unsigned int m_nextSeed;
|
||||
QPushButton* m_reloadButton;
|
||||
QPushButton* m_startAllButton;
|
||||
QPushButton* m_logButton;
|
||||
QScrollArea* m_scrollArea;
|
||||
QTimer* m_pollTimer;
|
||||
|
||||
|
||||
@@ -52,6 +52,9 @@ InspectWindow::InspectWindow(ArenaSimulation* sim, const GameConfig* config,
|
||||
nameLabel->setFont(nameFont);
|
||||
headerLayout->addWidget(nameLabel);
|
||||
|
||||
m_durationLabel = new QLabel(header);
|
||||
headerLayout->addWidget(m_durationLabel);
|
||||
|
||||
headerLayout->addStretch();
|
||||
|
||||
const char* labels[] = { "0x", "0.5x", "1x", "2x", "10x" };
|
||||
@@ -93,6 +96,8 @@ InspectWindow::InspectWindow(ArenaSimulation* sim, const GameConfig* config,
|
||||
team1Layout->addWidget(m_team1Header);
|
||||
m_team1Threat = new QLabel(infoPanel);
|
||||
team1Layout->addWidget(m_team1Threat);
|
||||
m_team1Ehp = new QLabel(infoPanel);
|
||||
team1Layout->addWidget(m_team1Ehp);
|
||||
m_team1Content = new QLabel(infoPanel);
|
||||
team1Layout->addWidget(m_team1Content);
|
||||
team1Layout->addStretch();
|
||||
@@ -104,6 +109,8 @@ InspectWindow::InspectWindow(ArenaSimulation* sim, const GameConfig* config,
|
||||
team2Layout->addWidget(m_team2Header);
|
||||
m_team2Threat = new QLabel(infoPanel);
|
||||
team2Layout->addWidget(m_team2Threat);
|
||||
m_team2Ehp = new QLabel(infoPanel);
|
||||
team2Layout->addWidget(m_team2Ehp);
|
||||
m_team2Content = new QLabel(infoPanel);
|
||||
team2Layout->addWidget(m_team2Content);
|
||||
team2Layout->addStretch();
|
||||
@@ -198,11 +205,16 @@ void InspectWindow::pollStatus()
|
||||
|
||||
void InspectWindow::updateInfoPanel(const ArenaStatus& status)
|
||||
{
|
||||
m_durationLabel->setText(status.finished
|
||||
? tr("Duration: %1 s").arg(QString::number(status.durationSeconds, 'f', 1))
|
||||
: QString());
|
||||
|
||||
for (int ti = 0; ti < 2; ++ti)
|
||||
{
|
||||
const ArenaStatus::TeamStatus& team = status.teams[ti];
|
||||
QLabel* header = (ti == 0) ? m_team1Header : m_team2Header;
|
||||
QLabel* threat = (ti == 0) ? m_team1Threat : m_team2Threat;
|
||||
QLabel* ehp = (ti == 0) ? m_team1Ehp : m_team2Ehp;
|
||||
QLabel* content = (ti == 0) ? m_team1Content : m_team2Content;
|
||||
|
||||
if (status.finished && status.winnerTeam == ti)
|
||||
@@ -215,6 +227,7 @@ void InspectWindow::updateInfoPanel(const ArenaStatus& status)
|
||||
}
|
||||
|
||||
threat->setText(tr("Threat: %1").arg(QString::number(team.threatLevel, 'f', 0)));
|
||||
ehp->setText(tr("EHP: %1").arg(QString::fromStdString(team.ehpPercentText())));
|
||||
|
||||
QString lines;
|
||||
for (const ArenaStatus::Entry& entry : team.entries)
|
||||
|
||||
@@ -54,10 +54,13 @@ private:
|
||||
ArenaView* m_arenaView;
|
||||
|
||||
std::vector<QPushButton*> m_speedButtons;
|
||||
QLabel* m_durationLabel;
|
||||
QLabel* m_team1Header;
|
||||
QLabel* m_team2Header;
|
||||
QLabel* m_team1Threat;
|
||||
QLabel* m_team2Threat;
|
||||
QLabel* m_team1Ehp;
|
||||
QLabel* m_team2Ehp;
|
||||
QLabel* m_team1Content;
|
||||
QLabel* m_team2Content;
|
||||
QTimer* m_pollTimer;
|
||||
|
||||
@@ -30,6 +30,7 @@ double computeMaterialThreat(const ThreatCostTable& table,
|
||||
return total;
|
||||
}
|
||||
|
||||
// Returns true if every input of the recipe has a resolved threat value.
|
||||
bool allInputsResolved(const RecipeDef& recipe,
|
||||
const std::map<std::string, double>& resolved)
|
||||
{
|
||||
@@ -43,15 +44,18 @@ bool allInputsResolved(const RecipeDef& recipe,
|
||||
return true;
|
||||
}
|
||||
|
||||
double computeRecipeThreat(const RecipeDef& recipe,
|
||||
const std::map<std::string, double>& resolved)
|
||||
// Computes the raw recipe threat (duration + sum of input threats × amounts),
|
||||
// divided by the output amount to get the per-unit threat.
|
||||
double computeRecipeThreatPerUnit(const RecipeDef& recipe,
|
||||
int outputAmount,
|
||||
const std::map<std::string, double>& resolved)
|
||||
{
|
||||
double threat = recipe.durationSeconds;
|
||||
for (const RecipeIngredient& input : recipe.inputs)
|
||||
{
|
||||
threat += resolved.at(input.item) * input.amount;
|
||||
}
|
||||
return threat;
|
||||
return threat / static_cast<double>(outputAmount);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -69,118 +73,259 @@ ThreatCostTable computeThreatCostTable(const GameConfig& config)
|
||||
? 1.0 / config.world.scrapPerThreat
|
||||
: 0.0;
|
||||
|
||||
// Build lookup: output item → non-reprocessing recipes and reprocessing recipes.
|
||||
std::map<std::string, std::vector<RecipeRef>> nonReprocessingRecipes;
|
||||
// -------------------------------------------------------------------------
|
||||
// Build per-item recipe lookup tables.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Items that have at least one non-reprocessing recipe that does NOT consume
|
||||
// scrap — these items' scrap-consuming recipes are excluded from threat
|
||||
// computation (REQ-THREAT-ITEM: scrap-consuming recipes are a fallback only).
|
||||
std::set<std::string> scrapFreeItems;
|
||||
|
||||
// nonReprocessingRecipes: item → all eligible non-reprocessing (recipe, output)
|
||||
// pairs. Scrap-consuming recipes are collected here temporarily; they are
|
||||
// filtered out per item after we know which items have a scrap-free producer.
|
||||
struct EligiblePair
|
||||
{
|
||||
const RecipeDef* recipe;
|
||||
int outputAmount;
|
||||
bool consumesScrap;
|
||||
};
|
||||
std::map<std::string, std::vector<EligiblePair>> nonReprocessingCandidates;
|
||||
|
||||
// reprocessingRecipes: item → all reprocessing-recipe refs (probability
|
||||
// values are raw from config; we normalize them per-recipe below).
|
||||
std::map<std::string, std::vector<RecipeRef>> reprocessingRecipes;
|
||||
|
||||
for (const RecipeDef& recipe : config.recipes.recipes)
|
||||
{
|
||||
if (recipe.building == BuildingType::ReprocessingPlant)
|
||||
{
|
||||
// Compute the total weight across all outputs of this reprocessing recipe
|
||||
// so we can normalize each output's probability.
|
||||
double totalWeight = 0.0;
|
||||
for (const RecipeOutput& out : recipe.outputs)
|
||||
{
|
||||
totalWeight += out.probability.value_or(1.0);
|
||||
}
|
||||
if (totalWeight <= 0.0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const RecipeOutput& out : recipe.outputs)
|
||||
{
|
||||
RecipeRef ref;
|
||||
ref.recipe = &recipe;
|
||||
ref.outputItem = out.item;
|
||||
ref.outputAmount = out.amount;
|
||||
ref.probability = out.probability.value_or(1.0);
|
||||
ref.probability = out.probability.value_or(1.0) / totalWeight;
|
||||
reprocessingRecipes[out.item].push_back(ref);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check whether this non-reprocessing recipe consumes scrap.
|
||||
bool consumesScrap = false;
|
||||
for (const RecipeIngredient& input : recipe.inputs)
|
||||
{
|
||||
if (input.item == "scrap")
|
||||
{
|
||||
consumesScrap = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const RecipeOutput& out : recipe.outputs)
|
||||
{
|
||||
RecipeRef ref;
|
||||
ref.recipe = &recipe;
|
||||
ref.outputItem = out.item;
|
||||
ref.outputAmount = out.amount;
|
||||
ref.probability = 1.0;
|
||||
nonReprocessingRecipes[out.item].push_back(ref);
|
||||
if (!consumesScrap)
|
||||
{
|
||||
scrapFreeItems.insert(out.item);
|
||||
}
|
||||
|
||||
EligiblePair pair;
|
||||
pair.recipe = &recipe;
|
||||
pair.outputAmount = out.amount;
|
||||
pair.consumesScrap = consumesScrap;
|
||||
nonReprocessingCandidates[out.item].push_back(pair);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all item names that need resolving.
|
||||
std::set<std::string> unresolved;
|
||||
for (const std::pair<const std::string, std::vector<RecipeRef>>& entry : nonReprocessingRecipes)
|
||||
// Filter nonReprocessingCandidates: for items that have at least one
|
||||
// scrap-free producer, drop their scrap-consuming recipes.
|
||||
// Build the final per-item list of (recipe, outputAmount) pairs eligible
|
||||
// for the max-across-recipes rule (REQ-THREAT-ITEM).
|
||||
std::map<std::string, std::vector<std::pair<const RecipeDef*, int>>> eligibleRecipes;
|
||||
for (std::map<std::string, std::vector<EligiblePair>>::const_iterator it =
|
||||
nonReprocessingCandidates.begin();
|
||||
it != nonReprocessingCandidates.end();
|
||||
++it)
|
||||
{
|
||||
unresolved.insert(entry.first);
|
||||
}
|
||||
for (const std::pair<const std::string, std::vector<RecipeRef>>& entry : reprocessingRecipes)
|
||||
{
|
||||
unresolved.insert(entry.first);
|
||||
const std::string& item = it->first;
|
||||
const std::vector<EligiblePair>& candidates = it->second;
|
||||
bool hasScrapFree = (scrapFreeItems.find(item) != scrapFreeItems.end());
|
||||
|
||||
for (const EligiblePair& candidate : candidates)
|
||||
{
|
||||
if (candidate.consumesScrap && hasScrapFree)
|
||||
{
|
||||
// Scrap-consuming recipe excluded: item has a scrap-free producer.
|
||||
continue;
|
||||
}
|
||||
eligibleRecipes[item].emplace_back(candidate.recipe, candidate.outputAmount);
|
||||
}
|
||||
}
|
||||
|
||||
// Iteratively resolve non-reprocessing items.
|
||||
bool progress = true;
|
||||
while (progress)
|
||||
// -------------------------------------------------------------------------
|
||||
// Resolution: seed resolved map with scrap, then alternate the
|
||||
// non-reprocessing pass and the reprocessing pass to a fixpoint.
|
||||
// Fix (8): iterate until neither pass makes progress, rather than running
|
||||
// the reprocessing pass once at the end.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::map<std::string, double>& resolved = table.itemThreat;
|
||||
resolved["scrap"] = table.scrapThreat;
|
||||
|
||||
// Non-reprocessing resolution pass.
|
||||
// Fix (9): commit an item only when EVERY eligible recipe for it is
|
||||
// computable, not just the first one that resolves. This ensures a shallow
|
||||
// shortcut recipe cannot undercut a deeper base recipe by resolving earlier.
|
||||
auto runNonReprocessingPass = [&](bool requireAllRecipes) -> bool
|
||||
{
|
||||
progress = false;
|
||||
std::set<std::string> newlyResolved;
|
||||
for (const std::string& item : unresolved)
|
||||
bool progress = false;
|
||||
std::map<std::string, double> newValues;
|
||||
|
||||
for (std::map<std::string, std::vector<std::pair<const RecipeDef*, int>>>::const_iterator
|
||||
it = eligibleRecipes.begin();
|
||||
it != eligibleRecipes.end();
|
||||
++it)
|
||||
{
|
||||
std::map<std::string, std::vector<RecipeRef>>::const_iterator it =
|
||||
nonReprocessingRecipes.find(item);
|
||||
if (it == nonReprocessingRecipes.end())
|
||||
const std::string& item = it->first;
|
||||
if (resolved.find(item) != resolved.end())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const std::vector<std::pair<const RecipeDef*, int>>& pairs = it->second;
|
||||
|
||||
bool allComputable = true;
|
||||
double maxThreat = -1.0;
|
||||
for (const RecipeRef& ref : it->second)
|
||||
for (const std::pair<const RecipeDef*, int>& pair : pairs)
|
||||
{
|
||||
if (allInputsResolved(*ref.recipe, table.itemThreat))
|
||||
if (!allInputsResolved(*pair.first, resolved))
|
||||
{
|
||||
double threat = computeRecipeThreat(*ref.recipe, table.itemThreat);
|
||||
if (threat > maxThreat)
|
||||
allComputable = false;
|
||||
if (requireAllRecipes)
|
||||
{
|
||||
maxThreat = threat;
|
||||
break;
|
||||
}
|
||||
// In fallback mode: skip this recipe but continue gathering
|
||||
// the computable subset.
|
||||
continue;
|
||||
}
|
||||
double threat = computeRecipeThreatPerUnit(*pair.first, pair.second, resolved);
|
||||
if (threat > maxThreat)
|
||||
{
|
||||
maxThreat = threat;
|
||||
}
|
||||
}
|
||||
|
||||
if (requireAllRecipes && !allComputable)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (maxThreat >= 0.0)
|
||||
{
|
||||
table.itemThreat[item] = maxThreat;
|
||||
newlyResolved.insert(item);
|
||||
progress = true;
|
||||
newValues[item] = maxThreat;
|
||||
}
|
||||
}
|
||||
for (const std::string& item : newlyResolved)
|
||||
{
|
||||
unresolved.erase(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve reprocessing-only items.
|
||||
for (const std::string& item : unresolved)
|
||||
for (std::map<std::string, double>::const_iterator it = newValues.begin();
|
||||
it != newValues.end();
|
||||
++it)
|
||||
{
|
||||
resolved[it->first] = it->second;
|
||||
progress = true;
|
||||
}
|
||||
return progress;
|
||||
};
|
||||
|
||||
// Reprocessing pass: resolve items produced exclusively by reprocessing.
|
||||
// Items that also have a non-reprocessing recipe are skipped here (they are
|
||||
// covered by the non-reprocessing pass or do not need the reprocessing path).
|
||||
auto runReprocessingPass = [&]() -> bool
|
||||
{
|
||||
std::map<std::string, std::vector<RecipeRef>>::const_iterator it =
|
||||
reprocessingRecipes.find(item);
|
||||
if (it == reprocessingRecipes.end())
|
||||
bool progress = false;
|
||||
for (std::map<std::string, std::vector<RecipeRef>>::const_iterator it =
|
||||
reprocessingRecipes.begin();
|
||||
it != reprocessingRecipes.end();
|
||||
++it)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const RecipeRef& ref : it->second)
|
||||
{
|
||||
int scrapPerCycle = 0;
|
||||
for (const RecipeIngredient& input : ref.recipe->inputs)
|
||||
const std::string& item = it->first;
|
||||
if (resolved.find(item) != resolved.end())
|
||||
{
|
||||
scrapPerCycle += input.amount;
|
||||
continue;
|
||||
}
|
||||
// Reprocessing defines an item's threat only when nothing else
|
||||
// produces it (REQ-THREAT-ITEM).
|
||||
if (scrapFreeItems.find(item) != scrapFreeItems.end())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Also skip items covered by eligible (non-reprocessing) recipes.
|
||||
if (eligibleRecipes.find(item) != eligibleRecipes.end())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double threat = (table.scrapThreat * scrapPerCycle
|
||||
+ ref.recipe->durationSeconds) / ref.probability;
|
||||
std::map<std::string, double>::iterator existing = table.itemThreat.find(item);
|
||||
if (existing == table.itemThreat.end() || threat > existing->second)
|
||||
for (const RecipeRef& ref : it->second)
|
||||
{
|
||||
table.itemThreat[item] = threat;
|
||||
// Sum all scrap inputs for this reprocessing recipe.
|
||||
int scrapPerCycle = 0;
|
||||
for (const RecipeIngredient& input : ref.recipe->inputs)
|
||||
{
|
||||
scrapPerCycle += input.amount;
|
||||
}
|
||||
|
||||
double threat = (table.scrapThreat * scrapPerCycle
|
||||
+ ref.recipe->durationSeconds) / ref.probability;
|
||||
|
||||
std::map<std::string, double>::iterator existing = resolved.find(item);
|
||||
if (existing == resolved.end() || threat > existing->second)
|
||||
{
|
||||
resolved[item] = threat;
|
||||
progress = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return progress;
|
||||
};
|
||||
|
||||
// Main fixpoint loop: alternate non-reprocessing and reprocessing passes
|
||||
// until neither makes any progress (fix 8).
|
||||
bool anyProgress = true;
|
||||
while (anyProgress)
|
||||
{
|
||||
anyProgress = runNonReprocessingPass(true);
|
||||
anyProgress = runReprocessingPass() || anyProgress;
|
||||
}
|
||||
|
||||
// Deadlock fallback: if any items remain unresolved due to recipe cycles,
|
||||
// fall back to committing with the max over the currently computable subset
|
||||
// of recipes (fix 9, deadlock guard — same approach as threat_report.py's
|
||||
// require_all_recipes=False mode).
|
||||
anyProgress = true;
|
||||
while (anyProgress)
|
||||
{
|
||||
anyProgress = runNonReprocessingPass(false);
|
||||
anyProgress = runReprocessingPass() || anyProgress;
|
||||
}
|
||||
|
||||
// Remove the sentinel scrap entry — scrapThreat is already stored on the
|
||||
// table struct; having it in itemThreat would confuse callers iterating items.
|
||||
resolved.erase("scrap");
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
|
||||
@@ -107,3 +107,62 @@ TEST_CASE("ThreatCostCalculator: unknown ship returns zero", "[threat]")
|
||||
double threat = calculateShipThreatCost(table, cfg, "nonexistent_ship", {});
|
||||
CHECK(threat == Approx(0.0));
|
||||
}
|
||||
|
||||
// Fix 6: scrap-consuming recipes are a fallback only.
|
||||
// iron_ingot has a scrap-free smelter recipe, so the scrap_iron recipe must
|
||||
// be excluded. iron_ingot threat must not be inflated by the scrap path.
|
||||
TEST_CASE("ThreatCostCalculator: scrap-consuming recipe excluded when scrap-free recipe exists", "[threat]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const ThreatCostTable& table = cfg.threatCosts;
|
||||
|
||||
// scrap_iron recipe: duration=1.0, 1 scrap (threat=1.0) -> 1 iron_ingot.
|
||||
// That would give 1.0 + 1.0*1 = 2.0 per unit — but it must be excluded
|
||||
// because the scrap-free iron_ingot smelter recipe (threat=4.0) exists.
|
||||
// iron_ingot threat stays at 4.0.
|
||||
CHECK(table.itemThreat.at("iron_ingot") == Approx(4.0));
|
||||
|
||||
// The pure reprocessing-only item (advanced_alloy) must still be resolved
|
||||
// via the reprocessing path.
|
||||
CHECK(table.itemThreat.count("advanced_alloy") == 1u);
|
||||
}
|
||||
|
||||
// Fix 7: per-unit item threat divides by output amount.
|
||||
// dual_wire: assembler, 1 iron_ore -> 2 dual_wire, duration 3.0.
|
||||
// Per-unit threat = (3.0 + iron_ore(1.0)*1) / 2 = 4.0 / 2 = 2.0.
|
||||
TEST_CASE("ThreatCostCalculator: per-unit division by output amount", "[threat]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const ThreatCostTable& table = cfg.threatCosts;
|
||||
|
||||
CHECK(table.itemThreat.at("dual_wire") == Approx(2.0));
|
||||
}
|
||||
|
||||
// Fix 8: fixpoint resolution — items downstream of reprocessing-only items
|
||||
// must be resolved after the reprocessing pass re-enables the non-reprocessing
|
||||
// pass.
|
||||
// downstream_product: assembler, 1 advanced_alloy -> 1, duration 2.0.
|
||||
// advanced_alloy = 80.0 (reprocessing-only).
|
||||
// downstream_product = 2.0 + 80.0*1 = 82.0.
|
||||
TEST_CASE("ThreatCostCalculator: downstream-of-reprocessing item resolves via fixpoint", "[threat]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const ThreatCostTable& table = cfg.threatCosts;
|
||||
|
||||
CHECK(table.itemThreat.at("advanced_alloy") == Approx(80.0));
|
||||
CHECK(table.itemThreat.at("downstream_product") == Approx(82.0));
|
||||
}
|
||||
|
||||
// Fix 9: max rule across staggered recipes — item is committed only once
|
||||
// every eligible recipe for it is computable.
|
||||
// staggered_item has two recipes:
|
||||
// cheap: 1 iron_ore (1.0) + 1.0 s = 2.0 (resolves early)
|
||||
// expensive: 1 circuit_board (28.0) + 1.0 s = 29.0 (resolves later)
|
||||
// expected threat = max(2.0, 29.0) = 29.0, not 2.0.
|
||||
TEST_CASE("ThreatCostCalculator: staggered recipes committed only when all computable", "[threat]")
|
||||
{
|
||||
const GameConfig cfg = loadConfig();
|
||||
const ThreatCostTable& table = cfg.threatCosts;
|
||||
|
||||
CHECK(table.itemThreat.at("staggered_item") == Approx(29.0));
|
||||
}
|
||||
|
||||
263
tools/threat_report.py
Normal file
263
tools/threat_report.py
Normal file
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Report threat values, ship costs, ratios, and belt feasibility.
|
||||
|
||||
Reads recipes.toml, ships.toml, modules.toml, and world.toml and prints:
|
||||
|
||||
1. Item threats — production-seconds per item unit, resolved
|
||||
recursively over the recipe tree (REQ-THREAT-ITEM):
|
||||
- non-reprocessing recipe: (duration + sum(input threat * qty))
|
||||
divided by the output amount, so threat is per unit;
|
||||
- multiple recipes: the maximum across them;
|
||||
- recipes consuming scrap participate only if no scrap-free
|
||||
recipe produces the item (fallback rule);
|
||||
- reprocessing-only items: (scrap threat * scrap per cycle
|
||||
+ duration) / normalized probability (REQ-THREAT-ITEM).
|
||||
Scrap threat is the constant 1 / world.scrap_per_threat
|
||||
(REQ-THREAT-SCRAP).
|
||||
2. Module contributions — material threat + production time per
|
||||
module instance (the amount a module adds to a ship's threat).
|
||||
3. Ship threats — hull-only and fitted with default_modules (the
|
||||
loadout enemy waves spawn with, REQ-WAV-DEFAULT-MODULES).
|
||||
4. Producer:consumer ratios — buildings of the input recipe needed
|
||||
per building of the consuming recipe at 100% throughput.
|
||||
5. Belt feasibility — input demand in items/s per building vs. the
|
||||
single-belt cap (belt_speed_mps / tile_size_m, in items/s).
|
||||
|
||||
NOTE: the semantics described above — per-unit division, scrap fallback
|
||||
rule, fixpoint resolution including reprocessing, and the commit-all-
|
||||
recipes max rule — are implemented in both this script and in
|
||||
src/lib/sim/ThreatCostCalculator.cpp. The two should produce identical
|
||||
values for any given config.
|
||||
|
||||
Usage (from the repository root or anywhere else):
|
||||
|
||||
python dota_factory/tools/threat_report.py
|
||||
python dota_factory/tools/threat_report.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.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
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 consumes_scrap(recipe):
|
||||
return any(inp["item"] == "scrap" for inp in recipe.get("inputs", []))
|
||||
|
||||
|
||||
def recipe_threat_per_unit(recipe, output, item_threat):
|
||||
threat = recipe["duration_seconds"]
|
||||
for inp in recipe.get("inputs", []):
|
||||
if inp["item"] not in item_threat:
|
||||
return None
|
||||
threat += item_threat[inp["item"]] * inp["amount"]
|
||||
return threat / output["amount"]
|
||||
|
||||
|
||||
def resolve_items(recipes, scrap_threat):
|
||||
"""Return {item: threat} resolved per REQ-THREAT-ITEM."""
|
||||
non_repro = [r for r in recipes if r["building"] != "reprocessing_plant"]
|
||||
repro = [r for r in recipes if r["building"] == "reprocessing_plant"]
|
||||
|
||||
# Items with at least one scrap-free producer: their scrap-consuming
|
||||
# recipes never participate (fallback rule).
|
||||
scrap_free_items = set()
|
||||
for recipe in non_repro:
|
||||
if not consumes_scrap(recipe):
|
||||
for output in recipe.get("outputs", []):
|
||||
scrap_free_items.add(output["item"])
|
||||
|
||||
def eligible(recipe, output):
|
||||
if consumes_scrap(recipe) and output["item"] in scrap_free_items:
|
||||
return False
|
||||
return True
|
||||
|
||||
item_threat = {"scrap": scrap_threat}
|
||||
|
||||
# All eligible (recipe, output) pairs per item: the max rule requires
|
||||
# committing an item only once EVERY eligible recipe for it is
|
||||
# computable — otherwise a shallow shortcut recipe that resolves one
|
||||
# pass earlier than the base path would win and underprice the item.
|
||||
recipes_per_item = {}
|
||||
for recipe in non_repro:
|
||||
for output in recipe.get("outputs", []):
|
||||
if eligible(recipe, output):
|
||||
recipes_per_item.setdefault(output["item"], []).append(
|
||||
(recipe, output))
|
||||
|
||||
def resolve_pass(require_all_recipes=True):
|
||||
progress = False
|
||||
best = {}
|
||||
for item, pairs in recipes_per_item.items():
|
||||
if item in item_threat:
|
||||
continue
|
||||
threats = [recipe_threat_per_unit(r, o, item_threat)
|
||||
for r, o in pairs]
|
||||
if require_all_recipes and any(t is None for t in threats):
|
||||
continue
|
||||
threats = [t for t in threats if t is not None]
|
||||
if threats:
|
||||
best[item] = max(threats)
|
||||
for item, threat in best.items():
|
||||
item_threat[item] = threat
|
||||
progress = True
|
||||
return progress
|
||||
|
||||
def repro_pass():
|
||||
progress = False
|
||||
for recipe in repro:
|
||||
scrap_per_cycle = sum(inp["amount"]
|
||||
for inp in recipe.get("inputs", []))
|
||||
total_weight = sum(out.get("probability", 1.0)
|
||||
for out in recipe.get("outputs", []))
|
||||
for output in recipe.get("outputs", []):
|
||||
# Reprocessing defines an item's threat only when nothing
|
||||
# else produces it (REQ-THREAT-ITEM).
|
||||
if output["item"] in item_threat:
|
||||
continue
|
||||
if output["item"] in scrap_free_items:
|
||||
continue
|
||||
probability = output.get("probability", 1.0) / total_weight
|
||||
if probability <= 0.0:
|
||||
continue
|
||||
item_threat[output["item"]] = (
|
||||
(scrap_threat * scrap_per_cycle
|
||||
+ recipe["duration_seconds"]) / probability)
|
||||
progress = True
|
||||
return progress
|
||||
|
||||
# Iterate to a fixpoint: items downstream of reprocessing-only items
|
||||
# (e.g. capital parts built from voidsteel) need another recipe pass
|
||||
# after the reprocessing pass has resolved their inputs.
|
||||
progress = True
|
||||
while progress:
|
||||
progress = resolve_pass()
|
||||
progress = repro_pass() or progress
|
||||
|
||||
# Deadlock guard: if recipe cycles keep items waiting on each other,
|
||||
# fall back to committing with the computable subset of recipes.
|
||||
progress = True
|
||||
while progress:
|
||||
progress = resolve_pass(require_all_recipes=False)
|
||||
progress = repro_pass() or progress
|
||||
|
||||
return item_threat
|
||||
|
||||
|
||||
def material_threat(materials, item_threat, missing):
|
||||
total = 0.0
|
||||
for material in materials:
|
||||
if material["item"] in item_threat:
|
||||
total += item_threat[material["item"]] * material["amount"]
|
||||
else:
|
||||
missing.add(material["item"])
|
||||
return total
|
||||
|
||||
|
||||
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="Report threat values, ship costs, ratios, and belt"
|
||||
" feasibility from 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"]
|
||||
world = load_toml(os.path.join(args.config_dir, "world.toml"))["world"]
|
||||
|
||||
scrap_per_threat = world.get("scrap_per_threat", 0.0)
|
||||
scrap_threat = 1.0 / scrap_per_threat if scrap_per_threat > 0 else 0.0
|
||||
belt_cap = world["belt_speed_mps"] / world["tile_size_m"] # items/s
|
||||
|
||||
item_threat = resolve_items(recipes, scrap_threat)
|
||||
missing = set()
|
||||
|
||||
print("scrap_per_threat = {} => threat(scrap) = {:.2f}".format(
|
||||
scrap_per_threat, scrap_threat))
|
||||
print()
|
||||
|
||||
print("== item threats (production-seconds per unit) ==")
|
||||
for item in sorted(item_threat):
|
||||
if item != "scrap":
|
||||
print(" {:24s} {:10.2f}".format(item, item_threat[item]))
|
||||
|
||||
print()
|
||||
print("== module contributions (materials + production time) ==")
|
||||
module_contribution = {}
|
||||
for module in modules:
|
||||
contribution = (material_threat(module["materials"], item_threat, missing)
|
||||
+ module["production_time_seconds"])
|
||||
module_contribution[module["id"]] = contribution
|
||||
print(" {:24s} {:10.2f}".format(module["id"], contribution))
|
||||
|
||||
print()
|
||||
print("== ship threats (hull-only / fitted with default_modules) ==")
|
||||
for ship in ships:
|
||||
hull = (material_threat(ship["schematic"]["materials"], item_threat, missing)
|
||||
+ ship["schematic"]["production_time_seconds"])
|
||||
fitted = hull
|
||||
for placed in ship.get("default_modules", []):
|
||||
fitted += module_contribution.get(placed["type"], 0.0)
|
||||
print(" {:16s} hull {:9.2f} fitted {:9.2f}".format(
|
||||
ship["id"], hull, fitted))
|
||||
|
||||
print()
|
||||
print("== producer:consumer ratios (producing buildings per consuming"
|
||||
" building) ==")
|
||||
producers = {} # item -> [(recipe id, items/s per building)]
|
||||
for recipe in recipes:
|
||||
if recipe["building"] == "reprocessing_plant":
|
||||
continue
|
||||
for output in recipe.get("outputs", []):
|
||||
rate = output["amount"] / recipe["duration_seconds"]
|
||||
producers.setdefault(output["item"], []).append((recipe["id"], rate))
|
||||
for recipe in recipes:
|
||||
for inp in recipe.get("inputs", []):
|
||||
need = inp["amount"] / recipe["duration_seconds"]
|
||||
for producer_id, supply in producers.get(inp["item"], []):
|
||||
print(" {:20s} -> {:24s} {:6.3f}".format(
|
||||
producer_id, recipe["id"], need / supply))
|
||||
|
||||
print()
|
||||
print("== belt feasibility (input demand items/s per building,"
|
||||
" cap {:.2f}/s) ==".format(belt_cap))
|
||||
over = False
|
||||
for recipe in recipes:
|
||||
for inp in recipe.get("inputs", []):
|
||||
rate = inp["amount"] / recipe["duration_seconds"]
|
||||
if rate > belt_cap:
|
||||
print(" OVER: {} <- {}: {:.2f}/s".format(
|
||||
recipe["id"], inp["item"], rate))
|
||||
over = True
|
||||
if not over:
|
||||
print(" all input demands under the single-belt cap")
|
||||
|
||||
if missing:
|
||||
print()
|
||||
for item in sorted(missing):
|
||||
print("WARNING: no threat value for material '{}'".format(item))
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user