7 Commits

40 changed files with 1077 additions and 538 deletions

View File

@@ -6,6 +6,10 @@
# realized: tier 1 ratios are 1:1, tier 2 ratios are 2:3, tier 3+ ratios
# are deliberately strange.
#
# A recipe id names the process, not the product — the display name shown in
# the UI is derived from it — so an item and the recipe that makes it never
# carry the same name.
#
# Input chain per game phase — each phase transition adds exactly one new
# base input:
#
@@ -27,21 +31,21 @@
# -----------------------------------------------------------------------------
[[recipe]]
id = "mine_iron_ore"
id = "iron_mining"
building = "miner"
inputs = []
outputs = [{item = "iron_ore", amount = 1}]
duration_seconds = 1.0
[[recipe]]
id = "mine_copper_ore"
id = "copper_mining"
building = "miner"
inputs = []
outputs = [{item = "copper_ore", amount = 1}]
duration_seconds = 1.0
[[recipe]]
id = "mine_quartz"
id = "quartz_mining"
building = "miner"
inputs = []
outputs = [{item = "quartz", amount = 1}]
@@ -52,21 +56,21 @@ duration_seconds = 2.0
# -----------------------------------------------------------------------------
[[recipe]]
id = "iron_ingot"
id = "iron_smelting"
building = "smelter"
inputs = [{item = "iron_ore", amount = 1}]
outputs = [{item = "iron_ingot", amount = 1}]
duration_seconds = 1.0
[[recipe]]
id = "copper_ingot"
id = "copper_smelting"
building = "smelter"
inputs = [{item = "copper_ore", amount = 1}]
outputs = [{item = "copper_ingot", amount = 1}]
duration_seconds = 1.0
[[recipe]]
id = "silicon"
id = "quartz_reduction"
building = "smelter"
inputs = [{item = "quartz", amount = 1}]
outputs = [{item = "silicon", amount = 1}]
@@ -90,7 +94,7 @@ duration_seconds = 1.0
# -----------------------------------------------------------------------------
[[recipe]]
id = "reprocessing_cycle"
id = "scrap_reprocessing"
building = "reprocessing_plant"
inputs = [{item = "scrap", amount = 4}]
duration_seconds = 4.0
@@ -120,21 +124,21 @@ duration_seconds = 4.0
# -----------------------------------------------------------------------------
[[recipe]]
id = "steel_plate"
id = "steel_rolling"
building = "assembler"
inputs = [{item = "iron_ingot", amount = 2}]
outputs = [{item = "steel_plate", amount = 1}]
duration_seconds = 3.0
[[recipe]]
id = "copper_wire"
id = "wire_drawing"
building = "assembler"
inputs = [{item = "copper_ingot", amount = 1}]
outputs = [{item = "copper_wire", amount = 2}]
duration_seconds = 1.0
[[recipe]]
id = "copper_coil"
id = "coil_winding"
building = "assembler"
inputs = [{item = "copper_wire", amount = 2}]
outputs = [{item = "copper_coil", amount = 1}]
@@ -145,7 +149,7 @@ duration_seconds = 1.5
# unlocked_at_start: building blocks appear in no schematic's materials, so the
# implicit item graph can never reach this recipe (REQ-LOCK-IMPLICIT).
[[recipe]]
id = "building_block"
id = "block_pressing"
building = "assembler"
unlocked_at_start = true
inputs = [{item = "steel_plate", amount = 2}]
@@ -157,14 +161,14 @@ duration_seconds = 2.0
# -----------------------------------------------------------------------------
[[recipe]]
id = "control_chip"
id = "chip_etching"
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"
id = "capacitor_assembly"
building = "assembler"
inputs = [{item = "copper_coil", amount = 2}, {item = "silicon", amount = 1}]
outputs = [{item = "capacitor_bank", amount = 1}]
@@ -173,21 +177,21 @@ duration_seconds = 5.0
# The quality gate for m+ hulls: a deliberately long-running recipe
# (time-heavy archetype).
[[recipe]]
id = "hardened_steel"
id = "steel_hardening"
building = "assembler"
inputs = [{item = "steel_plate", amount = 3}]
outputs = [{item = "hardened_steel", amount = 1}]
duration_seconds = 12.0
[[recipe]]
id = "ceramic_plate"
id = "ceramic_firing"
building = "assembler"
inputs = [{item = "quartz", amount = 2}]
outputs = [{item = "ceramic_plate", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "drive_unit"
id = "drive_assembly"
building = "assembler"
inputs = [
{item = "steel_plate", amount = 2},
@@ -202,14 +206,14 @@ duration_seconds = 8.0
# -----------------------------------------------------------------------------
[[recipe]]
id = "voidsteel_plate"
id = "voidsteel_forging"
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"
id = "core_assembly"
building = "assembler"
inputs = [
{item = "voidsteel", amount = 2},
@@ -223,25 +227,26 @@ duration_seconds = 10.0
# Shortcut recipes — drop-only assembler recipes, gated by unlock groups in
# unlocks.toml (REQ-LOCK-EXPLICIT). 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.
# efficiency without shifting any balance. Named "direct_*" after the base
# process they skip a step of, so the reward reads as what it is.
# -----------------------------------------------------------------------------
[[recipe]]
id = "shortcut_steel_plate"
id = "direct_rolling"
building = "assembler"
inputs = [{item = "iron_ore", amount = 3}]
outputs = [{item = "steel_plate", amount = 1}]
duration_seconds = 2.0
[[recipe]]
id = "shortcut_control_chip"
id = "direct_etching"
building = "assembler"
inputs = [{item = "quartz", amount = 2}]
outputs = [{item = "control_chip", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "shortcut_hardened_steel"
id = "direct_hardening"
building = "assembler"
inputs = [{item = "iron_ingot", amount = 4}]
outputs = [{item = "hardened_steel", amount = 1}]
@@ -252,35 +257,35 @@ duration_seconds = 8.0
# -----------------------------------------------------------------------------
[[recipe]]
id = "drone_hull"
id = "drone_framing"
building = "assembler"
inputs = [{item = "iron_ingot", amount = 1}]
outputs = [{item = "drone_hull", amount = 1}]
duration_seconds = 1.0
[[recipe]]
id = "frigate_hull"
id = "frigate_framing"
building = "assembler"
inputs = [{item = "steel_plate", amount = 2}, {item = "copper_wire", amount = 1}]
outputs = [{item = "frigate_hull", amount = 1}]
duration_seconds = 2.0
[[recipe]]
id = "destroyer_hull"
id = "destroyer_framing"
building = "assembler"
inputs = [{item = "steel_plate", amount = 3}, {item = "copper_coil", amount = 2}]
outputs = [{item = "destroyer_hull", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "cruiser_hull"
id = "cruiser_framing"
building = "assembler"
inputs = [{item = "hardened_steel", amount = 2}, {item = "control_chip", amount = 2}]
outputs = [{item = "cruiser_hull", amount = 1}]
duration_seconds = 6.0
[[recipe]]
id = "battlecruiser_hull"
id = "battlecruiser_framing"
building = "assembler"
inputs = [
{item = "hardened_steel", amount = 3},
@@ -291,7 +296,7 @@ outputs = [{item = "battlecruiser_hull", amount = 1}]
duration_seconds = 8.0
[[recipe]]
id = "battleship_hull"
id = "battleship_framing"
building = "assembler"
inputs = [
{item = "voidsteel_plate", amount = 3},
@@ -302,7 +307,7 @@ outputs = [{item = "battleship_hull", amount = 1}]
duration_seconds = 10.0
[[recipe]]
id = "dreadnought_hull"
id = "dreadnought_framing"
building = "assembler"
inputs = [
{item = "voidsteel_plate", amount = 5},
@@ -313,7 +318,7 @@ outputs = [{item = "dreadnought_hull", amount = 1}]
duration_seconds = 12.0
[[recipe]]
id = "carrier_hull"
id = "carrier_framing"
building = "assembler"
inputs = [
{item = "voidsteel_plate", amount = 5},
@@ -328,21 +333,21 @@ duration_seconds = 12.0
# -----------------------------------------------------------------------------
[[recipe]]
id = "railgun_s_module"
id = "railgun_s_kit"
building = "assembler"
inputs = [{item = "copper_coil", amount = 1}]
outputs = [{item = "railgun_s_module", amount = 1}]
duration_seconds = 1.0
[[recipe]]
id = "salvager_module"
id = "salvager_kit"
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"
id = "repair_tool_kit"
building = "assembler"
inputs = [{item = "steel_plate", amount = 1}, {item = "copper_wire", amount = 2}]
outputs = [{item = "repair_tool_module", amount = 1}]
@@ -350,56 +355,56 @@ duration_seconds = 2.0
# Material-heavy, fast: the armor archetype.
[[recipe]]
id = "armor_plates_module"
id = "armor_plates_kit"
building = "assembler"
inputs = [{item = "steel_plate", amount = 4}]
outputs = [{item = "armor_plates_module", amount = 1}]
duration_seconds = 3.0
[[recipe]]
id = "maneuvering_thrusters_module"
id = "maneuvering_thrusters_kit"
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"
id = "sensor_booster_kit"
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"
id = "afterburner_kit"
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"
id = "weapon_stabilizer_kit"
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"
id = "weapon_primer_kit"
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"
id = "weapon_upgrade_kit"
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"
id = "railgun_m_kit"
building = "assembler"
inputs = [
{item = "capacitor_bank", amount = 1},
@@ -410,7 +415,7 @@ outputs = [{item = "railgun_m_module", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "drone_bay_module"
id = "drone_bay_kit"
building = "assembler"
inputs = [
{item = "control_chip", amount = 1},
@@ -421,7 +426,7 @@ outputs = [{item = "drone_bay_module", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "railgun_l_module"
id = "railgun_l_kit"
building = "assembler"
inputs = [
{item = "capacitor_bank", amount = 1},
@@ -432,7 +437,7 @@ outputs = [{item = "railgun_l_module", amount = 1}]
duration_seconds = 6.0
[[recipe]]
id = "drone_hangar_module"
id = "drone_hangar_kit"
building = "assembler"
inputs = [
{item = "voidsteel_plate", amount = 1},

View File

@@ -125,16 +125,16 @@ modules = ["drone_hangar"]
# --- Assembler recipes -----------------------------------------------------
[[unlock]]
id = "shortcut_steel_plate"
id = "direct_rolling"
station_level = 1
recipes = ["shortcut_steel_plate"]
recipes = ["direct_rolling"]
[[unlock]]
id = "shortcut_control_chip"
id = "direct_etching"
station_level = 2
recipes = ["shortcut_control_chip"]
recipes = ["direct_etching"]
[[unlock]]
id = "shortcut_hardened_steel"
id = "direct_hardening"
station_level = 2
recipes = ["shortcut_hardened_steel"]
recipes = ["direct_hardening"]

View File

@@ -44,9 +44,9 @@ move. Combat stats were tuned empirically against the arena suite in
| 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).
via the max rule): `direct_rolling` 3 iron_ore → 1 plate (2 s,
level 1), `direct_etching` 2 quartz → 1 chip (4 s, level 2),
`direct_hardening` 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
@@ -122,13 +122,13 @@ range 120, scrap 40. Enemy station: 3000+1500x HP, 25+12x dmg,
## Pacing
**Unlock ladder** (level → unlocks; ← marks `unlock_requires`; starting
set at 1: drone, frigate, railgun_s, salvager, building_block recipe):
set at 1: drone, frigate, railgun_s, salvager, block_pressing 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 |
| 1 | | maneuvering_thrusters, sensor_booster | direct_rolling |
| 2 | cruiser | railgun_m, afterburner | direct_etching, direct_hardening |
| 3 | | weapon_stabilizer | |
| 4 | battlecruiser ← cruiser | weapon_primer, weapon_upgrade | |
| 5 | | drone_bay | |

View File

@@ -145,16 +145,20 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
- REQ-BLD-DECON-QUEUE: Fully-built factory buildings marked for demolition (REQ-BLD-DECONSTRUCT) enter a **deconstruction queue** that is processed one building at a time and runs in parallel with the construction queue (REQ-BLD-QUEUE) — the two queues advance independently and simultaneously. Each building takes `world.toml [world].deconstruction_time_seconds` (default 0.1) to deconstruct, the same duration for every building type. When a building's deconstruction completes it is removed from the world and its refund is credited (REQ-BLD-DECONSTRUCT). A building **stops operating the moment it enters the queue**: it runs no production and transports no items, and no longer participates as a live building (its tunnel pairing is re-evaluated as if it were gone, REQ-BLD-TUNNEL-PAIR), but it still physically occupies its tiles until removed, so those tiles stay blocked for placement. A queued building can be taken back out of the deconstruction queue before it is removed (REQ-BLD-DECONSTRUCT-CLICK, REQ-BLD-DECONSTRUCT-BOX) — including the one currently being deconstructed; doing so discards any deconstruction progress, credits no refund, and the building resumes operating (and re-pairs, REQ-BLD-TUNNEL-PAIR). Construction sites never enter the deconstruction queue (REQ-BLD-DECONSTRUCT). Every building in the deconstruction queue is rendered with the deconstruct tint — the `visuals.toml [overlays].deconstruct_tint` color, the same tint applied to a building hovered in deconstruct mode (REQ-UI-DECONSTRUCT-BORDER) — so queued buildings are visually distinct.
- REQ-BLD-DECONSTRUCT-CLICK: While in deconstruct mode (REQ-UI-HOTKEYS, REQ-UI-DECONSTRUCT-BUTTON), left-clicking a placed factory building or construction site in the game world marks it for demolition, following the rules of REQ-BLD-DECONSTRUCT: a fully-built building is added to the deconstruction queue (REQ-BLD-DECON-QUEUE), and a construction site is removed instantly with the full refund. Left-clicking a fully-built building that is **already in the deconstruction queue** instead removes it from the queue (un-queues it, REQ-BLD-DECON-QUEUE), with no refund; repeated clicks on the same building therefore alternate between queueing and un-queueing it. Clicking a building that cannot be deconstructed (the HQ or a player defence station, per REQ-BLD-DECONSTRUCT), or clicking empty world space, has no effect. Deconstruct mode stays active after each action so the player can continue without re-entering the mode; it is exited via the Q toggle (REQ-UI-HOTKEYS) or the Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON).
- REQ-BLD-DECONSTRUCT-BOX: While in deconstruct mode (REQ-UI-HOTKEYS, REQ-UI-DECONSTRUCT-BUTTON), the player can click and drag a selection box in the game world. A selection rectangle is drawn while dragging, using the same box-drag gesture and coverage semantics as the multi-select box (REQ-UI-MULTI-SELECT). On mouse up, following the rules of REQ-BLD-DECONSTRUCT: every construction site covered by the box is removed instantly with the full refund; and among the fully-built deconstructible buildings covered by the box, if **all** of them are already in the deconstruction queue they are all removed from it (un-queued, REQ-BLD-DECON-QUEUE), otherwise every covered building not yet in the queue is added to the deconstruction queue (already-queued ones stay). Buildings that cannot be deconstructed (the HQ and player defence stations, per REQ-BLD-DECONSTRUCT) are excluded from the box demolition; ships and defence stations are never affected.
- REQ-BLD-SITE-CONFIG: A construction site — a building that has been placed but is still queued or under construction (REQ-BLD-QUEUE) — can be selected and configured exactly like the equivalent operational building, before it finishes building. Whatever configuration the building type supports is available on the site: the recipe for a Miner or Assembler (REQ-UI-SELECT-BUTTON), the produced-ship schematic and its module layout for a Shipyard (REQ-UI-SELECT-BUTTON, REQ-MOD-UI-PREVIEW, REQ-MOD-UI-DIALOG), and the output filters for a Splitter (REQ-BLD-SPLITTER) — all set through the same selection panel controls (REQ-UI-CONFIG-INLINE). Only currently unlocked recipes and schematics are offered, exactly as for operational buildings (REQ-LOCK-UI-RECIPE, REQ-LOCK-UI-SCHEMATIC, REQ-LOCK-UI-SPLITTER). The configuration is stored on the construction site and carries over unchanged when construction completes, so the building becomes operational already configured. A construction site has no input/output buffers and runs no production cycle, so the buffer and production-progress portions of the panel (REQ-UI-SINGLE-SELECTION, REQ-UI-PRODUCTION-PROGRESS) are not shown for it; only its construction progress (REQ-UI-CONSTRUCTION-PROGRESS) and its configuration controls appear. (Blueprint placement already applies a stored recipe or schematic to a construction site on placement per REQ-UI-BLUEPRINT-PLACE; this requirement additionally lets the player set or change that configuration directly on an existing site.)
- REQ-BLD-SITE-CONFIG: A construction site — a building that has been placed but is still queued or under construction (REQ-BLD-QUEUE) — can be selected and configured exactly like the equivalent operational building, before it finishes building. Whatever configuration the building type supports is available on the site: the recipe for a Miner, Assembler, Smelter or Reprocessing Plant (REQ-UI-SELECT-BUTTON, REQ-BLD-AUTO-RECIPE), the produced-ship schematic and its module layout for a Shipyard (REQ-UI-SELECT-BUTTON, REQ-MOD-UI-PREVIEW, REQ-MOD-UI-DIALOG), and the output filters for a Splitter (REQ-BLD-SPLITTER) — all set through the same selection panel controls (REQ-UI-CONFIG-INLINE). Only currently unlocked recipes and schematics are offered, exactly as for operational buildings (REQ-LOCK-UI-RECIPE, REQ-LOCK-UI-SCHEMATIC, REQ-LOCK-UI-SPLITTER). The configuration is stored on the construction site and carries over unchanged when construction completes, so the building becomes operational already configured. A construction site has no input/output buffers and runs no production cycle, so the buffer and production-progress portions of the panel (REQ-UI-SINGLE-SELECTION, REQ-UI-PRODUCTION-PROGRESS) are not shown for it; only its construction progress (REQ-UI-CONSTRUCTION-PROGRESS) and its configuration controls appear. (Blueprint placement already applies a stored recipe or schematic to a construction site on placement per REQ-UI-BLUEPRINT-PLACE; this requirement additionally lets the player set or change that configuration directly on an existing site.)
## Building Types
- REQ-BLD-MINER: **Miner** (2×2): The player selects which ore type it extracts. Each ore type corresponds to a `recipes.toml [[recipe]]` entry with `building = "miner"`, defining the output item and `duration_seconds`. Every asteroid tile is equivalent for mining — any miner can produce any ore type based solely on its selected recipe. Ore never depletes. Only implicitly unlocked ore-type recipes are available for selection (REQ-LOCK-UI-RECIPE).
- REQ-BLD-SMELTER: **Smelter** (2×2): Converts ore or scrap into basic materials. No recipe selection required. Inputs, outputs, and rates are defined in `recipes.toml [[recipe]]` entries with `building = "smelter"`.
- REQ-BLD-SMELTER: **Smelter** (2×2): Converts ore or scrap into basic materials. Its recipe is selected as any other building's is, except that it also picks one for itself from the first material it is offered (REQ-BLD-AUTO-RECIPE). Inputs, outputs, and rates are defined in `recipes.toml [[recipe]]` entries with `building = "smelter"`.
- REQ-BLD-ASSEMBLER: **Assembler** (3×3): The player selects a recipe from the config-defined crafting tree. Produces the selected output item at the rate defined in the corresponding `recipes.toml [[recipe]]` entry with `building = "assembler"`. Only implicitly unlocked recipes are available for selection (REQ-LOCK-UI-RECIPE).
- REQ-BLD-REPROCESSING: **Reprocessing Plant** (3×3): Consumes scrap per cycle and produces exactly one higher-level intermediate product per cycle via weighted random pick. The input quantity, possible output items, per-output weights, and amounts are defined in `recipes.toml [[recipe]]` entries with `building = "reprocessing_plant"` (`inputs`, `outputs[].item`, `outputs[].amount`, `outputs[].weight`). Weights are normalized at load time; their sum does not need to equal 1. The output is rolled at cycle start (see REQ-MAT-CYCLE); the pool of eligible outputs is restricted to implicitly unlocked item types (REQ-LOCK-REPROCESSING-POOL). The output buffer holds at most one cycle's output — see REQ-MAT-OUTPUT-BUFFER-REPROCESSING.
- REQ-BLD-REPROCESSING: **Reprocessing Plant** (3×3): Consumes scrap per cycle and produces exactly one **kind** of higher-level intermediate product per cycle via weighted random pick, in that output's configured amount (which may be more than one item). The input quantity, possible output items, per-output weights, and amounts are defined in `recipes.toml [[recipe]]` entries with `building = "reprocessing_plant"` (`inputs`, `outputs[].item`, `outputs[].amount`, `outputs[].weight`). Weights are normalized at load time; their sum does not need to equal 1. The output is rolled at cycle start (see REQ-MAT-CYCLE); the pool of eligible outputs is restricted to implicitly unlocked item types (REQ-LOCK-REPROCESSING-POOL). Its output side follows the general rules with no exception: one buffer per possible output item (REQ-MAT-OUTPUT-BUFFER), and a cycle starts only when every possible roll would fit (REQ-MAT-CYCLE) — which is also what denies the player a reroll by stalling the output belt. Like the Smelter it picks its recipe from the first material it is offered while none is set (REQ-BLD-AUTO-RECIPE).
- REQ-BLD-AUTO-RECIPE: **Automatic recipe selection.** The Smelter and the Reprocessing Plant (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) are *auto-recipe buildings*. They carry a selected recipe and are configured exactly as a Miner or Assembler is — the same selection button and dialog (REQ-UI-SELECT-BUTTON), buffers sized for that one recipe alone (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER), and the same pre-configuration on a construction site (REQ-BLD-SITE-CONFIG). They differ in one respect only:
- **Selection while none is set.** When such a building has no recipe, the first material offered at any of its input ports that some recipe of its type consumes selects that recipe; the material is then accepted as normal. This holds for every intake path — a belt, splitter or tunnel exit at an input port, and a directly coupled producer (REQ-MAT-DIRECT-COUPLE) — because a building that accepts nothing would otherwise leave a coupled producer stuck at its port forever. The choice is deterministic: input ports are examined in order, and where several recipes of the type consume the offered material the first in config order wins.
- **No further switching.** Once a recipe is set the building keeps it. It does not switch when its buffers run empty, nor when a material belonging to another of its recipes arrives — that material is simply not an accepted input, exactly as for any other building.
- **The player is always in control.** Selecting a different recipe clears the buffers (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER); that is how a building left holding part of a cycle nothing feeds any more is freed, and how a selection the player did not want is corrected. For these buildings the dialog's clearing option is captioned `(Auto)` rather than `(None)` (REQ-UI-SELECT-OPTIONS): it unsets the recipe and hands the building back to automatic selection.
- REQ-BLD-SHIPYARD: **Shipyard** (4×2): The player selects a schematic. When all required materials — the ship's base materials (`[ship.schematic].materials`) plus the materials of all modules in the configured layout (REQ-MOD-MATERIALS) — are present in its input buffer, the shipyard consumes them and begins a production cycle lasting the ship's base `[ship.schematic].production_time_seconds` plus the sum of production times contributed by all module instances in the configured layout (REQ-MOD-PRODUCTION-TIME). One ship of that type is spawned with the configured modules when the cycle completes. The shipyard cannot start a new cycle while one is in progress. If the player confirms a layout change (REQ-MOD-UI-DIALOG) while a production cycle is in progress, the current cycle is cancelled and all consumed materials are discarded; the shipyard returns to idle with the new layout configuration. Confirming a layout identical to the one already configured is not a change and cancels nothing (REQ-MAT-INPUT-BUFFER).
- REQ-BLD-SALVAGE-BAY: **Salvage Bay** (3×2): A dedicated drop-off point for salvage ships. It has an output buffer whose holding capacity is defined by the `output_buffer_capacity` field of the `salvage_bay` entry in `buildings.toml` (rather than by a production cycle, since the Salvage Bay has no recipe). A ship at the bay hands over one unit of scrap per tick while the buffer has free space; a full buffer blocks further drop-off until space frees up (consistent with the buffer-full semantics of REQ-MAT-OUTPUT-BUFFER). Held scrap is pushed onto connected output belts.
- REQ-BLD-SALVAGE-BAY: **Salvage Bay** (3×2): A dedicated drop-off point for salvage ships. It has a single output buffer — scrap is the only thing it ever holds — whose holding capacity is defined by the `output_buffer_capacity` field of the `salvage_bay` entry in `buildings.toml` (rather than by a production cycle, since the Salvage Bay has no recipe). A ship at the bay hands over one unit of scrap per tick while the buffer has free space; a full buffer blocks further drop-off until space frees up (consistent with the buffer-full semantics of REQ-MAT-OUTPUT-BUFFER). Held scrap is pushed onto connected output belts.
- REQ-BLD-BELT: **Belt** (1×1): Transports items. A belt tile has one direction (N, S, E, W) set at placement (modified by rotation). Curved belts are auto-derived: when a belt tile's outgoing direction leads into another belt whose direction is orthogonal, the downstream belt is rendered and behaves as a curve. Belt speed is defined in `world.toml [world].belt_speed_tiles_per_second` (REQ-GW-BELT-SPEED). A belt accepts items only through a non-output edge (REQ-MAT-ACCEPT-DIR).
- REQ-BLD-SPLITTER: **Splitter** (1×1): Distributes incoming items between two output directions. Incoming items are accepted only through the splitter's non-output edges (REQ-MAT-ACCEPT-DIR). Each output can optionally have a filter (a list of item types), configurable via the selection panel; only implicitly unlocked item types are available as filter options (REQ-LOCK-UI-SPLITTER). Routing rules:
- An item matching only one output's filter is routed to that output.
@@ -187,8 +191,8 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
- **Intake rendering (no pop-out).** Mirror of the emergence rendering in REQ-MAT-OUTPUT-EMERGE: the building is rendered over the input belt, so an in-transit item is occluded while inside the footprint and is only visible as it crosses the outer edge — appearing to sink into the port. The portion inside the footprint is hidden, and the item disappears at the tile centre (progress 0.5) as it enters the buffer.
- REQ-MAT-OUTPUT-PORT: Each building has one or more fixed output port(s) defined by its surface_mask (direction determined by rotation). Produced items do not appear on the outgoing belt instantly; each item leaves the building by first emerging across the output port tile on that port's own output belt and then transferring onto the adjacent real belt tile (REQ-MAT-OUTPUT-EMERGE). The adjacent belt's direction is otherwise unconstrained (it may flow away from the building or perpendicular to it), except that a belt oriented with its own output edge facing back into the building refuses the transfer and the item stays stuck at the port (REQ-MAT-ACCEPT-DIR, REQ-MAT-OUTPUT-EMERGE).
- REQ-MAT-OUTPUT-EMERGE: Items emerge from a building output port as an animation rather than popping directly onto the outgoing belt. Each output port has its own **output belt** — a virtual belt tile occupying the output port tile, oriented in the port's facing direction, with progress 0.0 at the tile's inner edge and 1.0 at the outer (port) edge adjacent to the next real belt tile. It reuses the belt subsystem: movement at belt speed (REQ-GW-BELT-SPEED), item rendering and spacing (REQ-GW-TILE-SIZE), and capacity/packing (REQ-GW-BELT-CAPACITY), but restricted to the 0.5→1.0 half of the tile. This applies to every building that outputs items onto belts (Miner, Smelter, Assembler, Reprocessing Plant, Salvage Bay); it does not apply to the Shipyard, which spawns a ship rather than a belt item (REQ-SHP-SPAWN-PLAYER).
- **Feeding.** While the output buffer (REQ-MAT-OUTPUT-BUFFER) holds an item that has not yet begun emerging and the output belt's entry slot at progress 0.5 is free (per REQ-GW-BELT-CAPACITY spacing — no emerging item within a quarter tile of progress 0.5), the next buffered item is placed on the output belt at progress 0.5. Because only the 0.5→1.0 span is used, the output belt holds at most three emerging items (progress 0.5, 0.75, 1.0); once that span is full the building places no further items on it even if the output buffer still holds more.
- **Cosmetic hold.** An emerging item still counts as residing in the output buffer (REQ-MAT-GLOBAL-STOCK) for the whole animation; it only leaves the building when it transfers onto a real belt tile at progress 1.0. The output belt therefore adds no inventory capacity beyond the output buffer, and clearing the output buffer on a recipe or schematic change (REQ-MAT-OUTPUT-BUFFER) also removes any items currently emerging.
- **Feeding.** While the output buffers (REQ-MAT-OUTPUT-BUFFER) hold an item that has not yet begun emerging and the output belt's entry slot at progress 0.5 is free (per REQ-GW-BELT-CAPACITY spacing — no emerging item within a quarter tile of progress 0.5), the longest-waiting buffered item is placed on the output belt at progress 0.5. Items leave in the order they were produced whatever their type, so per-item-type buffers do not reorder what comes out of the port. Because only the 0.5→1.0 span is used, the output belt holds at most three emerging items (progress 0.5, 0.75, 1.0); once that span is full the building places no further items on it even if the output buffers still hold more.
- **Cosmetic hold.** An emerging item still counts as residing in its output buffer (REQ-MAT-GLOBAL-STOCK) for the whole animation; it only leaves the building when it transfers onto a real belt tile at progress 1.0. The output belt therefore adds no inventory capacity beyond the output buffers, and clearing them on a recipe or schematic change (REQ-MAT-OUTPUT-BUFFER) also removes any items currently emerging.
- **Travel & handoff.** An emerging item advances from progress 0.5 to 1.0 at belt speed. At progress 1.0 it attempts to transfer onto the adjacent real belt tile using the normal belt hand-off and accept-direction rules (REQ-MAT-OUTPUT-PORT, REQ-MAT-ACCEPT-DIR): the transfer succeeds only if a transport tile exists there, is not oriented with its output edge facing back into the building, and has free space. On success the item leaves the output buffer and becomes an ordinary item on that belt tile. If instead the output port tile is a directly adjacent building's input edge, the item transfers straight into that building (REQ-MAT-DIRECT-COUPLE).
- **Stuck items.** If there is no next real belt tile and no directly-coupled building (REQ-MAT-DIRECT-COUPLE), or the transfer is refused or blocked, the emerging item stops at progress 1.0 and is rendered there (still counted in the output buffer). Following items pile up behind it at progress 0.75 and 0.5 per the packing above, and once the 0.5→1.0 span is full no further items emerge until the front item transfers.
- **Emergence rendering (no pop-in).** An emerging item must not simply appear at progress 0.5. The output port tile's building is rendered over the output belt, so an emerging item is occluded while inside the footprint and is revealed progressively as it slides past the port edge — appearing to physically emerge from the building. The portion of the item still within the output port tile is hidden; the portion past the outer edge is drawn.
@@ -198,9 +202,8 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
- REQ-MAT-ACCEPT-DIR: A transport tile (belt, splitter, tunnel entry, or tunnel exit) accepts an incoming item only through a non-output edge; an item that would enter through one of the tile's output edges is refused. For a belt or a tunnel entry/exit the sole output edge is the one in its facing direction; for a splitter either of its two output directions is an output edge. This applies both to items pushed from an adjacent transport tile and to items deposited by a building's output port (REQ-MAT-OUTPUT-PORT).
- REQ-MAT-INPUT-BUFFER: Each building has one input buffer per required input material. Each per-material buffer holds up to twice that material's per-cycle requirement. When the player selects a new recipe or schematic, all items in all input buffers are cleared.
- **Setting a configuration to the value it already holds is a no-op.** Selecting the recipe or schematic already set, or applying a ship layout identical to the one already configured, changes nothing: buffers are not cleared, an in-progress production cycle is not cancelled (REQ-BLD-SHIPYARD), and a construction site's progress and stored settings are untouched. This holds however the setting is applied — through the selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), a blueprint placement (REQ-UI-BLUEPRINT-PLACE), or a blueprint configuration transfer (REQ-UI-BLUEPRINT-TRANSFER). Only a setting that genuinely differs has effects.
- REQ-MAT-OUTPUT-BUFFER: Each building has an output buffer that holds up to twice the quantity produced by one production cycle. If the output buffer is full, production stops until space is available. When the player selects a new recipe or schematic, all items in the output buffer are cleared (relevant when the adjacent belt is jammed and items have accumulated). Re-applying a setting the building already has clears nothing, per REQ-MAT-INPUT-BUFFER.
- REQ-MAT-OUTPUT-BUFFER-REPROCESSING: Exception to REQ-MAT-OUTPUT-BUFFER — the Reprocessing Plant's output buffer holds at most one cycle's output. This prevents exploits where the player stalls the output belt to force the plant to reroll.
- REQ-MAT-CYCLE: Production cycle lifecycle. When a building is idle, it attempts to start a new cycle: (a) all required inputs must be present in the per-material input buffers, and (b) the cycle's output must fit in the output buffer. For the Reprocessing Plant, the output is picked at cycle start (weighted pick); the cycle only starts if that chosen output fits. On cycle start, inputs are consumed immediately and the production timer begins. On cycle completion, the (already-decided) output is deposited into the output buffer and the building returns to idle.
- REQ-MAT-OUTPUT-BUFFER: Each building has **one output buffer per item its recipe can produce** — each output of a deterministic recipe, and every possible roll of a probabilistic one (REQ-BLD-REPROCESSING). Each per-material buffer holds up to twice that item's per-cycle amount, mirroring the input side (REQ-MAT-INPUT-BUFFER); a buffer's contents never occupy another item's capacity. An emerging item still occupies its buffer for the whole animation (REQ-MAT-OUTPUT-EMERGE). Whether a buffer without room stops production is decided by REQ-MAT-CYCLE. When the player selects a new recipe or schematic, all items in all output buffers are cleared (relevant when the adjacent belt is jammed and items have accumulated). Re-applying a setting the building already has clears nothing, per REQ-MAT-INPUT-BUFFER. Two buildings stand outside this rule: the Shipyard has no output buffer at all, since it spawns a ship rather than producing items (REQ-BLD-SHIPYARD), and the Salvage Bay has no recipe, so its single scrap buffer is sized by config instead (REQ-BLD-SALVAGE-BAY).
- REQ-MAT-CYCLE: Production cycle lifecycle. When a building is idle, it attempts to start a new cycle: (a) all required inputs must be present in the per-material input buffers, and (b) **every** output the cycle could produce must fit in that item's own output buffer (REQ-MAT-OUTPUT-BUFFER). For a deterministic recipe (b) is simply its own outputs. The Reprocessing Plant rolls its output at cycle start (weighted pick, REQ-BLD-REPROCESSING), so every possible roll must fit: the roll is committed the moment the cycle begins, and a cycle whose result could not be stored must not be started at all. Testing every possibility rather than the rolled one is what keeps a stalled output belt from biasing the distribution — with one item type's buffer full the plant stops entirely instead of going on producing only the others. On cycle start, inputs are consumed immediately and the production timer begins. On cycle completion, the (already-decided) output is deposited into its output buffer and the building returns to idle.
- REQ-MAT-GLOBAL-STOCK: The building blocks stock is the only global inventory. All other materials exist only in building buffers or on belt tiles.
## Resources
@@ -391,7 +394,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
- REQ-LOCK-REPROCESSING-POOL: The pool of possible outputs for a Reprocessing Plant cycle (REQ-BLD-REPROCESSING) is restricted to item types that are currently implicitly unlocked (REQ-LOCK-IMPLICIT). Weights are renormalized over the eligible outputs. If no eligible outputs remain, the Reprocessing Plant cannot start a production cycle.
- REQ-LOCK-UI-RECIPE: Locked miner ore-type recipes and assembler recipes are not shown in their respective recipe-selection dialogs (REQ-UI-SELECT-BUTTON).
- REQ-LOCK-UI-RECIPE: Locked miner ore-type recipes and assembler recipes are not shown in their respective recipe-selection dialogs (REQ-UI-SELECT-BUTTON). Smelter and Reprocessing Plant recipes are never granted by an unlock group (REQ-LOCK-EXPLICIT restricts `recipes` grants to assembler recipes), so they are available from the start and their dialogs list them all; what gates them in practice is whether the building itself is unlocked (REQ-LOCK-BUILDING).
- REQ-LOCK-UI-SCHEMATIC: Locked ship schematics are not shown in the shipyard's schematic-selection dialog (REQ-UI-SELECT-BUTTON).
@@ -489,17 +492,17 @@ The screen is a single column: a header bar across the top and the game world vi
Because the contest-zone boundaries shift as the scrollable area grows with each push (REQ-GW-PUSH-EXPAND, REQ-GW-SCROLL-LIMIT), the ramp bands are recomputed from the current contest-zone boundaries. This is a presentation-only concern and does not affect the simulation, consistent with REQ-UI-NO-ZOOM.
- REQ-UI-WORLD-ICON: In the game world, a building is drawn with an icon's glyph symbol centered on its footprint, in place of the letter identity glyph. The icon is an SVG loaded from `data/icons/buildings/`; only the icon's glyph is drawn in the world — in a contrasting ink (white over dark fills, dark over light fills) so it stays legible — and its colored chip background is omitted, because the footprint is already filled with the building's `visuals.toml` fill color. This applies to the production buildings (Miner, Smelter, Assembler, Reprocessing Plant, Shipyard, Salvage Bay), the HQ, and the player and enemy defence stations, wherever the identity label appears: operational buildings, construction sites (REQ-UI-CONSTRUCTION-PROGRESS), and the builder-mode and blueprint-placement ghosts. **Belts, splitters, and tunnels are excluded** — they keep their existing tile rendering so their orientation and flow stay readable (a centered icon would obscure direction). Their build-menu buttons still use icons (REQ-UI-BUILD-ICON); in particular the shared Tunnel button's `tunnel_entry.svg` is a build-button icon only, not a world icon. The directional output-port glyphs (REQ-UI-PORT-GLYPH, REQ-UI-PORT-TARGET-GLYPH) are a separate indicator and are unaffected. A building or station with no icon file falls back to its `visuals.toml` text glyph; a type with neither icon nor glyph shows no identity label. A missing icon is not an error, consistent with REQ-UI-BUILD-ICON.
- REQ-UI-ITEM-ICON: In the game world, an item is drawn as its `visuals.toml` colored square (`fill` + `outline`, the square of REQ-GW-TILE-SIZE) carrying its **item icon** on top. The square is drawn for every item, with or without an icon: it is what gives the item contrast against the tile beneath it, and its outline is what separates neighbouring items where they overlap on a belt. The icon is a self-contained, full-color SVG (rendered as-is, unlike the glyph-only building icons of REQ-UI-WORLD-ICON), loaded at runtime from `data/icons/items/` — a sibling of the config directory, read the same way as the building icons (REQ-UI-BUILD-ICON) — one file per item type named after the item's id (e.g. `iron_ore.svg`). The square fills the item's half-tile rect, keeping the size, spacing, and draw-order rules of REQ-GW-TILE-SIZE; the icon is drawn **inset** within that rect so a frame of the square's color stays visible all around it — required because each icon's viewBox is cropped tight to its artwork, so an icon drawn at the full rect would cover the square entirely. This applies wherever an item is drawn: on belts, splitters, and tunnel ends, and while emerging from or sinking into a building port (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-INPUT-INTAKE). An item type with no icon file shows the colored square alone; a missing icon is not an error, consistent with REQ-UI-BUILD-ICON. **The colored square travels with the icon into the UI.** Wherever the UI displays an item *as an item* — the selection panel's item chips (REQ-UI-SINGLE-SELECTION, REQ-UI-HQ-PANEL), the recipe summary's input and output icons (REQ-UI-RECIPE-SUMMARY), and those same icons wherever a recipe is drawn in that form: on the selection dialog's option buttons (REQ-UI-SELECT-OPTIONS), in the item production tooltip (REQ-UI-ITEM-TOOLTIP), and in the unlock-choice dialog's recipe lines (REQ-DEF-SCHEMATIC-DROP) — the icon is drawn on that same square, by the same rules: the item's `fill` and `outline` from `visuals.toml`, the icon inset within the square so a frame of the square's color stays visible all around it, and the square alone when the item has no icon file. The square fills the rect the display allots to the icon, as it fills the half-tile rect in the world. One item therefore reads the same in a panel as it does on a belt. The one exception is the inline `building_block` icon that stands in for the word `Blocks` beside a number (REQ-UI-BLOCKS-ICON, REQ-UI-EXPAND-BUTTON, REQ-UI-BUILD-COST, REQ-UI-MULTI-SELECTION, REQ-UI-BLUEPRINT-CARD): there the icon is a decoration on a line of text rather than an item display, and it is drawn bare, without the square. For performance, each item icon is rasterized to a pixmap cached per target pixel size — re-rasterized only when the tile pixel size changes (e.g. on view resize) — rather than re-rendered from vector every frame.
- REQ-UI-ITEM-ICON: In the game world, an item is drawn as its `visuals.toml` colored square (`fill` + `outline`, the square of REQ-GW-TILE-SIZE) carrying its **item icon** on top. The square is drawn for every item, with or without an icon: it is what gives the item contrast against the tile beneath it, and its outline is what separates neighbouring items where they overlap on a belt. The icon is a self-contained, full-color SVG (rendered as-is, unlike the glyph-only building icons of REQ-UI-WORLD-ICON), loaded at runtime from `data/icons/items/` — a sibling of the config directory, read the same way as the building icons (REQ-UI-BUILD-ICON) — one file per item type named after the item's id (e.g. `iron_ore.svg`). The square fills the item's half-tile rect, keeping the size, spacing, and draw-order rules of REQ-GW-TILE-SIZE; the icon is drawn **inset** within that rect so a frame of the square's color stays visible all around it — required because each icon's viewBox is cropped tight to its artwork, so an icon drawn at the full rect would cover the square entirely. This applies wherever an item is drawn: on belts, splitters, and tunnel ends, and while emerging from or sinking into a building port (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-INPUT-INTAKE). An item type with no icon file shows the colored square alone; a missing icon is not an error, consistent with REQ-UI-BUILD-ICON. **The colored square travels with the icon into the UI.** Wherever the UI displays an item *as an item* — the selection panel's item chips (REQ-UI-SINGLE-SELECTION, REQ-UI-HQ-PANEL), the recipe summary's input and output icons (REQ-UI-RECIPE-SUMMARY), and those same icons wherever a recipe is drawn in that form: on the selection dialog's option buttons (REQ-UI-SELECT-OPTIONS), in the item production tooltip (REQ-UI-ITEM-TOOLTIP), and in the unlock-choice dialog's recipe lines (REQ-DEF-SCHEMATIC-DROP) — the icon is drawn on that same square, by the same rules: the item's `fill` and `outline` from `visuals.toml`, the icon inset within the square so a frame of the square's color stays visible all around it, and the square alone when the item has no icon file. The square fills the rect the display allots to the icon, as it fills the half-tile rect in the world. One item therefore reads the same in a panel as it does on a belt. The one exception is an **inline icon standing in for the item's name beside a number** on a line of text: the `building_block` icon in place of the word `Blocks` (REQ-UI-BLOCKS-ICON, REQ-UI-EXPAND-BUTTON, REQ-UI-BUILD-COST, REQ-UI-MULTI-SELECTION, REQ-UI-BLUEPRINT-CARD), and the `scrap` icon in place of the word `Scrap` in the debris panel's remaining-scrap value (REQ-UI-DEBRIS-PANEL, REQ-UI-FIELD-MULTI-SELECTION). There the icon is a decoration on a line of text rather than an item display: it is drawn bare, without the square, and sized to the height of the text it sits in. Where the same value is instead shown *as an item* it keeps the square, as the HQ's block stock chip does beside the header bar's bare block icon (REQ-UI-HQ-PANEL). For performance, each item icon is rasterized to a pixmap cached per target pixel size — re-rasterized only when the tile pixel size changes (e.g. on view resize) — rather than re-rendered from vector every frame.
- REQ-UI-CONSTRUCTION-PROGRESS: Construction sites display the building's identity symbol centered on the footprint (same as an operational building) — its icon glyph, or the text glyph as a fallback (REQ-UI-WORLD-ICON). Below the symbol — or centered on the footprint if the building has neither an icon nor a glyph — a construction progress percentage is shown (integer, e.g. `42%`), increasing from 0% to 100% as construction completes.
- REQ-UI-PORT-GLYPH: Every output port of every building is indicated by a directional glyph drawn on the port's tile. The glyph is a `>` rotated to face the port's exit direction (`>` for East, `^` for North, `<` for West, `v` for South). It is drawn at the midpoint between the tile center and the tile edge that the port exits through (i.e. halfway from center toward the exit edge). The indicator is rendered for all building states: operational buildings, construction sites, and the builder-mode ghost. Buildings with multiple output ports (e.g. splitters) show one indicator per port.
- REQ-UI-PORT-TARGET-GLYPH: While in builder mode (REQ-BLD-BUILDER-MODE), the builder-mode ghost additionally shows, for each of the building's output ports, a directional glyph drawn centered in the port's **target cell** — the cell immediately outside the footprint that the port pushes into, i.e. the cell the surface-mask output-port indicator occupies (see Surface Mask Format). As in REQ-UI-PORT-GLYPH the glyph is a `>` rotated to face the port's exit direction (`>` East, `^` North, `<` West, `v` South), previewing where the port's output will go before placement. This is in addition to the on-tile port glyph of REQ-UI-PORT-GLYPH, and — unlike that indicator — is shown only for the builder-mode ghost, not for operational buildings, construction sites, or the blueprint-placement ghost (REQ-UI-BLUEPRINT-PLACE). A building with multiple output ports (e.g. a splitter) shows one target-cell glyph per port. The target-cell glyph is drawn larger than the on-tile port glyph so it stands out as the flow-direction preview. Exceptions: the Tunnel Entry shows no target-cell glyph, because it receives items (which may arrive from any of its non-mouth edges, REQ-BLD-TUNNEL-ENTRY) rather than emitting into a single adjacent cell; the Shipyard shows none either, because its output port is a ship-spawn point (REQ-SHP-SPAWN-PLAYER) rather than a belt-item output (REQ-MAT-OUTPUT-EMERGE).
- REQ-UI-STATUS-LIGHT: Every operational production building — Miner, Smelter, Assembler, Reprocessing Plant, Shipyard, and Salvage Bay — renders a small **status light**: a filled circle with a black outline drawn in the building's upper-right corner, letting the player read a building's production state without selecting it. The light is anchored to the footprint corner that is the upper-right corner in the building's default orientation and rotates with the building — like the output-port glyph (REQ-UI-PORT-GLYPH) — so it stays on the same physical corner of the building as it is rotated. The status light is rendered only for operational buildings; construction sites (which instead show construction progress, REQ-UI-CONSTRUCTION-PROGRESS) and the builder-mode ghost do not render it. Buildings that are not production buildings — belts, splitters, tunnel entries/exits, and the HQ — have no status light. The black outline is constant; the fill color reflects the building's current production state.
- For the five production buildings (Miner, Smelter, Assembler, Reprocessing Plant, Shipyard), the fill color is determined by evaluating, in order:
- **Grey** — no recipe or schematic is selected. This applies only to buildings with a player-facing selection (Miner, Assembler, Shipyard); the Smelter and Reprocessing Plant always run an implicit recipe (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) and are never grey.
- **Grey** — no recipe or schematic is selected. Every production building can show it, an auto-recipe building included: it is grey until something is offered at an input port for it to select a recipe from, and again whenever the player hands it back to automatic selection (REQ-BLD-AUTO-RECIPE).
- **Green** — the building is currently producing: a production cycle is active (REQ-MAT-CYCLE; for the Shipyard, an in-progress production cycle per REQ-BLD-SHIPYARD).
- **Red** — the building is idle because a required input is missing from its input buffers, so it cannot start a cycle. Missing input takes precedence over a full output buffer: if any required input is missing the light is red even when the output buffer is also full.
- **Yellow** — the building is idle with all required inputs present but its output buffer full, so no new cycle can start (REQ-MAT-OUTPUT-BUFFER, REQ-MAT-CYCLE).
- A configured building that is momentarily idle yet blocked by neither condition (all inputs present and the output buffer has room — a transient state that resolves into a started cycle on the same or the next tick per REQ-MAT-CYCLE) shows green.
- **Red** — the building is idle because a required input is missing from its input buffers, so it cannot start a cycle. Missing input takes precedence over a blocked output side: if any required input is missing the light is red even when an output buffer is also out of room.
- **Yellow** — the building is idle with all required inputs present but the output side cannot take the cycle: at least one item the cycle could produce has no room in its own buffer, so no new cycle can start (REQ-MAT-OUTPUT-BUFFER, REQ-MAT-CYCLE). A buffer short of a full cycle's worth of free space blocks just as a completely full one does.
- A configured building that is momentarily idle yet blocked by neither condition (all inputs present and room for every output the cycle could produce — a transient state that resolves into a started cycle on the same or the next tick per REQ-MAT-CYCLE) shows green.
- The Salvage Bay has no recipe and no production cycle (REQ-BLD-SALVAGE-BAY); its status light uses only two states: **green** while its output buffer holds at least one unit of scrap, and **red** while its output buffer is empty. The Salvage Bay's status light is never grey or yellow.
- The four fill colors (grey, green, red, yellow) and the outline color are read from `visuals.toml [status_light]`, consistent with the other rendering-only colors. The status light is presentation-only and has no effect on the simulation.
- REQ-UI-HP-BARS: All entities with HP — the HQ, player and enemy defence stations, and player and enemy ships — render an HP bar below them. The bar is always visible regardless of current HP. The bar's filled portion represents the fraction of current HP to maximum HP.
@@ -554,13 +557,12 @@ The panel shows exactly one **content** at a time, picked from the catalog in RE
- **Configuration group** — the controls that change how the selected object is set up: the recipe/schematic selection control (REQ-UI-SELECT-BUTTON), a shipyard's layout preview and Configure button (REQ-MOD-UI-PREVIEW), and a splitter's output filters (REQ-BLD-SPLITTER). It is shown identically for an operational building and for a construction site of the same type (REQ-BLD-SITE-CONFIG).
- **Runtime group** — what the object is currently doing: buffer contents, production progress, HP, remaining scrap, and the belt clear action (REQ-UI-BELT-CLEAR). Where the object has **HP**, its bar is the first thing in this group, above everything else the card shows (REQ-UI-HQ-PANEL, REQ-UI-SHIP-STATS-PANEL, REQ-UI-STATION-STATS-PANEL) — how close the thing is to dying outranks what it is holding. For a **construction site** the entire runtime group is replaced by a captioned `Construction` section: a progress bar filled to the site's construction completion with that completion as an integer percentage beside the caption — the same value the world draws on the footprint (REQ-UI-CONSTRUCTION-PROGRESS) — followed by a note that buffers appear once the building is built, because a site has neither buffers nor a production cycle (REQ-BLD-SITE-CONFIG). That section sits **directly below the header, above the configuration group**, so how far along the site is reads first; the configuration group is otherwise unaffected and stays visible on a site.
A group with nothing to show takes no space, so a content may consist of a header alone. Within a group, related parts form **sections** carrying a short caption above them (e.g. `Layout`, `Input buffers`, `Production`, `Output buffer`); a section and its caption are shown only while that section has content, so e.g. a Miner (which consumes nothing) shows no input buffer section.
A group with nothing to show takes no space, so a content may consist of a header alone. Within a group, related parts form **sections** carrying a short caption above them (e.g. `Layout`, `Input buffers`, `Production`, `Output buffers`); a section and its caption are shown only while that section has content, so e.g. a Miner (which consumes nothing) shows no input buffer section.
- REQ-UI-SELECTION-CONTENT: **Content catalog.** Which content the panel shows follows from the selection alone:
| Selection | Header right slot | Configuration group | Runtime group |
|---|---|---|---|
| Miner, Assembler | status | recipe control + recipe summary | buffers + production |
| Smelter, Reprocessing Plant | status | recipe summary | buffers + production |
| Miner, Assembler, Smelter, Reprocessing Plant | status | recipe control + recipe summary | buffers + production |
| Shipyard | status | schematic control + layout preview + Configure | buffers + production |
| Salvage Bay | status | — | buffers |
| HQ | — | — | block stock + HP |
@@ -581,28 +583,26 @@ The panel shows exactly one **content** at a time, picked from the catalog in RE
Every other multi-selection falls back to the count summary. In particular a selection mixing a splitter with belts does not aggregate (a splitter carries per-object output filters, which have no aggregate), and neither do several production buildings of one type (per-building buffers and cycle progress have no aggregate).
- REQ-UI-SINGLE-SELECTION: When one building is selected, the panel shows its symbol and name in the header (REQ-UI-SELECTION-CARD), its current recipe or schematic selection (REQ-UI-SELECT-BUTTON) and recipe summary (REQ-UI-RECIPE-SUMMARY) in the configuration group, and its input and output buffer contents in the runtime group. Each buffered item is shown as an **item chip** bearing that item's icon on its colored square (REQ-UI-ITEM-ICON) and its current count, and hovering a chip shows that item's production tooltip (REQ-UI-ITEM-TOOLTIP):
- an **input** chip shows the per-cycle amount below the count (the items consumed per run, e.g. `/ 2 per cycle`), or the count alone when the building has no selected recipe or schematic to give one;
- an **output** chip shows the count against the output buffer's capacity as `a / b` (REQ-MAT-OUTPUT-BUFFER), with the item's name below.
- an **output** chip shows the count against **that item's own** output buffer capacity as `a / b` (REQ-MAT-OUTPUT-BUFFER), with the item's name below. Each output chip therefore stands for one buffer, and a Reprocessing Plant shows one per possible roll.
Input and output chips form separately captioned sections (REQ-UI-SELECTION-CARD). A section lists a chip for **every item the building's cycle involves**, and for an auto-recipe building every item it handles at all, whether or not the buffer currently holds any: an empty buffer reads `0` rather than its chip disappearing, so the card keeps one shape while the building runs. A section left with no chips at all is not shown. The production section (REQ-UI-PRODUCTION-PROGRESS) sits **between them**, so the card reads in the direction the materials flow: what goes in, what is being made of it, what has come out. For a selected construction site the buffer sections are omitted (REQ-BLD-SITE-CONFIG).
Input and output chips form separately captioned sections (REQ-UI-SELECTION-CARD). A section lists a chip for **every item the building's cycle involves**, whether or not the buffer currently holds any: an empty buffer reads `0` rather than its chip disappearing, so the card keeps one shape while the building runs. A section left with no chips at all is not shown. The production section (REQ-UI-PRODUCTION-PROGRESS) sits **between them**, so the card reads in the direction the materials flow: what goes in, what is being made of it, what has come out. For a selected construction site the buffer sections are omitted (REQ-BLD-SITE-CONFIG).
**Only unlocked items are listed.** A building's buffers may carry entries for items the player cannot make yet — an auto-recipe building's buffers are sized over *every* recipe of its type (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING), including recipes that are still locked. Those entries are left out of both sections, consistent with the rest of the UI hiding what is not unlocked yet (REQ-LOCK-UI-RECIPE, REQ-LOCK-UI-SPLITTER), so a Smelter shows the ores it can actually smelt rather than every ore in the game.
**An idle auto-recipe building still shows what it handles.** Having no selected recipe (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING), it would otherwise show empty sections whenever it happens to be between cycles. Its input and output sections instead list the unlocked items of every recipe of its type — the same union its buffers were sized over — with a count and no per-cycle denominator, since no one recipe is in force. While a cycle is running, that cycle's recipe supplies the denominators as for any other building.
- REQ-UI-RECIPE-SUMMARY: Below the recipe/schematic selection control, a building running a recipe or schematic shows a one-line **recipe summary**: each input item's icon on its colored square (REQ-UI-ITEM-ICON) with its per-cycle amount and the inputs separated by `+`, an arrow, each output item's icon on its colored square with its per-cycle amount, and the cycle time in seconds. It restates what the building will do without opening the selection dialog, and it is the panel's only display of the cycle time. For a Shipyard the summary is built from the schematic's materials and production time including the placed modules' contributions (REQ-BLD-SHIPYARD, REQ-MOD-STAT-CALC), matching the buffers beneath it. Auto-recipe buildings (Smelter, Reprocessing Plant — REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) have no player-selected recipe and so show no selection control; they show the summary of the recipe currently in production and, while between cycles, of the one they ran last. They keep it rather than dropping it, because a summary that came and went with each cycle would resize the card in step with the building's status (REQ-UI-SELECTION-STATUS), which is the one thing the panel must not do while the player is reading it (REQ-UI-SELECTION-PANEL). Such a building shows no summary only until it has run its first cycle. A building with no recipe or schematic selected shows no summary.
**Only unlocked items are listed.** Should a building's buffers carry an entry for an item the player cannot make yet, it is left out of both sections, consistent with the rest of the UI hiding what is not unlocked yet (REQ-LOCK-UI-RECIPE, REQ-LOCK-UI-SPLITTER).
- REQ-UI-RECIPE-SUMMARY: Below the recipe/schematic selection control, a building running a recipe or schematic shows a one-line **recipe summary**: each input item's icon on its colored square (REQ-UI-ITEM-ICON) with its per-cycle amount and the inputs separated by `+`, an arrow, each output item's icon on its colored square with its per-cycle amount, and the cycle time in seconds. It restates what the building will do without opening the selection dialog, and it is the panel's only display of the cycle time. For a Shipyard the summary is built from the schematic's materials and production time including the placed modules' contributions (REQ-BLD-SHIPYARD, REQ-MOD-STAT-CALC), matching the buffers beneath it. A building with no recipe or schematic selected shows no summary — including an auto-recipe building that has yet to select one (REQ-BLD-AUTO-RECIPE), which shows none until it does and keeps it from then on, so the card does not resize in step with the building's status (REQ-UI-SELECTION-STATUS, REQ-UI-SELECTION-PANEL).
- REQ-UI-PRODUCTION-PROGRESS: For buildings that produce items or ships (miner, smelter, assembler, reprocessing plant, shipyard), the panel's runtime group shows a captioned **production section** between the input and output buffer sections (REQ-UI-SINGLE-SELECTION): a horizontal progress bar filled to the completion of the active production cycle, with that completion beside the caption as an integer percentage (e.g. `72%`), or the text `idle` in place of the percentage and an empty bar when no production cycle is active. The cycle time is shown in the recipe summary (REQ-UI-RECIPE-SUMMARY) rather than repeated here. When no recipe or schematic is selected, the production section is not shown at all.
- REQ-UI-MULTI-SELECT: The player selects multiple objects by box-drag or by Ctrl+clicking individual objects to add or remove them from the selection. Multi-select operates within a single category (REQ-UI-SELECTION-CATEGORIES). A box-drag that covers at least one building selects buildings (any field objects within the box are ignored — buildings win); a box-drag that covers no building but does cover ships, defence stations, or debris selects all of those field objects together (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-MULTI-SELECT).
- REQ-UI-MULTI-SELECTION: When multiple buildings are selected and the selection does not aggregate (REQ-UI-SELECTION-AGGREGATE), the panel shows a count summary. Its header names the size of the selection as `<n> buildings` in place of an object name, and carries no symbol and nothing in its right slot. Below it is one row per selected building type — the type's symbol, its name, and the number selected as `x<count>` — one type per row, and no per-building detail. A final row shows the **total building block cost** of the selection, captioned `Total cost` with the value followed by the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON): the sum of each selected building's placement cost (`buildings.toml [[building]].cost`, per REQ-BLD-COST), counting only player-placeable buildings (buildings with a button in the build button bar); non-player-placeable buildings (the HQ and defence stations) are excluded from the total, consistent with the blueprint total (REQ-UI-BLUEPRINT-CARD). Construction sites count at their building type's full placement cost regardless of construction progress.
- REQ-UI-CONFIG-INLINE: Recipe and schematic configuration for a selected building is shown within this panel, in its configuration group (REQ-UI-SELECTION-CARD). Recipe selection (miner, assembler) and schematic selection (shipyard) use the selection button and dialog (REQ-UI-SELECT-BUTTON) rather than an inline control. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic selection button (REQ-MOD-UI-PREVIEW).
- REQ-UI-SELECT-BUTTON: **Recipe and schematic selection control.** Recipe selection (Miner ore type, Assembler recipe) and schematic selection (Shipyard) are each presented in the selection panel as a single **selection button** whose caption is the name of the currently selected recipe or schematic, or a placeholder ("Select recipe" / "Select schematic") when none is selected. Clicking the button opens a modal **selection dialog** that pauses the game (speed set to 0×; on close, the speed is restored to what it was before the dialog was opened) and that opens centered on the selection panel, as every modal opened from the panel does (REQ-UI-PANEL-MODAL). The dialog contains a vertical list of option buttons, one per selectable option, each describing itself (REQ-UI-SELECT-OPTIONS) — only options that are currently unlocked are shown (REQ-LOCK-UI-RECIPE for recipes, REQ-LOCK-UI-SCHEMATIC for schematics). Neither the option buttons nor the selection button carries a tooltip: an option button states what it makes on its own face, and what the building has selected is drawn beneath the selection button as the recipe summary (REQ-UI-RECIPE-SUMMARY), with the production paths of the items involved reachable by hovering the card's item chips (REQ-UI-ITEM-TOOLTIP). Clicking an option button selects that recipe/schematic, closes the dialog, and updates the selection button's caption in the selection panel. The dialog can be dismissed without changing the current selection (e.g. closing it without clicking an option). Selecting a new recipe or schematic has the same effects as before (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD).
- REQ-UI-CONFIG-INLINE: Recipe and schematic configuration for a selected building is shown within this panel, in its configuration group (REQ-UI-SELECTION-CARD). Recipe selection (miner, assembler, smelter, reprocessing plant) and schematic selection (shipyard) use the selection button and dialog (REQ-UI-SELECT-BUTTON) rather than an inline control. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic selection button (REQ-MOD-UI-PREVIEW).
- REQ-UI-SELECT-BUTTON: **Recipe and schematic selection control.** Recipe selection (Miner ore type, Assembler recipe, and the Smelter's and Reprocessing Plant's recipe per REQ-BLD-AUTO-RECIPE) and schematic selection (Shipyard) are each presented in the selection panel as a single **selection button** whose caption is the name of the currently selected recipe or schematic, or a placeholder ("Select recipe" / "Select schematic") when none is selected. Clicking the button opens a modal **selection dialog** that pauses the game (speed set to 0×; on close, the speed is restored to what it was before the dialog was opened) and that opens centered on the selection panel, as every modal opened from the panel does (REQ-UI-PANEL-MODAL). The dialog contains a vertical list of option buttons, one per selectable option, each describing itself (REQ-UI-SELECT-OPTIONS) — only options that are currently unlocked are shown (REQ-LOCK-UI-RECIPE for recipes, REQ-LOCK-UI-SCHEMATIC for schematics). Neither the option buttons nor the selection button carries a tooltip: an option button states what it makes on its own face, and what the building has selected is drawn beneath the selection button as the recipe summary (REQ-UI-RECIPE-SUMMARY), with the production paths of the items involved reachable by hovering the card's item chips (REQ-UI-ITEM-TOOLTIP). Clicking an option button selects that recipe/schematic, closes the dialog, and updates the selection button's caption in the selection panel. The dialog can be dismissed without changing the current selection (e.g. closing it without clicking an option). Selecting a new recipe or schematic has the same effects as before (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD).
- REQ-UI-SELECT-OPTIONS: **Option list of the selection dialog.** The selection dialog (REQ-UI-SELECT-BUTTON) lists its options as a **single vertical column** of buttons, one per option, rather than a grid: each button is as wide as the dialog and states what the option does, which needs a line of its own. No option button carries a tooltip — the button face is the whole description, so the player reads every option's inputs, product, and time from the list itself without hovering anything. Should the column be taller than the space the window leaves the dialog — a fully unlocked Assembler offers more options than any window can hold, and a modal is never resized to fit (REQ-UI-PANEL-MODAL) — the list **scrolls** within the dialog rather than the dialog growing past the window.
- A **recipe** option (Miner, Assembler) shows the **recipe name** on its first line and, beneath it, that recipe drawn as the recipe summary line draws it (REQ-UI-RECIPE-SUMMARY): each input item's icon on its colored square with its per-cycle amount, an arrow, each output item's icon with its amount, and the cycle time. A miner recipe consumes nothing, so its line begins at the arrow.
- A **ship schematic** option (Shipyard) shows the ship's `display_name` on its first line and, beneath it, the icons and quantities of its base required materials (`[ship.schematic].materials`, excluding any module contributions) with the base production time (`[ship.schematic].production_time_seconds`). A ship is not an item and has no icon of its own, so this line names no output: the button's caption is what it produces.
- The `(None)` option shows its text caption alone.
- The `(None)` option shows its text caption alone. On an auto-recipe building it is captioned `(Auto)` instead, because there it does not leave the building idle but returns it to automatic selection (REQ-BLD-AUTO-RECIPE).
- REQ-UI-ITEM-TOOLTIP: **Item production tooltip.** Hovering an item chip in the selection panel — an input or output buffer chip (REQ-UI-SINGLE-SELECTION) or the HQ's block stock chip (REQ-UI-HQ-PANEL) — shows a tooltip telling the player where that item comes from. It has a heading and a body:
- the heading is the **hovered item's name**. For an input chip this is the only place the item is named at all, since such a chip carries a count and no name (REQ-UI-SINGLE-SELECTION).
- the body is the caption `Produced by` followed by one **recipe line** per unlocked recipe that produces the item. A recipe line is a small **card** of two rows: the icon of the building that runs the recipe (REQ-UI-BUILD-ICON) and the recipe's name on the first, and the recipe itself on the second, drawn as the recipe summary draws it (REQ-UI-RECIPE-SUMMARY) — each input item's icon on its colored square with its per-cycle amount, the inputs separated by `+`, an arrow, each output item's icon with its amount, and the cycle time. Two rows rather than one because an identity and a cycle read as different things, and a single row of icons, names and numbers runs too long to scan; a card around each because several producers stacked as bare lines read as one field of icons and numbers rather than as separate recipes. A line drawn where its surroundings already frame it — on an option button (REQ-UI-SELECT-OPTIONS), or as the panel's recipe summary — takes no card of its own. An item may have several producers — an iron ingot is smelted from ore, smelted from scrap, and recovered by reprocessing — and the building icon and recipe name are what tell those lines apart and tell the player which building to place for which path.
**Only recipes the player can run are listed**, consistent with the rest of the UI hiding what the player cannot make yet. What that means differs by building, because only Miner and Assembler recipes are unlocked individually (REQ-LOCK-UI-RECIPE): those are listed once unlocked, while a Smelter's or Reprocessing Plant's implicit recipes (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) are listed once **their building** is unlocked (REQ-LOCK-BUILDING) — there is no sense in naming a path through a plant the player cannot place. Two cases have no recipe line to show, and each says so in place of the list rather than leaving the tooltip bare:
**Only recipes the player can run are listed**, consistent with the rest of the UI hiding what the player cannot make yet. What that means differs by building, because only Miner and Assembler recipes are unlocked individually (REQ-LOCK-UI-RECIPE): those are listed once unlocked, while a Smelter's or Reprocessing Plant's recipes (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) are listed once **their building** is unlocked (REQ-LOCK-BUILDING) — there is no sense in naming a path through a plant the player cannot place. Two cases have no recipe line to show, and each says so in place of the list rather than leaving the tooltip bare:
- **An item no recipe produces at all.** Scrap is salvaged from debris (REQ-RES-DEBRIS-DROP) rather than crafted, so its tooltip reads `Salvaged from debris` in place of the `Produced by` caption and lists nothing beneath it.
- **An item whose every producing recipe is still locked.** The caption stays `Produced by`, and the single line beneath it reads `Undiscovered`: the player is told the item is made somehow, without being shown a path they have not unlocked yet.
@@ -620,10 +620,10 @@ The panel shows exactly one **content** at a time, picked from the catalog in RE
- **Standby** — the ship is holding with its fleet (REQ-SHP-STANDBY).
- **Advancing** — the ship is executing the baseline forward advance with no higher-priority behavior active (player: REQ-SHP-COMBAT advance toward the enemy; enemy: REQ-SHP-ENEMY-AI advance toward the asteroid).
- REQ-UI-STATION-STATS-PANEL: When exactly one defence station is selected (REQ-UI-ENTITY-CLICK-SELECT) and no debris is selected, the selection panel shows a **station stats panel** displaying the station's stats computed at its current level: HP (current / maximum) as a **bar** with the two values beside its caption, then damage, range, and fire rate as label/value rows, matching the ship stats panel's rendering (REQ-UI-SHIP-STATS-PANEL). Its header carries no right slot: a station has no behavior label and no status light. (If debris is also selected, the panel shows the compact count summary instead, per REQ-UI-FIELD-MULTI-SELECTION.)
- REQ-UI-FIELD-MULTI-SELECTION: A full single-object stats panel (REQ-UI-SHIP-STATS-PANEL, REQ-UI-STATION-STATS-PANEL, REQ-UI-DEBRIS-PANEL) is shown when the field selection holds exactly one object — one ship, one defence station, or one piece of debris — and, for debris only, when it holds several pieces of debris and nothing else, which aggregate into that same content (REQ-UI-SELECTION-AGGREGATE). Every other field selection of more than one object — multiple actors, or any mix of actors and debris — shows a **count summary** instead. Its header reads `Mixed selection` with the total number of selected objects in the right slot (REQ-UI-SELECTION-CARD). Below it is one row per type — the type's symbol, its name, and the number selected as `x<count>`, the same `x`-count notation as the recipe tooltip and the building multi-selection (REQ-UI-MULTI-SELECTION). Ships are grouped by schematic display name and defence stations as a group, distinguishing player from enemy; all selected pieces of debris are grouped into a single `Debris` row whose count is the number of selected pieces. No per-object detail is shown. If debris is part of the selection, its row is followed by an indented sub-row giving the summed remaining scrap across all selected debris (REQ-UI-DEBRIS-PANEL). Building selections use REQ-UI-SINGLE-SELECTION / REQ-UI-MULTI-SELECTION instead.
- REQ-UI-FIELD-MULTI-SELECTION: A full single-object stats panel (REQ-UI-SHIP-STATS-PANEL, REQ-UI-STATION-STATS-PANEL, REQ-UI-DEBRIS-PANEL) is shown when the field selection holds exactly one object — one ship, one defence station, or one piece of debris — and, for debris only, when it holds several pieces of debris and nothing else, which aggregate into that same content (REQ-UI-SELECTION-AGGREGATE). Every other field selection of more than one object — multiple actors, or any mix of actors and debris — shows a **count summary** instead. Its header reads `Mixed selection` with the total number of selected objects in the right slot (REQ-UI-SELECTION-CARD). Below it is one row per type — the type's symbol, its name, and the number selected as `x<count>`, the same `x`-count notation as the recipe tooltip and the building multi-selection (REQ-UI-MULTI-SELECTION). Ships are grouped by schematic display name and defence stations as a group, distinguishing player from enemy; all selected pieces of debris are grouped into a single `Debris` row whose count is the number of selected pieces. No per-object detail is shown. If debris is part of the selection, its row is followed by an indented sub-row giving the summed remaining scrap across all selected debris as the amount followed by the bare `scrap` icon, exactly as the debris panel states it (REQ-UI-DEBRIS-PANEL, REQ-UI-ITEM-ICON). Building selections use REQ-UI-SINGLE-SELECTION / REQ-UI-MULTI-SELECTION instead.
- REQ-UI-DEBRIS-CLICK-SELECT: The player can click any piece of debris (REQ-RES-DEBRIS-DROP) in the game world to select it. Debris are field objects (REQ-UI-SELECTION-CATEGORIES) and can be selected together with ships and defence stations, but not with buildings. A plain click on a piece of debris makes it the sole selection, clearing any previous selection; selecting a building clears any debris (buildings win), and selecting a piece of debris clears any building selection. Hit-testing prefers a building over a coincident actor or piece of debris, and an actor (ship or defence station) over a coincident piece of debris: a piece of debris is selected only when no building or actor is under the cursor. A selected piece of debris that despawns or is fully collected (REQ-RES-DEBRIS-DROP) is removed from the selection; if no selected object remains, the panel becomes empty (REQ-UI-EMPTY-SELECTION).
- REQ-UI-DEBRIS-MULTI-SELECT: Multiple pieces of debris can be selected by box-drag or by Ctrl+clicking individual pieces to add or remove them, mirroring building multi-select (REQ-UI-MULTI-SELECT). Debris shares the field-object category with ships and defence stations (REQ-UI-SELECTION-CATEGORIES), so a field selection may hold debris and actors together. Ctrl+clicking a piece of debris while a field selection is active adds or removes that piece within the same selection; Ctrl+clicking a piece of debris while a building selection is active first clears the buildings and begins a field selection (buildings win). Conversely, selecting a building while a field selection is active clears it. Box-drag disambiguation follows REQ-UI-MULTI-SELECT (a box covering any building selects buildings; a box covering no building selects the ships, defence stations, and debris within it).
- REQ-UI-DEBRIS-PANEL: When debris is selected and no actors are (REQ-UI-FIELD-MULTI-SELECTION), the selection panel shows a **debris stats panel** structured like the ship and station stats panels (REQ-UI-SHIP-STATS-PANEL, REQ-UI-STATION-STATS-PANEL): a header reading **"Debris"**, followed by a single stat row, **"Scrap remaining"**, in the same label/value style as a ship hull stat row. With one piece selected the row shows that piece's remaining scrap amount (REQ-RES-DEBRIS-DROP) and the header's right slot is empty. With several pieces selected the same content is shown aggregated (REQ-UI-SELECTION-AGGREGATE): the number of selected pieces appears in the header's right slot as `x<count>` and the row shows the summed remaining scrap across them. When debris is selected together with actors, the debris are instead summarized within the count summary (REQ-UI-FIELD-MULTI-SELECTION): a `Debris` row giving the number of selected pieces, followed by an indented sub-row with their summed remaining scrap. The displayed scrap value updates as selected debris are partially collected or despawn (REQ-UI-DEBRIS-CLICK-SELECT).
- REQ-UI-DEBRIS-PANEL: When debris is selected and no actors are (REQ-UI-FIELD-MULTI-SELECTION), the selection panel shows a **debris stats panel** structured like the ship and station stats panels (REQ-UI-SHIP-STATS-PANEL, REQ-UI-STATION-STATS-PANEL): a header reading **"Debris"**, followed by a single stat row captioned **"Remaining"**, in the same label/value style as a ship hull stat row. Its value is the scrap amount followed by the `scrap` item icon drawn **bare, without its colored square** and sized to the row's text height — the same inline form the header bar uses for building blocks (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON). The icon stands in for the word `Scrap`, which is why the caption names neither the item nor its unit; when no icon file exists for `scrap` (a missing icon is not an error, REQ-UI-ITEM-ICON), the row falls back to the caption `Scrap remaining` with the amount alone as its value. With one piece selected the row shows that piece's remaining scrap amount (REQ-RES-DEBRIS-DROP) and the header's right slot is empty. With several pieces selected the same content is shown aggregated (REQ-UI-SELECTION-AGGREGATE): the number of selected pieces appears in the header's right slot as `x<count>` and the row shows the summed remaining scrap across them. When debris is selected together with actors, the debris are instead summarized within the count summary (REQ-UI-FIELD-MULTI-SELECTION): a `Debris` row giving the number of selected pieces, followed by an indented sub-row with their summed remaining scrap, stated in the same amount-plus-bare-icon form. The displayed scrap value updates as selected debris are partially collected or despawn (REQ-UI-DEBRIS-CLICK-SELECT).
### Build Button Bar

View File

@@ -13,6 +13,9 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/BarRow.h
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SectionBox.h
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SelectionNames.h
# A stat row states a value with an inline item icon (REQ-UI-ITEM-ICON), which this
# composes; it needs nothing but Qt's painting.
${CMAKE_CURRENT_SOURCE_DIR}/../ui/IconCaption.h
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsConfig.h
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.h
# Shared world-space shapes so the arena keeps looking like the game
@@ -36,6 +39,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/BarRow.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SectionBox.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SelectionNames.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../ui/IconCaption.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../ui/WorldPrimitives.cpp
PARENT_SCOPE

View File

@@ -59,6 +59,8 @@ bool isConfigurableBuildingType(BuildingType type)
{
case BuildingType::Miner: // recipe (REQ-BLD-MINER)
case BuildingType::Assembler: // recipe (REQ-BLD-ASSEMBLER)
case BuildingType::Smelter: // recipe (REQ-BLD-AUTO-RECIPE)
case BuildingType::ReprocessingPlant: // recipe (REQ-BLD-AUTO-RECIPE)
case BuildingType::Shipyard: // schematic and layout (REQ-BLD-SHIPYARD, REQ-MOD-LAYOUT)
case BuildingType::Splitter: // output filters (REQ-BLD-SPLITTER)
return true;

View File

@@ -30,9 +30,9 @@ std::optional<BuildingType> parseBuildingType(const std::string& id);
// Canonical id string for a BuildingType. The inverse of parseBuildingType.
std::string buildingTypeId(BuildingType type);
// Smelter and Reprocessing Plant have no player-selected recipe
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING). They auto-process whatever inputs
// they receive, matching against every recipe of their building type.
// Smelter and Reprocessing Plant pick a recipe for themselves from the first material
// offered to them while they have none (REQ-BLD-AUTO-RECIPE). In every other respect
// their recipe is selected and held exactly as any other building's.
bool isAutoRecipeBuildingType(BuildingType type);
// Buildings that run a production cycle: Miner, Smelter, Assembler, Reprocessing
@@ -45,7 +45,9 @@ bool isProductionBuildingType(BuildingType type);
bool isBeltSubsystemType(BuildingType type);
// Building types with player-facing settings that a blueprint can carry and hand to an
// existing building (REQ-UI-BLUEPRINT-TRANSFER): Miner and Assembler (recipe), Shipyard
// (schematic and module layout), Splitter (output filters). Every other type has nothing
// to configure, so a blueprint of one has nothing to transfer.
// existing building (REQ-UI-BLUEPRINT-TRANSFER): Miner, Assembler, Smelter and
// Reprocessing Plant (recipe -- the last two select their own when they have none,
// REQ-BLD-AUTO-RECIPE), Shipyard (schematic and module layout), Splitter (output
// filters). Every other type has nothing to configure, so a blueprint of one has
// nothing to transfer.
bool isConfigurableBuildingType(BuildingType type);

View File

@@ -27,11 +27,14 @@ struct InputBuffer
std::map<ItemType, int> caps; // max items per material (2× per-cycle requirement)
};
// Output buffer shared by all output materials for a production building.
// Per-material output buffer for a production building. The items are held in one
// production-ordered queue -- that is the order they leave at the output port
// (REQ-MAT-OUTPUT-EMERGE) -- while the capacity is per item type, so one item's backlog
// never occupies another's room (REQ-MAT-OUTPUT-BUFFER).
struct OutputBuffer
{
std::vector<Item> items;
int capacity = 0; // 2× per-cycle output; 1× for ReprocessingPlant
std::vector<Item> items; // production order; feeds the output belt
std::map<ItemType, int> caps; // max items per material (2x its per-cycle amount)
};
// Active production cycle for a building.
@@ -96,6 +99,25 @@ struct Building
return count;
}
// The same over one material, which is what its own capacity is measured against
// (REQ-MAT-OUTPUT-BUFFER).
int getOutputItemCount(const ItemType& type) const
{
int count = 0;
for (const Item& item : outputBuffer.items)
{
if (item.type == type) { ++count; }
}
for (const std::vector<BeltItemSlot>& lane : emergingItems)
{
for (const BeltItemSlot& slot : lane)
{
if (slot.item.type == type) { ++count; }
}
}
return count;
}
// Items currently travelling inward on each input port's virtual input belt
// (REQ-MAT-INPUT-INTAKE); one lane per input port, parallel to inputPorts. Each
// lane holds slots at progress [0.0, 0.5], front (highest progress) first. An

View File

@@ -2,12 +2,48 @@
#include <algorithm>
#include <cassert>
#include <map>
#include "BuildingType.h"
#include "ItemType.h"
#include "ModulesConfig.h"
#include "ShipsConfig.h"
namespace
{
// Folds the output capacities one recipe implies into `caps`: twice each produced
// item's per-cycle amount (REQ-MAT-OUTPUT-BUFFER). A Reprocessing Plant rolls exactly
// one of its outputs per cycle (REQ-BLD-REPROCESSING), so its per-cycle amount for an
// item is that one outcome's amount rather than a sum over the entries.
//
// Where a cap is already present the larger wins, which is how an auto-recipe building
// unions the recipes of its type -- the same rule its input caps follow.
void addOutputCaps(std::map<ItemType, int>& caps, BuildingType type,
const RecipeDef& recipe)
{
std::map<ItemType, int> perCycle;
for (const RecipeOutput& out : recipe.outputs)
{
const ItemType item{out.item};
if (type == BuildingType::ReprocessingPlant)
{
perCycle[item] = std::max(perCycle[item], out.amount);
}
else
{
perCycle[item] += out.amount;
}
}
for (const std::pair<const ItemType, int>& entry : perCycle)
{
caps[entry.first] = std::max(caps[entry.first], 2 * entry.second);
}
}
} // namespace
void initBuffers(Building& b, const RecipeDef& recipe)
{
b.inputBuffer.counts.clear();
@@ -20,87 +56,18 @@ void initBuffers(Building& b, const RecipeDef& recipe)
}
b.outputBuffer.items.clear();
if (b.type == BuildingType::ReprocessingPlant)
{
// 1× max-per-roll (REQ-MAT-OUTPUT-BUFFER-REPROCESSING).
int maxAmount = 0;
for (const RecipeOutput& out : recipe.outputs)
{
if (out.amount > maxAmount)
{
maxAmount = out.amount;
}
}
b.outputBuffer.capacity = maxAmount;
}
else
{
// 2× per-cycle output.
int totalAmount = 0;
for (const RecipeOutput& out : recipe.outputs)
{
totalAmount += out.amount;
}
b.outputBuffer.capacity = 2 * totalAmount;
}
}
void initAutoBuffers(const GameConfig& config, Building& b)
{
b.inputBuffer.counts.clear();
b.inputBuffer.caps.clear();
// Union the inputs of every recipe of this building type; the cap for each
// item is twice the largest per-cycle requirement across those recipes.
// Output capacity follows the same rules as initBuffers: the Reprocessing
// Plant holds one cycle's max output (REQ-MAT-OUTPUT-BUFFER-REPROCESSING),
// other auto buildings hold twice the largest per-cycle output.
int outputCapacity = 0;
for (const RecipeDef& recipe : config.recipes.recipes)
{
if (recipe.building != b.type)
{
continue;
}
for (const RecipeIngredient& ing : recipe.inputs)
{
const ItemType type{ing.item};
b.inputBuffer.counts[type] = 0;
b.inputBuffer.caps[type] =
std::max(b.inputBuffer.caps[type], 2 * ing.amount);
}
if (b.type == BuildingType::ReprocessingPlant)
{
int maxAmount = 0;
for (const RecipeOutput& out : recipe.outputs)
{
maxAmount = std::max(maxAmount, out.amount);
}
outputCapacity = std::max(outputCapacity, maxAmount);
}
else
{
int totalAmount = 0;
for (const RecipeOutput& out : recipe.outputs)
{
totalAmount += out.amount;
}
outputCapacity = std::max(outputCapacity, 2 * totalAmount);
}
}
b.outputBuffer.items.clear();
b.outputBuffer.capacity = outputCapacity;
b.outputBuffer.caps.clear();
addOutputCaps(b.outputBuffer.caps, b.type, recipe);
}
void initShipyardBuffers(const GameConfig& config, Building& b)
{
b.inputBuffer.counts.clear();
b.inputBuffer.caps.clear();
// A shipyard spawns a ship rather than producing items, so it holds no output
// buffer at all (REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD).
b.outputBuffer.items.clear();
b.outputBuffer.capacity = 0;
b.outputBuffer.caps.clear();
const ShipDef* def = config.ships.findShipDef(b.recipeId);
if (!def)
{
@@ -133,11 +100,13 @@ void initShipyardBuffers(const GameConfig& config, Building& b)
void initSalvageBayBuffer(const GameConfig& config, Building& b)
{
// Salvage Bay has no recipe-driven buffer; its output-buffer holding size for
// ship drop-off is config-defined (REQ-BLD-SALVAGE-BAY).
// Salvage Bay has no recipe-driven buffer; scrap is the only thing it ever holds,
// and that single buffer's holding size for ship drop-off is config-defined
// (REQ-BLD-SALVAGE-BAY).
b.outputBuffer.items.clear();
b.outputBuffer.caps.clear();
const BuildingDef* def = config.buildings.findBuildingDef(BuildingType::SalvageBay);
b.outputBuffer.capacity =
b.outputBuffer.caps[ItemType{"scrap"}] =
(def && def->outputBufferCapacity) ? *def->outputBufferCapacity : 0;
}

View File

@@ -13,15 +13,11 @@
// to BeltSystem. Free functions over the config and the building — they read no
// factory state, so both BuildingSystem and ConstructionSystem can use them.
// Buffers for a building running one known recipe: inputs capped at twice each
// ingredient's per-cycle amount, output at twice the per-cycle total (one cycle's
// max for a Reprocessing Plant, REQ-MAT-OUTPUT-BUFFER-REPROCESSING).
// Buffers for a building running one known recipe: one buffer per material on each
// side, capped at twice that material's per-cycle amount (REQ-MAT-INPUT-BUFFER,
// REQ-MAT-OUTPUT-BUFFER).
void initBuffers(Building& b, const RecipeDef& recipe);
// Buffers for an auto-recipe building (Smelter, Reprocessing Plant), unioned over
// every recipe of its type (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING).
void initAutoBuffers(const GameConfig& config, Building& b);
// Buffers for a shipyard: its schematic's materials plus those of every placed
// module (REQ-BLD-SHIPYARD).
void initShipyardBuffers(const GameConfig& config, Building& b);

View File

@@ -200,12 +200,6 @@ void BuildingSystem::setRecipe(FactoryState& state, BuildingId id, const std::st
{
if (site.id == id)
{
// Auto-recipe buildings have no player-selected recipe
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING); ignore any attempt to set one.
if (isAutoRecipeBuildingType(site.type))
{
return;
}
// No-op if the recipe is unchanged, so a redundant selection does
// not wipe an already-configured ship layout.
if (site.recipeId == recipeId)
@@ -223,12 +217,6 @@ void BuildingSystem::setRecipe(FactoryState& state, BuildingId id, const std::st
{
if (building.id == id)
{
// Auto-recipe buildings have no player-selected recipe
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING); ignore any attempt to set one.
if (isAutoRecipeBuildingType(building.type))
{
return;
}
// No-op if the recipe is unchanged, so a redundant selection does
// not wipe an already-configured ship layout or reset buffers.
if (building.recipeId == recipeId)
@@ -240,7 +228,7 @@ void BuildingSystem::setRecipe(FactoryState& state, BuildingId id, const std::st
building.inputBuffer.counts.clear();
building.inputBuffer.caps.clear();
building.outputBuffer.items.clear();
building.outputBuffer.capacity = 0;
building.outputBuffer.caps.clear();
// Emerging items are part of the output buffer, so clearing it on a
// recipe change discards them too (REQ-MAT-OUTPUT-EMERGE); in-transit
// input items are discarded and their reservations released
@@ -307,7 +295,7 @@ void BuildingSystem::setShipLayout(FactoryState& state, BuildingId id, const Shi
building.inputBuffer.counts.clear();
building.inputBuffer.caps.clear();
building.outputBuffer.items.clear();
building.outputBuffer.capacity = 0;
building.outputBuffer.caps.clear();
for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
for (std::vector<BeltItemSlot>& lane : building.incomingItems) { lane.clear(); }
if (!building.recipeId.empty() && building.type == BuildingType::Shipyard)
@@ -407,6 +395,10 @@ void BuildingSystem::tickBeltPull(FactoryState& state)
{
const std::optional<ItemType> peeked = m_belts.peekItem(building.inputPorts[i]);
if (!peeked) { continue; }
// A Smelter or Reprocessing Plant without a recipe takes the first material
// offered to it as its selection (REQ-BLD-AUTO-RECIPE); the ports are walked
// in order, so which offer comes first is fixed.
selectAutoRecipeIfUnset(building, *peeked);
if (!canAcceptInput(building, i, *peeked)) { continue; }
const std::optional<Item> taken = m_belts.tryTakeItem(building.inputPorts[i]);
if (taken)
@@ -417,6 +409,25 @@ void BuildingSystem::tickBeltPull(FactoryState& state)
}
}
void BuildingSystem::selectAutoRecipeIfUnset(Building& building, const ItemType& offered)
{
// Only while it holds none: once set, a recipe is the player's to change
// (REQ-BLD-AUTO-RECIPE). Buildings that select their own recipe are the only ones
// this applies to; everyone else ignores an offer they have no recipe for.
if (!building.recipeId.empty())
{
return;
}
const RecipeDef* recipe = findAutoRecipeFor(m_config, building.type, offered);
if (!recipe)
{
return;
}
building.recipeId = recipe->id;
initBuffers(building, *recipe);
}
bool BuildingSystem::canAcceptInput(const Building& consumer,
std::size_t inputPortIndex,
const ItemType& type) const
@@ -477,6 +488,10 @@ bool BuildingSystem::tryDirectCoupleDeposit(FactoryState& state, BuildingId prod
if (in.direction != outputPort.direction) { continue; }
if (inputBodyTile(in.tile, in.direction) != outputPort.tile) { continue; }
// A coupling is an offer too, so an unset auto-recipe building selects from it
// (REQ-BLD-AUTO-RECIPE). Without this a Smelter placed flush against a producer
// would accept nothing and leave it stuck at its port for good.
selectAutoRecipeIfUnset(*consumer, item.type);
if (!canAcceptInput(*consumer, j, item.type)) { return false; }
depositToInputBelt(*consumer, j, item);
return true;
@@ -502,88 +517,92 @@ void BuildingSystem::tickProduction(FactoryState& state, Tick currentTick)
continue;
}
const bool autoRecipe = isAutoRecipeBuildingType(building.type);
if (!autoRecipe && building.recipeId.empty())
if (building.recipeId.empty())
{
continue;
}
// If a production cycle is active, check for completion. Completion only
// needs the already-decided outputs, so it does not depend on which
// recipe is selected or auto-chosen.
// recipe is selected.
if (building.production)
{
if (currentTick >= building.production->completesAt)
if (currentTick < building.production->completesAt)
{
for (const Item& item : building.production->chosenOutputs)
{
building.outputBuffer.items.push_back(item);
}
building.production = std::nullopt;
continue;
}
// Whether we just completed or are still running, do not start
// another cycle in the same tick.
for (const Item& item : building.production->chosenOutputs)
{
building.outputBuffer.items.push_back(item);
}
building.production = std::nullopt;
// Fall through to the start attempt below rather than idling for a tick,
// so a cycle takes exactly its recipe duration and a building fed to
// capacity produces at the configured rate (REQ-MAT-CYCLE). The start
// code runs once per building per tick, so at most one cycle begins here
// even when a duration rounds to zero ticks. The outputs just deposited
// count against the space check, so a cycle whose output no longer fits
// waits, exactly as it would have on the following tick.
}
// Idle: try to start the building's one selected recipe. Every type holds
// exactly one, a Smelter and a Reprocessing Plant included -- they differ only
// in how theirs first got set (REQ-BLD-AUTO-RECIPE).
const RecipeDef* recipe = getSelectedRecipe(m_config, building);
if (!recipe)
{
continue;
}
// Idle: gather the candidate recipes to try. Auto-recipe buildings
// (Smelter, Reprocessing Plant) have no selected recipe and try every
// recipe of their type in config order, running the first whose inputs
// are satisfied (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING). Other buildings
// try only their selected recipe.
const std::vector<const RecipeDef*> candidates =
gatherCandidateRecipes(m_config, building);
for (const RecipeDef* recipe : candidates)
// 1. All required inputs present?
if (!recipeInputsAvailable(building, *recipe))
{
// 1. All required inputs present?
if (!recipeInputsAvailable(building, *recipe))
{
continue;
}
continue;
}
// 2. Determine chosen outputs (roll for reprocessing).
std::vector<Item> chosen;
if (building.type == BuildingType::ReprocessingPlant)
// 2. Room for every output this cycle could produce -- checked before anything
// is rolled (REQ-MAT-CYCLE). The roll below is committed the moment the cycle
// starts, so a plant that could not store some outcome must not start at all:
// that is what stops a stalled output belt from biasing the distribution
// towards the outputs that still fit. Emerging items count against their
// buffer (REQ-MAT-OUTPUT-EMERGE). The status light asks the same question to
// decide yellow (REQ-UI-STATUS-LIGHT), so the test lives in one place.
if (!recipeOutputsFit(building, *recipe))
{
continue;
}
// 3. Determine chosen outputs (roll for reprocessing).
std::vector<Item> chosen;
if (building.type == BuildingType::ReprocessingPlant)
{
chosen = rollReprocessingOutput(*recipe);
if (chosen.empty()) { continue; }
}
else
{
for (const RecipeOutput& out : recipe->outputs)
{
chosen = rollReprocessingOutput(*recipe);
if (chosen.empty()) { continue; }
}
else
{
for (const RecipeOutput& out : recipe->outputs)
Item item;
item.type.id = out.item;
for (int i = 0; i < out.amount; ++i)
{
Item item;
item.type.id = out.item;
for (int i = 0; i < out.amount; ++i)
{
chosen.push_back(item);
}
chosen.push_back(item);
}
}
// 3. Output buffer has space for chosen outputs? Emerging items still
// count against the buffer (REQ-MAT-OUTPUT-EMERGE).
const int newSize = building.getOutputItemCount()
+ static_cast<int>(chosen.size());
if (newSize > building.outputBuffer.capacity)
{
continue;
}
// 4. Consume inputs and start cycle.
for (const RecipeIngredient& ing : recipe->inputs)
{
building.inputBuffer.counts[ItemType{ing.item}] -= ing.amount;
}
Production prod;
prod.recipeId = recipe->id;
prod.completesAt = currentTick + secondsToTicks(recipe->durationSeconds);
prod.chosenOutputs = std::move(chosen);
building.production = std::move(prod);
break; // At most one cycle starts per tick.
}
// 4. Consume inputs and start cycle.
for (const RecipeIngredient& ing : recipe->inputs)
{
building.inputBuffer.counts[ItemType{ing.item}] -= ing.amount;
}
Production prod;
prod.recipeId = recipe->id;
prod.completesAt = currentTick + secondsToTicks(recipe->durationSeconds);
prod.chosenOutputs = std::move(chosen);
building.production = std::move(prod);
}
}
@@ -612,26 +631,29 @@ void BuildingSystem::tickShipyardProduction(FactoryState& state, Tick currentTic
// If a cycle is in progress, check for completion.
if (building.production)
{
if (currentTick >= building.production->completesAt)
if (currentTick < building.production->completesAt)
{
if (!building.outputPorts.empty())
{
const Port& p = building.outputPorts[0];
const QVector2D spawnPos(p.tile.x() + 0.5f, p.tile.y() + 0.5f);
// A shipyard builds exactly what the player configured and
// paid for. When no layout is set it produces a bare hull, so
// pass an explicit empty layout rather than nullopt: the latter
// would make ShipSystem fall back to the schematic's
// defaultModules (a wave-only loadout) and yield free weapons.
const std::optional<ShipLayoutConfig> layout =
building.shipLayout.has_value()
? building.shipLayout
: std::make_optional<ShipLayoutConfig>();
m_spawnShip(building.recipeId, spawnPos, layout);
}
building.production = std::nullopt;
continue;
}
continue;
if (!building.outputPorts.empty())
{
const Port& p = building.outputPorts[0];
const QVector2D spawnPos(p.tile.x() + 0.5f, p.tile.y() + 0.5f);
// A shipyard builds exactly what the player configured and
// paid for. When no layout is set it produces a bare hull, so
// pass an explicit empty layout rather than nullopt: the latter
// would make ShipSystem fall back to the schematic's
// defaultModules (a wave-only loadout) and yield free weapons.
const std::optional<ShipLayoutConfig> layout =
building.shipLayout.has_value()
? building.shipLayout
: std::make_optional<ShipLayoutConfig>();
m_spawnShip(building.recipeId, spawnPos, layout);
}
building.production = std::nullopt;
// Fall through and start the next cycle in this same tick, so a ship takes
// exactly its computed production time (REQ-BLD-SHIPYARD), as for the
// recipe buildings in tickProduction.
}
// Build combined materials list (base + modules).
@@ -942,21 +964,22 @@ void appendItems(Hasher& hasher, const std::vector<Item>& items)
}
}
// std::map<ItemType, int> iterates in sorted-id order (ItemType::operator<), so both
// buffer sides hash the same way in every run.
void appendItemCounts(Hasher& hasher, const std::map<ItemType, int>& counts)
{
hasher.append(counts.size());
for (const std::pair<const ItemType, int>& entry : counts)
{
hasher.append(entry.first.id);
hasher.append(entry.second);
}
}
void appendInputBuffer(Hasher& hasher, const InputBuffer& buffer)
{
// std::map<ItemType, int> iterates in sorted-id order (ItemType::operator<).
hasher.append(buffer.counts.size());
for (const std::pair<const ItemType, int>& entry : buffer.counts)
{
hasher.append(entry.first.id);
hasher.append(entry.second);
}
hasher.append(buffer.caps.size());
for (const std::pair<const ItemType, int>& entry : buffer.caps)
{
hasher.append(entry.first.id);
hasher.append(entry.second);
}
appendItemCounts(hasher, buffer.counts);
appendItemCounts(hasher, buffer.caps);
}
} // namespace
@@ -976,7 +999,7 @@ void BuildingSystem::appendChecksum(const FactoryState& state, Hasher& hasher) c
hasher.append(b.recipeId);
appendInputBuffer(hasher, b.inputBuffer);
appendItems(hasher, b.outputBuffer.items);
hasher.append(b.outputBuffer.capacity);
appendItemCounts(hasher, b.outputBuffer.caps);
hasher.append(b.emergingItems.size());
for (const std::vector<BeltItemSlot>& lane : b.emergingItems)
{

View File

@@ -188,6 +188,13 @@ private:
// (on construction completion, or when un-queuing a deconstruction). No-op for
// non-belt-subsystem types. Splitter filters are (re)applied after placement.
// Selects a recipe for an auto-recipe building that has none, from a material being
// offered to it at one of its input ports (REQ-BLD-AUTO-RECIPE). No-op for every
// other building, for one that already holds a recipe, and for a material none of
// its recipes consumes. Called from both intake paths -- the belt pull and the
// direct coupling -- since either can be where the first material arrives.
void selectAutoRecipeIfUnset(Building& building,
const ItemType& offered);
// True if the consumer would accept `type` at the given input port right now:
// it is a required input (or a building block for the HQ), the reservation-aware
// buffer has room, and the input belt entry is free (REQ-MAT-INPUT-INTAKE).

View File

@@ -67,12 +67,6 @@ void ConstructionSystem::tick(FactoryState& state, BeltSystem& belts, Tick curre
{
initSalvageBayBuffer(m_config, building);
}
else if (isAutoRecipeBuildingType(building.type))
{
// Smelter/Reprocessing Plant need no recipe selection; buffers are set
// up from all recipes of the type (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING).
initAutoBuffers(m_config, building);
}
else if (!building.recipeId.empty())
{
if (building.type == BuildingType::Shipyard)

View File

@@ -4,6 +4,7 @@
#include <limits>
#include "PortGeometry.h"
#include "ProductionRules.h"
#include "SurfaceMask.h"
#include "Item.h"
@@ -122,12 +123,14 @@ bool deliverScrapToSalvageBay(FactoryState& state, BuildingId bayId)
return false; // queued for deconstruction: stopped operating (REQ-BLD-DECON-QUEUE)
}
// Emerging scrap still counts against the bay's holding capacity
// (REQ-MAT-OUTPUT-EMERGE).
if (bay->getOutputItemCount() >= bay->outputBuffer.capacity)
// (REQ-MAT-OUTPUT-EMERGE). Scrap is all the bay ever holds, so its single buffer is
// the one being filled (REQ-BLD-SALVAGE-BAY).
const ItemType scrap{"scrap"};
if (!outputBufferHasRoom(*bay, scrap, 1))
{
return false;
}
bay->outputBuffer.items.push_back(Item{ItemType{"scrap"}});
bay->outputBuffer.items.push_back(Item{scrap});
return true;
}

View File

@@ -1,33 +1,44 @@
#include "ProductionRules.h"
#include <algorithm>
#include <map>
#include "BuildingType.h"
#include "ItemType.h"
#include "ModulesConfig.h"
#include "ShipsConfig.h"
std::vector<const RecipeDef*>
gatherCandidateRecipes(const GameConfig& config, const Building& b)
const RecipeDef* getSelectedRecipe(const GameConfig& config, const Building& b)
{
std::vector<const RecipeDef*> candidates;
if (isAutoRecipeBuildingType(b.type))
if (b.recipeId.empty())
{
for (const RecipeDef& r : config.recipes.recipes)
return nullptr;
}
return config.recipes.findRecipeDef(b.recipeId, b.type);
}
const RecipeDef* findAutoRecipeFor(const GameConfig& config, BuildingType type,
const ItemType& item)
{
if (!isAutoRecipeBuildingType(type))
{
return nullptr;
}
// Config order decides where a material feeds more than one recipe of the type, so
// the same offer always picks the same recipe (REQ-BLD-AUTO-RECIPE).
for (const RecipeDef& recipe : config.recipes.recipes)
{
if (recipe.building != type) { continue; }
for (const RecipeIngredient& ing : recipe.inputs)
{
if (r.building == b.type && !r.inputs.empty())
if (ItemType{ing.item} == item)
{
candidates.push_back(&r);
return &recipe;
}
}
}
else
{
const RecipeDef* recipe = config.recipes.findRecipeDef(b.recipeId, b.type);
if (recipe)
{
candidates.push_back(recipe);
}
}
return candidates;
return nullptr;
}
bool recipeInputsAvailable(const Building& b, const RecipeDef& recipe)
{
@@ -127,18 +138,67 @@ bool hasInputsToStart(const GameConfig& config, const Building& b)
return true;
}
// Recipe buildings: startable if any candidate recipe's inputs are satisfied.
// A Miner recipe has no inputs, so an idle Miner is always startable and its
// only idle reason is a full output buffer.
for (const RecipeDef* recipe : gatherCandidateRecipes(config, b))
// Recipe buildings: startable if the selected recipe's inputs are satisfied. A Miner
// recipe has no inputs, so an idle Miner is always startable here and its only idle
// reason is an output buffer without room for the next cycle.
const RecipeDef* recipe = getSelectedRecipe(config, b);
return recipe != nullptr && recipeInputsAvailable(b, *recipe);
}
bool outputBufferHasRoom(const Building& b, const ItemType& type, int itemCount)
{
const std::map<ItemType, int>::const_iterator capIt = b.outputBuffer.caps.find(type);
const int cap = (capIt != b.outputBuffer.caps.end()) ? capIt->second : 0;
return b.getOutputItemCount(type) + itemCount <= cap;
}
bool recipeOutputsFit(const Building& b, const RecipeDef& recipe)
{
if (b.type == BuildingType::ReprocessingPlant)
{
if (recipeInputsAvailable(b, *recipe))
// One roll yields one of these, so each is measured on its own -- but all of them
// have to fit, since which one it will be is not known yet.
for (const RecipeOutput& out : recipe.outputs)
{
return true;
if (!outputBufferHasRoom(b, ItemType{out.item}, out.amount))
{
return false;
}
}
return true;
}
// A deterministic cycle deposits all of its outputs together. An item listed more
// than once is produced in the sum of those amounts, so it is judged once, as a sum.
std::map<ItemType, int> perCycle;
for (const RecipeOutput& out : recipe.outputs)
{
perCycle[ItemType{out.item}] += out.amount;
}
for (const std::pair<const ItemType, int>& entry : perCycle)
{
if (!outputBufferHasRoom(b, entry.first, entry.second))
{
return false;
}
}
return false;
return true;
}
bool canStartCycle(const GameConfig& config, const Building& b)
{
// A shipyard's completed cycle spawns a ship instead of filling an output buffer
// (REQ-BLD-SHIPYARD), so holding the materials is the whole condition.
if (b.type == BuildingType::Shipyard)
{
return hasInputsToStart(config, b);
}
const RecipeDef* recipe = getSelectedRecipe(config, b);
return recipe != nullptr && recipeInputsAvailable(b, *recipe)
&& recipeOutputsFit(b, *recipe);
}
std::optional<ProductionStatus>
getProductionStatus(const GameConfig& config, const Building& building)
{
@@ -157,9 +217,10 @@ getProductionStatus(const GameConfig& config, const Building& building)
return std::nullopt;
}
// Grey only applies to player-configured types; auto-recipe buildings
// (Smelter, Reprocessing Plant) always run an implicit recipe.
if (!isAutoRecipeBuildingType(building.type) && building.recipeId.empty())
// Every production building can be unconfigured, an auto-recipe building included:
// it holds no recipe until one is offered to it, and the player can hand it back to
// automatic selection (REQ-BLD-AUTO-RECIPE, REQ-UI-STATUS-LIGHT).
if (building.recipeId.empty())
{
return ProductionStatus::Unconfigured;
}
@@ -169,9 +230,18 @@ getProductionStatus(const GameConfig& config, const Building& building)
return ProductionStatus::Producing;
}
// Idle: missing inputs (red) take precedence over a full output buffer
// (yellow). If inputs are present yet the building is idle, the only remaining
// reason it could not start a cycle is a full output buffer (REQ-MAT-CYCLE).
// Idle, but blocked by neither condition: the building is only between cycles and
// the simulation starts the next one on a following tick. A building running back
// to back sits here for exactly one tick per cycle, since tickProduction never
// starts a cycle in the tick one completed, so this must read as producing rather
// than blink (REQ-UI-STATUS-LIGHT, REQ-MAT-CYCLE).
if (canStartCycle(config, building))
{
return ProductionStatus::Producing;
}
// Idle for a reason: a missing input (red) takes precedence over an output buffer
// with no room for the next cycle's output (yellow).
return hasInputsToStart(config, building) ? ProductionStatus::Blocked
: ProductionStatus::Starved;
: ProductionStatus::Starved;
}

View File

@@ -25,10 +25,16 @@ enum class ProductionStatus
// Pure functions of the config and the building itself — they read no factory
// state, so they are free functions rather than BuildingSystem members.
// Recipes this building could run: every recipe of its type for an auto-recipe
// building (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING), otherwise just its selected one.
std::vector<const RecipeDef*> gatherCandidateRecipes(const GameConfig& config,
const Building& b);
// The recipe this building runs, or null when it has none selected. Every building type
// holds exactly one, a Smelter and a Reprocessing Plant included -- they only differ in
// how theirs first gets set (REQ-BLD-AUTO-RECIPE).
const RecipeDef* getSelectedRecipe(const GameConfig& config, const Building& b);
// The recipe an auto-recipe building adopts when this material is offered to it while it
// has none: the first recipe of its type, in config order, that consumes the material
// (REQ-BLD-AUTO-RECIPE). Null when no recipe of the type takes it.
const RecipeDef* findAutoRecipeFor(const GameConfig& config, BuildingType type,
const ItemType& item);
// True when the building's input buffer holds every ingredient the recipe needs.
bool recipeInputsAvailable(const Building& b, const RecipeDef& recipe);
@@ -53,6 +59,24 @@ double computeShipyardProductionTimeSeconds(
// True when a production cycle could start right now, ignoring output-buffer space.
bool hasInputsToStart(const GameConfig& config, const Building& b);
// True when the building can take `itemCount` more items of `type` beside what it
// already holds of it. An emerging item has not left the building yet and so still
// counts against that material's capacity (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-OUTPUT-BUFFER).
bool outputBufferHasRoom(const Building& b, const ItemType& type, int itemCount);
// True when every output a cycle of this recipe could produce would fit -- the gate a
// cycle has to pass before it may start (REQ-MAT-CYCLE). For a deterministic recipe that
// is its own outputs. A Reprocessing Plant rolls one of its outputs per cycle
// (REQ-BLD-REPROCESSING), so each possibility is judged on its own and all must fit: the
// roll is committed the moment the cycle starts, and testing every outcome rather than
// the rolled one is what keeps a stalled output belt from biasing the distribution.
bool recipeOutputsFit(const Building& b, const RecipeDef& recipe);
// True when a production cycle could actually start right now: some candidate recipe
// has its inputs *and* passes recipeOutputsFit (REQ-MAT-CYCLE). Stricter than
// hasInputsToStart, which looks at the input buffers alone.
bool canStartCycle(const GameConfig& config, const Building& b);
// Status light for a building, or nullopt for types that show none — belts,
// splitters, tunnels, HQ and defence stations (REQ-UI-STATUS-LIGHT).
std::optional<ProductionStatus> getProductionStatus(const GameConfig& config,

View File

@@ -1000,8 +1000,9 @@ TEST_CASE("SalvagerSystem: full-cargo ship at its SalvageBay hands over cargo",
}
const Building* bay = findBuilding(f.state, bayId);
REQUIRE(bay != nullptr);
// Config-driven output-buffer capacity is applied on placement (REQ-BLD-SALVAGE-BAY).
REQUIRE(bay->outputBuffer.capacity == 20);
// Config-driven output-buffer capacity is applied on placement, onto the single
// scrap buffer the bay holds (REQ-BLD-SALVAGE-BAY).
REQUIRE(bay->outputBuffer.caps.at(ItemType{"scrap"}) == 20);
const QVector2D bayCenter(bay->anchor.x() + bay->footprint.width() / 2.0f,
bay->anchor.y() + bay->footprint.height() / 2.0f);

View File

@@ -14,6 +14,7 @@
#include "BeltSystem.h"
#include "Building.h"
#include "BuildingBuffers.h"
#include "BuildingSystem.h"
#include "ConstructionSystem.h"
#include "DeconstructionSystem.h"
@@ -495,13 +496,13 @@ TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
Tick tick = 0;
// Construction (10s) then cycle 1 starts at tick 300 (completesAt=330).
// Cycle 1 completes at tick 330: deposit item, continue (no same-tick restart).
// Cycle 2 starts at tick 331 (completesAt=361).
// Cycle 2 completes at tick 361: deposit item → buffer=2, cycle 3 stalls.
// Need to process through tick 361: 362 ticks total.
// Cycle 1 completes at tick 330 and cycle 2 starts in that same tick
// (completesAt=360). Cycle 2 completes at tick 360: deposit item -> 2 items held,
// which fills the buffer (capacity 2), so cycle 3 cannot start.
// Need to process through tick 360: 361 ticks total.
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
static_cast<int>(secondsToTicks(10.0))
+ 2 * static_cast<int>(secondsToTicks(1.0)) + 2,
+ 2 * static_cast<int>(secondsToTicks(1.0)) + 1,
tick);
const Building* b = findBuilding(f.state, id);
@@ -512,6 +513,49 @@ TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
REQUIRE_FALSE(b->production.has_value());
}
TEST_CASE("BuildingSystem: the next cycle starts on the tick the last one completed",
"[building]")
{
// A cycle takes exactly its recipe duration, so a building whose output keeps
// draining produces at the configured rate (REQ-MAT-CYCLE). An idle tick between
// cycles would cost a one-second recipe about 3% of its throughput.
PlacementFixture f;
const BuildingId id =
f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
f.bs.setRecipe(f.state, id, "mine_iron_ore");
const Tick cycleTicks = secondsToTicks(1.0); // mine_iron_ore duration
Tick tick = 0;
// Construction completes at tick 300 and cycle 1 starts in that same tick.
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
static_cast<int>(secondsToTicks(10.0)) + 1, tick);
const Building* b = findBuilding(f.state, id);
REQUIRE(b != nullptr);
REQUIRE(b->production.has_value());
const Tick firstCompletesAt = b->production->completesAt;
// Process up to and including that completion tick: the next cycle is already
// running, due exactly one duration later rather than one duration plus a tick.
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(cycleTicks), tick);
b = findBuilding(f.state, id);
REQUIRE(b->getOutputItemCount() == 1);
REQUIRE(b->production.has_value());
REQUIRE(b->production->completesAt == firstCompletesAt + cycleTicks);
// Nothing hauls the ore away here, so the buffer (capacity 2) would stall the third
// cycle. Drain it and confirm the cadence holds across the next boundary too.
f.bs.forEachBuilding(f.state, [](Building& building) {
building.outputBuffer.items.clear();
for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
});
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(cycleTicks), tick);
b = findBuilding(f.state, id);
REQUIRE(b->production.has_value());
REQUIRE(b->production->completesAt == firstCompletesAt + 2 * cycleTicks);
}
// ---------------------------------------------------------------------------
// REQ-UI-DEBUG-OVERLAY production counts
// ---------------------------------------------------------------------------
@@ -585,12 +629,13 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing
// Smelter mask ["AA ","AA>"] → body (0,0),(1,0),(0,1),(1,1).
// Output port (2,1) East. Input port example: (2,0) West.
const BuildingId sid = f.bs.place(f.state, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();
// Smelters have no recipe selection (REQ-BLD-SMELTER); they auto-accept any
// ore/scrap that is an input to a smelter recipe.
// A smelter starts with no recipe and picks one from the first material offered to
// it (REQ-BLD-AUTO-RECIPE), which is what lets it accept the ore below.
// Complete construction (15s → tick 450+1 = 451 ticks).
Tick tick = 0;
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
REQUIRE(findBuilding(f.state, sid)->recipeId.empty());
// Place west-flowing belt at (2,0): belt flows West, delivers to smelter.
f.belts.placeBelt(QPoint(2, 0), Rotation::West);
@@ -601,6 +646,13 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing
const Building* b = findBuilding(f.state, sid);
REQUIRE(b != nullptr);
// The ore selected the recipe that consumes it, and its buffers were sized for that
// recipe alone -- copper ore is not one of its inputs any more.
REQUIRE(b->recipeId == "iron_ingot");
REQUIRE(b->inputBuffer.caps.count(ItemType{"iron_ore"}) == 1);
REQUIRE(b->inputBuffer.caps.count(ItemType{"copper_ore"}) == 0);
REQUIRE(b->outputBuffer.caps.count(ItemType{"iron_ingot"}) == 1);
REQUIRE(b->outputBuffer.caps.count(ItemType{"copper_ingot"}) == 0);
// The item was accepted; it may still be travelling inward on the input belt,
// so count buffered + in-transit (REQ-MAT-INPUT-INTAKE).
REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) >= 1);
@@ -705,10 +757,10 @@ TEST_CASE("BuildingSystem: smelter auto-smelts ore without a recipe selection",
REQUIRE(hasIronIngot);
}
// With mixed inputs, the smelter runs whichever recipe is currently satisfiable
// and leaves an incomplete batch of another input waiting (see the union-of-
// inputs caps in initAutoBuffers).
TEST_CASE("BuildingSystem: smelter runs a satisfiable recipe while an incomplete batch waits",
// A belt carrying mixed ore is the realistic case for REQ-BLD-AUTO-RECIPE: the first ore
// to arrive settles the recipe, and everything else on that belt is refused rather than
// smelted alongside it.
TEST_CASE("BuildingSystem: mixed ore on one belt leaves the smelter on the first ore's recipe",
"[building]")
{
PlacementFixture f(kFastBeltSpeed_tps);
@@ -718,8 +770,7 @@ TEST_CASE("BuildingSystem: smelter runs a satisfiable recipe while an incomplete
Tick tick = 0;
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
// Feed 1 iron_ore (iron_ingot needs 2 — incomplete) then 2 copper_ore
// (copper_ingot needs 2 — satisfiable) via the west-flowing input belt.
// Feed 1 iron_ore, then 2 copper_ore, via the west-flowing input belt.
f.belts.placeBelt(QPoint(2, 0), Rotation::West);
const char* fed[] = { "iron_ore", "copper_ore", "copper_ore" };
for (const char* id : fed)
@@ -729,24 +780,26 @@ TEST_CASE("BuildingSystem: smelter runs a satisfiable recipe while an incomplete
f.bs.tickBeltPull(f.state);
}
// copper_ingot cycle is 2.5s; run to completion.
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(2.5)) + 2, tick);
const Building* b = findBuilding(f.state, sid);
REQUIRE(b != nullptr);
// Copper was smelted; the lone iron_ore still waits for a second unit.
bool hasCopperIngot = false;
// The iron ore came first, so the smelter smelts iron and nothing else. The copper
// was never taken in, so no copper ingot was made.
REQUIRE(b->recipeId == "iron_ingot");
for (const Item& item : outputSideItems(*b))
{
if (item.type.id == "copper_ingot") { hasCopperIngot = true; }
REQUIRE(item.type.id != "copper_ingot");
}
REQUIRE(hasCopperIngot);
REQUIRE(b->pendingInputCount(ItemType{"copper_ore"}) == 0);
// The lone iron ore still waits for a second unit: the recipe needs two.
const std::map<ItemType, int>::const_iterator ironIt =
b->inputBuffer.counts.find(ItemType{"iron_ore"});
REQUIRE(ironIt != b->inputBuffer.counts.end());
REQUIRE(ironIt->second == 1);
REQUIRE_FALSE(b->production.has_value());
}
// ---------------------------------------------------------------------------
@@ -833,7 +886,8 @@ TEST_CASE("BuildingSystem: direct coupling to a non-consumer leaves the item stu
REQUIRE(sink != nullptr);
// Nothing was delivered, and the producer's output side has backed up to its cap.
REQUIRE(sink->pendingInputCount(ItemType{"iron_ore"}) == 0);
REQUIRE(miner->getOutputItemCount() == miner->outputBuffer.capacity);
REQUIRE(miner->getOutputItemCount(ItemType{"iron_ore"})
== miner->outputBuffer.caps.at(ItemType{"iron_ore"}));
}
// ---------------------------------------------------------------------------
@@ -870,28 +924,250 @@ TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production"
}
// ---------------------------------------------------------------------------
// Reprocessing plant output buffer capacity (REQ-MAT-OUTPUT-BUFFER-REPROCESSING)
// Reprocessing plant -- per-item output buffers (REQ-MAT-OUTPUT-BUFFER)
// ---------------------------------------------------------------------------
TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max output per roll",
TEST_CASE("BuildingSystem: reprocessing plant sizes one output buffer per possible roll",
"[building]")
{
PlacementFixture f;
const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0).value();
// Reprocessing plants have no recipe selection (REQ-BLD-REPROCESSING); the
// single reprocessing recipe is applied automatically on completion.
// Complete construction (25s).
Tick tick = 0;
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast<int>(secondsToTicks(25.0)) + 1, tick);
// A plant holds no buffers until it has a recipe (REQ-BLD-AUTO-RECIPE); selecting
// one sizes them, exactly as the first scrap offered to it would.
REQUIRE(findBuilding(f.state, id)->outputBuffer.caps.empty());
f.bs.setRecipe(f.state, id, "reprocessing_cycle");
const Building* b = findBuilding(f.state, id);
REQUIRE(b != nullptr);
// reprocessing_cycle outputs: 2 iron_ingot (60%), 1 circuit_board (30%),
// 1 advanced_alloy (10%). Max per roll = 2. Capacity = 2 (1× max).
REQUIRE(b->outputBuffer.capacity == 2);
// 1 advanced_alloy (10%). One roll yields one of them, so each buffer holds twice
// that outcome's own amount (REQ-MAT-OUTPUT-BUFFER).
REQUIRE(b->outputBuffer.caps.size() == 3);
REQUIRE(b->outputBuffer.caps.at(ItemType{"iron_ingot"}) == 4);
REQUIRE(b->outputBuffer.caps.at(ItemType{"circuit_board"}) == 2);
REQUIRE(b->outputBuffer.caps.at(ItemType{"advanced_alloy"}) == 2);
}
TEST_CASE("BuildingSystem: one full output buffer stops the plant even when the others have room",
"[building]")
{
// The gate that replaced the old one-item cap: a cycle may only start when *every*
// outcome would fit, because the roll is committed once it starts (REQ-MAT-CYCLE).
// Were the plant to roll first and skip a result that does not fit, a player could
// stall one output belt to filter the distribution towards the other items.
PlacementFixture f(kFastBeltSpeed_tps);
const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
static_cast<int>(secondsToTicks(25.0)) + 1, tick);
// Feed a full cycle's scrap (5) so only the output side can hold it back.
f.belts.placeBelt(QPoint(-1, 0), Rotation::East);
for (int i = 0; i < 5; ++i)
{
f.belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East);
f.belts.tick();
f.bs.tickBeltPull(f.state);
}
// Fill the iron_ingot buffer to its cap and leave the other two empty.
f.bs.forEachBuilding(f.state, [](Building& building) {
if (building.type != BuildingType::ReprocessingPlant) { return; }
const int cap = building.outputBuffer.caps.at(ItemType{"iron_ingot"});
for (int i = 0; i < cap; ++i)
{
building.outputBuffer.items.push_back(makeItem("iron_ingot"));
}
});
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 5, tick);
const Building* b = findBuilding(f.state, id);
REQUIRE(b != nullptr);
// circuit_board and advanced_alloy have room, but iron_ingot does not, so no cycle
// starts at all and the scrap is still waiting.
REQUIRE(b->outputBuffer.caps.at(ItemType{"circuit_board"}) > 0);
REQUIRE(outputBufferHasRoom(*b, ItemType{"circuit_board"}, 1));
REQUIRE_FALSE(outputBufferHasRoom(*b, ItemType{"iron_ingot"}, 1));
REQUIRE_FALSE(b->production.has_value());
REQUIRE(b->pendingInputCount(ItemType{"scrap"}) == 5);
REQUIRE(getProductionStatus(f.cfg, *b) == ProductionStatus::Blocked);
}
TEST_CASE("BuildingSystem: reprocessing plant runs a second cycle while holding the first output",
"[building]")
{
// Its buffers hold twice each outcome's amount (REQ-MAT-OUTPUT-BUFFER), so a held
// result no longer stops the next cycle. The old one-item cap made this impossible:
// whatever the first roll was, the plant stalled until that item left the building.
PlacementFixture f(kFastBeltSpeed_tps);
const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
static_cast<int>(secondsToTicks(25.0)) + 1, tick);
// Two cycles' worth of scrap (5 each), which is exactly the input cap.
f.belts.placeBelt(QPoint(-1, 0), Rotation::East);
for (int i = 0; i < 10; ++i)
{
f.belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East);
f.belts.tick();
f.bs.tickBeltPull(f.state);
}
REQUIRE(findBuilding(f.state, id)->pendingInputCount(ItemType{"scrap"}) == 10);
// No belt carries the output away, so the first cycle's result is still held.
// reprocessing_cycle runs 3s; run through the completion tick.
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
static_cast<int>(secondsToTicks(3.0)) + 1, tick);
const Building* b = findBuilding(f.state, id);
REQUIRE(b != nullptr);
REQUIRE(b->getOutputItemCount() > 0);
// Whichever outcome was rolled, every outcome still fits, so the second cycle is
// already running rather than the plant sitting blocked.
REQUIRE(b->production.has_value());
REQUIRE(getProductionStatus(f.cfg, *b) == ProductionStatus::Producing);
}
// ---------------------------------------------------------------------------
// Automatic recipe selection (REQ-BLD-AUTO-RECIPE)
// ---------------------------------------------------------------------------
// Places a smelter and runs it to completion, leaving it with no recipe.
static BuildingId buildSmelter(PlacementFixture& f, QPoint anchor, Tick& tick)
{
const BuildingId id =
f.bs.place(f.state, BuildingType::Smelter, anchor, Rotation::East, 0).value();
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
static_cast<int>(secondsToTicks(15.0)) + 1, tick);
return id;
}
TEST_CASE("BuildingSystem: an unset auto-recipe building is unconfigured", "[building]")
{
// It holds no recipe until one is offered to it, so it reads grey like any other
// unconfigured building (REQ-BLD-AUTO-RECIPE, REQ-UI-STATUS-LIGHT).
PlacementFixture f;
Tick tick = 0;
const BuildingId id = buildSmelter(f, QPoint(0, 0), tick);
const Building* b = findBuilding(f.state, id);
REQUIRE(b != nullptr);
REQUIRE(b->recipeId.empty());
REQUIRE(b->inputBuffer.caps.empty());
REQUIRE(b->outputBuffer.caps.empty());
REQUIRE(getProductionStatus(f.cfg, *b) == ProductionStatus::Unconfigured);
}
TEST_CASE("BuildingSystem: a set recipe is never replaced by a later material",
"[building]")
{
// Once set the recipe is the player's to change: a material belonging to another of
// its recipes is simply not an accepted input (REQ-BLD-AUTO-RECIPE).
PlacementFixture f(kFastBeltSpeed_tps);
Tick tick = 0;
const BuildingId id = buildSmelter(f, QPoint(0, 0), tick);
// Iron ore first, which selects the iron recipe.
f.belts.placeBelt(QPoint(2, 0), Rotation::West);
f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
f.belts.tick();
f.bs.tickBeltPull(f.state);
REQUIRE(findBuilding(f.state, id)->recipeId == "iron_ingot");
// Copper ore next: refused, and the recipe stands.
f.belts.tryPutItem(QPoint(2, 0), makeItem("copper_ore"));
f.belts.tick();
f.bs.tickBeltPull(f.state);
const Building* b = findBuilding(f.state, id);
REQUIRE(b->recipeId == "iron_ingot");
REQUIRE(b->pendingInputCount(ItemType{"copper_ore"}) == 0);
// The copper is still sitting on the belt, refused rather than swallowed.
REQUIRE(f.belts.peekItem(westPort(QPoint(2, 0))).has_value());
}
TEST_CASE("BuildingSystem: a manually selected recipe is not overridden", "[building]")
{
// The player's selection is a recipe like any other, so auto-selection stays out of
// the way and the smelter refuses ore it does not smelt (REQ-BLD-AUTO-RECIPE).
PlacementFixture f(kFastBeltSpeed_tps);
Tick tick = 0;
const BuildingId id = buildSmelter(f, QPoint(0, 0), tick);
f.bs.setRecipe(f.state, id, "copper_ingot");
REQUIRE(findBuilding(f.state, id)->recipeId == "copper_ingot");
f.belts.placeBelt(QPoint(2, 0), Rotation::West);
f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
f.belts.tick();
f.bs.tickBeltPull(f.state);
const Building* b = findBuilding(f.state, id);
REQUIRE(b->recipeId == "copper_ingot");
REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) == 0);
}
TEST_CASE("BuildingSystem: selecting a different recipe frees a stuck auto-recipe building",
"[building]")
{
// A smelter left holding part of a cycle nothing feeds any more is freed by
// selecting another recipe, which clears the buffers -- that is why no separate
// clear action exists (REQ-BLD-AUTO-RECIPE, REQ-MAT-INPUT-BUFFER).
PlacementFixture f(kFastBeltSpeed_tps);
Tick tick = 0;
const BuildingId id = buildSmelter(f, QPoint(0, 0), tick);
// One iron ore, where the recipe needs two: it can never run.
f.belts.placeBelt(QPoint(2, 0), Rotation::West);
f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
f.belts.tick();
f.bs.tickBeltPull(f.state);
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 30, tick);
const Building* stuck = findBuilding(f.state, id);
REQUIRE(stuck->recipeId == "iron_ingot");
REQUIRE(stuck->pendingInputCount(ItemType{"iron_ore"}) == 1);
REQUIRE_FALSE(stuck->production.has_value());
f.bs.setRecipe(f.state, id, "copper_ingot");
const Building* freed = findBuilding(f.state, id);
REQUIRE(freed->recipeId == "copper_ingot");
REQUIRE(freed->pendingInputCount(ItemType{"iron_ore"}) == 0);
REQUIRE(freed->inputBuffer.caps.count(ItemType{"copper_ore"}) == 1);
}
TEST_CASE("BuildingSystem: selecting (Auto) returns the building to automatic selection",
"[building]")
{
// The dialog's clearing option unsets the recipe rather than leaving the building
// idle for good: the next material offered selects one again (REQ-BLD-AUTO-RECIPE).
PlacementFixture f(kFastBeltSpeed_tps);
Tick tick = 0;
const BuildingId id = buildSmelter(f, QPoint(0, 0), tick);
f.bs.setRecipe(f.state, id, "copper_ingot");
f.bs.setRecipe(f.state, id, std::string()); // the "(Auto)" option
REQUIRE(findBuilding(f.state, id)->recipeId.empty());
f.belts.placeBelt(QPoint(2, 0), Rotation::West);
f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
f.belts.tick();
f.bs.tickBeltPull(f.state);
REQUIRE(findBuilding(f.state, id)->recipeId == "iron_ingot");
}
TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then stalls",
@@ -902,8 +1178,8 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta
const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0).value();
// Reprocessing plants have no recipe selection (REQ-BLD-REPROCESSING); the
// single reprocessing recipe is applied automatically on completion.
// The plant selects its recipe from the first scrap offered to it
// (REQ-BLD-AUTO-RECIPE), which is what the belt feeding below does.
// Complete construction (25s).
Tick tick = 0;
@@ -1081,10 +1357,12 @@ TEST_CASE("isConfigurableBuildingType: only types with player-facing settings",
CHECK(isConfigurableBuildingType(BuildingType::Shipyard));
CHECK(isConfigurableBuildingType(BuildingType::Splitter));
// Smelter and Reprocessing Plant run implicit recipes (REQ-BLD-SMELTER,
// REQ-BLD-REPROCESSING) and the rest have no settings whatsoever.
CHECK_FALSE(isConfigurableBuildingType(BuildingType::Smelter));
CHECK_FALSE(isConfigurableBuildingType(BuildingType::ReprocessingPlant));
// Smelter and Reprocessing Plant carry a recipe like any other, even though they can
// also select it themselves (REQ-BLD-AUTO-RECIPE).
CHECK(isConfigurableBuildingType(BuildingType::Smelter));
CHECK(isConfigurableBuildingType(BuildingType::ReprocessingPlant));
// The rest have no settings whatsoever.
CHECK_FALSE(isConfigurableBuildingType(BuildingType::SalvageBay));
CHECK_FALSE(isConfigurableBuildingType(BuildingType::Belt));
CHECK_FALSE(isConfigurableBuildingType(BuildingType::TunnelEntry));
@@ -1134,8 +1412,15 @@ TEST_CASE("resolveBlueprintGhost: a partial overlap of the same type is invalid"
// Both footprints stay on the asteroid, so terrain is not what fails here.
f.bs.place(f.state, BuildingType::Smelter, QPoint(-3, 0), Rotation::East, 0);
CHECK(resolveOne(f, BuildingType::Smelter, QPoint(-2, 0), Rotation::East).action
// Judged on where it sits, with no cursor to hit-test: the overlap is what decides.
CHECK(resolveInConstellation(f, BuildingType::Smelter, QPoint(-2, 0), Rotation::East).action
== BlueprintGhostAction::Invalid);
// With the cursor on the existing smelter the single-building gesture answers first
// and hands it the settings, since a smelter carries a recipe (REQ-BLD-AUTO-RECIPE,
// REQ-UI-BLUEPRINT-TRANSFER) -- the same as for a miner.
CHECK(resolveOne(f, BuildingType::Smelter, QPoint(-2, 0), Rotation::East).action
== BlueprintGhostAction::Transfer);
}
TEST_CASE("resolveBlueprintGhost: a single configurable building transfers its settings",
@@ -1296,11 +1581,11 @@ TEST_CASE("resolveBlueprintGhost: a constellation mixes transfers and plain over
REQUIRE(miner.action == BlueprintGhostAction::Transfer);
CHECK(*miner.targetId == minerId);
// A smelter runs an implicit recipe (REQ-BLD-SMELTER), so there is nothing to hand
// over and it is simply left as it is.
// A smelter carries a recipe too now (REQ-BLD-AUTO-RECIPE), so a blueprint of one
// has something to hand over just as the miner does.
const BlueprintGhostResolved smelter =
resolveInConstellation(f, BuildingType::Smelter, QPoint(-5, 0), Rotation::East);
REQUIRE(smelter.action == BlueprintGhostAction::CompatibleOverlap);
REQUIRE(smelter.action == BlueprintGhostAction::Transfer);
CHECK(*smelter.targetId == smelterId);
}
@@ -1515,41 +1800,187 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
miner.production = Production{};
REQUIRE(statusOf(miner) == ProductionStatus::Producing); // active cycle -> green
// A miner has no inputs, so its only idle reason is a full output buffer.
// A miner has no inputs, so its only idle reason is an output buffer with no
// room for the next cycle's output.
miner.production = std::nullopt;
miner.outputBuffer.capacity = 2;
miner.outputBuffer.items = { makeItem("iron_ore"), makeItem("iron_ore") };
miner.outputBuffer.caps[ItemType{"iron_ore"}] = 2;
miner.outputBuffer.items = { makeItem("iron_ore"), makeItem("iron_ore") };
REQUIRE(statusOf(miner) == ProductionStatus::Blocked); // -> yellow
// One item handed off: the next cycle fits again, so the idle tick between two
// cycles reads as producing rather than blinking yellow (REQ-UI-STATUS-LIGHT).
miner.outputBuffer.items.pop_back();
REQUIRE(statusOf(miner) == ProductionStatus::Producing); // -> green
// An emerging item has not left the building, so it fills the freed room and
// blocks the cycle again (REQ-MAT-OUTPUT-EMERGE).
miner.emergingItems.push_back({ BeltItemSlot{ makeItem("iron_ore"), 0.5 } });
REQUIRE(miner.getOutputItemCount(ItemType{"iron_ore"}) == 2);
REQUIRE(statusOf(miner) == ProductionStatus::Blocked); // -> yellow
// Another item's backlog is measured against its own buffer, so it changes
// nothing here (REQ-MAT-OUTPUT-BUFFER).
miner.outputBuffer.caps[ItemType{"copper_ore"}] = 2;
miner.outputBuffer.items.push_back(makeItem("copper_ore"));
REQUIRE(statusOf(miner) == ProductionStatus::Blocked);
miner.outputBuffer.items.clear();
REQUIRE(statusOf(miner) == ProductionStatus::Producing);
}
SECTION("Assembler: starved vs blocked, input-missing takes precedence")
SECTION("Assembler: starved, the transient between cycles, then blocked")
{
Building assembler; assembler.type = BuildingType::Assembler;
assembler.recipeId = assemblerRecipe->id;
// Sized the way the simulation sizes it (REQ-MAT-OUTPUT-BUFFER).
initBuffers(assembler, *assemblerRecipe);
const std::string outputItemId = assemblerRecipe->outputs.front().item;
int cycleOutput = 0;
for (const RecipeOutput& out : assemblerRecipe->outputs)
{
cycleOutput += out.amount;
}
REQUIRE(cycleOutput > 0);
// Idle with inputs missing -> red.
REQUIRE(statusOf(assembler) == ProductionStatus::Starved);
// Inputs present but idle -> the only remaining reason is a full output
// buffer -> yellow.
// Inputs present and the output fits: nothing blocks a cycle, so the building
// is merely between cycles -> green, not yellow (REQ-UI-STATUS-LIGHT).
for (const RecipeIngredient& ing : assemblerRecipe->inputs)
{
assembler.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
}
REQUIRE(statusOf(assembler) == ProductionStatus::Producing);
// That item's own buffer filled to within less than one cycle's output of its
// capacity: no cycle can start -> yellow.
for (int i = 0; i < cycleOutput + 1; ++i)
{
assembler.outputBuffer.items.push_back(makeItem(outputItemId));
}
REQUIRE(statusOf(assembler) == ProductionStatus::Blocked);
// Inputs missing AND output full -> red wins over yellow.
// Inputs missing AND output blocked -> red wins over yellow.
assembler.inputBuffer.counts.clear();
assembler.outputBuffer.capacity = 2;
assembler.outputBuffer.items = { makeItem("x"), makeItem("x") };
REQUIRE(statusOf(assembler) == ProductionStatus::Starved);
}
SECTION("Smelter (auto-recipe) is never grey")
SECTION("A multi-item cycle blocks before the output buffer is full")
{
// Free space smaller than one cycle's output stops the cycle even though the
// buffer still has room, so yellow is not the same as "full" (REQ-MAT-CYCLE).
const RecipeDef* multiOutputRecipe = nullptr;
for (const RecipeDef& r : f.cfg.recipes.recipes)
{
if (r.building != BuildingType::Assembler || r.inputs.empty()) { continue; }
int total = 0;
for (const RecipeOutput& out : r.outputs) { total += out.amount; }
if (total >= 2) { multiOutputRecipe = &r; break; }
}
REQUIRE(multiOutputRecipe != nullptr);
int cycleOutput = 0;
for (const RecipeOutput& out : multiOutputRecipe->outputs)
{
cycleOutput += out.amount;
}
const std::string outputItemId = multiOutputRecipe->outputs.front().item;
Building assembler; assembler.type = BuildingType::Assembler;
assembler.recipeId = multiOutputRecipe->id;
initBuffers(assembler, *multiOutputRecipe);
for (const RecipeIngredient& ing : multiOutputRecipe->inputs)
{
assembler.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
}
// One item short of a full cycle's worth of free space.
for (int i = 0; i < cycleOutput + 1; ++i)
{
assembler.outputBuffer.items.push_back(makeItem(outputItemId));
}
REQUIRE(assembler.getOutputItemCount(ItemType{outputItemId})
< assembler.outputBuffer.caps.at(ItemType{outputItemId}));
REQUIRE(statusOf(assembler) == ProductionStatus::Blocked);
// Exactly one cycle's worth of free space: the cycle fits again.
assembler.outputBuffer.items.pop_back();
REQUIRE(statusOf(assembler) == ProductionStatus::Producing);
}
SECTION("Reprocessing Plant: blocked once any possible roll has no room")
{
// The plant rolls one of its outputs per cycle (REQ-BLD-REPROCESSING) and the
// roll is committed at cycle start, so every outcome has to fit before it may
// begin: one full buffer blocks it whatever room the others have (REQ-MAT-CYCLE).
const RecipeDef* reprocessingRecipe = nullptr;
for (const RecipeDef& r : f.cfg.recipes.recipes)
{
if (r.building == BuildingType::ReprocessingPlant && !r.inputs.empty())
{
reprocessingRecipe = &r;
break;
}
}
REQUIRE(reprocessingRecipe != nullptr);
REQUIRE(reprocessingRecipe->outputs.size() >= 2);
Building plant; plant.type = BuildingType::ReprocessingPlant;
plant.recipeId = reprocessingRecipe->id;
initBuffers(plant, *reprocessingRecipe);
for (const RecipeIngredient& ing : reprocessingRecipe->inputs)
{
plant.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
}
// Every buffer empty: whatever the roll turns out to be, it fits -> green.
REQUIRE(statusOf(plant) == ProductionStatus::Producing);
// Fill one outcome's buffer and leave the rest untouched -> yellow, even though
// the other outcomes still have room.
const std::string firstItemId = reprocessingRecipe->outputs.front().item;
const std::string lastItemId = reprocessingRecipe->outputs.back().item;
for (int i = 0; i < plant.outputBuffer.caps.at(ItemType{firstItemId}); ++i)
{
plant.outputBuffer.items.push_back(makeItem(firstItemId));
}
REQUIRE(outputBufferHasRoom(plant, ItemType{lastItemId}, 1));
REQUIRE_FALSE(outputBufferHasRoom(plant, ItemType{firstItemId}, 1));
REQUIRE(statusOf(plant) == ProductionStatus::Blocked);
// Without the scrap it is starved regardless of the buffers.
plant.inputBuffer.counts.clear();
REQUIRE(statusOf(plant) == ProductionStatus::Starved);
}
SECTION("Smelter: grey until it has a recipe, then judged like any other building")
{
// It holds no recipe until one is offered to it, so grey now applies to it too
// (REQ-BLD-AUTO-RECIPE, REQ-UI-STATUS-LIGHT).
Building smelter; smelter.type = BuildingType::Smelter;
// No player-selectable recipe and empty inputs -> red, not grey.
REQUIRE(statusOf(smelter) == ProductionStatus::Starved);
REQUIRE(statusOf(smelter) == ProductionStatus::Unconfigured);
const RecipeDef* smelterRecipe = nullptr;
for (const RecipeDef& r : f.cfg.recipes.recipes)
{
if (r.building == BuildingType::Smelter && !r.inputs.empty())
{
smelterRecipe = &r;
break;
}
}
REQUIRE(smelterRecipe != nullptr);
smelter.recipeId = smelterRecipe->id;
initBuffers(smelter, *smelterRecipe);
REQUIRE(statusOf(smelter) == ProductionStatus::Starved); // recipe, but no ore
for (const RecipeIngredient& ing : smelterRecipe->inputs)
{
smelter.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
}
REQUIRE(statusOf(smelter) == ProductionStatus::Producing);
}
SECTION("Shipyard: unconfigured, then starved without materials, then producing")
@@ -1567,7 +1998,7 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
SECTION("Salvage Bay: red when empty, green when holding scrap")
{
Building bay; bay.type = BuildingType::SalvageBay;
bay.outputBuffer.capacity = 20;
bay.outputBuffer.caps[ItemType{"scrap"}] = 20;
REQUIRE(statusOf(bay) == ProductionStatus::Starved); // empty -> red
bay.outputBuffer.items = { makeItem("scrap") };

View File

@@ -266,9 +266,7 @@ void BlueprintSelectionDialog::rebuildGrid()
// Block icon shown to the right of each cost (REQ-UI-BLUEPRINT-CARD); null when no
// building_block icon exists, in which case the cost is the bare number.
const QPixmap blockIcon = m_itemIcons->hasIcon(kBlockItemId)
? m_itemIcons->getPixmap(kBlockItemId, QFontMetrics(font()).height())
: QPixmap();
const QPixmap blockIcon = m_itemIcons->getInlineIcon(kBlockItemId, font());
const QSize cardSize = getCardSize(font());

View File

@@ -175,9 +175,7 @@ BuildButtonBar::BuildButtonBar(Simulation* sim, const GameConfig* config,
// Block icon shown to the right of each button's cost (REQ-UI-BUILD-COST); null
// when no building_block icon exists, in which case the cost is the bare number.
const QPixmap blockIcon = m_itemIcons->hasIcon(kBlockItemId)
? m_itemIcons->getPixmap(kBlockItemId, QFontMetrics(font()).height())
: QPixmap();
const QPixmap blockIcon = m_itemIcons->getInlineIcon(kBlockItemId, font());
for (const BuildingDef& def : config->buildings.buildings)
{

View File

@@ -3,7 +3,6 @@
#include <cmath>
#include <string>
#include <QFontMetrics>
#include <QHBoxLayout>
#include <QIcon>
#include <QLabel>
@@ -120,19 +119,11 @@ void HeaderBar::handleEvent(std::shared_ptr<const ExpansionCostChangedEvent> /*e
updateExpandButton();
}
QPixmap HeaderBar::blockIcon() const
{
if (!m_itemIcons->hasIcon(kBlockItemId)) { return QPixmap(); }
// Sized to the header text height so it sits inline with the caption.
const int sizePx = QFontMetrics(font()).height();
return m_itemIcons->getPixmap(kBlockItemId, sizePx);
}
void HeaderBar::updateBlocksLabel()
{
const int blocks = m_sim->getBuildingBlocksStock();
const QPixmap icon = blockIcon();
const QPixmap icon = m_itemIcons->getInlineIcon(kBlockItemId, font());
if (icon.isNull())
{
// Fallback text form when no building_block icon exists (REQ-UI-BLOCKS-ICON).
@@ -151,7 +142,7 @@ void HeaderBar::updateExpandButton()
m_expandButton->setEnabled(blocks >= expansionCost);
const QPixmap icon = blockIcon();
const QPixmap icon = m_itemIcons->getInlineIcon(kBlockItemId, font());
if (icon.isNull())
{
// Fallback text form when no building_block icon exists (REQ-UI-EXPAND-BUTTON).

View File

@@ -61,10 +61,6 @@ private:
// (REQ-UI-BLOCKS-ICON).
void updateBlocksLabel();
// The building_block icon at the header's text height, or a null pixmap when no
// icon file exists. Loaded once via m_itemIcons on first use.
QPixmap blockIcon() const;
QLabel* m_timeLabel;
QLabel* m_blocksLabel;
QLabel* m_artifactsLabel;

View File

@@ -21,8 +21,11 @@ QPixmap renderCaptionWithIcon(const QString& text, const QPixmap& icon,
// their raw size is not the size to lay them out at.
QSize getLogicalSize(const QPixmap& pixmap);
// Item id of the building blocks resource, whose icon stands in for the "Blocks"
// word wherever a cost or stock is captioned (REQ-UI-BLOCKS-ICON, REQ-UI-BUILD-COST,
// REQ-UI-EXPAND-BUTTON). It lives next to the caption helper because every caller of
// one is a caller of the other.
// The items whose icons stand in for their names beside a number, drawn bare and without
// their colored square (REQ-UI-ITEM-ICON): building blocks beside a cost or a stock
// (REQ-UI-BLOCKS-ICON, REQ-UI-BUILD-COST, REQ-UI-EXPAND-BUTTON), and scrap beside the
// amount left in debris (REQ-UI-DEBRIS-PANEL, REQ-UI-FIELD-MULTI-SELECTION). They live
// next to the caption helper because every caller of one is a caller of the other;
// ItemIconCache::getInlineIcon() is what turns an id here into that icon.
const char* const kBlockItemId = "building_block";
const char* const kScrapItemId = "scrap";

View File

@@ -1,6 +1,7 @@
#include "ItemIconCache.h"
#include <QFile>
#include <QFontMetrics>
#include <QPainter>
#include <QRectF>
#include <QSvgRenderer>
@@ -94,6 +95,12 @@ QPixmap ItemIconCache::getPixmap(const std::string& itemId, int sizePx)
return getPixmap(itemId, itemId, sizePx, false);
}
QPixmap ItemIconCache::getInlineIcon(const std::string& itemId, const QFont& font)
{
if (!hasIcon(itemId)) { return QPixmap(); }
return getPixmap(itemId, QFontMetrics(font).height());
}
QPixmap ItemIconCache::getPixmap(const std::string& cacheKey, const std::string& itemId,
int sizePx, bool withSquare)
{

View File

@@ -5,6 +5,7 @@
#include <utility>
#include <QByteArray>
#include <QFont>
#include <QPixmap>
#include <QString>
@@ -26,9 +27,11 @@ struct VisualsConfig;
// two forms: paintItem() for the world's fractional geometry, getSquarePixmap() for
// widgets that want a ready-made pixmap.
//
// The bare icon of getPixmap() has one remaining use: the inline building_block icon
// that stands in for the word "Blocks" beside a number (REQ-UI-BLOCKS-ICON), which is a
// decoration on a line of text rather than an item display and takes no square.
// The bare icon of getPixmap() has one remaining use: an inline icon standing in for the
// item's name beside a number -- building blocks beside a cost or a stock
// (REQ-UI-BLOCKS-ICON), scrap beside the amount left in debris (REQ-UI-DEBRIS-PANEL) --
// which is a decoration on a line of text rather than an item display and takes no
// square. getInlineItemIcon() in IconCaption.h is how callers ask for that form.
//
// A missing icon file is not an error: hasIcon() returns false for it and the item shows
// its colored square alone.
@@ -64,6 +67,11 @@ public:
// icon file (callers should gate on hasIcon()).
QPixmap getPixmap(const std::string& itemId, int sizePx);
// The same bare icon sized to the height of `font`'s text, for the inline form above:
// an icon standing in for the item's name on a line of text. Null when the item has
// no icon file, which is not an error -- the caller names the item in words instead.
QPixmap getInlineIcon(const std::string& itemId, const QFont& font);
// Drops every rasterized pixmap. Called when the visuals are reloaded on a restart
// (REQ-CFG-RELOAD), because the composed squares carry the colors they were painted
// with; they are re-rasterized on next use.

View File

@@ -27,7 +27,7 @@ struct ItemProduction
//
// What counts as available differs by building type, because only Miner and Assembler
// recipes are individually unlocked (REQ-LOCK-UI-RECIPE): those are filtered by the
// unlock state, while a Smelter's or Reprocessing Plant's implicit recipes
// unlock state, while a Smelter's or Reprocessing Plant's recipes
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) are filtered by whether their building is
// unlocked yet (REQ-LOCK-BUILDING) -- there is no sense in naming a path through a
// plant the player cannot place.

View File

@@ -50,7 +50,13 @@ std::vector<RecipeSelectionOption> buildRecipeSelectionOptions(
BuildingType type, Simulation& sim, const GameConfig& config)
{
std::vector<RecipeSelectionOption> options;
options.push_back({std::string(), QObject::tr("(None)"), RecipeLineRow::Spec()});
// The clearing option. On a building that picks its own recipe it does not leave the
// building idle but hands it back to that selection, and says so
// (REQ-UI-SELECT-OPTIONS, REQ-BLD-AUTO-RECIPE).
options.push_back({std::string(),
isAutoRecipeBuildingType(type) ? QObject::tr("(Auto)")
: QObject::tr("(None)"),
RecipeLineRow::Spec()});
if (type == BuildingType::Shipyard)
{

View File

@@ -1,75 +0,0 @@
#include "AutoProductionContent.h"
#include "Building.h"
#include "BuildingTarget.h"
#include "GameConfig.h"
#include "ProductionRules.h"
AutoProductionContent::AutoProductionContent(const SelectionContext& context,
const SelectionRequest& request,
QWidget* parent)
: BufferedBuildingContent(context, request.buildings.front(), parent)
{
}
BufferedBuildingContent::CycleInfo AutoProductionContent::getCycleInfo(
const BuildingTarget& target) const
{
CycleInfo info;
// An auto-recipe building always runs an implicit recipe (REQ-BLD-SMELTER,
// REQ-BLD-REPROCESSING), so its production section is always shown -- but only a
// running cycle names a recipe, so while it is idle there is no cycle to describe.
info.runsProduction = true;
if (target.building)
{
// What the building handles at all, so its buffers are not blank whenever it
// happens to be between cycles (REQ-UI-SINGLE-SELECTION). This is the same union
// of every recipe of its type that the simulation sized the buffers over, and
// the locked ones among them are dropped when the card lists them.
for (const RecipeDef* recipe :
gatherCandidateRecipes(*getContext().config, *target.building))
{
for (const RecipeIngredient& ingredient : recipe->inputs)
{
info.handledInputs.push_back(ingredient.item);
}
for (const RecipeOutput& output : recipe->outputs)
{
info.handledOutputs.push_back(output.item);
}
}
}
// Which recipe describes the cycle: the one running, or -- between cycles -- the one
// that ran last. Dropping it while idle would take the summary row and the chips'
// per-cycle amounts away and bring them back with every cycle, resizing the card in
// step with the building's status (REQ-UI-RECIPE-SUMMARY). Only a building that has
// never run has nothing to describe.
if (target.building && target.building->production.has_value())
{
m_lastRecipeId = target.building->production->recipeId;
}
if (!target.building || m_lastRecipeId.empty())
{
return info;
}
const RecipeDef* recipe =
getContext().config->recipes.findRecipeDef(m_lastRecipeId, target.type);
if (!recipe)
{
return info;
}
for (const RecipeIngredient& ingredient : recipe->inputs)
{
info.perCycleInputs[ingredient.item] = ingredient.amount;
}
for (const RecipeOutput& output : recipe->outputs)
{
info.perCycleOutputs[output.item] = output.amount;
}
info.durationSeconds = recipe->durationSeconds;
return info;
}

View File

@@ -1,29 +0,0 @@
#pragma once
#include <string>
#include "BufferedBuildingContent.h"
#include "SelectionContentFactory.h"
// The card for a Smelter or a Reprocessing Plant (REQ-UI-SELECTION-CONTENT). Both
// auto-process whatever they receive and have no player-facing recipe selection
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING), so the card has no configuration group at
// all; its cycle is whichever recipe is in production, or the last one that was.
class AutoProductionContent : public BufferedBuildingContent
{
Q_OBJECT
public:
AutoProductionContent(const SelectionContext& context,
const SelectionRequest& request, QWidget* parent = nullptr);
protected:
CycleInfo getCycleInfo(const BuildingTarget& target) const override;
private:
// The recipe last seen in production, which keeps describing the cycle while the
// building sits between cycles (REQ-UI-RECIPE-SUMMARY). Mutable because it is a
// record of what getCycleInfo() has observed rather than state of its own: the card
// shows the same thing whether or not it has been asked before.
mutable std::string m_lastRecipeId;
};

View File

@@ -75,7 +75,7 @@ BufferedBuildingContent::BufferedBuildingContent(const SelectionContext& context
m_production = new ProductionSection(this);
m_outputSection = new SectionBox(tr("Output buffer"), this);
m_outputSection = new SectionBox(tr("Output buffers"), this);
m_outputChips = new ItemChipRow(context, m_outputSection);
m_outputSection->getContentLayout()->addWidget(m_outputChips);
@@ -200,11 +200,16 @@ std::vector<ItemChipRow::Entry> BufferedBuildingContent::buildOutputEntries(
ItemChipRow::Entry chip;
chip.itemId = itemId;
// Counted against the buffer's capacity, which is what production stops at
// (REQ-MAT-OUTPUT-BUFFER).
chip.countText = building.outputBuffer.capacity > 0
? tr("%1 / %2").arg(lookUp(buffered, itemId))
.arg(building.outputBuffer.capacity)
// Counted against this item's own buffer capacity, which is what production
// stops at (REQ-MAT-OUTPUT-BUFFER, REQ-UI-SINGLE-SELECTION). A chip for an item
// the building has no buffer for -- one left over from a previous recipe --
// carries the bare count, as an unsized buffer has no denominator to state.
const std::map<ItemType, int>::const_iterator capIt =
building.outputBuffer.caps.find(ItemType{itemId});
const int cap =
(capIt != building.outputBuffer.caps.end()) ? capIt->second : 0;
chip.countText = cap > 0
? tr("%1 / %2").arg(lookUp(buffered, itemId)).arg(cap)
: QString::number(lookUp(buffered, itemId));
chip.subLine = QString::fromStdString(toDisplayName(itemId));
entries.push_back(chip);

View File

@@ -20,7 +20,6 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ClearBeltControl.h
${CMAKE_CURRENT_SOURCE_DIR}/BufferedBuildingContent.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeProductionContent.h
${CMAKE_CURRENT_SOURCE_DIR}/AutoProductionContent.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipyardContent.h
${CMAKE_CURRENT_SOURCE_DIR}/StorageContent.h
${CMAKE_CURRENT_SOURCE_DIR}/HqContent.h
@@ -55,7 +54,6 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/ClearBeltControl.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BufferedBuildingContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/RecipeProductionContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/AutoProductionContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipyardContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/StorageContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/HqContent.cpp

View File

@@ -3,6 +3,8 @@
#include <QVBoxLayout>
#include "DebrisScrap.h"
#include "IconCaption.h"
#include "ItemIconCache.h"
#include "Simulation.h"
#include "StatRow.h"
@@ -10,8 +12,16 @@ DebrisContent::DebrisContent(const SelectionContext& context,
const SelectionRequest& request, QWidget* parent)
: SelectionContent(context, std::nullopt, parent)
, m_debris(request.debris)
, m_scrapIcon(context.itemIcons->getInlineIcon(kScrapItemId, font()))
{
m_scrapRow = new StatRow(tr("Scrap remaining"), this);
// The row states the amount and the bare scrap icon, the icon standing in for the
// word so the row spells out neither the item nor its unit -- the form the header bar
// states the block stock in (REQ-UI-DEBRIS-PANEL, REQ-UI-ITEM-ICON,
// REQ-UI-BLOCKS-ICON). Without an icon file the caption has to name the item again,
// since nothing else on the row would.
m_scrapRow = new StatRow(
m_scrapIcon.isNull() ? tr("Scrap remaining") : tr("Remaining"), this);
m_scrapRow->setValueIcon(m_scrapIcon);
m_scrapRow->setValueEmphasized(true);
getRuntimeLayout()->addWidget(m_scrapRow);

View File

@@ -2,6 +2,8 @@
#include <vector>
#include <QPixmap>
#include "entt/entity/entity.hpp"
#include "SelectionContent.h"
@@ -28,4 +30,8 @@ protected:
private:
std::vector<entt::entity> m_debris;
StatRow* m_scrapRow;
// The bare scrap icon the row states its amount with, or null where there is no icon
// file and the row names the item in words instead (REQ-UI-DEBRIS-PANEL). Taken once:
// it depends on the card's font, not on the value.
QPixmap m_scrapIcon;
};

View File

@@ -10,6 +10,8 @@
#include "DisplayName.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "IconCaption.h"
#include "ItemIconCache.h"
#include "ShipIdentityComponent.h"
#include "Simulation.h"
#include "StatRow.h"
@@ -20,6 +22,7 @@ FieldMultiContent::FieldMultiContent(const SelectionContext& context,
: SelectionContent(context, std::nullopt, parent)
, m_debris(request.debris)
, m_scrapRow(nullptr)
, m_scrapIcon(context.itemIcons->getInlineIcon(kScrapItemId, font()))
{
setIdentity(QPixmap(), tr("Mixed selection"));
setCountSlot(static_cast<int>(request.actors.size() + request.debris.size()));
@@ -84,9 +87,11 @@ void FieldMultiContent::buildSummary(const std::vector<entt::entity>& actors)
getRuntimeLayout()->addWidget(new CountRow(
QPixmap(), tr("Debris"), static_cast<int>(m_debris.size()), this));
// Indented under the debris row, so the total reads as belonging to it
// (REQ-UI-DEBRIS-PANEL).
// Indented under the debris row, so the total reads as belonging to it, and
// stated in the same amount-plus-bare-icon form the debris card uses
// (REQ-UI-DEBRIS-PANEL, REQ-UI-FIELD-MULTI-SELECTION).
m_scrapRow = new StatRow(tr("holding"), this);
m_scrapRow->setValueIcon(m_scrapIcon);
m_scrapRow->setIndented(true);
m_scrapRow->setValueEmphasized(true);
getRuntimeLayout()->addWidget(m_scrapRow);
@@ -99,7 +104,9 @@ void FieldMultiContent::refreshRuntime()
// selection and rebuilds this card -- but the scrap falls as the debris is collected.
if (m_scrapRow)
{
m_scrapRow->setValue(tr("%1 scrap")
.arg(sumDebrisScrap(getContext().sim->getAdmin(), m_debris)));
const int scrap = sumDebrisScrap(getContext().sim->getAdmin(), m_debris);
// The icon names the item; only without one does the value have to say the word.
m_scrapRow->setValue(m_scrapIcon.isNull() ? tr("%1 scrap").arg(scrap)
: QString::number(scrap));
}
}

View File

@@ -2,6 +2,8 @@
#include <vector>
#include <QPixmap>
#include "entt/entity/entity.hpp"
#include "SelectionContent.h"
@@ -30,4 +32,7 @@ private:
// Null unless debris is part of the selection; the only value here that changes
// while the selection stands.
StatRow* m_scrapRow;
// The bare scrap icon the sub-row states its total with, or null where there is no
// icon file and the total names the item in words instead (REQ-UI-DEBRIS-PANEL).
QPixmap m_scrapIcon;
};

View File

@@ -10,6 +10,8 @@
#include "CountRow.h"
#include "FactoryQueries.h"
#include "GameConfig.h"
#include "IconCaption.h"
#include "ItemIconCache.h"
#include "SelectionNames.h"
#include "Simulation.h"
#include "StatRow.h"
@@ -92,7 +94,13 @@ void MultiBuildingContent::buildSummary()
}
}
// The block icon stands beside the total in place of the word, as it does on a build
// button and a blueprint card (REQ-UI-MULTI-SELECTION, REQ-UI-BLOCKS-ICON). With no
// icon file the total is the bare number, as it is there
// (REQ-UI-BUILD-COST, REQ-UI-BLUEPRINT-CARD).
StatRow* totalRow = new StatRow(tr("Total cost"), this);
totalRow->setValueIcon(
getContext().itemIcons->getInlineIcon(kBlockItemId, font()));
totalRow->setValue(QString::number(totalCost));
totalRow->setValueEmphasized(true);
getRuntimeLayout()->addWidget(totalRow);

View File

@@ -1,6 +1,5 @@
#include "SelectionContentFactory.h"
#include "AutoProductionContent.h"
#include "BeltContent.h"
#include "DebrisContent.h"
#include "FactoryQueries.h"
@@ -27,10 +26,9 @@ SelectionContentKind getKindForType(BuildingType type)
{
case BuildingType::Miner:
case BuildingType::Assembler:
return SelectionContentKind::RecipeProduction;
case BuildingType::Smelter:
case BuildingType::ReprocessingPlant:
return SelectionContentKind::AutoProduction;
return SelectionContentKind::RecipeProduction;
case BuildingType::Shipyard:
return SelectionContentKind::Shipyard;
case BuildingType::SalvageBay:
@@ -160,8 +158,6 @@ SelectionContent* createContent(const ContentKey& key, const SelectionRequest& r
return nullptr;
case SelectionContentKind::RecipeProduction:
return new RecipeProductionContent(context, request, parent);
case SelectionContentKind::AutoProduction:
return new AutoProductionContent(context, request, parent);
case SelectionContentKind::Shipyard:
return new ShipyardContent(context, request, parent);
case SelectionContentKind::Storage:

View File

@@ -31,8 +31,7 @@ struct SelectionRequest
enum class SelectionContentKind
{
None, // nothing selected: the panel hides itself entirely
RecipeProduction, // Miner, Assembler
AutoProduction, // Smelter, Reprocessing Plant
RecipeProduction, // Miner, Assembler, Smelter, Reprocessing Plant
Shipyard,
Storage, // Salvage Bay
Hq,

View File

@@ -4,6 +4,8 @@
#include <QLabel>
#include <QPalette>
#include "IconCaption.h"
namespace
{
@@ -15,6 +17,7 @@ const int kIndentPx = 12;
StatRow::StatRow(const QString& label, QWidget* parent)
: QWidget(parent)
, m_valueEmphasized(false)
{
QHBoxLayout* layout = new QHBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
@@ -36,19 +39,48 @@ void StatRow::setLabel(const QString& label)
void StatRow::setValue(const QString& value)
{
m_valueLabel->setText(value);
m_value = value;
updateValue();
}
void StatRow::setValueIcon(const QPixmap& icon)
{
m_valueIcon = icon;
updateValue();
}
void StatRow::setValueEmphasized(bool emphasized)
{
m_valueEmphasized = emphasized;
QPalette valuePalette = m_valueLabel->palette();
valuePalette.setColor(QPalette::WindowText,
palette().color(emphasized ? QPalette::Highlight
: QPalette::WindowText));
valuePalette.setColor(QPalette::WindowText, getValueColor());
m_valueLabel->setPalette(valuePalette);
updateValue();
}
void StatRow::setIndented(bool indented)
{
layout()->setContentsMargins(indented ? kIndentPx : 0, 0, 0, 0);
}
void StatRow::updateValue()
{
if (m_valueIcon.isNull())
{
m_valueLabel->setText(m_value);
return;
}
// A label shows either text or a pixmap, so the two are composed into one -- the same
// way the header bar states the block stock (REQ-UI-BLOCKS-ICON). The color is passed
// in rather than left to the palette, which a pixmap does not follow.
m_valueLabel->setPixmap(renderCaptionWithIcon(m_value, m_valueIcon,
m_valueLabel->font(), getValueColor()));
}
QColor StatRow::getValueColor() const
{
return palette().color(m_valueEmphasized ? QPalette::Highlight
: QPalette::WindowText);
}

View File

@@ -1,5 +1,7 @@
#pragma once
#include <QColor>
#include <QPixmap>
#include <QString>
#include <QWidget>
@@ -20,6 +22,12 @@ public:
void setLabel(const QString& label);
void setValue(const QString& value);
// Draws an item icon after the value, bare and without its colored square: the icon
// stands in for the item's name beside the number rather than displaying the item
// (REQ-UI-ITEM-ICON), which is how a debris card states its scrap
// (REQ-UI-DEBRIS-PANEL). A null pixmap -- what a missing icon file yields -- leaves
// the value plain text, and the caller names the item in the label instead.
void setValueIcon(const QPixmap& icon);
// Draws the value in the palette's highlight color rather than its text color, for
// the one value a card is really about.
void setValueEmphasized(bool emphasized);
@@ -28,6 +36,15 @@ public:
void setIndented(bool indented);
private:
// Re-states the value in whichever form it currently takes. Needed because the icon
// form is one composed pixmap, which has to be built anew whenever the text or the
// color it is drawn in changes.
void updateValue();
QColor getValueColor() const;
QLabel* m_labelLabel;
QLabel* m_valueLabel;
QString m_value;
QPixmap m_valueIcon;
bool m_valueEmphasized;
};