Gas burner building and the energy resource (#54)

A new building that consumes gas to produce energy, a new resource
stored and moved exactly like ore and gas.

The resource: EnergyResourceGlyph (a cell/bolt mark) + energy's own
warm yellow-green ResourceDefinition, registered in
ResourceDefinitions.ORDER/DEFINITIONS and GameState.RESOURCE_ENERGY.
Inventory/ResourceBar/ResourceTransferList needed no changes - #49
already made them resource-agnostic, confirmed structurally in the
test rather than touched.

The building: GasBurnerBuildingVisual (a hexagonal chamber with intake
vents in gas's colour and output ports in energy's, so the silhouette
itself reads "gas in, energy out"), GasBurnerBuildingDefinition
(capabilities=["gas_burner"], its own build-menu icon appended to
IconButton.Icon rather than inserted, per the ticket's own warning
about renumbering stored scene values), and the matching
UI/BuildMenu/GasBurner* nodes in Main.tscn. Gated behind a new
"Power Systems" skill-tree node, mirroring how "freight" unlocks the
transport hub.

Conversion: _advance_gas_burners(), simulated in GameState alongside
_advance_production/extractor mining so it keeps running while the
player is elsewhere. Withdraws gas and deposits energy in the
building's OWN inventory only - never the bank - so it stalls with no
local gas exactly like a lab waiting on materials.

Logistics: a gas burner's ongoing gas deficit is folded into the
existing queued-building-supply dispatch path (same "needs resource X"
loop production/research orders already use, extended with
gas_burner_gas_deficit()), and a new TO_BUILDING_PICKUP/
TO_MOTHERSHIP_FROM_BUILDING transporter state pair collects its energy
to the bank, mirroring the extractor pickup path per the ticket. The
existing f5c60f8 priority (queued deliveries outrank collection) holds:
the pickup dispatch loop runs last.

Tests: new tests/test_gas_burner.gd (resource genericity, stall
semantics, research gate, and full delivery+pickup logistics via a
real hub), run_tests transporter/resource regressions, and screenshots
of the build menu (all 7 buildings) and transfer menu (all 3
resources). Full suite: 81/81.

Two real bugs found and fixed along the way, neither gas-burner-
specific:
- _transporter_inbound_to() matched ANY non-idle transporter with a
  matching target_id, regardless of purpose. Once pickup states shared
  target_id with delivery states (both address a building, not an
  extractor), a transporter delivering gas made the SAME building look
  "busy" to the unrelated energy-pickup dispatch, starving it even
  with a second, genuinely free transporter sitting idle at the hub.
  Split into _transporter_inbound_to() (delivery states only) and a
  new _transporter_inbound_for_pickup() (pickup states only).
- The build menu's 3-row/6-slot layout (MENU_ROW_TOPS + row content
  sizing) had no room for a 7th building - despite the ticket's own
  note that no layout changes should be needed. A naive 4th row ran
  off the bottom of a 720x1280 viewport; fitting one meant both adding
  a 4th row top AND shrinking the shared button/label/cost sizing so
  each row's content no longer taller than the compressed row spacing.
  Caught only by actually screenshotting it - a real build menu, not
  headless, since --headless renders nothing.

🤖 Generated with Claude Code

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-16 12:10:31 +02:00
commit 097e17f7aa
18 changed files with 567 additions and 12 deletions

View file

@ -0,0 +1,21 @@
extends Node2D
# Preview, not a test - #54's build menu screenshot. Everything researched,
# including power_systems, so all 7 buildings show at once: the 7th (gas
# burner) is the first to land beyond the menu's original 3-row/6-slot
# layout, worth checking for fit even though the ticket calls
# BuildController._layout_build_menu() already generic enough to need no
# changes.
#
# bash tests/screenshot.sh res://tests/_gas_burner_build_menu_preview.tscn /tmp/menu.png 20 720x1280
func _ready() -> void:
GameState.buildings.clear()
GameState.extractors.clear()
GameState.rocks = 500
GameState.unlocked = ["shipyards", "logistics", "coordination", "freight", "vessel_science", "power_systems"]
var main: Node = load("res://Main.tscn").instantiate()
add_child(main)
main.get_node("UI/BuildMenu").visible = true
main.get_node("BuildController")._refresh_build_status()

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_gas_burner_build_menu_preview.gd" id="1"]
[node name="GasBurnerBuildMenuPreview" type="Node2D"]
script = ExtResource("1")

