26 Commits

Author SHA1 Message Date
a93f7c3897 Arena round 6: revert railgun_m damage, correct playtest record
Arena re-run after the playtest-1 adjustments:

- spam-cruiser arena landed at par (default +9%) - the range buff alone
  priced out the small-gun-spam meta, so revert railgun_m damage 16 -> 14
  (range stays 80). The buff had regressed drone-swarm-vs-cruisers to
  +47%, largely via a hit-count breakpoint (60 HP drone: 5 hits at
  14 dmg, 4 at 16).
- battleship +30% / dreadnought +29% vs pure railgun_s fleets accepted
  as reach-doctrine texture (the l gun's 130 m standoff working as
  intended); repair kept at 4 HP/s (below par in-fight is the correct
  price for free between-wave sustain).

Also corrects the playtest-1 record: it was two FULL playthroughs won in
~40 min each with cruisers only (not two pushes) - a 2.5-3x run-length
gap. Pacing knobs deliberately deferred to playtest 2, since those runs
predate the repair nerf and the l-gun siege range.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-06 22:10:06 +02:00
f8028a6ca2 Adjust combat stats after playtest 1
Playtest 1 (first two pushes, ~40 min game time — pacing on target)
found the railgun_s-spam-cruiser meta and confirmed the repair tool as
overpowered (second signal after the persistent arena escort margin):

- repair_tool 9 -> 4 HP/s: the arena only prices in-fight sustain; real
  runs add free between-wave top-offs across the whole swarm
- railgun_l range 100 -> 130: now outranges stations (120), buying the
  siege role the capital ladder promises
- railgun_m 14 -> 16 dmg, range 70 -> 80: concentration tax softened so
  the ladder reads "pay some DPS efficiency for reach", not
  "strictly worse"
- new tracked arena: railgun_s-spam cruisers vs default cruisers

Module costs untouched, so all threat values stay valid. Docs updated
(derived.md, history.md playtest record, README status).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-06 22:00:34 +02:00
b4fa3e6dff Restructure balancing docs into docs/balancing/
Split the cluttered progression_design.md and content_design.md into
separated documents by role:

- docs/balancing/rules.md    - design rules and principles (moved from
                               progression_design.md, which is removed)
- docs/balancing/targets.md  - the chosen base numbers: run shape,
                               factory curve, threat ladder (achieved
                               values adopted), combat anchors, pacing
                               anchors
- docs/balancing/derived.md  - current tuned state of all derived
                               numbers, mirroring the configs
- docs/balancing/process.md  - pass order, tuning discipline learned in
                               arena rounds 1-5, tools, next-round
                               checklist
- docs/balancing/history.md  - chronological record: decisions, numbers
                               pass, calculator bugs, arena rounds,
                               pacing pass
- docs/balancing/README.md   - index, status, open action items (moved
                               from progression_design.md)

content_design.md slims back down to actual content: footprint gating,
hull grids, gating matrix (module names updated to railguns), and the
production tree structure with its fiction - numbers, anchors, arena
logs, and pacing all moved to docs/balancing/. Stale references in the
config comments updated to the new locations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-06 17:15:08 +02:00
dcc6af123f implement cost formula for asteroid expansion 2026-07-04 19:59:29 +02:00
f62b7bb78e Pacing pass: unlock ladder, threat rate, economy values
Replace the everything-at--1 test setup with the real progression:
starting set is exactly drone/frigate/railgun_s/salvager; the ladder
runs destroyer+repair+armor at level 0, supports at 1, the quartz gate
(cruiser, railgun_m, afterburner, chip/hardened shortcuts) at 2,
battlecruiser at 4, battleship+railgun_l at 6, dreadnought at 8,
carrier+drone_hangar at 9, with unlock_requires edges cruiser ->
battlecruiser -> battleship -> dreadnought/carrier and railgun_m ->
railgun_l.

world.toml: threat_rate_formula 2*x + 0.15*x*x (below the player's
achievable military output early, crossing at the late boundary,
overwhelming by cycle ~24), starting_building_blocks 1000 -> 200,
expansion cost 200 -> 400 flat (placeholder until action item 4),
artifact_win_count 3 -> 5 (winning means choosing the artifact over a
schematic five times across ~7 cumulative offers).

Bug fix found during the pass: the building_block recipe carried no
unlock_at_station_level, and since building blocks appear in no
schematic's materials, implicit unlocking could never reach it - the
recipe was silently locked at game start. Now explicitly -1.

All values and derivations documented in content_design.md, "Pacing
pass - first values".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-04 12:49:35 +02:00
27c3a7b65b Combat stats round 6: combat pass converged
Round 5 (with fight durations): TTK anchor validated - mirrors run
22.7/71.2/94.6 s (drone/cruiser/battleship), most fights within
22-72 s, destroyers-vs-dreadnought the accepted 214 s outlier. All
matchups converged: mirrors 5-10%, swarm vs cruisers +15%, frigates
vs battleship +13% (dropped on its own, confirming the noise floor),
dreadnought +3%, glass vs armored +10%, two-to-one 78%, stations 44%.

Two two-round signals actioned per the convergence policy:
battlecruiser hull 2200 -> 2400 (mixed fleet won +20%/+18% twice) and
repair_amount 12 -> 9 (~0.7 HP/s per threat; escorted team held +24%
twice with all frigates surviving). Everything else frozen; further
refinement moves to real-game playtests. Removed the arena
end-condition action item (fixed upstream).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-04 12:43:05 +02:00
9ddd622870 fix but where balancing matches did not finish until enemy hq was destroyed 2026-07-04 09:29:13 +02:00
df9f0d5ccd show total time in balancing target arenas 2026-07-04 09:17:32 +02:00
db7a03dc1f Combat stats round 5: final nudges and convergence policy
Round 4: mirrors 0-3%, swarm vs cruisers +7%, glass vs armored
resolved at +3% for armored (armor 1200 confirmed). Noise floor
established at ~+/-10% per single run (battleship margin ignored a
-10% EHP cut; repair drifted 14->24% with no repair changes).

Round-5 nudges for the armor-coupling overshoots: battlecruiser hull
2000 -> 2200, dreadnought/carrier 22500 -> 24000 (two-round
persistent deficit vs destroyers). The battleship's +23% over a pure
frigate fleet is accepted as doctrine texture rather than chased: the
edge is mechanical (range) and the fair anti-capital answer is the
mixed fleet, whose arena is balanced. Convergence policy documented:
act only on two-round signals, +/-20% counts as converged for v1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-04 07:56:52 +02:00
67054f432f Combat stats round 4 from arena round 3 margins
Round 3 (narrow lanes; arena geometry is part of the fixture from
here): dreadnought closed to -11%, battleship improved to +22%, the
drone swarm flipped to +14% over cruisers under full engagement, glass
beat armored for the third consecutive round, mirrors/repair/
two-to-one healthy, stations cracked at 51% by the 3x swarm under the
new geometry (watch, not touched).

Round-4 knobs: armor_plates 1000 -> 1200 (~37 HP/threat; persistent
glass signal, also lifts armor-carrying cruiser/BC/destroyer/DN
loadouts), battlecruiser hull 2500 -> 2000 (net -300 EHP after its
armor gain), battleship 7000 -> 6300 (loadout has no armor, clean
-10%), dreadnought/carrier 19000 -> 22500 (opposing destroyers gain
armor too, so the larger step). Anchors doc notes the emerging shape:
the arena prices capitals as tanks with taxed guns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-04 07:50:50 +02:00
7a9a7a35ad make arenas more narrow to avoid fleets passing each other 2026-07-04 07:47:08 +02:00
f822047b4e Combat stats round 3 from arena round 2 margins
Round 2 (with the new EHP-margin logging): mirrors healthy and the
round-1 side bias was noise; drones-vs-cruisers (10%) and
mixed-vs-battlecruisers (14%) near parity; battleship over at +33%
while dreadnought under at -37%; glass-vs-armored narrowed to 11%
(watch); repair escort fair at 11%; two-to-one decisive; the 3x swarm
now cracks the station set at 13%.

Round-3 knobs: weapon_stabilizer range multiplier 1.5 -> 1.3 (range is
the strongest stat under the orbit AI, and the stabilized battleship
was the one loadout still overperforming), plus per-hull HP trims with
the 15 HP/threat anchor demoted to a prior and per-hull HP documented
as the empirical trim knob: battlecruiser 2700 -> 2500, battleship
7500 -> 7000, dreadnought/carrier 15500 -> 19000.

Action items: mirror-bias and EHP-logging items resolved (the latter
implemented upstream); new item to verify the arena end condition seen
idling in round 1 with all ships dead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-04 07:34:47 +02:00
d9e7dd0fe8 show team EHP in balancing target 2026-07-04 07:27:11 +02:00
4d5b218fec implement logging of arena states 2026-07-04 07:27:11 +02:00
97bafc95c3 Combat stats round 2 from arena round 1 findings
Round 1 findings: concentrated fleets won all four equal-threat
cross-tier matchups flawlessly; glass destroyers beat armored; repair
escort beat raw numbers flawlessly; two stations annihilated a 3x
threat swarm through 200 m approach fire; team 1 won all three mirror
matches (possible sim bias, recorded as action item 6, plus action
item 7 for HP-margin logging).

Knob changes, deliberately few for clean attribution: concentration
tax on m/l gun DPS (railgun_m damage 17 -> 14, railgun_l 70 -> 52),
armor_plates 640 -> 1000 HP (~31 HP/threat: killing removes enemy DPS
while surviving merely delays, so HP must be cheaper than DPS), repair
25 -> 12 HP per cycle (~1 HP/s per threat: in-combat sustain removes
enemy DPS and must be priced like DPS), station range 200 -> 120. Gun
ranges untouched; if concentration still wins flawlessly in round 2,
the range ladder is the next suspect. Anchors and findings documented
in content_design.md; fitted threat values are unaffected by stat
changes, so the arena suite counts stay valid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-04 06:55:43 +02:00
5aa0544cf2 Combat stats pass: anchor-derived stats and canonical arena suite
Derive all combat stats from documented anchors (content_design.md,
'Combat stat anchors'): weapon DPS ~0.6 per threat with a mild size
discount traded for range (railgun m/l damage 17/70), hull HP at 15
per hull threat (drone 60 up to capitals 15500), armor at a 20/threat
premium (armor_plates 640 HP), repair at ~2 HP/s per threat (25 HP per
cycle), salvager range fixed to 60 m with cargo 20, and a monotone
mobility ladder (the placeholder drone was the least agile ship).
Stations anchored to 'a fresh station holds one early parity wave';
enemy level 0 matches the player station and scales per push. HQ 5000
HP. scrap_despawn_seconds 30 -> 120 so capital-sized scrap drops are
collectable.

Rewrite balancing.toml as the canonical 12-arena suite: class mirrors,
equal-threat cross-tier matchups with counts from the verified threat
values, a two-to-one decisiveness check, armored-vs-glass and
repair-escort doctrine matchups, and station assault. The carrier
arena is expected to lose until drone launching exists (hangar is dead
threat) - documented, not stat-compensated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-03 20:19:24 +02:00
38fd2e4e89 Fix ThreatCostCalculator: per-unit division, scrap fallback, fixpoint, staggered-recipe max (action items 6-9)
Four algorithm fixes to bring ThreatCostCalculator.cpp into agreement with
tools/threat_report.py and the newly amended REQ-THREAT-ITEM semantics:

6. Scrap-consuming recipes as threat fallback only. Non-reprocessing recipes
   that take scrap as an input are excluded from an item's threat computation
   whenever at least one scrap-free recipe (miner/smelter/assembler) produces
   that item. Previously the scrap_smelting recipe (1 scrap → 1 iron_ingot)
   would have inflated iron_ingot's threat via the max rule.

7. Per-unit item threat. computeRecipeThreatPerUnit() now divides by the
   recipe's output amount, so multi-output recipes price each unit correctly.
   Example: copper_wire (1 copper_ingot, 1 s, output 2) is now 1.5, not 3.

8. Fixpoint resolution. The resolution loop now alternates the non-reprocessing
   pass and the reprocessing pass until neither makes progress, rather than
   running the reprocessing pass once at the end. Items downstream of
   reprocessing-only items (voidsteel_plate, capital_core, capital hulls,
   drone_hangar_module) now resolve correctly.

9. Max rule across staggered recipes. An item is committed only once every
   eligible recipe producing it is computable, so a shallow shortcut recipe
   (e.g. shortcut_steel_plate: 3 iron_ore → 1 steel_plate, resolvable one
   iteration earlier) cannot undercut the expensive base path. A deadlock
   fallback (require_all_recipes=False) handles potential recipe cycles.

docs/requirements.md: REQ-THREAT-ITEM amended for per-unit division, the
scrap-fallback rule, and order-independence via fixpoint.

docs/progression_design.md: action items 6-9 removed (completed); remaining
items 1-5 renumbered unchanged.

tools/threat_report.py: NOTE updated — C++ now matches Python semantics.

bin/test/data/config/recipes.toml: four minimal test recipes added (one per
fix: scrap_iron, dual_wire, downstream_product, staggered_item_{cheap,expensive}).

src/test/ThreatCostCalculatorTest.cpp: four new TEST_CASEs covering each fix.

Expected values with the live config (bin/app/data/config) verified by
threat_report.py: iron_ingot 2, copper_wire 1.5, steel_plate 7, control_chip
12, voidsteel_plate 141, capital_core 240; fitted ships 10.5/47/99/233.5/
354.5/722.5/1491.5/1436.5. All 378 test cases pass.
2026-07-03 18:45:39 +02:00
d889b79658 Write the v2 production tree into the config files
recipes.toml: full v2 rewrite - iron/copper/quartz mining, smelting
incl. the value-losing scrap sink, reprocessing (4 scrap / 4 s,
voidsteel at 20% of the full pool), tiered intermediates, hulls,
module prefabs, and the three drop-only shortcut recipes.

modules.toml: lasers renamed to railguns (implementation unchanged),
prefab materials and numbers-pass production times; combat stats stay
placeholders for the arena pass.

ships.toml: hull-item materials, numbers-pass base production times,
and geometry-validated default_modules loadouts for every ship (waves
now spawn armed).

visuals.toml: item entries for the new palette (quartz, silicon,
copper_coil, control_chip, capacitor_bank, hardened_steel,
ceramic_plate, voidsteel, voidsteel_plate, railgun modules); retired
titanium/alloy/laser items removed.

world.toml: scrap_per_threat = 0.25 per the numbers pass.

threat_report.py: commit an item's threat only once every eligible
recipe is computable - the previous first-resolved-wins behavior let
shortcut recipes underprice items (same flaw exists in
ThreatCostCalculator, recorded as action item 9). Verified: all
default_modules placements valid, verify_recipes and verify_layouts
pass, and the report reproduces the numbers-pass tables exactly
(fitted: 10.5/47/99/233.5/354.5/722.5/1491.5/1436.5). Doc tables
updated for the three geometry-corrected loadouts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-03 17:05:19 +02:00
b9e70ba83a Add tools/threat_report.py (action item 7)
Reads recipes/ships/modules/world.toml and reports per-item threat
values, module contributions, hull-only and fitted ship threats
(default_modules), producer:consumer ratio tables, and belt
feasibility against the single-belt cap.

Implements the agreed design semantics: per-unit threat (recipe threat
divided by output amount), the scrap-fallback rule, and fixpoint
resolution through reprocessing-only items. Running it against the
current configs surfaced two ThreatCostCalculator deviations, recorded
as new action items: multi-output recipes are double-priced (no
per-unit division), and items downstream of reprocessing-only items
never resolve, underestimating capital hull threat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-03 16:47:40 +02:00
3aefc05118 Scrap-consuming recipes as threat fallback, not excluded
Refine the REQ-THREAT-ITEM fix per review: recipes taking scrap as
input participate in an item threat computation only when no
scrap-free recipe produces that item, mirroring the existing
reprocessing-path rule instead of a blanket exclusion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-03 16:34:06 +02:00
fe7703d5c3 Add first numbers pass for the v2 production tree
content_design.md: full recipe quantities and durations computed with
a recursive threat calculator - economy constants (scrap_per_threat
0.25, reprocessing 4 scrap / 4 s, voidsteel at threat 100), per-item
threat values, module contributions, and fitted ship threats vs the
ladder (96-124%, smooth ~x2-per-class curve). Checks recorded: ratio
curve realized (t1 1:1, t2 2:3, t3 strange), belt feasibility under
the single-belt cap, block at 4 threat implying ~18 blocks average
building cost for the 4-minute doubling target, and a small-end
deviation note recommending ladder adjustment over chain thinning.

progression_design.md: two new action items - amend REQ-THREAT-ITEM to
exclude scrap-consuming recipes from item threat (the max rule would
otherwise inflate basic materials via the scrap smelting recipe), and
port the calculator to tools/threat_report.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-03 15:05:40 +02:00
72b38df0dd Draft the v2 production tree structure
Add the tree structure draft to the v2 decisions section: mined and
smelted items, reprocessing pool, intermediates per tier with ratio
classes and archetypes, hull and module recipes as input lists, the
resolved m+ hull gate (hardened_steel plus control_chip), shortcut
recipe candidates, and a refactorability spot-check. Quantities and
durations are deferred to the numbers pass against the threat ladder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-03 14:26:29 +02:00
c7a168f4a9 Record production tree v2 decisions and resource deposit rules
progression_design.md: rewrite the resource phases around the four
decided inputs (two mined everywhere, one deposit-gated mid resource,
one scrap-only late input), add the Resource deposits rule set
(freedom first / geography later, deposits only in expansions, patch
area as throughput cap, deterministic content, binary mining), note
the dual gating of the mid resource, and add the deposit-layer action
item.

content_design.md: mark the first-pass tree as superseded and record
the v2 decisions — iron/copper/quartz/voidsteel with their fiction
(M-type asteroid, quartz geodes, battle-forged voidsteel), titanium
dropped with its gating role moved to electronics and possibly a
quality-steel step, the material palette fingerprints, and the
laser-to-railgun rename with lasers reserved as a future weapon type.
Update the balancing target phase boundaries accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-03 13:54:24 +02:00
a6451f2bdf Add balancing targets section to content_design.md
Record the six root numbers for the balancing pass: run length (win
within ~2 h game time, cycle 20-24), phase boundaries, factory size
curve, fitted threat-cost ladder, swarm fleet size (~25 ships), and
block economy roots (bootstrap, doubling time, expansion cadence).
Derived values are always re-derived from these roots, never patched
directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-03 11:30:43 +02:00
493208d230 Add growth curve rules to the building block economy
Diagnose that player output equals building count, so the growth
curve must be shaped by the block and space economy: escalating
expansion costs (formula of expansions purchased) as the long-run
curve, a designed doubling time for the block feedback loop, and an
explicit rule that growth is limited by economy, never by
construction waiting. Note the HQ intake ceiling and add the
condensed-building-block idea to future work, plus an action item
for the expansion cost formula.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-03 10:50:42 +02:00
4098e40c8c Sync progression_design.md with implemented action items
Prune completed action items 1-4 and 8 (scrap-from-threat, schematic
upgrade and ship level removal, balancing tool cleanup, unlock
prerequisites), renumber the remaining three, and update stale
cross-references: cite REQ-LOCK-PREREQ and unlock_requires, reflect
that REQ-THREAT-SCRAP is now the 1/scrap_per_threat constant, and
drop the obsolete level-up wording from the artifact rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
2026-07-03 09:44:53 +02:00
42 changed files with 2802 additions and 714 deletions

View File

