From c6db4bf24ae44eea5785c866ab18e410c8960231 Mon Sep 17 00:00:00 2001 From: mlangkabel Date: Wed, 8 Jul 2026 20:27:25 +0200 Subject: [PATCH] first full balancing round --- bin/app/data/config/modules.toml | 69 ++-- bin/app/data/config/recipes.toml | 611 ++++++++++++++++--------------- bin/app/data/config/ships.toml | 72 +++- bin/app/data/config/visuals.toml | 60 +-- bin/app/data/config/world.toml | 2 +- docs/content_design.md | 320 +++++++++++++++- docs/progression_design.md | 403 ++++++++++++++++++++ tools/threat_report.py | 262 +++++++++++++ 8 files changed, 1421 insertions(+), 378 deletions(-) create mode 100644 docs/progression_design.md create mode 100644 tools/threat_report.py diff --git a/bin/app/data/config/modules.toml b/bin/app/data/config/modules.toml index 16ae89c..91f59c2 100644 --- a/bin/app/data/config/modules.toml +++ b/bin/app/data/config/modules.toml @@ -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,15 +46,15 @@ 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 @@ -60,17 +63,17 @@ 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 @@ -86,7 +89,7 @@ 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" @@ -101,7 +104,7 @@ 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" @@ -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,7 +153,7 @@ 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" @@ -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,7 +217,7 @@ surface_mask = [ "OX", ] materials = [{item = "weapon_stabilizer_module", amount = 1}] -production_time_seconds = 4 +production_time_seconds = 1 fill_color = "#FF4040" glyph = "Ws" @@ -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" diff --git a/bin/app/data/config/recipes.toml b/bin/app/data/config/recipes.toml index aa3c54a..256bb1c 100644 --- a/bin/app/data/config/recipes.toml +++ b/bin/app/data/config/recipes.toml @@ -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 diff --git a/bin/app/data/config/ships.toml b/bin/app/data/config/ships.toml index f21bf9d..39394a2 100644 --- a/bin/app/data/config/ships.toml +++ b/bin/app/data/config/ships.toml @@ -24,11 +24,11 @@ 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 @@ -55,10 +55,15 @@ 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 @@ -84,10 +89,17 @@ 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 @@ -115,10 +127,16 @@ 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 @@ -147,10 +165,18 @@ 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 @@ -182,10 +208,18 @@ 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 @@ -217,10 +251,20 @@ 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 @@ -250,10 +294,18 @@ 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 diff --git a/bin/app/data/config/visuals.toml b/bin/app/data/config/visuals.toml index df788dc..7a31445 100644 --- a/bin/app/data/config/visuals.toml +++ b/bin/app/data/config/visuals.toml @@ -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" diff --git a/bin/app/data/config/world.toml b/bin/app/data/config/world.toml index f67aca8..3a2a92f 100644 --- a/bin/app/data/config/world.toml +++ b/bin/app/data/config/world.toml @@ -3,7 +3,7 @@ height_tiles = 40 refund_percentage = 100 starting_building_blocks = 1000 scrap_despawn_seconds = 30 -scrap_per_threat = 0.01 +scrap_per_threat = 0.25 tile_size_m = 10 belt_speed_mps = 20 tunnel_max_distance_tiles = 10 diff --git a/docs/content_design.md b/docs/content_design.md index ed4e49a..bb2a727 100644 --- a/docs/content_design.md +++ b/docs/content_design.md @@ -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,319 @@ 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** (`_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`, 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). + +## 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 diff --git a/docs/progression_design.md b/docs/progression_design.md new file mode 100644 index 0000000..1c5853c --- /dev/null +++ b/docs/progression_design.md @@ -0,0 +1,403 @@ +# 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. **Scrap-consuming recipes as threat fallback only.** Amend + REQ-THREAT-ITEM: recipes that take scrap as an input participate in + an item's threat computation only if no scrap-free recipe (miner, + smelter, or assembler) produces that item — mirroring the existing + rule for the reprocessing path. Otherwise the scrap→ingot smelter + recipe would inflate the basic materials' threat via the + max-across-recipes rule, poisoning every downstream value. +7. **Per-unit item threat.** Amend REQ-THREAT-ITEM and + `ThreatCostCalculator`: a recipe's threat is divided by its output + amount, so item threat is production-seconds *per unit*. Currently a + recipe producing 2 copper_wire per run assigns each wire the full + run's threat, double-pricing multi-output items and everything + downstream of them. +8. **Fixpoint resolution in ThreatCostCalculator.** Items downstream of + reprocessing-only items (e.g. capital parts built from the scrap-only + input) never resolve, because resolution stops after the reprocessing + pass instead of iterating; their consumers silently drop the missing + materials, so capital hull threat is currently underestimated (found + by `tools/threat_report.py`, which implements the correct fixpoint). +9. **Max rule across staggered recipes in ThreatCostCalculator.** An + item is committed at the first iteration where *any* of its recipes + resolves, taking the max only over the recipes resolvable at that + point. A shallow shortcut recipe (e.g. steel plate from raw ore) + resolves one iteration earlier than the base path and wins, silently + underpricing the item and everything downstream — violating the + "shortcuts are pure rewards" rule. Fix: commit an item's threat only + once every eligible recipe for it is computable (as + `tools/threat_report.py` does), with a fallback for recipe cycles. diff --git a/tools/threat_report.py b/tools/threat_report.py new file mode 100644 index 0000000..92a6614 --- /dev/null +++ b/tools/threat_report.py @@ -0,0 +1,262 @@ +#!/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 per-unit division (1) and the scrap fallback rule are the +agreed design semantics (see docs/progression_design.md action items); +src/lib/sim/ThreatCostCalculator.cpp does not implement them yet. Until +it does, this report is the design reference, not a mirror of the game. + +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())