View file

@ -0,0 +1,31 @@
extends Node2D
# Preview, not a test - #54's "transfer menu with three resources laid out
# cleanly" screenshot. A gas burner holding all three resources at once
# (ore incidentally, gas as its fuel, energy as its output) exercises the
# transfer list at its widest.
#
# bash tests/screenshot.sh res://tests/_gas_burner_transfer_preview.tscn /tmp/m.png 20 720x1280
func _ready() -> void:
GameState.buildings.clear()
GameState.extractors.clear()
GameState.transporters.clear()
GameState.rocks = 180
GameState.gas = 90
GameState.fleet_cargo = 24
GameState.fleet_gas_cargo = 12
GameState.unlocked = ["shipyards", "logistics", "freight", "power_systems"]
var main: Node = load("res://Main.tscn").instantiate()
add_child(main)
main.get_node("BuildingField").place(GameState.BUILDING_GAS_BURNER, Vector2(400.0, 0.0))
var burner_id: int = int(GameState.buildings[-1].id)
GameState.deposit_resource_to_building(burner_id, GameState.RESOURCE_ORE, 14)
GameState.deposit_resource_to_building(burner_id, GameState.RESOURCE_GAS, 22)
GameState.deposit_resource_to_building(burner_id, GameState.RESOURCE_ENERGY, 36)
var bc: Node = main.get_node("BuildController")
bc.transfer_building_id = burner_id
main.get_node("UI/TransferMenu").visible = true
bc._refresh_transfer()
bc.transfer_status.text = "Moved 25 ore into this building"

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_gas_burner_transfer_preview.gd" id="1"]
[node name="GasBurnerTransferPreview" type="Node2D"]
script = ExtResource("1")

163
tests/test_gas_burner.gd Normal file
View file