@@ -1,26 +1,29 @@
# modules.toml # modules.toml
# #
# First real-content iteration: module ids and surface masks are the designed # Production tree v2: all weapons are railguns for now — the implementation
# content; stats, materials, and threat costs are placeholders until the # (instant damage, no projectile, no ammunition) stays as-is and the beam
# recipe and balancing passes. # 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 # Unlock progression is mostly disabled (unlock_at_station_level = -1) pending
# the balancing pass. The laser cannons are the exception: laser_cannon_m is # the pacing pass. The railguns are the exception: railgun_m is gated to
# gated to station level 1, and laser_cannon_l requires laser_cannon_m to be # station level 1, and railgun_l requires railgun_m to be unlocked first
# unlocked first (unlock_requires) — a demonstration of the prerequisite chain. # (unlock_requires) — a demonstration of the prerequisite chain.
# #
# Surface mask footprint ladder — footprints gate which hulls can mount a # Surface mask footprint ladder — footprints gate which hulls can mount a
# module, purely through geometry (see ships.toml for the matching hull # module, purely through geometry (see ships.toml for the matching hull
# grids): # 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, # 1x2 maneuvering_thrusters, sensor_booster,
# armor_plates frigate and up # armor_plates frigate and up
# 1x3 afterburner frigate and up (eats most of a frigate) # 1x3 afterburner frigate and up (eats most of a frigate)
# L-shape weapon_stabilizer, weapon_primer, # L-shape weapon_stabilizer, weapon_primer,
# weapon_upgrade frigate and up # weapon_upgrade frigate and up
# 2x2 laser_cannon_m, drone_bay cruiser and up (no 2x2 area on s hulls) # 2x2 railgun_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) # 3x3 railgun_l battleship and up (no 3x3 area on m hulls)
# 2x6 drone_hangar carrier only # 2x6 drone_hangar carrier only
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -28,13 +31,13 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
[[module]] [[module]]
id = "laser_cannon_s" id = "railgun_s"
unlock_at_station_level = -1 unlock_at_station_level = -1
surface_mask = ["O"] surface_mask = ["O"]
materials = [{item = "laser_cannon_s_module", amount = 1}] materials = [{item = "railgun_s_module", amount = 1}]
production_time_seconds = 0.5 production_time_seconds = 1
fill_color = "#FF8040" fill_color = "#FF8040"
glyph = "Ls" glyph = "Rs"
[module.weapon] [module.weapon]
damage = 2 damage = 2
@@ -43,38 +46,44 @@ attack_rate_hz = 2.0
[[module]] [[module]]
id = "laser_cannon_m" id = "railgun_m"
unlock_at_station_level = 1 unlock_at_station_level = 2
surface_mask = [ surface_mask = [
"OO", "OO",
"OO"] "OO"]
materials = [{item = "laser_cannon_m_module", amount = 1}] materials = [{item = "railgun_m_module", amount = 1}]
production_time_seconds = 2 production_time_seconds = 3
fill_color = "#FF8040" fill_color = "#FF8040"
glyph = "Lm" glyph = "Rm"
# damage 14 keeps a 60 HP drone at 5 hits — 15+ crosses a breakpoint that
# silently adds ~25% effective DPS vs drones (docs/balancing/history.md,
# round 6).
[module.weapon] [module.weapon]
damage = 10 damage = 14
attack_range_m = 70 attack_range_m = 80
attack_rate_hz = 1.5 attack_rate_hz = 1.5
[[module]] [[module]]
id = "laser_cannon_l" id = "railgun_l"
unlock_at_station_level = 1 unlock_at_station_level = 6
unlock_requires = ["laser_cannon_m"] unlock_requires = ["railgun_m"]
surface_mask = [ surface_mask = [
"OOO", "OOO",
"OOO", "OOO",
"OOO"] "OOO"]
materials = [{item = "laser_cannon_l_module", amount = 1}] materials = [{item = "railgun_l_module", amount = 1}]
production_time_seconds = 8 production_time_seconds = 4
fill_color = "#FF8040" fill_color = "#FF8040"
glyph = "Ll" glyph = "Rl"
# attack_range_m 130 deliberately exceeds the station range of 120
# (stations.toml) — the l gun is the only weapon that can besiege stations
# without tanking their fire (docs/balancing/history.md, playtest 1).
[module.weapon] [module.weapon]
damage = 40 damage = 52
attack_range_m = 100 attack_range_m = 130
attack_rate_hz = 0.8 attack_rate_hz = 0.8
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -86,28 +95,28 @@ id = "salvager"
unlock_at_station_level = -1 unlock_at_station_level = -1
surface_mask = ["O"] surface_mask = ["O"]
materials = [{item = "salvager_module", amount = 1}] materials = [{item = "salvager_module", amount = 1}]
production_time_seconds = 2 production_time_seconds = 1
fill_color = "#AACC44" fill_color = "#AACC44"
glyph = "Sv" glyph = "Sv"
[module.salvage] [module.salvage]
collection_range_m = 500 collection_range_m = 60
cargo_capacity = 10 cargo_capacity = 20
collection_rate_hz = 0.5 collection_rate_hz = 0.5
[[module]] [[module]]
id = "repair_tool" id = "repair_tool"
unlock_at_station_level = -1 unlock_at_station_level = 0
surface_mask = ["O"] surface_mask = ["O"]
materials = [{item = "repair_tool_module", amount = 1}] materials = [{item = "repair_tool_module", amount = 1}]
production_time_seconds = 2 production_time_seconds = 1
fill_color = "#66CCFF" fill_color = "#66CCFF"
glyph = "Rp" glyph = "Rp"
[module.repair] [module.repair]
repair_rate_hz = 1 repair_rate_hz = 1
repair_amount_hp = 6 repair_amount_hp = 4
repair_range_m = 80 repair_range_m = 80
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -116,10 +125,10 @@ repair_range_m = 80
[[module]] [[module]]
id = "afterburner" id = "afterburner"
unlock_at_station_level = -1 unlock_at_station_level = 2
surface_mask = ["OOO"] surface_mask = ["OOO"]
materials = [{item = "afterburner_module", amount = 1}] materials = [{item = "afterburner_module", amount = 1}]
production_time_seconds = 2 production_time_seconds = 1
fill_color = "#40A0FF" fill_color = "#40A0FF"
glyph = "Ab" glyph = "Ab"
@@ -130,10 +139,10 @@ added_main_acceleration_mpss = 60
[[module]] [[module]]
id = "maneuvering_thrusters" id = "maneuvering_thrusters"
unlock_at_station_level = -1 unlock_at_station_level = 1
surface_mask = ["OO"] surface_mask = ["OO"]
materials = [{item = "maneuvering_thrusters_module", amount = 1}] materials = [{item = "maneuvering_thrusters_module", amount = 1}]
production_time_seconds = 2 production_time_seconds = 1
fill_color = "#40A0FF" fill_color = "#40A0FF"
glyph = "Mt" glyph = "Mt"
@@ -147,23 +156,23 @@ added_maneuvering_acceleration_mpss = 10
[[module]] [[module]]
id = "armor_plates" id = "armor_plates"
unlock_at_station_level = -1 unlock_at_station_level = 0
surface_mask = ["OO"] surface_mask = ["OO"]
materials = [{item = "armor_plates_module", amount = 1}] materials = [{item = "armor_plates_module", amount = 1}]
production_time_seconds = 3 production_time_seconds = 1
fill_color = "#808080" fill_color = "#808080"
glyph = "A" glyph = "A"
[module.health] [module.health]
added_hp = 40 added_hp = 1200
[[module]] [[module]]
id = "sensor_booster" id = "sensor_booster"
unlock_at_station_level = -1 unlock_at_station_level = 1
surface_mask = ["OO"] surface_mask = ["OO"]
materials = [{item = "sensor_booster_module", amount = 1}] materials = [{item = "sensor_booster_module", amount = 1}]
production_time_seconds = 2 production_time_seconds = 1
fill_color = "#40A0FF" fill_color = "#40A0FF"
glyph = "S" glyph = "S"
@@ -176,13 +185,13 @@ added_sensor_range_m = 50
[[module]] [[module]]
id = "weapon_upgrade" id = "weapon_upgrade"
unlock_at_station_level = -1 unlock_at_station_level = 4
surface_mask = [ surface_mask = [
"OO", "OO",
"OX", "OX",
] ]
materials = [{item = "weapon_upgrade_module", amount = 1}] materials = [{item = "weapon_upgrade_module", amount = 1}]
production_time_seconds = 4 production_time_seconds = 2
fill_color = "#FF4040" fill_color = "#FF4040"
glyph = "Wu" glyph = "Wu"
@@ -192,13 +201,13 @@ multiplied_damage = 1.2
[[module]] [[module]]
id = "weapon_primer" id = "weapon_primer"
unlock_at_station_level = -1 unlock_at_station_level = 4
surface_mask = [ surface_mask = [
"OO", "OO",
"OX", "OX",
] ]
materials = [{item = "weapon_primer_module", amount = 1}] materials = [{item = "weapon_primer_module", amount = 1}]
production_time_seconds = 4 production_time_seconds = 2
fill_color = "#FF4040" fill_color = "#FF4040"
glyph = "Wp" glyph = "Wp"
@@ -208,18 +217,18 @@ multiplied_attack_rate_hz = 1.2
[[module]] [[module]]
id = "weapon_stabilizer" id = "weapon_stabilizer"
unlock_at_station_level = -1 unlock_at_station_level = 3
surface_mask = [ surface_mask = [
"OO", "OO",
"OX", "OX",
] ]
materials = [{item = "weapon_stabilizer_module", amount = 1}] materials = [{item = "weapon_stabilizer_module", amount = 1}]
production_time_seconds = 4 production_time_seconds = 1
fill_color = "#FF4040" fill_color = "#FF4040"
glyph = "Ws" glyph = "Ws"
[module.weapon] [module.weapon]
multiplied_attack_range_m = 1.5 multiplied_attack_range_m = 1.3
multiplied_attack_rate_hz = 0.8 multiplied_attack_rate_hz = 0.8
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -231,23 +240,23 @@ multiplied_attack_rate_hz = 0.8
[[module]] [[module]]
id = "drone_bay" id = "drone_bay"
unlock_at_station_level = -1 unlock_at_station_level = 5
surface_mask = [ surface_mask = [
"OO", "OO",
"OO"] "OO"]
materials = [{item = "drone_bay_module", amount = 1}] materials = [{item = "drone_bay_module", amount = 1}]
production_time_seconds = 5 production_time_seconds = 3
fill_color = "#CC66FF" fill_color = "#CC66FF"
glyph = "Db" glyph = "Db"
[[module]] [[module]]
id = "drone_hangar" id = "drone_hangar"
unlock_at_station_level = -1 unlock_at_station_level = 9
surface_mask = [ surface_mask = [
"OOOOOO", "OOOOOO",
"OOOOOO"] "OOOOOO"]
materials = [{item = "drone_hangar_module", amount = 1}] materials = [{item = "drone_hangar_module", amount = 1}]
production_time_seconds = 20 production_time_seconds = 6
fill_color = "#9933CC" fill_color = "#9933CC"
glyph = "Dh" glyph = "Dh"

View File

@@ -1,26 +1,26 @@
# recipes.toml # recipes.toml
# #
# First real-content iteration of the production tree. Quantities and # Production tree v2 (structure in docs/content_design.md, numbers with
# durations are a first guess; the balancing pass will tune them and assign # derivations in docs/balancing/derived.md). Quantities and durations are tuned so that every
# real unlock_at_station_level values. Almost everything is unlocked for now so # fitted ship lands on the threat-cost ladder and the ratio curve is
# the full tree is testable; the only gated recipe schematics are the medium and # realized: tier 1 ratios are 1:1, tier 2 ratios are 2:3, tier 3+ ratios
# large laser-cannon item recipes, which demonstrate the unlock_requires # are deliberately strange.
# prerequisite chain (large requires medium — see modules.toml / ships.toml).
# #
# 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, # early iron_ore + copper_ore minable on every asteroid tile (the
# circuit_board # asteroid is an M-type body — its bulk
# mid + titanium_ore -> titanium_frame; assembler-made # rock IS ore)
# mechanical_parts, targeting_unit, # mid + quartz geode deposits in expansion territory
# drive_unit # (deposit gating pending — see action
# late + advanced_alloy -> reinforced_plating, capital_core. # item 5 in docs/balancing/README.md;
# advanced_alloy CANNOT be mined; it only # until then quartz mines anywhere)
# comes from reprocessing salvaged scrap, # late + voidsteel battle-forged: ONLY from reprocessing
# so capital production requires combat. # salvaged scrap, so capital production
# requires combat
# #
# Run tools/verify_recipes.py after editing to check that every consumed # Run tools/verify_recipes.py and tools/threat_report.py after editing.
# item has a producer and every item has a visuals.toml entry.
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Mining (tier 0) # Mining (tier 0)
@@ -38,405 +38,409 @@ id = "mine_copper_ore"
building = "miner" building = "miner"
inputs = [] inputs = []
outputs = [{item = "copper_ore", amount = 1}] 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]] [[recipe]]
id = "mine_titanium_ore" id = "mine_quartz"
building = "miner" building = "miner"
inputs = [] inputs = []
outputs = [{item = "titanium_ore", amount = 1}] outputs = [{item = "quartz", amount = 1}]
duration_seconds = 3.0 duration_seconds = 2.0
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Smelting (tier 1) # Smelting (tier 1) — one recipe per input item; ratios are 1:1 with miners.
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
[[recipe]] [[recipe]]
id = "iron_ingot" id = "iron_ingot"
building = "smelter" building = "smelter"
inputs = [{item = "iron_ore", amount = 2}] inputs = [{item = "iron_ore", amount = 1}]
outputs = [{item = "iron_ingot", amount = 1}] outputs = [{item = "iron_ingot", amount = 1}]
duration_seconds = 2.0 duration_seconds = 1.0
[[recipe]] [[recipe]]
id = "copper_ingot" id = "copper_ingot"
building = "smelter" building = "smelter"
inputs = [{item = "copper_ore", amount = 2}] inputs = [{item = "copper_ore", amount = 1}]
outputs = [{item = "copper_ingot", amount = 1}] outputs = [{item = "copper_ingot", amount = 1}]
duration_seconds = 2.5 duration_seconds = 1.0
[[recipe]] [[recipe]]
id = "titanium_ingot" id = "silicon"
building = "smelter" building = "smelter"
inputs = [{item = "titanium_ore", amount = 3}] inputs = [{item = "quartz", amount = 1}]
outputs = [{item = "titanium_ingot", amount = 1}] outputs = [{item = "silicon", amount = 1}]
duration_seconds = 4.0 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 # Reprocessing — the only source of voidsteel (battle-forged; formed when
# # weapon plasma anneals hull metal in the violence of ship destruction).
# The only source of advanced_alloy: salvaged scrap from destroyed ships. # Weights are authored for the fully unlocked pool state; the pool
# renormalizes over implicitly unlocked items early game.
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
[[recipe]] [[recipe]]
id = "reprocessing_cycle" id = "reprocessing_cycle"
building = "reprocessing_plant" building = "reprocessing_plant"
inputs = [{item = "scrap", amount = 5}] inputs = [{item = "scrap", amount = 4}]
duration_seconds = 3.0 duration_seconds = 4.0
[[recipe.outputs]] [[recipe.outputs]]
item = "iron_ingot" item = "iron_ingot"
amount = 2 amount = 1
probability = 0.45 probability = 0.3
[[recipe.outputs]] [[recipe.outputs]]
item = "copper_ingot" item = "copper_ingot"
amount = 1 amount = 1
probability = 0.25 probability = 0.3
[[recipe.outputs]] [[recipe.outputs]]
item = "titanium_ingot" item = "silicon"
amount = 1 amount = 1
probability = 0.15 probability = 0.2
[[recipe.outputs]] [[recipe.outputs]]
item = "advanced_alloy" item = "voidsteel"
amount = 1 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]] [[recipe]]
id = "steel_plate" id = "steel_plate"
unlock_at_station_level = -1
building = "assembler" building = "assembler"
inputs = [{item = "iron_ingot", amount = 2}] inputs = [{item = "iron_ingot", amount = 2}]
outputs = [{item = "steel_plate", amount = 1}] outputs = [{item = "steel_plate", amount = 1}]
duration_seconds = 3.0
[[recipe]]
id = "copper_wire"
building = "assembler"
inputs = [{item = "copper_ingot", amount = 1}]
outputs = [{item = "copper_wire", amount = 2}]
duration_seconds = 1.0
[[recipe]]
id = "copper_coil"
building = "assembler"
inputs = [{item = "copper_wire", amount = 2}]
outputs = [{item = "copper_coil", amount = 1}]
duration_seconds = 1.5
# Depth-3 chain (ore -> ingot -> plate -> block) is the factory's
# doubling-time knob; see the block economy rules in docs/balancing/rules.md.
# Explicitly unlocked at start (-1): building blocks appear in no
# schematic's materials, so implicit unlocking can never reach this recipe.
[[recipe]]
id = "building_block"
unlock_at_station_level = -1
building = "assembler"
inputs = [{item = "steel_plate", amount = 2}]
outputs = [{item = "building_block", amount = 4}]
duration_seconds = 2.0
# -----------------------------------------------------------------------------
# Tier 3 — mid intermediates (strange ratios begin; need quartz)
# -----------------------------------------------------------------------------
[[recipe]]
id = "control_chip"
building = "assembler"
inputs = [{item = "silicon", amount = 1}, {item = "copper_wire", amount = 2}]
outputs = [{item = "control_chip", amount = 1}]
duration_seconds = 5.0
[[recipe]]
id = "capacitor_bank"
building = "assembler"
inputs = [{item = "copper_coil", amount = 2}, {item = "silicon", amount = 1}]
outputs = [{item = "capacitor_bank", amount = 1}]
duration_seconds = 5.0
# The quality gate for m+ hulls: a deliberately long-running recipe
# (time-heavy archetype).
[[recipe]]
id = "hardened_steel"
building = "assembler"
inputs = [{item = "steel_plate", amount = 3}]
outputs = [{item = "hardened_steel", amount = 1}]
duration_seconds = 12.0
[[recipe]]
id = "ceramic_plate"
building = "assembler"
inputs = [{item = "quartz", amount = 2}]
outputs = [{item = "ceramic_plate", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "drive_unit"
building = "assembler"
inputs = [
{item = "steel_plate", amount = 2},
{item = "copper_coil", amount = 2},
{item = "control_chip", amount = 1},
]
outputs = [{item = "drive_unit", amount = 1}]
duration_seconds = 8.0
# -----------------------------------------------------------------------------
# Tier 4 — late intermediates (need voidsteel)
# -----------------------------------------------------------------------------
[[recipe]]
id = "voidsteel_plate"
building = "assembler"
inputs = [{item = "voidsteel", amount = 1}, {item = "hardened_steel", amount = 1}]
outputs = [{item = "voidsteel_plate", amount = 1}]
duration_seconds = 8.0
[[recipe]]
id = "capital_core"
building = "assembler"
inputs = [
{item = "voidsteel", amount = 2},
{item = "capacitor_bank", amount = 1},
{item = "control_chip", amount = 1},
]
outputs = [{item = "capital_core", amount = 1}]
duration_seconds = 10.0
# -----------------------------------------------------------------------------
# Shortcut recipes — drop-only assembler recipe schematics
# (unlock_at_station_level >= 0). Pure rewards: item threat stays defined by
# the base (expensive) path via the max rule, so shortcuts give real factory
# efficiency without shifting any balance.
# -----------------------------------------------------------------------------
[[recipe]]
id = "shortcut_steel_plate"
unlock_at_station_level = 1
building = "assembler"
inputs = [{item = "iron_ore", amount = 3}]
outputs = [{item = "steel_plate", amount = 1}]
duration_seconds = 2.0 duration_seconds = 2.0
[[recipe]] [[recipe]]
id = "circuit_board" id = "shortcut_control_chip"
unlock_at_station_level = -1 unlock_at_station_level = 2
building = "assembler" building = "assembler"
inputs = [{item = "iron_ingot", amount = 1}, {item = "copper_wire", amount = 2}] inputs = [{item = "quartz", amount = 2}]
outputs = [{item = "circuit_board", amount = 1}] outputs = [{item = "control_chip", amount = 1}]
duration_seconds = 2.0 duration_seconds = 4.0
[[recipe]] [[recipe]]
id = "building_blocks" id = "shortcut_hardened_steel"
unlock_at_station_level = -1 unlock_at_station_level = 2
building = "assembler" building = "assembler"
inputs = [{item = "iron_ingot", amount = 4}] inputs = [{item = "iron_ingot", amount = 4}]
outputs = [{item = "building_block", amount = 10}] outputs = [{item = "hardened_steel", amount = 1}]
duration_seconds = 4.0
# -----------------------------------------------------------------------------
# Advanced components (tier 3, midgame)
# -----------------------------------------------------------------------------
[[recipe]]
id = "mechanical_parts"
unlock_at_station_level = -1
building = "assembler"
inputs = [{item = "steel_plate", amount = 1}, {item = "iron_ingot", amount = 1}]
outputs = [{item = "mechanical_parts", amount = 2}]
duration_seconds = 2.5
[[recipe]]
id = "targeting_unit"
unlock_at_station_level = -1
building = "assembler"
inputs = [{item = "circuit_board", amount = 2}, {item = "copper_wire", amount = 1}]
outputs = [{item = "targeting_unit", amount = 1}]
duration_seconds = 3.0
[[recipe]]
id = "drive_unit"
unlock_at_station_level = -1
building = "assembler"
inputs = [
{item = "steel_plate", amount = 1},
{item = "mechanical_parts", amount = 1},
{item = "circuit_board", amount = 1},
]
outputs = [{item = "drive_unit", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "titanium_frame"
unlock_at_station_level = -1
building = "assembler"
inputs = [{item = "titanium_ingot", amount = 2}, {item = "steel_plate", amount = 1}]
outputs = [{item = "titanium_frame", amount = 1}]
duration_seconds = 4.0
# -----------------------------------------------------------------------------
# Capital components (tier 4, lategame — gated on advanced_alloy)
# -----------------------------------------------------------------------------
[[recipe]]
id = "reinforced_plating"
unlock_at_station_level = -1
building = "assembler"
inputs = [{item = "steel_plate", amount = 2}, {item = "advanced_alloy", amount = 1}]
outputs = [{item = "reinforced_plating", amount = 1}]
duration_seconds = 5.0
[[recipe]]
id = "capital_core"
unlock_at_station_level = -1
building = "assembler"
inputs = [
{item = "targeting_unit", amount = 1},
{item = "drive_unit", amount = 1},
{item = "advanced_alloy", amount = 2},
]
outputs = [{item = "capital_core", amount = 1}]
duration_seconds = 8.0 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 # Ship hulls
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
[[recipe]] [[recipe]]
id = "drone_hull" id = "drone_hull"
unlock_at_station_level = -1
building = "assembler" 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}] outputs = [{item = "drone_hull", amount = 1}]
duration_seconds = 4.0 duration_seconds = 1.0
[[recipe]] [[recipe]]
id = "frigate_hull" id = "frigate_hull"
unlock_at_station_level = -1
building = "assembler" building = "assembler"
inputs = [ inputs = [{item = "steel_plate", amount = 2}, {item = "copper_wire", amount = 1}]
{item = "steel_plate", amount = 3},
{item = "mechanical_parts", amount = 1},
{item = "circuit_board", amount = 1},
]
outputs = [{item = "frigate_hull", amount = 1}] outputs = [{item = "frigate_hull", amount = 1}]
duration_seconds = 8.0 duration_seconds = 2.0
[[recipe]] [[recipe]]
id = "destroyer_hull" id = "destroyer_hull"
unlock_at_station_level = -1
building = "assembler" building = "assembler"
inputs = [ inputs = [{item = "steel_plate", amount = 3}, {item = "copper_coil", amount = 2}]
{item = "steel_plate", amount = 5},
{item = "mechanical_parts", amount = 2},
{item = "circuit_board", amount = 1},
]
outputs = [{item = "destroyer_hull", amount = 1}] outputs = [{item = "destroyer_hull", amount = 1}]
duration_seconds = 10.0 duration_seconds = 4.0
[[recipe]] [[recipe]]
id = "cruiser_hull" id = "cruiser_hull"
unlock_at_station_level = -1
building = "assembler" building = "assembler"
inputs = [ inputs = [{item = "hardened_steel", amount = 2}, {item = "control_chip", amount = 2}]
{item = "titanium_frame", amount = 2},
{item = "steel_plate", amount = 4},
{item = "drive_unit", amount = 1},
]
outputs = [{item = "cruiser_hull", amount = 1}] outputs = [{item = "cruiser_hull", amount = 1}]
duration_seconds = 15.0 duration_seconds = 6.0
[[recipe]] [[recipe]]
id = "battlecruiser_hull" id = "battlecruiser_hull"
unlock_at_station_level = -1
building = "assembler" building = "assembler"
inputs = [ inputs = [
{item = "titanium_frame", amount = 3}, {item = "hardened_steel", amount = 3},
{item = "steel_plate", amount = 6}, {item = "control_chip", amount = 2},
{item = "drive_unit", amount = 1}, {item = "drive_unit", amount = 1},
{item = "targeting_unit", amount = 1},
] ]
outputs = [{item = "battlecruiser_hull", amount = 1}] outputs = [{item = "battlecruiser_hull", amount = 1}]
duration_seconds = 20.0 duration_seconds = 8.0
[[recipe]] [[recipe]]
id = "battleship_hull" id = "battleship_hull"
unlock_at_station_level = -1
building = "assembler" building = "assembler"
inputs = [ inputs = [
{item = "titanium_frame", amount = 4}, {item = "voidsteel_plate", amount = 3},
{item = "reinforced_plating", amount = 2}, {item = "drive_unit", amount = 1},
{item = "drive_unit", amount = 2}, {item = "control_chip", amount = 2},
] ]
outputs = [{item = "battleship_hull", amount = 1}] outputs = [{item = "battleship_hull", amount = 1}]
duration_seconds = 30.0 duration_seconds = 10.0
[[recipe]] [[recipe]]
id = "dreadnought_hull" id = "dreadnought_hull"
unlock_at_station_level = -1
building = "assembler" building = "assembler"
inputs = [ inputs = [
{item = "voidsteel_plate", amount = 5},
{item = "capital_core", amount = 1}, {item = "capital_core", amount = 1},
{item = "titanium_frame", amount = 6},
{item = "reinforced_plating", amount = 4},
{item = "drive_unit", amount = 2}, {item = "drive_unit", amount = 2},
] ]
outputs = [{item = "dreadnought_hull", amount = 1}] outputs = [{item = "dreadnought_hull", amount = 1}]
duration_seconds = 60.0 duration_seconds = 12.0
[[recipe]] [[recipe]]
id = "carrier_hull" id = "carrier_hull"
unlock_at_station_level = -1
building = "assembler" building = "assembler"
inputs = [ inputs = [
{item = "voidsteel_plate", amount = 5},
{item = "capital_core", amount = 1}, {item = "capital_core", amount = 1},
{item = "titanium_frame", amount = 5},
{item = "reinforced_plating", amount = 3},
{item = "drive_unit", amount = 2}, {item = "drive_unit", amount = 2},
] ]
outputs = [{item = "carrier_hull", amount = 1}] 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

View File

@@ -24,21 +24,21 @@
id = "drone" id = "drone"
unlock_at_station_level = -1 unlock_at_station_level = -1
layout = ["O"] 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] [ship.schematic]
materials = [{item = "iron_ore", amount = 1}] materials = [{item = "drone_hull", amount = 1}]
production_time_seconds = 5 production_time_seconds = 1
[ship.health] [ship.health]
hp = 3 hp = 60
[ship.movement] [ship.movement]
speed_mps = 40 speed_mps = 45
main_acceleration_mpss = 2 main_acceleration_mpss = 60
maneuvering_acceleration_mpss = 1 maneuvering_acceleration_mpss = 30
angular_acceleration_radpss = 12.56 angular_acceleration_radpss = 12
max_rotation_speed_radps = 6.28 max_rotation_speed_radps = 6
[ship.sensor] [ship.sensor]
sensor_range_m = 150 sensor_range_m = 150
@@ -55,18 +55,23 @@ layout = [
"OOO", "OOO",
"XOX", "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] [ship.schematic]
materials = [{item = "frigate_hull", amount = 1}] materials = [{item = "frigate_hull", amount = 1}]
production_time_seconds = 10 production_time_seconds = 2
[ship.health] [ship.health]
hp = 30 hp = 300
[ship.movement] [ship.movement]
speed_mps = 30 speed_mps = 35
main_acceleration_mpss = 50 main_acceleration_mpss = 45
maneuvering_acceleration_mpss = 25 maneuvering_acceleration_mpss = 22
angular_acceleration_radpss = 8 angular_acceleration_radpss = 8
max_rotation_speed_radps = 4 max_rotation_speed_radps = 4
@@ -79,23 +84,30 @@ sensor_range_m = 200
# mount medium hardware. # mount medium hardware.
[[ship]] [[ship]]
id = "destroyer" id = "destroyer"
unlock_at_station_level = -1 unlock_at_station_level = 0
layout = [ layout = [
"OXOXO", "OXOXO",
"OOOOO", "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] [ship.schematic]
materials = [{item = "destroyer_hull", amount = 1}] materials = [{item = "destroyer_hull", amount = 1}]
production_time_seconds = 15 production_time_seconds = 3
[ship.health] [ship.health]
hp = 50 hp = 550
[ship.movement] [ship.movement]
speed_mps = 25 speed_mps = 30
main_acceleration_mpss = 40 main_acceleration_mpss = 35
maneuvering_acceleration_mpss = 20 maneuvering_acceleration_mpss = 18
angular_acceleration_radpss = 6 angular_acceleration_radpss = 6
max_rotation_speed_radps = 3 max_rotation_speed_radps = 3
@@ -108,25 +120,31 @@ sensor_range_m = 220
# supports; no 3x3 area exists for an l gun. # supports; no 3x3 area exists for an l gun.
[[ship]] [[ship]]
id = "cruiser" id = "cruiser"
unlock_at_station_level = -1 unlock_at_station_level = 2
layout = [ layout = [
"XOOX", "XOOX",
"OOOO", "OOOO",
"OOOO", "OOOO",
"XOOX", "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] [ship.schematic]
materials = [{item = "cruiser_hull", amount = 1}] materials = [{item = "cruiser_hull", amount = 1}]
production_time_seconds = 25 production_time_seconds = 4
[ship.health] [ship.health]
hp = 120 hp = 1500
[ship.movement] [ship.movement]
speed_mps = 20 speed_mps = 24
main_acceleration_mpss = 30 main_acceleration_mpss = 25
maneuvering_acceleration_mpss = 15 maneuvering_acceleration_mpss = 12
angular_acceleration_radpss = 4 angular_acceleration_radpss = 4
max_rotation_speed_radps = 2 max_rotation_speed_radps = 2
@@ -140,25 +158,34 @@ sensor_range_m = 250
# stern leave no 3x3 area for an l gun and no 2x6 area for a drone hangar. # stern leave no 3x3 area for an l gun and no 2x6 area for a drone hangar.
[[ship]] [[ship]]
id = "battlecruiser" id = "battlecruiser"
unlock_at_station_level = -1 unlock_at_station_level = 4
unlock_requires = ["cruiser"]
layout = [ layout = [
"OOXXOO", "OOXXOO",
"OOOOOO", "OOOOOO",
"XOOOOX", "XOOOOX",
"XXOOXX", "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] [ship.schematic]
materials = [{item = "battlecruiser_hull", amount = 1}] materials = [{item = "battlecruiser_hull", amount = 1}]
production_time_seconds = 35 production_time_seconds = 5
[ship.health] [ship.health]
hp = 180 hp = 2400
[ship.movement] [ship.movement]
speed_mps = 18 speed_mps = 20
main_acceleration_mpss = 25 main_acceleration_mpss = 20
maneuvering_acceleration_mpss = 12 maneuvering_acceleration_mpss = 10
angular_acceleration_radpss = 3 angular_acceleration_radpss = 3
max_rotation_speed_radps = 1.5 max_rotation_speed_radps = 1.5
@@ -174,7 +201,8 @@ sensor_range_m = 260
# so no 2x6 drone hangar fits. # so no 2x6 drone hangar fits.
[[ship]] [[ship]]
id = "battleship" id = "battleship"
unlock_at_station_level = 1 unlock_at_station_level = 6
unlock_requires = ["battlecruiser"]
layout = [ layout = [
"XOOOOX", "XOOOOX",
"OOOOOO", "OOOOOO",
@@ -182,18 +210,26 @@ layout = [
"OOOOOO", "OOOOOO",
"XOOOOX", "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] [ship.schematic]
materials = [{item = "battleship_hull", amount = 1}] materials = [{item = "battleship_hull", amount = 1}]
production_time_seconds = 60 production_time_seconds = 6
[ship.health] [ship.health]
hp = 350 hp = 6300
[ship.movement] [ship.movement]
speed_mps = 14 speed_mps = 15
main_acceleration_mpss = 18 main_acceleration_mpss = 14
maneuvering_acceleration_mpss = 8 maneuvering_acceleration_mpss = 7
angular_acceleration_radpss = 2 angular_acceleration_radpss = 2
max_rotation_speed_radps = 1 max_rotation_speed_radps = 1
@@ -208,7 +244,7 @@ sensor_range_m = 280
# stay the only hangar hull. Bow and stern strips hold supports. # stay the only hangar hull. Bow and stern strips hold supports.
[[ship]] [[ship]]
id = "dreadnought" id = "dreadnought"
unlock_at_station_level = 2 unlock_at_station_level = 8
unlock_requires = ["battleship"] unlock_requires = ["battleship"]
layout = [ layout = [
"XXXOOOOOXXX", "XXXOOOOOXXX",
@@ -217,18 +253,28 @@ layout = [
"OOOXOOOXOOO", "OOOXOOOXOOO",
"XXOOXXXOOXX", "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] [ship.schematic]
materials = [{item = "dreadnought_hull", amount = 1}] materials = [{item = "dreadnought_hull", amount = 1}]
production_time_seconds = 120 production_time_seconds = 8
[ship.health] [ship.health]
hp = 800 hp = 24000
[ship.movement] [ship.movement]
speed_mps = 8 speed_mps = 10
main_acceleration_mpss = 10 main_acceleration_mpss = 8
maneuvering_acceleration_mpss = 5 maneuvering_acceleration_mpss = 4
angular_acceleration_radpss = 1 angular_acceleration_radpss = 1
max_rotation_speed_radps = 0.5 max_rotation_speed_radps = 0.5
@@ -242,7 +288,8 @@ sensor_range_m = 300
# the lower decks hold supports and 2x2 point-defense m guns. # the lower decks hold supports and 2x2 point-defense m guns.
[[ship]] [[ship]]
id = "carrier" id = "carrier"
unlock_at_station_level = -1 unlock_at_station_level = 9
unlock_requires = ["battleship"]
layout = [ layout = [
"XOOOOOOOOX", "XOOOOOOOOX",
"OOOOOOOOOO", "OOOOOOOOOO",
@@ -250,18 +297,26 @@ layout = [
"XOOOOOOOOX", "XOOOOOOOOX",
"XXXOOOOXXX", "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] [ship.schematic]
materials = [{item = "carrier_hull", amount = 1}] materials = [{item = "carrier_hull", amount = 1}]
production_time_seconds = 120 production_time_seconds = 8
[ship.health] [ship.health]
hp = 700 hp = 24000
[ship.movement] [ship.movement]
speed_mps = 9 speed_mps = 10
main_acceleration_mpss = 10 main_acceleration_mpss = 8
maneuvering_acceleration_mpss = 5 maneuvering_acceleration_mpss = 4
angular_acceleration_radpss = 1 angular_acceleration_radpss = 1
max_rotation_speed_radps = 0.5 max_rotation_speed_radps = 0.5

View File

@@ -1,10 +1,18 @@
# stations.toml
#
# Combat-pass anchors (see docs/balancing/targets.md, "Combat anchors"):
# a fresh player defence station holds one early parity wave unaided; the
# enemy station at level 0 matches the player station exactly and scales
# with the push level x. Station scrap drops stay authored (pushing rewards
# are tuned independently of ship production costs, REQ-RES-SCRAP-DROP).
[hq] [hq]
surface_mask = [ surface_mask = [
"AAA", "AAA",
"AAA", "AAA",
"AAA", "AAA",
] ]
hp_formula = "1000" hp_formula = "5000"
[player_station] [player_station]
surface_mask = [ surface_mask = [
@@ -12,19 +20,19 @@ surface_mask = [
"SS", "SS",
] ]
level = 1 level = 1
hp_formula = "300" hp_formula = "3000"
damage_formula = "5" damage_formula = "25"
range_m_formula = "200" range_m_formula = "120"
fire_rate_hz_formula = "1" fire_rate_hz_formula = "1"
scrap_drop_formula = "10" scrap_drop_formula = "40"
[enemy_station] [enemy_station]
surface_mask = [ surface_mask = [
"SS", "SS",
"SS", "SS",
] ]
hp_formula = "300 + 150*x" hp_formula = "3000 + 1500*x"
damage_formula = "2 + 1*x" damage_formula = "25 + 12*x"
range_m_formula = "200" range_m_formula = "120"
fire_rate_hz_formula = "1.0 + 0.2*x" fire_rate_hz_formula = "1.0 + 0.1*x"
scrap_drop_formula = "10 + 5*x" scrap_drop_formula = "40 + 30*x"

View File

@@ -116,11 +116,11 @@ outline = "#201010"
fill = "#c47a3a" fill = "#c47a3a"
outline = "#3a1a0a" outline = "#3a1a0a"
[items.titanium_ore] [items.quartz]
fill = "#9aa3ad" fill = "#e0d4f0"
outline = "#2a2e33" outline = "#40345a"
# --- ingots --- # --- smelted basics ---
[items.iron_ingot] [items.iron_ingot]
fill = "#b0b0b8" fill = "#b0b0b8"
@@ -130,9 +130,9 @@ outline = "#202028"
fill = "#d48a4a" fill = "#d48a4a"
outline = "#402010" outline = "#402010"
[items.titanium_ingot] [items.silicon]
fill = "#c8d2dc" fill = "#33415e"
outline = "#3a4048" outline = "#0e1420"
# --- salvage loop --- # --- salvage loop ---
@@ -140,9 +140,9 @@ outline = "#3a4048"
fill = "#7a7268" fill = "#7a7268"
outline = "#201a14" outline = "#201a14"
[items.advanced_alloy] [items.voidsteel]
fill = "#a06acc" fill = "#4a3a6a"
outline = "#201030" outline = "#151020"
# --- basic components --- # --- basic components ---
@@ -154,9 +154,9 @@ outline = "#3a2008"
fill = "#8a92a0" fill = "#8a92a0"
outline = "#22262c" outline = "#22262c"
[items.circuit_board] [items.copper_coil]
fill = "#2ea35a" fill = "#d07030"
outline = "#0a2a14" outline = "#381808"
[items.building_block] [items.building_block]
fill = "#c8b070" fill = "#c8b070"
@@ -164,26 +164,30 @@ outline = "#302810"
# --- advanced components --- # --- advanced components ---
[items.mechanical_parts] [items.control_chip]
fill = "#6f7a66" fill = "#2ea35a"
outline = "#1c2018" outline = "#0a2a14"
[items.targeting_unit] [items.capacitor_bank]
fill = "#3a9e8c" fill = "#d0a030"
outline = "#0c2824" outline = "#302408"
[items.hardened_steel]
fill = "#6a7280"
outline = "#181c22"
[items.ceramic_plate]
fill = "#e0d8c8"
outline = "#3a3428"
[items.drive_unit] [items.drive_unit]
fill = "#4a6ad0" fill = "#4a6ad0"
outline = "#101a38" outline = "#101a38"
[items.titanium_frame]
fill = "#b8c4d4"
outline = "#343c48"
# --- capital components --- # --- capital components ---
[items.reinforced_plating] [items.voidsteel_plate]
fill = "#8a6ad0" fill = "#7a5aaa"
outline = "#1c1038" outline = "#1c1038"
[items.capital_core] [items.capital_core]
@@ -192,15 +196,15 @@ outline = "#280c30"
# --- module items --- # --- module items ---
[items.laser_cannon_s_module] [items.railgun_s_module]
fill = "#691313" fill = "#691313"
outline = "#f3ff4f" outline = "#f3ff4f"
[items.laser_cannon_m_module] [items.railgun_m_module]
fill = "#892020" fill = "#892020"
outline = "#f3ff4f" outline = "#f3ff4f"
[items.laser_cannon_l_module] [items.railgun_l_module]
fill = "#a92d2d" fill = "#a92d2d"
outline = "#f3ff4f" outline = "#f3ff4f"

View File

@@ -1,9 +1,9 @@
[world] [world]
height_tiles = 40 height_tiles = 40
refund_percentage = 100 refund_percentage = 100
starting_building_blocks = 1000 starting_building_blocks = 200
scrap_despawn_seconds = 30 scrap_despawn_seconds = 120
scrap_per_threat = 0.01 scrap_per_threat = 0.25
tile_size_m = 10 tile_size_m = 10
belt_speed_mps = 20 belt_speed_mps = 20
tunnel_max_distance_tiles = 10 tunnel_max_distance_tiles = 10
@@ -19,7 +19,9 @@ enemy_buffer_width_tiles = 20
[expansion] [expansion]
columns_per_expansion_tiles = 10 columns_per_expansion_tiles = 10
cost_building_blocks = 200 # x = expansions already purchased; ~1 per cycle mid-game, decelerating
# to 2-3 cycles late (docs/balancing/derived.md).
cost_building_blocks_formula = "300 + 50*x + 10*x*x"
[push] [push]
push_expand_columns_tiles = 10 push_expand_columns_tiles = 10
@@ -32,10 +34,13 @@ target_hysteresis = 0.40 # keep current target unless
[artifacts] [artifacts]
artifact_chance_formula = "0.05 * x" # 5% chance per station level artifact_chance_formula = "0.05 * x" # 5% chance per station level
artifact_win_count = 3 artifact_win_count = 5
[waves] [waves]
threat_rate_formula = "x" # Tuned against the factory-size curve (docs/balancing/targets.md, balancing
# targets): stays below the player's achievable military output early,
# crosses it around the late boundary (~cycle 15), overwhelms by ~24.
threat_rate_formula = "2*x + 0.15*x*x"
gap_min_seconds = 15 gap_min_seconds = 15
gap_max_seconds = 45 gap_max_seconds = 45
spawn_duration_seconds = 10 spawn_duration_seconds = 10

View File

@@ -1,68 +1,442 @@
# 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,
# railgun_s-spam cruiser (12 small guns) 178.
# 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]] [[arena]]
name = "Fighters vs Sniper" name = "Mirror: drones 20v20"
height_tiles = 20 height_tiles = 10
player_buffer_width_tiles = 10 player_buffer_width_tiles = 10
contest_zone_width_tiles = 60 contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10 enemy_buffer_width_tiles = 10
[[arena.team]] [[arena.team]]
name = "Alpha" name = "Alpha"
[[arena.team.ship]] [[arena.team.ship]]
schematic = "drone" schematic = "drone"
count = 5 count = 20
modules = [ modules = [
{type = "laser_cannon_s", x = 1, y = 1, rotation = "east"}, {type = "railgun_s", x = 0, y = 0, rotation = "east"},
] ]
[[arena.team]] [[arena.team]]
name = "Beta" name = "Beta"
[[arena.team.ship]] [[arena.team.ship]]
schematic = "drone" schematic = "drone"
count = 2 count = 20
modules = [ modules = [
{type = "laser_cannon_s", x = 1, y = 1, rotation = "east"}, {type = "railgun_s", x = 0, y = 0, rotation = "east"},
{type = "weapon_stabilizer", x = 1, y = 1, rotation = "east"},
{type = "weapon_stabilizer", x = 1, y = 1, rotation = "east"},
{type = "weapon_upgrade", x = 1, y = 1, rotation = "east"},
{type = "sensor_booster", x = 1, y = 1, rotation = "east"},
{type = "sensor_booster", x = 1, y = 1, rotation = "east"},
] ]
[[arena]] [[arena]]
name = "Fighters vs Supported" name = "Mirror: cruisers 6v6"
height_tiles = 20 height_tiles = 10
player_buffer_width_tiles = 10 player_buffer_width_tiles = 10
contest_zone_width_tiles = 60 contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10 enemy_buffer_width_tiles = 10
[[arena.team]] [[arena.team]]
name = "Fighters" name = "Alpha"
[[arena.team.ship]] [[arena.team.ship]]
schematic = "drone" schematic = "cruiser"
count = 5 count = 6
modules = [ 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]] [[arena.team]]
name = "Supported" name = "Beta"
[[arena.team.ship]] [[arena.team.ship]]
schematic = "drone" schematic = "cruiser"
count = 3 count = 6
modules = [ 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 = "Railgun_s-spam cruisers vs default cruisers (1424 vs 1401)"
# Tracks the playtest-1 meta: cruiser hulls filled with 12 small guns
# (max DPS/threat, no armor, range 50) against the default m-gun fit.
# The concentration tax means the spam side SHOULD win a brawl somewhat;
# this arena bounds its margin — a blowout here means the small-gun
# premium or the m-gun range edge needs retuning.
height_tiles = 10
player_buffer_width_tiles = 10
contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10
[[arena.team]]
name = "Spam"
[[arena.team.ship]]
schematic = "cruiser"
count = 8
modules = [
{type = "railgun_s", x = 1, y = 0, rotation = "east"},
{type = "railgun_s", x = 2, 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 = 0, y = 2, rotation = "east"},
{type = "railgun_s", x = 1, y = 2, rotation = "east"},
{type = "railgun_s", x = 2, y = 2, rotation = "east"},
{type = "railgun_s", x = 3, y = 2, rotation = "east"},
{type = "railgun_s", x = 1, y = 3, rotation = "east"},
{type = "railgun_s", x = 2, y = 3, rotation = "east"},
]
[[arena.team]]
name = "Default"
[[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]]
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]] [[arena.team.ship]]
schematic = "drone" schematic = "drone"
count = 2 count = 2
modules = [ modules = [
{type = "repair_tool", x = 1, y = 1, rotation = "east"}, {type = "railgun_s", x = 0, y = 0, rotation = "east"},
] ]
[[arena]] [[arena]]
name = "Stations and Ships" name = "Station assault (2 stations + 105 vs 315)"
height_tiles = 60 height_tiles = 10
player_buffer_width_tiles = 15 player_buffer_width_tiles = 15
contest_zone_width_tiles = 40 contest_zone_width_tiles = 40
enemy_buffer_width_tiles = 15 enemy_buffer_width_tiles = 15
@@ -71,9 +445,9 @@ enemy_buffer_width_tiles = 15
name = "Fortified" name = "Fortified"
[[arena.team.ship]] [[arena.team.ship]]
schematic = "drone" schematic = "drone"
count = 3 count = 10
modules = [ modules = [
{type = "laser_cannon_s", x = 1, y = 1, rotation = "east"}, {type = "railgun_s", x = 0, y = 0, rotation = "east"},
] ]
[[arena.team.station]] [[arena.team.station]]
type = "player_station" type = "player_station"
@@ -90,7 +464,7 @@ enemy_buffer_width_tiles = 15
name = "Swarm" name = "Swarm"
[[arena.team.ship]] [[arena.team.ship]]
schematic = "drone" schematic = "drone"
count = 8 count = 30
modules = [ modules = [
{type = "laser_cannon_s", x = 1, y = 1, rotation = "east"}, {type = "railgun_s", x = 0, y = 0, rotation = "east"},
] ]

View File

@@ -92,3 +92,60 @@ duration_seconds = 3.0
item = "advanced_alloy" item = "advanced_alloy"
amount = 1 amount = 1
probability = 0.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

View File

@@ -19,7 +19,7 @@ enemy_buffer_width_tiles = 15
[expansion] [expansion]
columns_per_expansion_tiles = 10 columns_per_expansion_tiles = 10
cost_building_blocks = 200 cost_building_blocks_formula = "400 * 2^x"
[push] [push]
push_expand_columns_tiles = 20 push_expand_columns_tiles = 20

60
docs/balancing/README.md Normal file
View File

@@ -0,0 +1,60 @@
# Balancing Documentation
Everything about balancing Dota Factory, separated by role:
- **[rules.md](rules.md)** — the design rules and principles. Timeless;
changes only when the design changes.
- **[targets.md](targets.md)** — the base numbers (roots/anchors) chosen
by design. Change these first; everything else re-derives.
- **[derived.md](derived.md)** — the current tuned state of all derived
numbers, mirroring the configs. Updated whenever configs change.
- **[process.md](process.md)** — how balancing is done: the pass order,
tuning discipline, tools, and the checklist for the next round.
- **[history.md](history.md)** — chronological record of decisions,
findings, bugs, and arena rounds.
Related: game content (hull grids, footprint gating, tree design and
fiction) in [../content_design.md](../content_design.md); rules with
REQ-* ids in [../requirements.md](../requirements.md).
## Status
First full balancing round complete (2026-07-06): targets → tree →
numbers → threat-calculator parity → combat stats (arena-converged) →
pacing. Playtesting in progress: playtest 1 (two full ~40-min wins,
cruisers only) found the railgun_s-spam-cruiser meta, confirmed repair
as overpowered, and exposed a 2.53× run-length gap; combat stats
adjusted and re-checked in arena round 6 (see `history.md`). Next:
playtest 2 with the new stats — pacing knobs (station scaling, threat
rate, win pacing) wait for its result.
## Open action items
Agreed changes that require edits to `requirements.md`, the code, or the
configs. Completed items are removed (their outcomes live in
`requirements.md`, `history.md`, and the git history).
1. **Fill unfillable schematic slots with artifacts.** With duplicates
removed, the schematic drop pool can run dry — previously unreachable.
Decision: every slot in the choice dialog that cannot be filled with a
schematic because the eligible pool is exhausted is filled with an
artifact option instead (in addition to any artifact option granted by
the regular artifact roll). A push therefore always awards a full
dialog. Update REQ-DEF-SCHEMATIC-DROP.
2. **Confirm wave scaling in playtests.** `threat_rate_formula` is the
only time-scaling axis; verify the tuned curve (see `derived.md`)
produces the intended difficulty race in real runs.
3. **Gate shortcut-recipe drops on their inputs.** Extend the assembler
recipe schematic pool eligibility in REQ-DEF-SCHEMATIC-DROP: in
addition to the existing station-level and output-item checks, all of
the recipe's input item types must be implicitly unlocked as well.
4. **Resource deposits.** Add a terrain deposit layer per the Resource
deposits rules (`rules.md`): deposit patches generated in expansion
columns (deterministic content per expansion, randomized placement
within the new columns), deposit rendering, and a miner condition (a
resource recipe is selectable only if the miner's footprint overlaps
at least one matching deposit tile). Touches REQ-BLD-MINER ("every
asteroid tile is equivalent" no longer holds),
REQ-GW-ASTEROID-EXPAND / REQ-EXP-*, `world.toml`, and `visuals.toml`.
Until this lands, quartz mines anywhere and the mid-game is
knowledge-gated only.

171
docs/balancing/derived.md Normal file
View File

@@ -0,0 +1,171 @@
# Derived Values (current tuned state)
Everything here is derived from `targets.md` under the rules in
`rules.md`, and mirrors the config files. Item threats, ship threats,
ratios, and belt checks are verified by `tools/threat_report.py` — re-run
it after any recipe or material change and update this file when values
move. Combat stats were tuned empirically against the arena suite in
`bin/balancing/data/balancing.toml` (round-by-round record in
`history.md`).
## Economy constants
- `scrap_per_threat = 0.25` — 1 scrap per 4 threat destroyed (a cruiser
kill drops ~59 scrap); threat(scrap) = 4.
- Scrap smelting: 1 scrap → 1 iron_ingot, 1 s — deliberately
value-losing; reprocessing is the value-preserving path.
- Reprocessing: 4 scrap per cycle, 4 s; full-pool weights iron_ingot 30 /
copper_ingot 30 / silicon 20 / voidsteel 20 → threat(voidsteel)
= (4·4 + 4)/0.2 = 100.
- `scrap_despawn_seconds = 120` (a capital kill drops hundreds of scrap,
collected one per salvage cycle).
## Recipes and item threats
(dur in seconds; threat is per output unit)
| item | recipe | dur | out | threat |
|---|---|---|---|---|
| iron_ore / copper_ore | miner | 1 | 1 | 1 |
| quartz | miner (deposit) | 2 | 1 | 2 |
| iron_ingot | 1 iron_ore | 1 | 1 | 2 |
| copper_ingot | 1 copper_ore | 1 | 1 | 2 |
| silicon | 1 quartz | 2 | 1 | 4 |
| steel_plate | 2 iron_ingot | 3 | 1 | 7 |
| copper_wire | 1 copper_ingot | 1 | 2 | 1.5 |
| copper_coil | 2 copper_wire | 1.5 | 1 | 4.5 |
| building_block | 2 steel_plate | 2 | 4 | 4 |
| control_chip | 1 silicon + 2 copper_wire | 5 | 1 | 12 |
| capacitor_bank | 2 copper_coil + 1 silicon | 5 | 1 | 18 |
| hardened_steel | 3 steel_plate | 12 | 1 | 33 |
| ceramic_plate | 2 quartz | 4 | 1 | 8 |
| drive_unit | 2 steel_plate + 2 copper_coil + 1 control_chip | 8 | 1 | 43 |
| voidsteel_plate | 1 voidsteel + 1 hardened_steel | 8 | 1 | 141 |
| capital_core | 2 voidsteel + 1 capacitor_bank + 1 control_chip | 10 | 1 | 240 |
Shortcut recipes (drop-only; item threat stays defined by the base path
via the max rule): `shortcut_steel_plate` 3 iron_ore → 1 plate (2 s,
level 1), `shortcut_control_chip` 2 quartz → 1 chip (4 s, level 2),
`shortcut_hardened_steel` 4 iron_ingot → 1 hardened (8 s, level 2).
Ratio curve realized: t1 all 1:1 (miner:smelter); t2 clean 2:3
(ingot→plate, wire→coil); t3 strange — 2:5 (silicon→chip), 3:5
(coil→capacitor), 3:4 (plate→hardened, plate→drive); t4 inverted 3:2
(hardened→voidsteel_plate). Belt check: worst input demand 1.33 items/s,
under the ~2/s single-belt cap everywhere.
## Module prefabs
(contribution = item threat + module production time)
| module | recipe | dur | mod. time | contribution |
|---|---|---|---|---|
| railgun_s | 1 copper_coil | 1 | 1 | 6.5 |
| salvager | 1 steel_plate + 2 copper_wire | 2 | 1 | 13 |
| repair_tool | 1 steel_plate + 2 copper_wire | 2 | 1 | 13 |
| armor_plates | 4 steel_plate | 3 | 1 | 32 |
| maneuvering_thrusters | 1 steel_plate + 1 copper_coil | 2 | 1 | 14.5 |
| sensor_booster | 2 copper_wire + 1 copper_coil | 2 | 1 | 10.5 |
| afterburner | 2 copper_coil + 1 steel_plate | 3 | 1 | 20 |
| weapon_stabilizer | 1 steel_plate + 1 copper_coil | 2 | 1 | 14.5 |
| weapon_primer | 1 capacitor_bank + 1 copper_coil | 4 | 2 | 28.5 |
| weapon_upgrade | 1 control_chip + 1 copper_coil | 4 | 2 | 22.5 |
| railgun_m | 1 capacitor_bank + 2 steel_plate + 1 copper_coil | 4 | 3 | 43.5 |
| drone_bay | 1 control_chip + 2 steel_plate + 1 copper_coil | 4 | 3 | 37.5 |
| railgun_l | 1 capacitor_bank + 2 hardened_steel + 1 ceramic_plate | 6 | 4 | 102 |
| drone_hangar | 1 voidsteel_plate + 2 control_chip + 1 drive_unit | 10 | 6 | 224 |
## Ships
(fitted = hull item + ship base time + default loadout; the default
loadouts are the `default_modules` used by enemy waves and are
geometry-validated against the hull grids)
| ship | hull recipe | dur | base | default loadout | fitted |
|---|---|---|---|---|---|
| drone | 1 iron_ingot | 1 | 1 | railgun_s | 10.5 |
| frigate | 2 steel_plate + 1 copper_wire | 2 | 2 | 2× railgun_s, maneuvering_thrusters | 47 |
| destroyer | 3 steel_plate + 2 copper_coil | 4 | 3 | 3× railgun_s, armor_plates, sensor_booster | 99 |
| cruiser | 2 hardened_steel + 2 control_chip | 6 | 4 | 2× railgun_m, armor_plates, maneuvering_thrusters | 233.5 |
| battlecruiser | 3 hardened_steel + 2 control_chip + 1 drive_unit | 8 | 5 | 3× railgun_m, armor_plates, 2× railgun_s | 354.5 |
| battleship | 3 voidsteel_plate + 1 drive_unit + 2 control_chip | 10 | 6 | railgun_l, 2× railgun_m, weapon_stabilizer, 2× railgun_s | 722.5 |
| dreadnought | 5 voidsteel_plate + 1 capital_core + 2 drive_unit | 12 | 8 | 3× railgun_l, 4× armor_plates, railgun_s | 1491.5 |
| carrier | 5 voidsteel_plate + 1 capital_core + 2 drive_unit | 12 | 8 | drone_hangar, 2× railgun_m, 2× armor_plates, sensor_booster | 1436.5 |
## Combat stats
(arena-converged 2026-07, rounds 15; playtest-1 adjustments 2026-07-06 —
see `history.md`)
**Weapons:** railgun_s 2 dmg × 2.0 Hz (4.0 DPS), range 50 m;
railgun_m 14 × 1.5 (21), range 80; railgun_l 52 × 0.8 (41.6), range 130.
DPS per threat: s 0.62, m 0.48, l 0.41 — the concentration tax stands;
reach is the bigger guns' compensation (ranges raised after playtest 1,
which alone priced out the small-gun-spam meta). railgun_l deliberately
outranges stations (120 m) to buy the siege role. railgun_m damage is
breakpoint-sensitive: 15+ drops a 60 HP drone from 5 hits to 4.
**Hull HP** (15/threat prior + empirical trims): drone 60, frigate 300,
destroyer 550, cruiser 1500, battlecruiser 2400, battleship 6300,
dreadnought/carrier 24000.
**Mobility ladder** (speed m/s | main accel | maneuvering | angular |
max rot): drone 45|60|30|12|6, frigate 35|45|22|8|4,
destroyer 30|35|18|6|3, cruiser 24|25|12|4|2, battlecruiser 20|20|10|3|1.5,
battleship 15|14|7|2|1, dreadnought/carrier 10|8|4|1|0.5.
Sensors: 150/200/220/250/260/280/300/350 m.
**Other modules:** armor_plates +1200 HP; repair_tool 4 HP × 1 Hz,
range 80 (halved after playtest 1 — free between-wave top-offs were never
priced by the arena escort test); salvager range 60, cargo 20, 0.5 collections/s; afterburner
×1.6 speed +60 accel; maneuvering_thrusters ×1.2 speed +10 maneuvering;
sensor_booster +50 m; weapon_upgrade ×1.2 damage; weapon_primer ×1.2
rate; weapon_stabilizer ×1.3 range ×0.8 rate.
**Stations:** HQ 5000 HP. Player station 3000 HP, 25 dmg × 1 Hz,
range 120, scrap 40. Enemy station: 3000+1500x HP, 25+12x dmg,
1.0+0.1x Hz, range 120, scrap 40+30x (x = push level).
## Pacing
**Unlock ladder** (level → unlocks; ← marks `unlock_requires`; starting
set at 1: drone, frigate, railgun_s, salvager, building_block recipe):
| level | ships | modules | recipes |
|---|---|---|---|
| 0 | destroyer | repair_tool, armor_plates | |
| 1 | | maneuvering_thrusters, sensor_booster | shortcut_steel_plate |
| 2 | cruiser | railgun_m, afterburner | shortcut_control_chip, shortcut_hardened_steel |
| 3 | | weapon_stabilizer | |
| 4 | battlecruiser ← cruiser | weapon_primer, weapon_upgrade | |
| 5 | | drone_bay | |
| 6 | battleship ← battlecruiser | railgun_l ← railgun_m | |
| 8 | dreadnought ← battleship | | |
| 9 | carrier ← battleship | drone_hangar | |
Level 0's pool has exactly three entries (a full first dialog). Level 2
is the quartz gate: cruiser and railgun_m are the first schematics whose
chains reach quartz; the shortcut outputs only become implicitly
unlocked alongside them, so shortcuts cannot drop early.
**Threat rate** `2*x + 0.15*x*x` (x = boss cycle counter), against the
factory-size curve with ~half the player's output assumed military:
| cycle x | rate (threat/s) | player military (≈ curve/2) |
|---|---|---|
| 2 | 4.6 | ~12 |
| 6 | 17.4 | ~30 |
| 15 | 63.8 | ~60 |
| 20 | 100 | ~75 |
| 24 | 134 | — |
**Economy:** `starting_building_blocks = 200`; expansion cost formula
`300 + 50*x + 10*x*x` (x = expansions already purchased: ~1 affordable
per cycle mid-game at ~1/3 of block income, stretching to 23 cycles
late — quadratic so costs outrun the roughly linear block income
gradually, never with a hard wall); `artifact_win_count = 5` with
`artifact_chance_formula = 0.05*x`. Building costs: belt 2, splitter 3,
tunnels 5, miner 15, smelter 20, assembler 35, reprocessing plant 40,
salvage bay 25, shipyard 60 — averaging ≈18 blocks per placed building
(belts included), which meets the 4-minute doubling target at block
threat 4.

161
docs/balancing/history.md Normal file
View File

@@ -0,0 +1,161 @@
# Balancing History
Chronological record of the balancing work: what was decided, what was
found, what changed. Current values live in `derived.md`; this file
explains how they got there.
## 2026-07-02/03 — rules and structural decisions
- Rules document written (now `rules.md`): ratio curve, shortcut
recipes, refactorability, cost archetypes, threat model, growth curve.
- Scrap derived from threat (`scrap_per_threat`), replacing authored
per-ship scrap drops; scrap threat became the constant
`1/scrap_per_threat`, removing the old min-scrap_drop derivation and
its circularity.
- Duplicate schematic drops removed (no level-ups); ship/module levels
removed entirely — all time scaling lives in the threat rate, push
scaling stays on stations. Mk2 upgrade recipes noted as the future
per-item progression.
- Growth-curve rules added: escalating expansion costs, designed
doubling time, growth limited by economy not waiting; resource
deposits designed (deposit-gated mid resource in expansion territory).
- Production tree v2 decided: iron/copper everywhere (M-type asteroid),
quartz in geodes (mid), voidsteel battle-forged from scrap (late);
titanium dropped; lasers renamed to railguns, lasers reserved as a
future weapon type.
## 2026-07-03 — targets, tree, numbers
- Balancing targets fixed: ≤2 h run, phases 15/614/15+, factory curve
25/60/120/150, threat ladder, 25-ship swarm, block roots.
- Tree structure drafted and numbers computed (recursive threat
calculator); ratio curve realized; fitted ships within 96124% of the
strawman ladder (small end hot from fixed chain overhead — ladder
later adopted the achieved values).
- **Rule bugs found by the numbers work:** the scrap→ingot smelter
recipe would inflate basic materials via the max rule (fixed:
scrap-consuming recipes are threat fallback only); recipe output
amounts were ignored (fixed: per-unit division); items downstream of
reprocessing-only items never resolved (fixed: fixpoint resolution);
a shortcut recipe resolving earlier than the base path silently
underpriced items (fixed: commit only when all eligible recipes are
computable). All four fixed in `ThreatCostCalculator` with tests, and
implemented in `tools/threat_report.py`.
- v2 tree written into the configs; `default_modules` loadouts
geometry-validated (the numbers-pass loadouts for battlecruiser and
dreadnought were geometrically impossible — L-modifiers don't fit
beside full gun complements; corrected loadouts landed closer to the
ladder).
## 2026-07-04 — combat stats, arena rounds 15
Initial stats derived from the anchors (weapon DPS ≈0.6/threat flat,
hull 15 HP/threat, armor 20/threat, repair 2 HP/s/threat, station
range 200).
- **Round 1:** concentrated fleets won all equal-threat cross-tier
matchups flawlessly; glass beat armored; repair escort flawless; two
stations shrugged off a 3× swarm. Changes: concentration tax on m/l
gun damage (railgun_m 17→14, railgun_l 70→52), armor 640→1000,
repair 25→12, station range 200→120. (Team-1 "bias" in mirrors later
shown to be noise.)
- **Round 2 (EHP-margin logging added):** battleship +33% while
dreadnought 37% (stabilizer range + opposing armor); glass still
+11%. Changes: stabilizer range ×1.5→×1.3; per-hull trims introduced
(BC 2700→2500, BS 7500→7000, DN/CV 15500→19000).
- **Round 3 (narrow lanes — geometry fixed into the fixture):**
DN closed to 11%, BS +22%, swarm flipped to +14% over cruisers,
glass +12% third time. Changes: armor 1000→1200, BC 2500→2000,
BS 7000→6300, DN/CV 19000→22500.
- **Round 4:** glass-vs-armored resolved (+3% armored); noise floor
established (~±10%/run: BS ignored a 10% EHP cut; repair drifted
14→24% untouched). Convergence policy adopted: two-round signals only,
±20% converged. Changes: BC 2000→2200, DN/CV 22500→24000; BS +23%
accepted as doctrine texture (mechanical range edge vs. pure small
fleets).
- **Round 5 (durations logged; end-condition bug fixed upstream):**
TTK anchor validated (mirrors 23/71/95 s; DN-vs-swarm 214 s outlier
accepted); dreadnought +3%, everything else inside band. Final
changes: BC 2200→2400, repair 12→9 (persistent +24% escort margin).
**Combat pass declared converged.**
## 2026-07-05/06 — pacing pass
- Unlock ladder set (starting set drone/frigate/railgun_s/salvager;
quartz gate at level 2; capitals at 89 with `unlock_requires`
chains); threat rate `2*x + 0.15*x*x`; starting blocks 1000→200;
expansion 400 flat pending the cost formula; artifacts 3→5.
- **Bug found:** the building_block recipe was silently locked at game
start (building blocks appear in no schematic's materials, so implicit
unlocking could never reach the recipe) — fixed with an explicit
`unlock_at_station_level = -1`.
- Expansion cost formula implemented and set (`300 + 50*x + 10*x*x`):
quadratic, so costs outrun the roughly linear block income gradually
— ~1 expansion per cycle mid-game, 23 cycles apart late.
- **First full balancing round complete.** Next: full-game playtests
against the run-shape targets.
## 2026-07-06 — playtest 1 (two full playthroughs)
Two complete runs, WON in ~40 min each with cruiser fleets only — never
needing capitals. (Initially misread as "two pushes in 40 min, pacing on
target"; corrected in round 6.) That is roughly cycle 8 against the
win-cycle target of 2024: a 2.53× pacing gap. Pacing knobs deliberately
untouched this round — the runs rode 9 HP/s repair and stations nothing
outranged, both nerfed below; playtest 2 measures the remaining gap.
- **Meta finding: cruisers filled with 12× railgun_s dominate.** Predicted
by the numbers in hindsight: the concentration tax makes railgun_s the
best DPS/threat (0.62 vs m 0.48, l 0.41), range is the big guns' only
mechanical edge (armor is added HP, not damage reduction — no anti-swarm
mechanic), repair sustain covers the closing distance, and stations
outranged every ship gun (120 vs railgun_l's 100), so even capitals had
to tank-and-brawl. The cruiser compounds it: first hull with a large
1×1 canvas (12 cells) and a nearly quartz-free chain.
- **Repair tool confirmed overpowered** (second signal after the
persistent +24% arena escort margin): the arena only prices in-fight
sustain; real runs add free full top-offs in every 1545 s wave gap
across the whole swarm. The 0.7 HP/s-per-threat prior is wrong for
wave defence.
- **Changes:** repair_tool 9→4 HP/s; railgun_l range 100→130 (now
outranges stations — buys the siege role the capital ladder promises);
railgun_m 14→16 dmg and range 70→80 (tax softened: m sits at 0.55
DPS/threat, between s and l). Module threats unchanged (costs
untouched), so no ladder recalculation needed.
- New tracked arena added: railgun_s-spam cruisers (8× 178) vs default
cruisers (6× 233.5) — the spam side should win a brawl somewhat, but a
blowout means the small-gun premium needs retuning.
- **Open:** re-run the arena suite to check the range/damage changes
against the round 15 results; next playtest should verify big guns now
feel worth climbing to and repair is merely good.
## 2026-07-06 — arena round 6 (checking the playtest-1 adjustments)
Mirrors healthy (58% margins, durations 24/66/91 s). Results:
- **Spam-cruiser arena: default cruisers +9% — the meta is priced out**,
and the range buff alone did the work.
- **Regression: drone swarm vs cruisers +47% for cruisers** (was +14%
swarm in round 3). Besides the wider range gap, the damage buff crossed
a breakpoint: 14 dmg kills a 60 HP drone in 5 hits, 16 in 4 — a hidden
~25% effective-DPS gain vs drones. Change: **railgun_m damage 16→14**
(range stays 80); the tax stands, reach is the compensation.
- Battleship +30% and dreadnought +29% vs pure railgun_s fleets:
**accepted as reach-doctrine texture** (BS was already accepted at
+23%) — the l gun's 130 m standoff is exactly what the range buff
bought; the counter is your own reach or 2:1 numbers, not equal-threat
small guns. Watch, don't tune.
- Repair escort flipped to raw +16%: **kept at 4 HP/s deliberately**
the arena cannot price the free between-wave top-offs, so slightly
below par in-fight is the correct price for a module whose run-value
includes them. Playtest 2 decides; 6 is the fallback if repair feels
dead.
- Station assault: the 3× swarm cracked the fortified position keeping
45% EHP. No knob this round touched it; together with playtest 1's
trivially easy pushes it flags **station strength as the first pacing
lever** for the next pass.
**Pacing deferred:** playtest 1's 40-min wins predate the repair nerf
and the l-gun siege range. If playtest 2 still wins by ~cycle 10, the
levers are enemy station scaling (`3000 + 1500*x` likely too shallow),
the threat rate, and possibly `artifact_win_count`.

98
docs/balancing/process.md Normal file
View File

@@ -0,0 +1,98 @@
# Balancing Process
How balancing is done in this project: the pass order, the tuning
discipline, and the tools. Refer to this when starting the next
balancing round.
## The pass order
Each pass depends on the ones before it; a change in an earlier pass
invalidates the later ones (but not vice versa). Redo from the earliest
pass whose inputs changed.
1. **Targets** (`targets.md`) — choose the root numbers: run shape,
factory curve, threat-cost ladder, fleet size, block roots, combat
anchors, pacing anchors. These are design decisions, not
measurements. Everything else is derived from them.
2. **Tree structure** (`../content_design.md`) — items, chains,
what-consumes-what, per the production tree rules (one input per
phase transition, generic parts, archetypes, refactorability).
Structure only, no quantities.
3. **Numbers** (`derived.md`, recipes/materials in the configs) —
quantities and durations so every fitted ship sums to its ladder
value, the ratio curve is realized, and the belt/buffer guardrails
hold. Verified computationally by `tools/threat_report.py`.
4. **Calculator/tooling parity** — the game's `ThreatCostCalculator`
and `tools/threat_report.py` must produce identical values; the
Python tool is the design reference. Any semantic change to
REQ-THREAT-* needs both updated plus tests.
5. **Combat stats** (arena-driven) — derive stats from the combat
anchors, then iterate against the arena suite
(`bin/balancing/data/balancing.toml`) until equal-threat matchups are
near-draws. Threat costs are stat-independent, so arena ship counts
stay valid across stat changes.
6. **Pacing** — unlock ladder, `unlock_requires` edges, threat rate,
block/artifact/expansion values, per the pacing anchors.
Then: **full-game playtests**, which are the only check for the pacing
pass and feed back into targets.
## Tuning discipline (learned in arena rounds 15)
- **Change anchors, not symptoms.** When a class of results is off,
adjust the anchor that explains all of them (e.g. the concentration
tax) rather than individual stats.
- **Fewest knobs per round.** Attribution dies when many knobs move at
once. Prefer one anchor change plus its mechanical compensations.
- **Shared vs. local knobs.** Guns and module stats are shared across
many hulls — changing them moves many matchups. Per-hull HP moves
exactly one matchup; it is the designated per-ship trim knob on top of
the HP-per-threat prior.
- **Mind the ride-alongs.** A module buff lands on every default loadout
containing it (e.g. an armor buff strengthens the destroyer swarm that
opposes the dreadnought). Compute the net effect per matchup before
choosing step sizes.
- **Two-round signal policy.** Single arena runs re-roll by ~±10% EHP
margin; a margin inside ±20% counts as converged for v1. Only act on
signals that persist across two rounds.
- **Arena geometry is part of the fixture.** Lane width/height changes
the results (full engagement vs. fleets slipping past); margins are
only comparable within the same geometry.
- **Accept mechanical texture.** Not every deviation is a bug: a margin
that survives a stat change is mechanical (usually range/kiting under
the orbit AI) and may be desirable doctrine texture. Document the
acceptance in `targets.md` instead of chasing it.
- **Range is the strongest stat** under the orbit AI — free approach
fire. Price range modifiers conservatively; station dominance is
controlled via range, not HP.
## Tools
- `tools/threat_report.py` — item threats, module contributions,
hull/fitted ship threats, producer:consumer ratios, belt feasibility;
reads the real configs. The design reference for threat semantics.
- `tools/verify_recipes.py` — recipe tree closure, visuals coverage,
orphans, reprocessing-only items.
- `tools/verify_layouts.py` — module footprint gating matrix per hull.
- **Balancing tool** (`balancing` target) — parallel arena simulation of
`bin/balancing/data/balancing.toml`; logs winner, surviving counts,
team EHP %, and fight duration per arena. The suite covers: class
mirrors (expect near-mutual annihilation, symmetric winners),
equal-threat cross-tier matchups (expect near-draws — power-per-threat
made empirical), a 2:1 decisiveness check, doctrine matchups
(armored-vs-glass, repair-escort), and station assault.
## Checklist for the next balancing round
1. Pull; run `verify_recipes.py`, `verify_layouts.py`,
`threat_report.py`; compare against the tables in `derived.md`.
2. If recipes/materials changed: re-check fitted threats vs. the ladder
in `targets.md`; update arena suite ship counts if fitted values
moved.
3. Run the arena suite; read EHP margins and durations against the
expectations noted in `balancing.toml` and the anchors.
4. Apply changes per the tuning discipline (two-round signals only);
record the round and its knob changes in `history.md`.
5. Update `derived.md` where values moved; if an anchor moved, update
`targets.md` and state why.
6. Commit and push (the review workflow reads the remote).

View File

@@ -1,9 +1,10 @@
# Progression & Balancing Design # Balancing & Progression Rules
Rules and principles that govern the production tree, progression pacing, Rules and principles that govern the production tree, progression pacing,
and balancing. This document contains **rules only**concrete content and balancing. This document contains **rules only**the chosen base
(item lists, recipes, unlock levels, stat numbers) lives in the config files numbers live in `targets.md`, everything derived from them in
and `content_design.md`; those numbers must follow the rules stated here. `derived.md`, and the concrete content in the config files and
`../content_design.md`. All of those must follow the rules stated here.
## Player-experience goals ## Player-experience goals
@@ -27,20 +28,55 @@ adapts later (see Refactorability).
## Resource phases ## Resource phases
- A run has exactly **four base inputs**: - A run has exactly **four base inputs**:
1. Two mined ores available from the start. 1. Two mined resources available from the start, minable on **every**
2. A third mined ore unlocked mid-game (slower to mine than the starting asteroid tile.
ores). 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 3. A fourth input unlocked late-game, obtainable **only** from
reprocessing salvaged scrap. reprocessing salvaged scrap.
- The fourth input is the core loop hook: capital ship production requires - The fourth input is the core loop hook: capital ship production requires
fighting (salvaging and reprocessing), not just mining. 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 - There is no direct "resource unlock" mechanism. Miner recipes unlock
**implicitly** (REQ-LOCK-IMPLICIT) when some unlocked schematic's material **implicitly** (REQ-LOCK-IMPLICIT) when some unlocked schematic's material
chain reaches that ore. Resource pacing is therefore controlled entirely chain reaches that resource. Resource pacing is therefore controlled
through the `unlock_at_station_level` values of ships, modules, and through the `unlock_at_station_level` values of ships, modules, and
assembler recipe schematics — and the content must guarantee that the assembler recipe schematics — and the content must guarantee that the
chains actually connect (a mid-game schematic must require an item whose chains actually connect (a mid-game schematic must require an item whose
chain reaches the third ore, or the ore never unlocks). 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 ## Production tree rules
@@ -48,11 +84,11 @@ adapts later (see Refactorability).
- **Each phase transition adds exactly one new base input chain.** A base - **Each phase transition adds exactly one new base input chain.** A base
input is a bottom-level resource entering the factory from outside — a input is a bottom-level resource entering the factory from outside — a
mined ore or the scrap-only input. The early game starts with two ores mined resource or the scrap-only input. The early game starts with two
as the baseline; the transition to mid adds one (the third ore), the ores as the baseline; the transition to mid adds one (the deposit-bound
transition to late adds one (the scrap-only input). No transition ever mid resource), the transition to late adds one (the scrap-only input).
introduces more than one unfamiliar bottom-level chain, so the factory No transition ever introduces more than one unfamiliar bottom-level
grows in one direction at a time. chain, so the factory grows in one direction at a time.
- **Intermediates are generic shared parts.** Keep the item count low — - **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 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. lower tiers' intermediates rather than each having bespoke inputs.
@@ -87,7 +123,8 @@ adapts later (see Refactorability).
already unlocked (in addition to the station level check). The player 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 is never offered a shortcut for a chain they have not built yet. The
output-item half of this check already exists in output-item half of this check already exists in
REQ-DEF-SCHEMATIC-DROP; the input half is new (see Action items). REQ-DEF-SCHEMATIC-DROP; the input half is an open action item (see
`README.md`).
- **Shortcuts are pure rewards, never balance factors.** An item's threat - **Shortcuts are pure rewards, never balance factors.** An item's threat
value is the *maximum* across its producing recipes (REQ-THREAT-ITEM), so value is the *maximum* across its producing recipes (REQ-THREAT-ITEM), so
unlocking a cheaper recipe does not lower the item's threat accounting — unlocking a cheaper recipe does not lower the item's threat accounting —
@@ -143,8 +180,8 @@ supporting different fleet doctrines feel structurally different to build.
modules, and tiers. Higher tiers are better per *ship* and per *module modules, and tiers. Higher tiers are better per *ship* and per *module
slot*, not per invested factory-second — their advantage is slot*, not per invested factory-second — their advantage is
concentration (fewer, bigger things; slot geometry per concentration (fewer, bigger things; slot geometry per
`content_design.md`) and qualitative capabilities, not a better exchange `../content_design.md`) and qualitative capabilities, not a better
rate. Deviations from this rule are deliberate and documented. exchange rate. Deviations from this rule are deliberate and documented.
- **Difficulty race:** the enemy threat rate (`threat_rate_formula`) is - **Difficulty race:** the enemy threat rate (`threat_rate_formula`) is
tuned against the factory output (threat/s) achievable by a competent tuned against the factory output (threat/s) achievable by a competent
player — slightly below it early, crossing above it eventually. The game player — slightly below it early, crossing above it eventually. The game
@@ -153,9 +190,8 @@ supporting different fleet doctrines feel structurally different to build.
- **All time scaling lives in the threat rate** — waves get bigger, ships - **All time scaling lives in the threat rate** — waves get bigger, ships
of a given schematic never get individually stronger. There is no ship of a given schematic never get individually stronger. There is no ship
level dimension: stat formulas are plain values, and per-ship level level dimension: stat formulas are plain values, and per-ship level
scaling (`ship_level_formula`) does not exist (see Action items). Push scaling does not exist. Push scaling on enemy defence stations is the
scaling on enemy defence stations is the separate, player-triggered separate, player-triggered difficulty axis and keeps its level formulas.
difficulty axis and keeps its level formulas.
## Unlock & drop pacing ## Unlock & drop pacing
@@ -169,23 +205,24 @@ supporting different fleet doctrines feel structurally different to build.
its materials need can be unlocked alongside it. its materials need can be unlocked alongside it.
- **Schematics can require other schematics.** Beyond the station-level - **Schematics can require other schematics.** Beyond the station-level
gate, a schematic (ship, module, or assembler recipe) may list gate, a schematic (ship, module, or assembler recipe) may list
prerequisite schematics that must already be unlocked before it enters prerequisite schematics (`unlock_requires`, REQ-LOCK-PREREQ) that must
the drop pool — e.g. the medium laser requires the small laser; a already be unlocked before it enters the drop pool — e.g. the medium
future Mk2 requires its base version. Station level gates the earliest gun requires the small gun; a future Mk2 requires its base version.
*when*; prerequisites gate the *order*, keeping drop offers coherent Station level gates the earliest *when*; prerequisites gate the
with what the player already owns. *order*, keeping drop offers coherent with what the player already
owns.
- **No duplicate drops.** Ship and module schematics leave the drop pool - **No duplicate drops.** Ship and module schematics leave the drop pool
once owned, exactly as assembler recipe schematics already do. There are once owned, exactly as assembler recipe schematics already do. There are
no schematic level-ups; player power grows through unlock breadth and no schematic level-ups; player power grows through unlock breadth and
factory scale only, which keeps power-per-threat exact on both sides. factory scale only, which keeps power-per-threat exact on both sides.
The pool therefore shrinks over a run and late pushes increasingly offer The pool therefore shrinks over a run and late pushes increasingly offer
artifacts — intended: the late game is a race for the win condition. artifacts — intended: the late game is a race for the win condition.
Per-item progression may return later as Mk2 upgrade recipes (see Open Per-item progression may return later as Mk2 upgrade recipes (see Future
tasks), never as free level-ups. work), never as free level-ups.
- **Artifacts trade power for progress.** Artifact options compete with - **Artifacts trade power for progress.** Artifact options compete with
schematic picks in the same choice dialog; the artifact chance must be schematic picks in the same choice dialog; the artifact chance must be
tuned so that taking one is a real decision (giving up a level-up or tuned so that taking one is a real decision (giving up an unlock), not
unlock), not automatic in either direction. automatic in either direction.
## Scrap & reprocessing economy ## Scrap & reprocessing economy
@@ -206,8 +243,8 @@ supporting different fleet doctrines feel structurally different to build.
the exception: they keep authored `scrap_drop_formula`s, because pushing the exception: they keep authored `scrap_drop_formula`s, because pushing
rewards are tuned independently of ship production costs. rewards are tuned independently of ship production costs.
- Consequence: the threat value of scrap is the constant - Consequence: the threat value of scrap is the constant
`1 / scrap_per_threat`. The min-`scrap_drop` schematic derivation `1 / scrap_per_threat` (REQ-THREAT-SCRAP). The former min-`scrap_drop`
(REQ-THREAT-SCRAP) and its potential circularity disappear. schematic derivation and its potential circularity are gone.
- **Rule:** the late-game input's income rate meaningfully gates capital - **Rule:** the late-game input's income rate meaningfully gates capital
production — unlocking a capital hull must not mean spamming it; the production — unlocking a capital hull must not mean spamming it; the
input trickles in slowly enough that every capital ship is a noticeable input trickles in slowly enough that every capital ship is a noticeable
@@ -229,9 +266,37 @@ supporting different fleet doctrines feel structurally different to build.
- **Rule:** the starting stock suffices for a minimal block loop plus the - **Rule:** the starting stock suffices for a minimal block loop plus the
first shipyard — with a little slack for beginner mistakes, but not first shipyard — with a little slack for beginner mistakes, but not
enough to skip the loop entirely. enough to skip the loop entirely.
- **Rule:** asteroid expansions compete with buildings for the same - **Rule: the growth curve lives here.** A saturated building produces
currency. Expansion pricing makes "more space" a real alternative to exactly 1 threat/s, so the player's output curve *is* their
"more stuff", not an afterthought. building-count curve — shaping growth over a run means shaping the
block and space economy, there is nowhere else it can live. Intended
shape: exponential bootstrap (block-limited) → ramp
(expansion-limited) → asymptotic squeeze as expansion costs outrun
income, racing the enemy threat rate throughout.
- **Rule: escalating expansion costs.** Expansion cost is a formula of
the number of expansions already purchased, rising steeply enough that
expansions eventually outrun any block income. The starting asteroid
is deliberately small — filled within the first boss cycle or two, so
the early exponential burst is a satisfying ramp, not a balance hole —
and from then on the output curve is the expansion curve. Blocks keep
a meaningful sink for the entire run, and "grow vs. army" stays a live
decision at every moment.
- **Rule: designed doubling time.** Block production is a positive
feedback loop (blocks buy assemblers, assemblers make blocks); its
time constant is a designed quantity, never an accident of quantity
choice. The block chain's depth and the per-building costs are tuned
against a stated target of the form: "a factory spending X% of its
capacity on blocks doubles in ~T minutes."
- **Rule: growth is limited by economy, never by waiting.** Construction
times stay short; the serial build queue must not be used as a growth
brake. Waiting for placed buildings to become operational — especially
at the start of a run — is frustration, not gameplay. All growth
limiting comes from block income and expansion pricing.
- Note: block income has a structural ceiling — blocks enter the stock
through the HQ's single belt port, so income is capped at belt
throughput regardless of assembler count. Per-building costs should be
high enough that this cap can bind late-game (see the condensed-block
idea under Future work).
## Numeric guardrails ## Numeric guardrails
@@ -250,15 +315,13 @@ Constraints that every recipe must respect, independent of tuning:
- **Cycle times scale with tier** monotonically — a higher-tier item never - **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. has a shorter total chain time than a lower-tier item of the same role.
## Open tasks / future work ## Future work
- **Rework `recipes.toml`** once the rules in this document are fixed: the - **Condensed building blocks** — a drop-unlockable shortcut-style
current tree feels too close to Factorio; apply the thematic-naming rule recipe that packs several blocks' worth of value into one belt item,
and the ratio curve to it (renames and quantity changes, not new items). relieving the HQ intake ceiling (see Building block economy) as a
- **Balancing pass** (see placeholders in `content_design.md`): set the late-game reward. The ceiling is the puzzle, the drop is the fix —
`unlock_at_station_level` ladder, real threat costs and same philosophy as shortcut recipes.
`default_modules`, reprocessing weights, and the threat-rate formulas
according to the rules above.
- **Mk2 upgrade recipes** — the deferred design for per-item progression, - **Mk2 upgrade recipes** — the deferred design for per-item progression,
to revisit once the config has stabilized. A duplicate-style drop to revisit once the config has stabilized. A duplicate-style drop
unlocks a distinct `*_mk2` item whose recipe consumes the Mk1 item plus unlocks a distinct `*_mk2` item whose recipe consumes the Mk1 item plus
@@ -268,51 +331,3 @@ Constraints that every recipe must respect, independent of tuning:
assembler), and keeps balancing one-dimensional (no level variable assembler), and keeps balancing one-dimensional (no level variable
anywhere). Enemy-side progression happens via `default_modules` anywhere). Enemy-side progression happens via `default_modules`
variants per era instead of a level formula. 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:
1. **Derive ship scrap from threat.** Add `scrap_per_threat` to
`world.toml`; remove `[ship.loot].scrap_drop` from `ships.toml`; update
REQ-RES-SCRAP-DROP; replace REQ-THREAT-SCRAP with the constant
`1 / scrap_per_threat`. Station `scrap_drop_formula`s stay.
2. **Remove schematic upgrades.** Owned ship/module schematics leave the
drop pool (REQ-DEF-SCHEMATIC-DROP); drop the new-unlock/level-up
annotation from the choice dialog; remove `player_production_level`
from `ships.toml`, `modules.toml`, and the code paths that increment
and evaluate it (REQ-BLD-SHIPYARD, REQ-MOD-CONFIG).
3. **Remove ship levels entirely.** Delete `ship_level_formula`
(REQ-WAV-SHIP-LEVEL); replace all stat formulas in `ships.toml` and
`modules.toml` with plain values (REQ-SHP-STATS, REQ-MOD-CONFIG,
REQ-MOD-STAT-CALC evaluation rules). Enemy defence stations keep their
station-level formulas — push scaling is unaffected.
4. **Balancing tool follow-up.** Remove the per-ship-entry `level` from
`balancing.toml` and the `L<level>` display format (REQ-BAL-TEAM,
REQ-BAL-CONFIG-GAME, REQ-BAL-UI-WIDGET, REQ-BAL-UI-INSPECT-WINDOW);
station entries keep their level. Ship stats panels no longer evaluate
at a level (REQ-MOD-UI-STATS-PANEL, REQ-UI-SHIP-STATS-PANEL).
5. **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.
6. **Confirm wave scaling carries alone.** With per-ship level scaling
gone, `threat_rate_formula` is the only time-scaling axis; verify the
current `world.toml` values still produce the intended difficulty
curve (the config reportedly already runs with a flat ship level, so
this is likely a review, not a retune).
7. **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.
8. **Schematic prerequisites.** Add an optional prerequisite list to
ship, module, and assembler recipe schematic entries (e.g.
`unlock_requires = ["laser_cannon_s"]`): a schematic enters the drop
pool only when every listed schematic is already unlocked, in addition
to the station-level check. Update REQ-DEF-SCHEMATIC-DROP and
REQ-LOCK-EXPLICIT. This is also the mechanism the future Mk2 design
builds on (`*_mk2` requires the base schematic).

104
docs/balancing/targets.md Normal file
View File

@@ -0,0 +1,104 @@
# Balancing Targets (base numbers)
The root numbers of the balancing. Everything in `derived.md` is tuned to
hit these; when rebalancing, **change these first and re-derive — never
patch derived values directly**. The rules these numbers follow live in
`rules.md`.
All time targets are in **game time**. The player can pause and
accelerate, so real session length differs; playtests measure both. The
time unit is the boss cycle (`world.toml boss_countdown_seconds`, 300 s).
Destroying a station set advances the boss countdown by
`boss_advance_seconds` (60 s), so cycles run shorter than nominal when
pushing actively — targets deliberately ignore that.
## Run shape
1. **Run length** — a winning run takes up to 2 hours of game time: win
around boss cycle 2024. Losing runs end earlier.
2. **Phase boundaries** — early = cycles 15 (iron/copper, small hulls),
mid = cycles 614 (quartz, medium hulls), late = cycles 15+
(voidsteel, capitals). Push cadence: first station set around cycle
23, roughly one per cycle from mid onward — so the destroyed set's
level is reached around cycle +2.
3. **Factory size curve** — producing buildings over time; when
saturated, output threat/s equals this count, so this curve IS the
player power curve: ~25 when the starting asteroid is full (end of
cycle 2), ~60 at the start of mid (cycle 6), ~120 at the start of
late (cycle 15), ~150 near the win. `threat_rate_formula` must remain
a fraction of this curve; buildings plus belts must physically fit
the asteroid plus affordable expansions.
4. **Threat-cost ladder** — total production-seconds per *fitted* hull
(including the typical/default module loadout): drone 10.5,
frigate 47, destroyer 99, cruiser 233.5, battlecruiser 354.5,
battleship 722.5, dreadnought 1491.5, carrier 1436.5. Every
production chain must sum to its ladder value. (The original strawman
was 10/40/80/200/350/700/1500; the small end runs ~1020% hot because
fixed chain overhead dominates small hulls — accepted, and the
achieved values adopted as the ladder. The ~×2-per-class curve shape
is the invariant.)
5. **Fleet size** — swarm-leaning: ~25 player combat ships as the
standing mid-game fleet. Standing fleet = build cadence (4) × average
ship lifetime, so this target drives time-to-kill and therefore all
combat stat magnitudes.
6. **Block economy roots** — bootstrap complete (starting asteroid full)
by the end of cycle 2; a factory spending ~30% of its capacity on
blocks doubles in ~4 minutes early game; one expansion affordable per
cycle at ~1/3 of block income mid-game, decelerating to one per 23
cycles late as escalating costs outrun income.
## Combat anchors
All combat stats derive from these; per-hull HP additionally carries
empirical trims from arena rounds (values in `derived.md`).
- **Weapon DPS per threat pays a concentration tax that grows with gun
size**: small ≈ 0.62, medium ≈ 0.48, large ≈ 0.41 DPS per threat of
weapon contribution, compensated by the range ladder 50/70/100 m.
Rationale: concentration itself (focus fire, no DPS loss to attrition,
range) is worth paying for — with a flat curve, concentrated fleets
win equal-threat fights outright (arena round 1).
- **Hull HP = 15 per threat of hull contribution** as the prior; per-hull
HP is the empirical trim knob (guns are shared across hulls, hull HP
moves exactly one matchup). The arena consistently prices capitals as
*tanks with taxed guns* — capital hulls sit well above the prior.
- **Armor HP ≈ 37 per threat** — a strong premium over hull HP because
armor is pure HP with no capability, and fights snowball: killing
removes enemy DPS, surviving merely delays — HP must be cheaper than
DPS.
- **Repair ≈ 0.7 HP/s per threat** — in-combat sustain effectively
removes enemy DPS and must be priced like DPS, not like HP.
- **TTK / fight duration**: parity fights in the 3060 s band at
mid-game scale; capital mirrors ~90 s deliberately; the extreme
tank-vs-chip-damage matchup (dreadnought vs destroyer swarm, ~3.5 min)
is an accepted outlier.
- **Mobility is monotone in size** — the smallest hulls are the fastest
and nimblest. Sensor ranges (150→350 m) always exceed weapon ranges.
- **Weapon modifiers are capital economy**: a ×1.2 damage modifier at
~22.5 threat beats adding a gun once a ship carries more than ~68
threat of weapons — modifiers pay off on gun-heavy big hulls, waste on
small ones. Range modifiers are the strongest and are priced/kept
small (×1.3): range is the dominant stat under the orbit AI (free
approach fire).
- **Stations**: a fresh player station holds one early parity wave
unaided; the enemy station at level 0 matches the player station
exactly and scales per push level. Station range is the dominance
lever, not HP (at 4× a small gun's range, two stations annihilated a
3× threat swarm through approach fire alone).
- **Accepted imbalances**: the carrier loses its equal-threat fights
until the drone-launching capability exists (the hangar is dead
threat) — fix by implementing drones, not stats. A pure smallest-ship
fleet modestly loses (~1525%) to a range-fitted capital — desirable
doctrine texture; the fair anti-capital answer is the mixed fleet.
## Pacing anchors
- **Starting set** is the rule-minimum: drone, frigate, small gun,
salvager (plus the explicitly unlocked building-block recipe).
- **Threat rate shape**: below the player's achievable military output
(≈ half the factory curve) early, crossing at the late boundary
(~cycle 15), overwhelming by ~cycle 24.
- **Winning = five real decisions**: `artifact_win_count` is set so that
across a winning run's ~1518 pushes (~7 cumulative artifact offers at
the current chance formula), the player must choose the artifact over
a schematic about five times.

View File

@@ -1,9 +1,10 @@
# Content Design — Ships & Modules # Content Design — Ships, Modules & Production Tree
First real-content iterations (June 2026). Pass 1 defined ship hull grids and The designed game content: hull layout grids, module footprints and the
module surface masks; pass 2 defined the production tree (recipes). Stats and gating between them, and the production tree (items, chains, fiction).
threat costs in the config files are still placeholders for the balancing All numbers — quantities, durations, threat values, stats, unlock levels
pass. — live in the config files and are documented with their derivations in
`docs/balancing/` (see `balancing/README.md` for the index).
## Design principle: footprint gating ## Design principle: footprint gating
@@ -17,12 +18,12 @@ and makes them trivially moddable through the config files alone.
| Footprint | Modules | Smallest hull that fits it | | Footprint | Modules | Smallest hull that fits it |
|-----------|---------|----------------------------| |-----------|---------|----------------------------|
| 1x1 | laser_cannon_s, salvager, repair_tool | drone | | 1x1 | railgun_s, salvager, repair_tool | drone |
| 1x2 | maneuvering_thrusters, sensor_booster, armor_plates | frigate | | 1x2 | maneuvering_thrusters, sensor_booster, armor_plates | frigate |
| 1x3 | afterburner | frigate (eats most of it) | | 1x3 | afterburner | frigate (eats most of it) |
| L-shape (3 cells) | weapon_stabilizer, weapon_primer, weapon_upgrade | frigate | | L-shape (3 cells) | weapon_stabilizer, weapon_primer, weapon_upgrade | frigate |
| 2x2 | laser_cannon_m, drone_bay | cruiser | | 2x2 | railgun_m, drone_bay | cruiser |
| 3x3 | laser_cannon_l | battleship | | 3x3 | railgun_l | battleship |
| 2x6 | drone_hangar | carrier (only) | | 2x6 | drone_hangar | carrier (only) |
### Hull grids ### Hull grids
@@ -131,64 +132,163 @@ drone hangar — carrier 1.
## Production tree ## Production tree
Design principle: each game phase adds exactly one new base input chain, so Designed against the rules in `docs/balancing/rules.md` (ratio curve,
factory complexity ramps alongside ship size. cost ladder, cost archetypes, refactorability). Quantities, durations,
and threat values live in `docs/balancing/derived.md`.
| Phase | New input | How acquired | Unlocks | ### Base inputs (4) and fiction
|-------|-----------|--------------|---------|
| early | iron_ore, copper_ore | mined | drone, frigate, destroyer; small guns and basic supports |
| mid | titanium_ore | mined (3x slower than iron) | cruiser, battlecruiser; m guns, drone bay, weapon modifiers |
| late | advanced_alloy | ONLY from reprocessing salvaged scrap | battleship, dreadnought, carrier; l guns, drone hangar |
The advanced_alloy gate is the core loop hook: capital ship production - **iron_ore, copper_ore** — from the start, minable on every asteroid
requires fighting (salvaging scrap from kills and reprocessing it), not just tile. Fiction: the asteroid is an M-type (metal) body — its bulk rock
mining. The reprocessing plant turns 5 scrap into iron/copper/titanium ingots *is* ore, which is why the shipyard operation was built here at all.
or advanced_alloy probabilistically. - **quartz** — mid-game, minable only on geode deposit patches in
expansion territory (see the Resource deposits rules in
`docs/balancing/rules.md`; the deposit mechanic itself is an open
action item — until it lands, quartz mines anywhere). Fiction:
ordinary silicate dust is everywhere and worthless; chips and optics
need rare, pocket-bound optical-grade crystal.
- **voidsteel** — late-game, obtained only by reprocessing scrap.
Fiction: battle-forged — formed when weapon plasma anneals hull metal
in the violence of ship destruction. Any wreck yields it, including the
player's own; no foundry can replicate it.
- **titanium was dropped** (v1 tree). Its hull-gating role moved to
quartz-era control systems ("you can smelt all the steel you want, but
you cannot steer a battlecruiser without electronics") plus the
hardened-steel quality step (a deliberately long-running, time-heavy
recipe) — explicitly not sheer steel quantity alone.
Intermediate components, by tier: ### Material palette (fingerprints per family)
- **Tier 2 (early):** copper_wire (copper), steel_plate (iron), circuit_board - **iron/steel** — structure.
(iron + wire), building_block (iron). - **copper** — conduction and heat: wiring, coils, heat sinks.
- **Tier 3 (mid):** mechanical_parts (steel + iron), targeting_unit (circuits - **silicon family** (all derived from quartz): silicon (logic,
+ wire), drive_unit (steel + mechanical_parts + circuit), titanium_frame sensors), ceramics (heat shielding, insulators); glass/optics are cut
(titanium + steel). from v1 — their only consumers would be lasers, which are deferred.
- **Tier 4 (late):** reinforced_plating (steel + advanced_alloy), - **voidsteel** — capital-tier structure and exotics.
capital_core (targeting_unit + drive_unit + 2 advanced_alloy). - Deliberately skipped: carbon (mostly redundant with copper/ceramics),
plastics (drags in Factorio-style chemical chains; ceramics read more
sci-fi anyway), volatiles/ice (materials are build costs only — no
consumption mechanic to justify fuel).
Hulls and modules consume intermediates of their tier: early items are built ### Weapons
from tier-2 parts, midgame items require tier-3 parts (deeper chains, more
assemblers), capital items require tier-4 parts (and therefore combat). Hull
items are named `<ship>_hull`; module items `<module>_module`. Every item has
an `[items.*]` entry in visuals.toml; hull item outlines match the ship's
fleet color from `[ships.*]`.
Consistency is checked by `tools/verify_recipes.py` — re-run it after editing - All v1 weapons are **railguns** (`railgun_s/m/l`, renamed from the
recipes, ship/module materials, or visuals: laser placeholders; footprints and the gating matrix unchanged).
Implementation is instant damage application with no projectile and no
ammunition — the beam visual reads as a tracer round. Materials: iron
slugs, copper coils, steel rails — the starting-metal fingerprint.
- **Lasers are reserved for later** as a genuinely distinct weapon type
(e.g. once projectile/ammunition mechanics exist for other families),
arriving with quartz optics. More weapon types are planned; railguns
are simply the baseline tech that ships with v1.
- `drone_bay` and `drone_hangar` are footprint-only placeholders: the
drone-launching capability does not exist in the simulation yet, so
they define no capability section. The carrier is deliberately weak
until that capability lands (see the accepted imbalances in
`docs/balancing/targets.md`).
### Tree structure
Input lists only — quantities, durations, and per-item threat values are
in `docs/balancing/derived.md` and the configs.
**Mined (miner):** `iron_ore`, `copper_ore` (every tile), `quartz`
(geode deposits in expansion territory).
**Smelted (smelter — exactly one recipe per input item):**
| output | input | ratio class |
|---|---|---|
| iron_ingot | iron_ore | nice (1:1) |
| copper_ingot | copper_ore | nice |
| silicon | quartz | mid entry |
| iron_ingot | scrap | the safe, boring scrap sink |
**Reprocessing pool (scrap):** `iron_ingot`, `copper_ingot`, `silicon`,
`voidsteel` — the only source of voidsteel. Weights authored for the
fully unlocked pool state.
**Tier 2 — early intermediates (clean ratios, ~2:3):**
| item | inputs | role |
|---|---|---|
| steel_plate | iron_ingot | structure backbone, highest volume |
| copper_wire | copper_ingot | conductors |
| copper_coil | copper_wire | electromagnets: railguns, thrusters |
| building_block | steel_plate | depth-3 chain = the doubling-time knob |
**Tier 3 — mid intermediates (strange ratios begin, need quartz):**
| item | inputs | role |
|---|---|---|
| control_chip | silicon + copper_wire | electronics gate for m+ hulls |
| capacitor_bank | copper_coil + silicon | power for railgun m/l |
| hardened_steel | steel_plate (long cycle) | quality gate for m+ hulls; time-heavy |
| ceramic_plate | quartz | heat shielding: drives, l guns, capitals |
| drive_unit | steel_plate + copper_coil + control_chip | propulsion for m+ hulls |
**Tier 4 — late intermediates (need voidsteel):**
| item | inputs | role |
|---|---|---|
| voidsteel_plate | voidsteel + hardened_steel | capital structure |
| capital_core | voidsteel + capacitor_bank + control_chip | capital heart |
**Hull items** (`<ship>_hull`, assembler-made; the shipyard consumes the
hull item plus module materials). The m+ hull gate is **both**
hardened_steel (quality steel, the time-heavy step) *and* control_chip
(electronics):
| hull | inputs |
|---|---|
| drone_hull | iron_ingot |
| frigate_hull | steel_plate + copper_wire |
| destroyer_hull | steel_plate + copper_coil |
| cruiser_hull | hardened_steel + control_chip |
| battlecruiser_hull | hardened_steel + control_chip + drive_unit |
| battleship_hull | voidsteel_plate + drive_unit + control_chip |
| dreadnought_hull | voidsteel_plate + capital_core + drive_unit |
| carrier_hull | voidsteel_plate + capital_core + drive_unit |
**Module items** (`<module>_module`, assembler-made prefabs — kept as
items so shipyard belt inputs stay simple and module production can be
stockpiled):
| module | inputs | archetype |
|---|---|---|
| railgun_s | copper_coil | lean |
| salvager | steel_plate + copper_wire | balanced |
| repair_tool | steel_plate + copper_wire | balanced |
| armor_plates | steel_plate (many) | material-heavy, fast |
| maneuvering_thrusters | steel_plate + copper_coil | balanced |
| sensor_booster | copper_wire + copper_coil | lean (an antenna, no chip) |
| afterburner | copper_coil + steel_plate | balanced |
| weapon_stabilizer | steel_plate + copper_coil | balanced |
| weapon_primer | capacitor_bank + copper_coil | mid; time-heavy |
| weapon_upgrade | control_chip + copper_coil | mid; time-heavy |
| railgun_m | capacitor_bank + steel_plate + copper_coil | mid |
| drone_bay | control_chip + steel_plate + copper_coil | mid |
| railgun_l | capacitor_bank + hardened_steel + ceramic_plate | late |
| drone_hangar | voidsteel_plate + control_chip + drive_unit | late (carrier only) |
**Refactorability check** (the default technique holds): railgun_s → m
introduces capacitor_bank, built from a subset of the small gun's inputs
(copper_coil) plus the new base resource (silicon); the m gun otherwise
reuses the small gun's inputs. Hulls likewise: the cruiser adds
hardening (fed by the existing steel line) and chips (fed by the new
quartz territory) without touching the iron/copper core.
**Shortcut recipes** (drop-only assembler schematics; not every strange
chain gets one): `iron_ore → steel_plate` (skips the ingot step on the
highest-volume chain), `quartz → control_chip` (skips silicon),
`iron_ingot → hardened_steel` (a nicer-ratio route past the deliberately
awkward hardening step).
Consistency is checked by `tools/verify_recipes.py` — re-run it after
editing recipes, ship/module materials, or visuals:
python dota_factory/tools/verify_recipes.py python dota_factory/tools/verify_recipes.py
It verifies every consumed item has a producer, every item has a visuals It verifies every consumed item has a producer, every item has a visuals
entry, flags orphaned items, and prints which items are reprocessing-only entry, flags orphaned items, and prints which items are
(currently exactly advanced_alloy). reprocessing-only (currently exactly voidsteel).
## Deliberate placeholders / open questions for later passes
- All new hulls have `threat.cost_formula = "0"` so enemy waves do not spawn
them yet (WaveSystem treats any ship with positive threat cost as wave-
eligible, regardless of unlock level). The balancing pass should set real
threat costs together with `default_modules` loadouts so waves spawn them
armed.
- All new hulls and all assembler recipes are `unlock_at_station_level = -1`
(available from the start) to make testing easy; the balancing pass should
stagger these so mid/lategame recipes drop as schematics from enemy defence
stations.
- Recipe quantities and durations are a first guess, deliberately roughly
tiered (capital hulls ~60 s, drones 4 s); the balancing pass tunes them.
- `drone_bay` and `drone_hangar` are footprint-only placeholders: the drone
launching capability does not exist in the simulation yet, so they define
no capability section.
- Renames in this pass: `laser_cannon_xs``laser_cannon_s` (the old 2x2
`laser_cannon_s` became `laser_cannon_m`), `armor_plate``armor_plates`,
`manuvering_thrusters``maneuvering_thrusters` (typo fix). Test data
under `bin/test/data/config` intentionally still uses the old ids — it is
an independent fixture set.

View File

@@ -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. 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). 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: - 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**: the recipe's `duration_seconds`. - **Miner recipe**: `duration_seconds / output_amount`, where `output_amount` is the number of units produced per cycle.
- **Smelter recipe**: the recipe's `duration_seconds` plus the sum of each input's threat value multiplied by that input's required quantity. - **Smelter recipe**: `(duration_seconds + Σ (input_threat × input_amount)) / output_amount`, where the sum is over all inputs.
- **Assembler recipe**: the recipe's `duration_seconds` plus the sum of each input's threat value multiplied by that input's required quantity. - **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-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 recipes. The reprocessing path is only used when no other recipe exists. - **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-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: - 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:
@@ -366,7 +367,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
## Asteroid Expansion ## Asteroid Expansion
- REQ-EXP-UNLOCK: The player can unlock additional asteroid tile columns to the left of the existing asteroid by spending building blocks from the global stock. - REQ-EXP-UNLOCK: The player can unlock additional asteroid tile columns to the left of the existing asteroid by spending building blocks from the global stock.
- REQ-EXP-COST: Each expansion adds `world.toml [expansion].columns_per_expansion` columns and costs `[expansion].cost_building_blocks` building blocks. - REQ-EXP-COST: Each expansion adds `world.toml [expansion].columns_per_expansion` columns. The building block cost of an expansion is defined by the formula `world.toml [expansion].cost_building_blocks_formula`, where `x` is the number of expansions already purchased (0 for the first expansion, incrementing by 1 for each subsequent expansion). The formula is evaluated at purchase time and its result is floored to an integer number of building blocks.
## UI ## UI
@@ -391,9 +392,10 @@ The screen is divided into two columns: a main column (75% width) containing the
(75% width) (25% width) (75% width) (25% width)
``` ```
- REQ-UI-HEADER: The header bar spans the width of the game world column (75% of the screen width) and always shows the elapsed survival time, the current global building blocks stock, and the artifact count (REQ-WIN-ARTIFACT-COUNT) displayed as `Artifacts: x/y` (where `x` is the current artifact count and `y` is `world.toml [world].artifact_win_count`) on the left, the boss wave counter and boss countdown (REQ-UI-BOSS-STATUS) to the left of the speed buttons, and game speed controls on the right. - REQ-UI-HEADER: The header bar spans the width of the game world column (75% of the screen width) and always shows the elapsed survival time, the current global building blocks stock, and the artifact count (REQ-WIN-ARTIFACT-COUNT) displayed as `Artifacts: x/y` (where `x` is the current artifact count and `y` is `world.toml [world].artifact_win_count`) on the left, the boss wave counter and boss countdown (REQ-UI-BOSS-STATUS) and an asteroid expansion button (REQ-UI-EXPAND-BUTTON) to the left of the speed buttons, and game speed controls on the right.
- REQ-UI-BOSS-STATUS: The header bar displays, to the left of the speed buttons, the current boss wave counter (REQ-WAV-BOSS-COUNTER) and the time remaining on the boss countdown (REQ-WAV-BOSS-COUNTDOWN). The boss wave counter is shown as `Boss Wave #<x>` and the countdown as `Next boss: <M:SS>`, where `<M:SS>` is the remaining seconds formatted as whole minutes and two-digit seconds. Both values update continuously as the simulation runs. - REQ-UI-BOSS-STATUS: The header bar displays, to the left of the speed buttons, the current boss wave counter (REQ-WAV-BOSS-COUNTER) and the time remaining on the boss countdown (REQ-WAV-BOSS-COUNTDOWN). The boss wave counter is shown as `Boss Wave #<x>` and the countdown as `Next boss: <M:SS>`, where `<M:SS>` is the remaining seconds formatted as whole minutes and two-digit seconds. Both values update continuously as the simulation runs.
- REQ-UI-SPEED: The game speed controls in the header bar are buttons for 0×, 0.5×, 1×, 2×, and 4× speed. The currently active speed is shown as selected. All game simulation (production, movement, threat accumulation, wave timing) scales with the selected speed. 0× pauses the game. - REQ-UI-SPEED: The game speed controls in the header bar are buttons for 0×, 0.5×, 1×, 2×, and 4× speed. The currently active speed is shown as selected. All game simulation (production, movement, threat accumulation, wave timing) scales with the selected speed. 0× pauses the game.
- REQ-UI-EXPAND-BUTTON: The header bar shows an asteroid expansion button captioned `Expand: <x> Blocks`, where `<x>` is the current expansion cost computed from `world.toml [expansion].cost_building_blocks_formula` at the current number of purchased expansions (REQ-EXP-COST). Clicking the button unlocks the next asteroid expansion (REQ-EXP-UNLOCK, REQ-GW-ASTEROID-EXPAND), spending that many building blocks from the global stock. The button is disabled when the player cannot currently afford the cost (consistent with REQ-UI-BUILD-DISABLED). The caption updates as the cost changes with each purchased expansion.
- REQ-UI-WORLD-SIZE: The game world view occupies the full height below the header bar in the main column (75% of the screen width). - REQ-UI-WORLD-SIZE: The game world view occupies the full height below the header bar in the main column (75% of the screen width).
- REQ-UI-PANEL-COLUMN: The side panel column occupies 25% of the screen width and the full screen height. It is divided into three equal-height panels stacked top to bottom: selected building panel (top), build button grid (middle), and blueprint panel (bottom). - REQ-UI-PANEL-COLUMN: The side panel column occupies 25% of the screen width and the full screen height. It is divided into three equal-height panels stacked top to bottom: selected building panel (top), build button grid (middle), and blueprint panel (bottom).
@@ -511,13 +513,17 @@ A separate executable target (`balancing`) that links against `lib` but contains
### UI ### 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-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-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-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-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: 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-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. - 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.

View File

@@ -2,6 +2,8 @@
#include <algorithm> #include <algorithm>
#include <cassert> #include <cassert>
#include <cmath>
#include <string>
#include <QVector2D> #include <QVector2D>
@@ -86,12 +88,49 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
placeStructures(); placeStructures();
spawnShips(); spawnShips();
computeTeamMaxEhp();
m_shipSystem->triggerRallyDeparture(); m_shipSystem->triggerRallyDeparture();
updateStatus(); 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; ArenaSimulation::~ArenaSimulation() = default;
BuildingId ArenaSimulation::allocateBuildingId() BuildingId ArenaSimulation::allocateBuildingId()
@@ -394,10 +433,13 @@ void ArenaSimulation::tickDeaths()
}); });
m_admin.forEach<StationBodyComponent, FactionComponent>( m_admin.forEach<StationBodyComponent, FactionComponent>(
[&team1HasUnits, &team2HasUnits](entt::entity /*e*/, [this, &team1HasUnits, &team2HasUnits](entt::entity e,
const StationBodyComponent& /*sb*/, const StationBodyComponent& /*sb*/,
const FactionComponent& f) 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; } if (f.isEnemy) { team2HasUnits = true; }
else { team1HasUnits = true; } else { team1HasUnits = true; }
}); });
@@ -476,12 +518,41 @@ void ArenaSimulation::updateStatus()
ArenaStatus newStatus; ArenaStatus newStatus;
newStatus.finished = m_finished; newStatus.finished = m_finished;
newStatus.winnerTeam = m_winnerTeam; 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>(
[&currentEhp](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, &currentEhp](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) for (int ti = 0; ti < 2; ++ti)
{ {
ArenaStatus::TeamStatus& teamStatus = newStatus.teams[ti]; ArenaStatus::TeamStatus& teamStatus = newStatus.teams[ti];
teamStatus.name = m_arenaConfig.teams[ti].name; teamStatus.name = m_arenaConfig.teams[ti].name;
teamStatus.threatLevel = m_teamThreat[ti]; teamStatus.threatLevel = m_teamThreat[ti];
teamStatus.currentEhp = currentEhp[ti];
teamStatus.maxEhp = m_teamMaxEhp[ti];
// HQ entry (always first). // HQ entry (always first).
{ {

View File

@@ -44,12 +44,24 @@ struct ArenaStatus
{ {
std::string name; std::string name;
double threatLevel = 0.0; // accumulated threat of the team's configured ships 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 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]; TeamStatus teams[2];
bool finished = false; bool finished = false;
int winnerTeam = -1; // 0 or 1 when finished; -1 while running 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 class ArenaSimulation
@@ -82,6 +94,7 @@ private:
BuildingId allocateBuildingId(); BuildingId allocateBuildingId();
void placeStructures(); void placeStructures();
void spawnShips(); void spawnShips();
void computeTeamMaxEhp();
void tick(); void tick();
void tickDeaths(); void tickDeaths();
void updateStatus(); void updateStatus();
@@ -115,6 +128,10 @@ private:
// Static accumulated threat per team, computed once from the configured roster. // Static accumulated threat per team, computed once from the configured roster.
double m_teamThreat[2] = {0.0, 0.0}; 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; std::vector<BeamFiredEvent> m_beamFiredEvents;
mutable std::mutex m_statusMutex; mutable std::mutex m_statusMutex;

View File

@@ -33,6 +33,9 @@ void ArenaWidget::buildLayout(const std::string& arenaName)
m_titleLabel->setFont(titleFont); m_titleLabel->setFont(titleFont);
titleRow->addWidget(m_titleLabel); titleRow->addWidget(m_titleLabel);
m_durationLabel = new QLabel(this);
titleRow->addWidget(m_durationLabel);
titleRow->addStretch(); titleRow->addStretch();
m_inspectButton = new QPushButton(tr("Inspect"), this); m_inspectButton = new QPushButton(tr("Inspect"), this);
@@ -63,6 +66,8 @@ void ArenaWidget::buildLayout(const std::string& arenaName)
team1Layout->addWidget(m_team1Header); team1Layout->addWidget(m_team1Header);
m_team1Threat = new QLabel(this); m_team1Threat = new QLabel(this);
team1Layout->addWidget(m_team1Threat); team1Layout->addWidget(m_team1Threat);
m_team1Ehp = new QLabel(this);
team1Layout->addWidget(m_team1Ehp);
m_team1Content = new QLabel(this); m_team1Content = new QLabel(this);
team1Layout->addWidget(m_team1Content); team1Layout->addWidget(m_team1Content);
team1Layout->addStretch(); team1Layout->addStretch();
@@ -75,6 +80,8 @@ void ArenaWidget::buildLayout(const std::string& arenaName)
team2Layout->addWidget(m_team2Header); team2Layout->addWidget(m_team2Header);
m_team2Threat = new QLabel(this); m_team2Threat = new QLabel(this);
team2Layout->addWidget(m_team2Threat); team2Layout->addWidget(m_team2Threat);
m_team2Ehp = new QLabel(this);
team2Layout->addWidget(m_team2Ehp);
m_team2Content = new QLabel(this); m_team2Content = new QLabel(this);
team2Layout->addWidget(m_team2Content); team2Layout->addWidget(m_team2Content);
team2Layout->addStretch(); team2Layout->addStretch();
@@ -99,13 +106,29 @@ void ArenaWidget::resetToGrey()
setStyleSheet("ArenaWidget { border: 2px solid #999999; padding: 8px; }"); 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) void ArenaWidget::updateStatus(const ArenaStatus& status)
{ {
m_lastStatus = status;
for (int ti = 0; ti < 2; ++ti) for (int ti = 0; ti < 2; ++ti)
{ {
const ArenaStatus::TeamStatus& team = status.teams[ti]; const ArenaStatus::TeamStatus& team = status.teams[ti];
QLabel* header = (ti == 0) ? m_team1Header : m_team2Header; QLabel* header = (ti == 0) ? m_team1Header : m_team2Header;
QLabel* threat = (ti == 0) ? m_team1Threat : m_team2Threat; QLabel* threat = (ti == 0) ? m_team1Threat : m_team2Threat;
QLabel* ehp = (ti == 0) ? m_team1Ehp : m_team2Ehp;
QLabel* content = (ti == 0) ? m_team1Content : m_team2Content; QLabel* content = (ti == 0) ? m_team1Content : m_team2Content;
if (status.finished && status.winnerTeam == ti) 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))); threat->setText(tr("Threat: %1").arg(QString::number(team.threatLevel, 'f', 0)));
ehp->setText(tr("EHP: %1").arg(QString::fromStdString(team.ehpPercentText())));
QString lines; QString lines;
for (const ArenaStatus::Entry& entry : team.entries) for (const ArenaStatus::Entry& entry : team.entries)
@@ -138,6 +162,10 @@ void ArenaWidget::updateStatus(const ArenaStatus& status)
content->setText(lines); 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) if (status.finished && !m_wasFinished)
{ {
m_wasFinished = true; m_wasFinished = true;

View File

@@ -14,21 +14,35 @@ class ArenaWidget : public QFrame
Q_OBJECT Q_OBJECT
public: public:
enum class State
{
NotStarted,
Running,
Ended
};
ArenaWidget(int arenaIndex, const std::string& arenaName, QWidget* parent = nullptr); ArenaWidget(int arenaIndex, const std::string& arenaName, QWidget* parent = nullptr);
void updateStatus(const ArenaStatus& status); void updateStatus(const ArenaStatus& status);
void startSimulation(); void startSimulation();
void resetToGrey(); void resetToGrey();
const ArenaStatus& getLastStatus() const { return m_lastStatus; }
State getState() const;
private: private:
void buildLayout(const std::string& arenaName); void buildLayout(const std::string& arenaName);
int m_arenaIndex; int m_arenaIndex;
ArenaStatus m_lastStatus;
QLabel* m_titleLabel; QLabel* m_titleLabel;
QLabel* m_durationLabel;
QLabel* m_team1Header; QLabel* m_team1Header;
QLabel* m_team2Header; QLabel* m_team2Header;
QLabel* m_team1Threat; QLabel* m_team1Threat;
QLabel* m_team2Threat; QLabel* m_team2Threat;
QLabel* m_team1Ehp;
QLabel* m_team2Ehp;
QLabel* m_team1Content; QLabel* m_team1Content;
QLabel* m_team2Content; QLabel* m_team2Content;
QPushButton* m_inspectButton; QPushButton* m_inspectButton;

View File

@@ -1,13 +1,70 @@
#include "BalancingWindow.h" #include "BalancingWindow.h"
#include <algorithm>
#include <QFile>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QMessageBox> #include <QMessageBox>
#include <QTextStream>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "ConfigLoader.h" #include "ConfigLoader.h"
#include "InspectWindow.h" #include "InspectWindow.h"
#include "VisualsLoader.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, BalancingWindow::BalancingWindow(const BalancingConfig& balancingConfig,
GameConfig gameConfig, GameConfig gameConfig,
const std::string& configDir, const std::string& configDir,
@@ -31,13 +88,16 @@ BalancingWindow::BalancingWindow(const BalancingConfig& balancingConfig,
QHBoxLayout* buttonRow = new QHBoxLayout(); QHBoxLayout* buttonRow = new QHBoxLayout();
m_reloadButton = new QPushButton(tr("Reload Config"), this); m_reloadButton = new QPushButton(tr("Reload Config"), this);
m_startAllButton = new QPushButton(tr("Start All"), this); m_startAllButton = new QPushButton(tr("Start All"), this);
m_logButton = new QPushButton(tr("Log"), this);
buttonRow->addWidget(m_reloadButton); buttonRow->addWidget(m_reloadButton);
buttonRow->addWidget(m_startAllButton); buttonRow->addWidget(m_startAllButton);
buttonRow->addWidget(m_logButton);
buttonRow->addStretch(); buttonRow->addStretch();
mainLayout->addLayout(buttonRow); mainLayout->addLayout(buttonRow);
connect(m_reloadButton, &QPushButton::clicked, this, &BalancingWindow::reloadConfig); connect(m_reloadButton, &QPushButton::clicked, this, &BalancingWindow::reloadConfig);
connect(m_startAllButton, &QPushButton::clicked, this, &BalancingWindow::startAll); connect(m_startAllButton, &QPushButton::clicked, this, &BalancingWindow::startAll);
connect(m_logButton, &QPushButton::clicked, this, &BalancingWindow::writeLog);
m_scrollArea = new QScrollArea(this); m_scrollArea = new QScrollArea(this);
m_scrollArea->setWidgetResizable(true); m_scrollArea->setWidgetResizable(true);
@@ -255,6 +315,7 @@ void BalancingWindow::setMainControlsEnabled(bool enabled)
{ {
m_reloadButton->setEnabled(enabled); m_reloadButton->setEnabled(enabled);
m_startAllButton->setEnabled(enabled); m_startAllButton->setEnabled(enabled);
m_logButton->setEnabled(enabled);
for (ArenaEntry& entry : m_arenas) for (ArenaEntry& entry : m_arenas)
{ {
for (QPushButton* btn : entry.widget->findChildren<QPushButton*>()) for (QPushButton* btn : entry.widget->findChildren<QPushButton*>())
@@ -291,4 +352,58 @@ void BalancingWindow::updateButtons()
m_reloadButton->setEnabled(!anyRunning); m_reloadButton->setEnabled(!anyRunning);
m_startAllButton->setEnabled(!allRunning); 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";
}
}
} }

View File

@@ -46,6 +46,7 @@ private slots:
void pollStatuses(); void pollStatuses();
void reloadConfig(); void reloadConfig();
void startAll(); void startAll();
void writeLog();
private: private:
void startArena(int index); void startArena(int index);
@@ -72,6 +73,7 @@ private:
unsigned int m_nextSeed; unsigned int m_nextSeed;
QPushButton* m_reloadButton; QPushButton* m_reloadButton;
QPushButton* m_startAllButton; QPushButton* m_startAllButton;
QPushButton* m_logButton;
QScrollArea* m_scrollArea; QScrollArea* m_scrollArea;
QTimer* m_pollTimer; QTimer* m_pollTimer;

View File

@@ -52,6 +52,9 @@ InspectWindow::InspectWindow(ArenaSimulation* sim, const GameConfig* config,
nameLabel->setFont(nameFont); nameLabel->setFont(nameFont);
headerLayout->addWidget(nameLabel); headerLayout->addWidget(nameLabel);
m_durationLabel = new QLabel(header);
headerLayout->addWidget(m_durationLabel);
headerLayout->addStretch(); headerLayout->addStretch();
const char* labels[] = { "0x", "0.5x", "1x", "2x", "10x" }; const char* labels[] = { "0x", "0.5x", "1x", "2x", "10x" };
@@ -93,6 +96,8 @@ InspectWindow::InspectWindow(ArenaSimulation* sim, const GameConfig* config,
team1Layout->addWidget(m_team1Header); team1Layout->addWidget(m_team1Header);
m_team1Threat = new QLabel(infoPanel); m_team1Threat = new QLabel(infoPanel);
team1Layout->addWidget(m_team1Threat); team1Layout->addWidget(m_team1Threat);
m_team1Ehp = new QLabel(infoPanel);
team1Layout->addWidget(m_team1Ehp);
m_team1Content = new QLabel(infoPanel); m_team1Content = new QLabel(infoPanel);
team1Layout->addWidget(m_team1Content); team1Layout->addWidget(m_team1Content);
team1Layout->addStretch(); team1Layout->addStretch();
@@ -104,6 +109,8 @@ InspectWindow::InspectWindow(ArenaSimulation* sim, const GameConfig* config,
team2Layout->addWidget(m_team2Header); team2Layout->addWidget(m_team2Header);
m_team2Threat = new QLabel(infoPanel); m_team2Threat = new QLabel(infoPanel);
team2Layout->addWidget(m_team2Threat); team2Layout->addWidget(m_team2Threat);
m_team2Ehp = new QLabel(infoPanel);
team2Layout->addWidget(m_team2Ehp);
m_team2Content = new QLabel(infoPanel); m_team2Content = new QLabel(infoPanel);
team2Layout->addWidget(m_team2Content); team2Layout->addWidget(m_team2Content);
team2Layout->addStretch(); team2Layout->addStretch();
@@ -198,11 +205,16 @@ void InspectWindow::pollStatus()
void InspectWindow::updateInfoPanel(const ArenaStatus& status) 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) for (int ti = 0; ti < 2; ++ti)
{ {
const ArenaStatus::TeamStatus& team = status.teams[ti]; const ArenaStatus::TeamStatus& team = status.teams[ti];
QLabel* header = (ti == 0) ? m_team1Header : m_team2Header; QLabel* header = (ti == 0) ? m_team1Header : m_team2Header;
QLabel* threat = (ti == 0) ? m_team1Threat : m_team2Threat; QLabel* threat = (ti == 0) ? m_team1Threat : m_team2Threat;
QLabel* ehp = (ti == 0) ? m_team1Ehp : m_team2Ehp;
QLabel* content = (ti == 0) ? m_team1Content : m_team2Content; QLabel* content = (ti == 0) ? m_team1Content : m_team2Content;
if (status.finished && status.winnerTeam == ti) 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))); threat->setText(tr("Threat: %1").arg(QString::number(team.threatLevel, 'f', 0)));
ehp->setText(tr("EHP: %1").arg(QString::fromStdString(team.ehpPercentText())));
QString lines; QString lines;
for (const ArenaStatus::Entry& entry : team.entries) for (const ArenaStatus::Entry& entry : team.entries)

View File

@@ -54,10 +54,13 @@ private:
ArenaView* m_arenaView; ArenaView* m_arenaView;
std::vector<QPushButton*> m_speedButtons; std::vector<QPushButton*> m_speedButtons;
QLabel* m_durationLabel;
QLabel* m_team1Header; QLabel* m_team1Header;
QLabel* m_team2Header; QLabel* m_team2Header;
QLabel* m_team1Threat; QLabel* m_team1Threat;
QLabel* m_team2Threat; QLabel* m_team2Threat;
QLabel* m_team1Ehp;
QLabel* m_team2Ehp;
QLabel* m_team1Content; QLabel* m_team1Content;
QLabel* m_team2Content; QLabel* m_team2Content;
QTimer* m_pollTimer; QTimer* m_pollTimer;

View File

@@ -279,7 +279,7 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path)
cfg.regions.enemyBufferWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["enemy_buffer_width_tiles"], file, "regions.enemy_buffer_width_tiles")); cfg.regions.enemyBufferWidth_tiles = static_cast<int>(requireInt(tbl["regions"]["enemy_buffer_width_tiles"], file, "regions.enemy_buffer_width_tiles"));
cfg.expansion.columnsPerExpansion_tiles = static_cast<int>(requireInt(tbl["expansion"]["columns_per_expansion_tiles"], file, "expansion.columns_per_expansion_tiles")); cfg.expansion.columnsPerExpansion_tiles = static_cast<int>(requireInt(tbl["expansion"]["columns_per_expansion_tiles"], file, "expansion.columns_per_expansion_tiles"));
cfg.expansion.costBuildingBlocks = static_cast<int>(requireInt(tbl["expansion"]["cost_building_blocks"], file, "expansion.cost_building_blocks")); cfg.expansion.costBuildingBlocksFormula = requireFormula(tbl["expansion"]["cost_building_blocks_formula"], file, "expansion.cost_building_blocks_formula");
cfg.push.pushExpandColumns_tiles = static_cast<int>(requireInt(tbl["push"]["push_expand_columns_tiles"], file, "push.push_expand_columns_tiles")); cfg.push.pushExpandColumns_tiles = static_cast<int>(requireInt(tbl["push"]["push_expand_columns_tiles"], file, "push.push_expand_columns_tiles"));
cfg.push.bossAdvanceSeconds = requireDouble(tbl["push"]["boss_advance_seconds"], file, "push.boss_advance_seconds"); cfg.push.bossAdvanceSeconds = requireDouble(tbl["push"]["boss_advance_seconds"], file, "push.boss_advance_seconds");

View File

@@ -15,7 +15,7 @@ struct WorldRegions
struct WorldExpansion struct WorldExpansion
{ {
int columnsPerExpansion_tiles; int columnsPerExpansion_tiles;
int costBuildingBlocks; Formula costBuildingBlocksFormula; // cost in building blocks; x = expansions already purchased
}; };
// Push effects (REQ-PSH-*, REQ-WAV-BOSS-ADVANCE). // Push effects (REQ-PSH-*, REQ-WAV-BOSS-ADVANCE).

View File

@@ -3,6 +3,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/TracePrintRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/TracePrintRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/TickAdvancedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/TickAdvancedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingBlocksChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingBlocksChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/ExpansionCostChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/EntitySelectedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/EntitySelectedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/GameSpeedChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/GameSpeedChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BossWaveUpdatedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BossWaveUpdatedEvent.h

View File

@@ -0,0 +1,17 @@
#ifndef EXPANSION_COST_CHANGED_EVENT_H
#define EXPANSION_COST_CHANGED_EVENT_H
#include "Event.h"
// Fired when the current asteroid-expansion cost changes (REQ-EXP-COST): once at
// startup and again after each expansion is purchased. Carries the cost in
// building blocks so the header Expand button can update its caption/enabled
// state (REQ-UI-EXPAND-BUTTON).
class ExpansionCostChangedEvent : public Event
{
public:
explicit ExpansionCostChangedEvent(int cost) : cost(cost) {}
const int cost;
};
#endif // EXPANSION_COST_CHANGED_EVENT_H

View File

@@ -24,6 +24,7 @@ BuildingSystem::BuildingSystem(const GameConfig& config,
, m_spawnShip(std::move(spawnShip)) , m_spawnShip(std::move(spawnShip))
, m_isItemUnlocked(std::move(isItemUnlocked)) , m_isItemUnlocked(std::move(isItemUnlocked))
, m_rng(rng) , m_rng(rng)
, m_asteroidWidth_tiles(config.world.regions.asteroidWidth_tiles)
{ {
} }
@@ -281,7 +282,7 @@ bool BuildingSystem::bodyCellsWithinWorldBounds(const std::vector<QPoint>& bodyC
QPoint anchor) const QPoint anchor) const
{ {
const int heightTiles = m_config.world.heightTiles; const int heightTiles = m_config.world.heightTiles;
const int leftEdgeX = -m_config.world.regions.asteroidWidth_tiles; const int leftEdgeX = -m_asteroidWidth_tiles;
for (const QPoint& cell : bodyCells) for (const QPoint& cell : bodyCells)
{ {
const QPoint worldCell = anchor + cell; const QPoint worldCell = anchor + cell;

View File

@@ -59,6 +59,11 @@ public:
bool isPlacementValid(BuildingType type, QPoint anchor, bool isPlacementValid(BuildingType type, QPoint anchor,
Rotation rotation) const; Rotation rotation) const;
// Sets the current buildable asteroid width in tiles. Grows the left
// placement bound as the player unlocks asteroid expansions (REQ-EXP-UNLOCK).
// Defaults to world.regions.asteroid_width_tiles at construction.
void setAsteroidWidth_tiles(int widthTiles) { m_asteroidWidth_tiles = widthTiles; }
// Remove a building or construction site by id. Returns the refund in // Remove a building or construction site by id. Returns the refund in
// building blocks (floor(cost * refundPercentage / 100)). Returns 0 for // building blocks (floor(cost * refundPercentage / 100)). Returns 0 for
// unknown ids. // unknown ids.
@@ -179,6 +184,7 @@ private:
const std::optional<ShipLayoutConfig>&)> m_spawnShip; const std::optional<ShipLayoutConfig>&)> m_spawnShip;
std::function<bool(const std::string&)> m_isItemUnlocked; std::function<bool(const std::string&)> m_isItemUnlocked;
std::mt19937& m_rng; std::mt19937& m_rng;
int m_asteroidWidth_tiles;
std::vector<Building> m_buildings; std::vector<Building> m_buildings;
std::deque<ConstructionSite> m_constructionQueue; std::deque<ConstructionSite> m_constructionQueue;

View File

@@ -35,6 +35,7 @@ enum class CommandKind
SetSplitterFilters, SetSplitterFilters,
ClearBeltTiles, ClearBeltTiles,
ApplySchematicChoice, ApplySchematicChoice,
ExpandAsteroid,
Reset Reset
}; };
@@ -129,6 +130,14 @@ struct ApplySchematicChoiceCommand : Command
int choiceIndex = 0; int choiceIndex = 0;
}; };
// Unlocks the next asteroid expansion (REQ-EXP-UNLOCK). Carries no payload: the
// simulation derives the cost and column count from its own expansion counter
// and config, so a recorded command replays identically.
struct ExpandAsteroidCommand : Command
{
ExpandAsteroidCommand() : Command(CommandKind::ExpandAsteroid) {}
};
// Restart boundary: reinitializes the simulation with a fresh seed and, if // Restart boundary: reinitializes the simulation with a fresh seed and, if
// config is set, a reloaded config (GameConfig is move-only, so it is carried by // config is set, a reloaded config (GameConfig is move-only, so it is carried by
// shared_ptr and moved into the sim on apply). A null config keeps the current // shared_ptr and moved into the sim on apply). A null config keeps the current

View File

@@ -2,6 +2,7 @@
#include <algorithm> #include <algorithm>
#include <cassert> #include <cassert>
#include <cmath>
#include "AiSystem.h" #include "AiSystem.h"
#include "Command.h" #include "Command.h"
@@ -132,6 +133,7 @@ void Simulation::reset(unsigned int seed)
m_nextDepartureTick = secondsToTicks(m_config.world.departureIntervalSeconds); m_nextDepartureTick = secondsToTicks(m_config.world.departureIntervalSeconds);
m_nextBuildingId = 1; m_nextBuildingId = 1;
m_buildingBlocksStock = m_config.world.startingBuildingBlocks; m_buildingBlocksStock = m_config.world.startingBuildingBlocks;
m_expansionsPurchased = 0;
m_gameOver = false; m_gameOver = false;
m_isWon = false; m_isWon = false;
m_artifactCount = 0; m_artifactCount = 0;
@@ -275,6 +277,9 @@ void Simulation::apply(const Command& command)
case CommandKind::ApplySchematicChoice: case CommandKind::ApplySchematicChoice:
applySchematicChoice(static_cast<const ApplySchematicChoiceCommand&>(command).choiceIndex); applySchematicChoice(static_cast<const ApplySchematicChoiceCommand&>(command).choiceIndex);
break; break;
case CommandKind::ExpandAsteroid:
tryExpandAsteroid();
break;
case CommandKind::Reset: case CommandKind::Reset:
{ {
const ResetCommand& c = static_cast<const ResetCommand&>(command); const ResetCommand& c = static_cast<const ResetCommand&>(command);
@@ -993,6 +998,7 @@ unsigned long long Simulation::computeStateChecksum() const
hasher.append(m_gameOver); hasher.append(m_gameOver);
hasher.append(m_isWon); hasher.append(m_isWon);
hasher.append(m_artifactCount); hasher.append(m_artifactCount);
hasher.append(m_expansionsPurchased);
// WaveSystem scalar state, reached through existing accessors. // WaveSystem scalar state, reached through existing accessors.
hasher.append(threatLevel()); hasher.append(threatLevel());
@@ -1100,6 +1106,31 @@ int Simulation::buildingBlocksStock() const
return m_buildingBlocksStock; return m_buildingBlocksStock;
} }
int Simulation::currentAsteroidWidth_tiles() const
{
return m_config.world.regions.asteroidWidth_tiles
+ m_expansionsPurchased * m_config.world.expansion.columnsPerExpansion_tiles;
}
int Simulation::currentExpansionCost() const
{
const double cost = m_config.world.expansion.costBuildingBlocksFormula.evaluate(
static_cast<double>(m_expansionsPurchased));
return static_cast<int>(std::floor(cost));
}
void Simulation::tryExpandAsteroid()
{
const int cost = currentExpansionCost();
if (m_buildingBlocksStock < cost)
{
return;
}
m_buildingBlocksStock -= cost;
++m_expansionsPurchased;
m_buildingSystem->setAsteroidWidth_tiles(currentAsteroidWidth_tiles());
}
bool Simulation::isGameOver() const bool Simulation::isGameOver() const
{ {
return m_gameOver; return m_gameOver;

View File

@@ -72,6 +72,12 @@ public:
// The seed this run was (re)initialized with; written to the replay header. // The seed this run was (re)initialized with; written to the replay header.
unsigned int getSeed() const; unsigned int getSeed() const;
int buildingBlocksStock() const; int buildingBlocksStock() const;
// Current asteroid width in tiles = base width + purchased expansions
// (REQ-EXP-UNLOCK, REQ-GW-ASTEROID-EXPAND).
int currentAsteroidWidth_tiles() const;
// Building block cost of the next expansion, floored to an integer
// (REQ-EXP-COST); x = number of expansions already purchased.
int currentExpansionCost() const;
bool isGameOver() const; bool isGameOver() const;
bool isWon() const; bool isWon() const;
int artifactCount() const; int artifactCount() const;
@@ -136,6 +142,11 @@ private:
// Clears the pending choices after application. // Clears the pending choices after application.
void applySchematicChoice(int choiceIndex); void applySchematicChoice(int choiceIndex);
// Unlocks one asteroid expansion if affordable (REQ-EXP-UNLOCK): checks the
// current cost against the stock, deducts it, increments the expansion
// counter, and widens the buildable asteroid. No-op if blocks are short.
void tryExpandAsteroid();
// Mutable subsystem accessors; same chokepoint rule as the mutators above. // Mutable subsystem accessors; same chokepoint rule as the mutators above.
BuildingSystem& buildingsMutable(); BuildingSystem& buildingsMutable();
BeltSystem& beltsMutable(); BeltSystem& beltsMutable();
@@ -165,6 +176,7 @@ private:
Tick m_nextDepartureTick; Tick m_nextDepartureTick;
BuildingId m_nextBuildingId; BuildingId m_nextBuildingId;
int m_buildingBlocksStock; int m_buildingBlocksStock;
int m_expansionsPurchased = 0; // REQ-EXP-COST formula variable x
bool m_gameOver = false; bool m_gameOver = false;
bool m_isWon = false; bool m_isWon = false;
int m_artifactCount = 0; int m_artifactCount = 0;

View File

@@ -30,6 +30,7 @@ double computeMaterialThreat(const ThreatCostTable& table,
return total; return total;
} }
// Returns true if every input of the recipe has a resolved threat value.
bool allInputsResolved(const RecipeDef& recipe, bool allInputsResolved(const RecipeDef& recipe,
const std::map<std::string, double>& resolved) const std::map<std::string, double>& resolved)
{ {
@@ -43,7 +44,10 @@ bool allInputsResolved(const RecipeDef& recipe,
return true; return true;
} }
double computeRecipeThreat(const RecipeDef& recipe, // 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) const std::map<std::string, double>& resolved)
{ {
double threat = recipe.durationSeconds; double threat = recipe.durationSeconds;
@@ -51,7 +55,7 @@ double computeRecipeThreat(const RecipeDef& recipe,
{ {
threat += resolved.at(input.item) * input.amount; threat += resolved.at(input.item) * input.amount;
} }
return threat; return threat / static_cast<double>(outputAmount);
} }
} // namespace } // namespace
@@ -69,102 +73,215 @@ ThreatCostTable computeThreatCostTable(const GameConfig& config)
? 1.0 / config.world.scrapPerThreat ? 1.0 / config.world.scrapPerThreat
: 0.0; : 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; std::map<std::string, std::vector<RecipeRef>> reprocessingRecipes;
for (const RecipeDef& recipe : config.recipes.recipes) for (const RecipeDef& recipe : config.recipes.recipes)
{ {
if (recipe.building == BuildingType::ReprocessingPlant) 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) for (const RecipeOutput& out : recipe.outputs)
{ {
RecipeRef ref; RecipeRef ref;
ref.recipe = &recipe; ref.recipe = &recipe;
ref.outputItem = out.item; ref.outputItem = out.item;
ref.outputAmount = out.amount; 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); reprocessingRecipes[out.item].push_back(ref);
} }
} }
else 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) for (const RecipeOutput& out : recipe.outputs)
{ {
RecipeRef ref; if (!consumesScrap)
ref.recipe = &recipe; {
ref.outputItem = out.item; scrapFreeItems.insert(out.item);
ref.outputAmount = out.amount; }
ref.probability = 1.0;
nonReprocessingRecipes[out.item].push_back(ref); 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. // Filter nonReprocessingCandidates: for items that have at least one
std::set<std::string> unresolved; // scrap-free producer, drop their scrap-consuming recipes.
for (const std::pair<const std::string, std::vector<RecipeRef>>& entry : nonReprocessingRecipes) // 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); 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);
} }
for (const std::pair<const std::string, std::vector<RecipeRef>>& entry : reprocessingRecipes)
{
unresolved.insert(entry.first);
} }
// Iteratively resolve non-reprocessing items. // -------------------------------------------------------------------------
bool progress = true; // Resolution: seed resolved map with scrap, then alternate the
while (progress) // 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; bool progress = false;
std::set<std::string> newlyResolved; std::map<std::string, double> newValues;
for (const std::string& item : unresolved)
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 = const std::string& item = it->first;
nonReprocessingRecipes.find(item); if (resolved.find(item) != resolved.end())
if (it == nonReprocessingRecipes.end())
{ {
continue; continue;
} }
const std::vector<std::pair<const RecipeDef*, int>>& pairs = it->second;
bool allComputable = true;
double maxThreat = -1.0; 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); allComputable = false;
if (requireAllRecipes)
{
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) if (threat > maxThreat)
{ {
maxThreat = threat; maxThreat = threat;
} }
} }
}
if (requireAllRecipes && !allComputable)
{
continue;
}
if (maxThreat >= 0.0) if (maxThreat >= 0.0)
{ {
table.itemThreat[item] = maxThreat; newValues[item] = maxThreat;
newlyResolved.insert(item);
progress = true;
}
}
for (const std::string& item : newlyResolved)
{
unresolved.erase(item);
} }
} }
// Resolve reprocessing-only items. for (std::map<std::string, double>::const_iterator it = newValues.begin();
for (const std::string& item : unresolved) it != newValues.end();
++it)
{ {
std::map<std::string, std::vector<RecipeRef>>::const_iterator it = resolved[it->first] = it->second;
reprocessingRecipes.find(item); progress = true;
if (it == reprocessingRecipes.end()) }
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
{
bool progress = false;
for (std::map<std::string, std::vector<RecipeRef>>::const_iterator it =
reprocessingRecipes.begin();
it != reprocessingRecipes.end();
++it)
{
const std::string& item = it->first;
if (resolved.find(item) != resolved.end())
{
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; continue;
} }
for (const RecipeRef& ref : it->second) for (const RecipeRef& ref : it->second)
{ {
// Sum all scrap inputs for this reprocessing recipe.
int scrapPerCycle = 0; int scrapPerCycle = 0;
for (const RecipeIngredient& input : ref.recipe->inputs) for (const RecipeIngredient& input : ref.recipe->inputs)
{ {
@@ -173,13 +290,41 @@ ThreatCostTable computeThreatCostTable(const GameConfig& config)
double threat = (table.scrapThreat * scrapPerCycle double threat = (table.scrapThreat * scrapPerCycle
+ ref.recipe->durationSeconds) / ref.probability; + ref.recipe->durationSeconds) / ref.probability;
std::map<std::string, double>::iterator existing = table.itemThreat.find(item);
if (existing == table.itemThreat.end() || threat > existing->second) std::map<std::string, double>::iterator existing = resolved.find(item);
if (existing == resolved.end() || threat > existing->second)
{ {
table.itemThreat[item] = threat; 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; return table;
} }

View File

@@ -76,6 +76,8 @@ TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[c
REQUIRE(cfg.world.regions.playerBufferWidth_tiles == 10); REQUIRE(cfg.world.regions.playerBufferWidth_tiles == 10);
REQUIRE(cfg.world.regions.enemyBufferWidth_tiles == 15); REQUIRE(cfg.world.regions.enemyBufferWidth_tiles == 15);
REQUIRE(cfg.world.expansion.columnsPerExpansion_tiles == 10); REQUIRE(cfg.world.expansion.columnsPerExpansion_tiles == 10);
REQUIRE(cfg.world.expansion.costBuildingBlocksFormula.evaluate(0) == Approx(400.0));
REQUIRE(cfg.world.expansion.costBuildingBlocksFormula.evaluate(1) == Approx(800.0));
REQUIRE(cfg.world.push.bossAdvanceSeconds == Approx(60.0)); REQUIRE(cfg.world.push.bossAdvanceSeconds == Approx(60.0));
REQUIRE(cfg.world.orbitFactor == Approx(0.8)); REQUIRE(cfg.world.orbitFactor == Approx(0.8));
REQUIRE(cfg.world.rallyOrbitRadius_tiles == Approx(5.0)); REQUIRE(cfg.world.rallyOrbitRadius_tiles == Approx(5.0));
@@ -186,7 +188,7 @@ contest_zone_width_tiles = 30
[expansion] [expansion]
columns_per_expansion_tiles = 10 columns_per_expansion_tiles = 10
cost_building_blocks = 200 cost_building_blocks_formula = "400 * 2^x"
[push] [push]
push_expand_columns_tiles = 20 push_expand_columns_tiles = 20
@@ -237,7 +239,7 @@ enemy_buffer_width_tiles = 15
[expansion] [expansion]
columns_per_expansion_tiles = 10 columns_per_expansion_tiles = 10
cost_building_blocks = 200 cost_building_blocks_formula = "400 * 2^x"
[push] [push]
push_expand_columns_tiles = 20 push_expand_columns_tiles = 20
@@ -284,7 +286,7 @@ enemy_buffer_width_tiles = 15
[expansion] [expansion]
columns_per_expansion_tiles = 10 columns_per_expansion_tiles = 10
cost_building_blocks = 200 cost_building_blocks_formula = "400 * 2^x"
[push] [push]
push_expand_columns_tiles = 20 push_expand_columns_tiles = 20

View File

@@ -107,3 +107,62 @@ TEST_CASE("ThreatCostCalculator: unknown ship returns zero", "[threat]")
double threat = calculateShipThreatCost(table, cfg, "nonexistent_ship", {}); double threat = calculateShipThreatCost(table, cfg, "nonexistent_ship", {});
CHECK(threat == Approx(0.0)); 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));
}

View File

@@ -59,6 +59,7 @@
#include "BuilderModeExitedEvent.h" #include "BuilderModeExitedEvent.h"
#include "BlueprintModeExitedEvent.h" #include "BlueprintModeExitedEvent.h"
#include "BuildingBlocksChangedEvent.h" #include "BuildingBlocksChangedEvent.h"
#include "ExpansionCostChangedEvent.h"
#include "GameSpeedChangedEvent.h" #include "GameSpeedChangedEvent.h"
#include "SchematicChoicesAvailableEvent.h" #include "SchematicChoicesAvailableEvent.h"
#include "TickAdvancedEvent.h" #include "TickAdvancedEvent.h"
@@ -276,6 +277,7 @@ void GameWorldView::onFrame()
{ {
const Tick newTick = m_sim->currentTick(); const Tick newTick = m_sim->currentTick();
const int newBlocks = m_sim->buildingBlocksStock(); const int newBlocks = m_sim->buildingBlocksStock();
const int newExpCost = m_sim->currentExpansionCost();
const int newBoss = m_sim->bossWaveCounter(); const int newBoss = m_sim->bossWaveCounter();
const Tick newCountdown = m_sim->bossCountdownTicks(); const Tick newCountdown = m_sim->bossCountdownTicks();
@@ -291,6 +293,12 @@ void GameWorldView::onFrame()
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<BuildingBlocksChangedEvent>(newBlocks)); std::make_shared<BuildingBlocksChangedEvent>(newBlocks));
} }
if (newExpCost != m_lastExpansionCost)
{
m_lastExpansionCost = newExpCost;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExpansionCostChangedEvent>(newExpCost));
}
if (newBoss != m_lastBossCounter || newCountdown != m_lastBossCountdown) if (newBoss != m_lastBossCounter || newCountdown != m_lastBossCountdown)
{ {
m_lastBossCounter = newBoss; m_lastBossCounter = newBoss;
@@ -435,7 +443,7 @@ QRect GameWorldView::viewportRect() const
float GameWorldView::asteroidLeftEdge() const float GameWorldView::asteroidLeftEdge() const
{ {
float leftX = -static_cast<float>(m_config->world.regions.asteroidWidth_tiles); float leftX = -static_cast<float>(m_sim->currentAsteroidWidth_tiles());
for (const Building& b : m_sim->buildings().allBuildings()) for (const Building& b : m_sim->buildings().allBuildings())
{ {
for (const QPoint& cell : b.bodyCells) for (const QPoint& cell : b.bodyCells)
@@ -1827,6 +1835,7 @@ void GameWorldView::resetForNewGame()
m_prevNonZeroSpeed = 1.0; m_prevNonZeroSpeed = 1.0;
m_lastTick = Tick(-1); m_lastTick = Tick(-1);
m_lastBlocks = -1; m_lastBlocks = -1;
m_lastExpansionCost = -1;
m_lastBossCounter = -1; m_lastBossCounter = -1;
m_lastBossCountdown = Tick(-1); m_lastBossCountdown = Tick(-1);
m_lastArtifactCount = -1; m_lastArtifactCount = -1;

View File

@@ -226,6 +226,7 @@ private:
Tick m_lastTick = Tick(-1); Tick m_lastTick = Tick(-1);
int m_lastBlocks = -1; int m_lastBlocks = -1;
int m_lastExpansionCost = -1;
int m_lastBossCounter = -1; int m_lastBossCounter = -1;
Tick m_lastBossCountdown = Tick(-1); Tick m_lastBossCountdown = Tick(-1);
int m_lastArtifactCount = -1; int m_lastArtifactCount = -1;

View File

@@ -8,6 +8,8 @@
#include <QPushButton> #include <QPushButton>
#include <QSignalMapper> #include <QSignalMapper>
#include "Command.h"
#include "CommandRequestedEvent.h"
#include "EventManager.h" #include "EventManager.h"
#include "SpeedChangeRequestedEvent.h" #include "SpeedChangeRequestedEvent.h"
#include "Tick.h" #include "Tick.h"
@@ -32,6 +34,17 @@ HeaderBar::HeaderBar(QWidget* parent)
layout->addStretch(); layout->addStretch();
layout->addWidget(m_bossLabel); layout->addWidget(m_bossLabel);
// Asteroid expansion button, to the left of the speed buttons (REQ-UI-HEADER,
// REQ-UI-EXPAND-BUTTON). Caption/enabled state are set on the first
// ExpansionCostChangedEvent; clicking requests an ExpandAsteroidCommand.
m_expandButton = new QPushButton(tr("Expand"), this);
layout->addWidget(m_expandButton);
connect(m_expandButton, &QPushButton::clicked, this, []() {
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(
std::make_shared<ExpandAsteroidCommand>()));
});
const char* labels[] = { "0x", "0.5x", "1x", "2x", "10x" }; const char* labels[] = { "0x", "0.5x", "1x", "2x", "10x" };
QSignalMapper* mapper = new QSignalMapper(this); QSignalMapper* mapper = new QSignalMapper(this);
for (int i = 0; i < kSpeedCount; ++i) for (int i = 0; i < kSpeedCount; ++i)
@@ -67,7 +80,21 @@ void HeaderBar::handleEvent(std::shared_ptr<const TickAdvancedEvent> event)
void HeaderBar::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) void HeaderBar::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event)
{ {
m_blocks = event->blocks;
m_blocksLabel->setText(tr("Blocks: %1").arg(event->blocks)); m_blocksLabel->setText(tr("Blocks: %1").arg(event->blocks));
updateExpandButton();
}
void HeaderBar::handleEvent(std::shared_ptr<const ExpansionCostChangedEvent> event)
{
m_expansionCost = event->cost;
updateExpandButton();
}
void HeaderBar::updateExpandButton()
{
m_expandButton->setText(tr("Expand: %1 Blocks").arg(m_expansionCost));
m_expandButton->setEnabled(m_blocks >= m_expansionCost);
} }
void HeaderBar::handleEvent(std::shared_ptr<const GameSpeedChangedEvent> event) void HeaderBar::handleEvent(std::shared_ptr<const GameSpeedChangedEvent> event)

View File

@@ -8,6 +8,7 @@
#include "BossWaveUpdatedEvent.h" #include "BossWaveUpdatedEvent.h"
#include "BuildingBlocksChangedEvent.h" #include "BuildingBlocksChangedEvent.h"
#include "EventHandler.h" #include "EventHandler.h"
#include "ExpansionCostChangedEvent.h"
#include "GameSpeedChangedEvent.h" #include "GameSpeedChangedEvent.h"
#include "Tick.h" #include "Tick.h"
#include "TickAdvancedEvent.h" #include "TickAdvancedEvent.h"
@@ -18,6 +19,7 @@ class QPushButton;
class HeaderBar : public QWidget, class HeaderBar : public QWidget,
public CombinedEventHandler<TickAdvancedEvent, public CombinedEventHandler<TickAdvancedEvent,
BuildingBlocksChangedEvent, BuildingBlocksChangedEvent,
ExpansionCostChangedEvent,
GameSpeedChangedEvent, GameSpeedChangedEvent,
BossWaveUpdatedEvent, BossWaveUpdatedEvent,
ArtifactCountChangedEvent> ArtifactCountChangedEvent>
@@ -34,16 +36,25 @@ private slots:
private: private:
void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override; void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override;
void handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) override; void handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) override;
void handleEvent(std::shared_ptr<const ExpansionCostChangedEvent> event) override;
void handleEvent(std::shared_ptr<const GameSpeedChangedEvent> event) override; void handleEvent(std::shared_ptr<const GameSpeedChangedEvent> event) override;
void handleEvent(std::shared_ptr<const BossWaveUpdatedEvent> event) override; void handleEvent(std::shared_ptr<const BossWaveUpdatedEvent> event) override;
void handleEvent(std::shared_ptr<const ArtifactCountChangedEvent> event) override; void handleEvent(std::shared_ptr<const ArtifactCountChangedEvent> event) override;
// Refreshes the Expand button caption and enabled state from the current
// expansion cost and building block stock (REQ-UI-EXPAND-BUTTON).
void updateExpandButton();
QLabel* m_timeLabel; QLabel* m_timeLabel;
QLabel* m_blocksLabel; QLabel* m_blocksLabel;
QLabel* m_artifactsLabel; QLabel* m_artifactsLabel;
QLabel* m_bossLabel; QLabel* m_bossLabel;
QPushButton* m_expandButton;
std::vector<QPushButton*> m_speedButtons; std::vector<QPushButton*> m_speedButtons;
int m_blocks = 0;
int m_expansionCost = 0;
static const double kSpeeds[]; static const double kSpeeds[];
static const int kSpeedCount; static const int kSpeedCount;
}; };

263
tools/threat_report.py Normal file
View 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())