@ -0,0 +1,163 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_gas_burner.gd
# Forgejo #54: gas burner building + the energy resource, stored/moved
# exactly like ore and gas.
#
# Modeled on tests/test_extractor_income.gd's shape: pure GameState state,
# no scene loaded, poking buildings/inventories directly and waiting out
# real production/transporter timing with generous margins rather than
# fixed frame counts (SceneTree physics can run ahead of _process(), trap 5
# in the repo notes).
#
# Covers:
# A. energy is fully generic - in resource_types(), has its own
# ResourceDefinition/glyph/colour - with no per-resource code anywhere
# (ResourceBar/ResourceTransferList already iterate resource_types())
# B. a burner with local gas converts it to energy, into its OWN
# inventory, at the configured rate; with no gas it stalls and never
# touches the bank
# C. the building is gated behind its own research node
# D. a hub transporter both delivers gas to a burner and collects its
# energy, following the existing supply/pickup dispatch paths
var game_state: Node
var stage: int = 0
var elapsed: float = 0.0
var mothership_id: int = 0
var hub_id: int = 0
var burner_id: int = 0
var load_limit: int = 0
var energy_before_stall: int = 0
const MOTHERSHIP_AT := Vector2.ZERO
const HUB_AT := Vector2(150.0, 0.0)
const BURNER_AT := Vector2(400.0, 0.0)
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.rocks = 0
game_state.gas = 0
game_state.buildings.clear()
game_state.extractors.clear()
game_state.transporters.clear()
game_state.reset_progression()
mothership_id = game_state.add_building(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
burner_id = game_state.add_building(GameState.BUILDING_GAS_BURNER, BURNER_AT)
game_state.deposit_resource_to_building(burner_id, GameState.RESOURCE_GAS, 20)
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
# --- A. energy is fully generic -------------------------------
if not game_state.resource_types().has(GameState.RESOURCE_ENERGY):
return _fail("energy is not in resource_types() - the HUD/transfer list would never show it")
var definition: Resource = game_state.resource_definition(GameState.RESOURCE_ENERGY)
if definition == null or definition.glyph_scene == null:
return _fail("energy has no ResourceDefinition or glyph_scene")
var energy_color: Color = game_state.resource_color(GameState.RESOURCE_ENERGY)
if energy_color == game_state.resource_color(GameState.RESOURCE_ORE) \
or energy_color == game_state.resource_color(GameState.RESOURCE_GAS):
return _fail("energy shares a colour with ore or gas")
print("energy is generic: in resource_types(), own definition, own colour %s" % energy_color)
# --- B. produces energy from local gas at the configured rate -
if elapsed < 2.5:
return false
var gas_left: int = game_state.building_resource(burner_id, GameState.RESOURCE_GAS)
var energy_made: int = game_state.building_resource(burner_id, GameState.RESOURCE_ENERGY)
var gas_burned: int = 20 - gas_left
if gas_burned < 2:
return _fail("burner only burned %d gas in 2.5s at rate %.1f/s" % [gas_burned, GameState.GAS_BURN_RATE])
if energy_made != gas_burned * GameState.ENERGY_PER_GAS:
return _fail("burner made %d energy for %d gas burned, expected %dx" % [
energy_made, gas_burned, GameState.ENERGY_PER_GAS
])
if game_state.rocks != 0 or game_state.gas != 0:
return _fail("conversion touched the bank (rocks=%d gas=%d) - it must only use local stock" % [
game_state.rocks, game_state.gas
])
print("burner converted %d gas -> %d energy locally, bank untouched" % [gas_burned, energy_made])
# --- stall with no gas -----------------------------------------
var burner_inventory: RefCounted = game_state.building_inventory(burner_id)
burner_inventory.withdraw(GameState.RESOURCE_GAS, burner_inventory.amount(GameState.RESOURCE_GAS))
energy_before_stall = game_state.building_resource(burner_id, GameState.RESOURCE_ENERGY)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 1.5:
return false
var energy_now: int = game_state.building_resource(burner_id, GameState.RESOURCE_ENERGY)
if energy_now != energy_before_stall:
return _fail("burner made energy with no local gas (energy %d -> %d) - it must stall" % [
energy_before_stall, energy_now
])
print("stalled with no gas: energy held at %d" % energy_now)
# --- C. gated behind research -----------------------------------
if game_state.is_building_unlocked(GameState.BUILDING_GAS_BURNER):
return _fail("gas_burner is buildable with no research at all")
var gate: String = game_state.research_for_building(GameState.BUILDING_GAS_BURNER)
if gate == "":
return _fail("no research node gates gas_burner")
game_state.unlocked = ["shipyards", "logistics", "freight", "power_systems"]
if not game_state.is_building_unlocked(GameState.BUILDING_GAS_BURNER):
return _fail("still locked after researching '%s'" % gate)
print("locked behind research '%s', unlockable" % gate)
# --- D. logistics: reset for a fresh delivery/pickup test -----
game_state.buildings.clear()
game_state.transporters.clear()
game_state.gas = 200
mothership_id = game_state.add_building(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
hub_id = game_state.add_building(GameState.BUILDING_TRANSPORT_HUB, HUB_AT)
burner_id = game_state.add_building(GameState.BUILDING_GAS_BURNER, BURNER_AT)
if game_state.transporters.size() != game_state.hub_capacity():
return _fail("hub did not provision its transporters")
load_limit = game_state.transporter_load_limit()
elapsed = 0.0
stage = 2
return false
2:
# --- a transporter delivers gas to the burner ------------------
if game_state.building_resource(burner_id, GameState.RESOURCE_GAS) > 0:
print("transporter delivered gas to the burner: %d" % [
game_state.building_resource(burner_id, GameState.RESOURCE_GAS)
])
# Force the burner's local energy up to a full load so pickup
# dispatch fires immediately, rather than waiting out the
# genuinely slow conversion rate for a second, separate check.
game_state.building_inventory(burner_id).set_amount(GameState.RESOURCE_ENERGY, load_limit)
elapsed = 0.0
stage = 3
return false
if elapsed > 10.0:
return _fail("no gas was ever delivered to the burner")
return false
3:
# --- a transporter collects the burner's energy to the bank ---
if game_state.banked_resource(GameState.RESOURCE_ENERGY) >= load_limit:
print("transporter collected %d energy to the bank" % game_state.banked_resource(GameState.RESOURCE_ENERGY))
print("TEST_GAS_BURNER: PASS")
quit(0)
return true
if elapsed > 10.0:
return _fail("burner's energy was never collected (bank=%d burner=%d)" % [
game_state.banked_resource(GameState.RESOURCE_ENERGY),
game_state.building_resource(burner_id, GameState.RESOURCE_ENERGY),
])
return false
return false
func _fail(message: String) -> bool:
print("TEST_GAS_BURNER: FAIL (%s)" % message)
quit(1)
return true