spaceship/GameState.gd

1868 lines
70 KiB
GDScript3
Raw Normal View History

extends Node
2026-08-14 20:32:58 +02:00
const INVENTORY = preload("res://Inventory.gd")
const RESOURCE_DEFINITIONS = preload("res://ResourceDefinitions.gd")
const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd")
const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd")
const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd")
Rework how a game starts: dev mode, a bare opening, and fleet growth Adds a Dev Mode entry to the main menu and takes the play-testing conveniences out of a normal game, which turn out to be the same change from two directions. DEV MODE. Banks 2000 of every resource, grants 500 research points, and plants one of each planet type in the chunks around the spawn. Built ON TOP of reset_new_game() rather than beside it, so a dev game is a normal game plus a stock rather than a second definition of "fresh game" to keep in sync. Offered only when running from Godot - OS.has_feature("editor"), the tag an export clears - as is the map editor. Hidden AND guarded: pressing either anyway is refused, because visibility is presentation and the rule is the rule. The menu now lays its stack out in code. Hiding two of four entries with scene-authored offsets would leave holes in a shipped build - the same failure the HUD columns had - so it measures the entries that actually exist and centres on them. A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both reset_new_game and the autoload's own default, so the two cannot disagree), and no gas giant guaranteed in the origin chunk. Both were play-testing conveniences that had become part of the real world's shape: every player got the same landmark in the same place. Play-testing wanted all four planet types anyway, and a normal game wanted none of them - different needs, now different mechanisms. WorldGen cannot read GameState back (an autoload is not resolvable as a global identifier that early, the hazard PlanetSetups.gd already works around), so GameState.dev_mode's setter PUSHES the flag down. World generation stays static and dependency-free, GameState stays the single source of truth, and dev planting is applied after every rng draw so a dev world and a normal one differ only in those four chunks. FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND how many ships you start with - which forced a new game to open with a full fleet, leaving the first ship crafter nothing to do. Split into BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening has two empty slots to fill. A third copy of the old value turned up in PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently degraded into "however many slots exist" once max_fleet_size() clamped it. The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron (70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game luxury but the ordinary way out of the opening, and pricing them as a luxury made the early game feel stuck. Armada follows at 90/2. Two new tiers, Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in one lane would be a long monotonous chain for a branch that only ever does one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size() clamps to its length and a doctrine past that would charge real resources for nothing; test_fleet_growth.gd asserts that invariant directly. TEST FALLOUT, all of it from the deliberate change rather than a defect: - Tests that silently inherited the old 5-ship default now ask for the fleet they need. Several had to crew it from their first frame rather than _initialize(), where the engine's deferred tree-entry finalization undoes it - the same hazard those tests already documented for set_process(false). - test_planet_landing_proximity and test_return_position_matches_planet take the FIRST chunk holding a planet and land there. The guaranteed gas giant had been masking that this can now be a black hole, which destroys ships on contact. They exclude it, and clear the asteroids the field already placed. - test_pivot_controls measured rotation with angle_difference, which wraps at +/-pi, so a hold that turned a full circle read as no rotation. Latent flakiness that only ever showed under load; it now reads the controller's unwrapped pivot_hold_amount. - test_research_points was failing on ore, not points: can_unlock() checks the ore price too, and a fresh game banks nothing now. - test_gas_system's "a reachable gas source near the spawn" premise moved to dev mode, which is where that guarantee now lives. Full suite green at 97/97. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
# By path, not by its `WorldGen` global name: an autoload compiles before the
# global class table is necessarily populated, the same hazard PlanetSetups.gd
# works around.
const WORLD_GEN = preload("res://WorldGen.gd")
2026-08-14 20:32:58 +02:00
2026-08-14 16:05:32 +02:00
# Autoload singleton - the one thing that survives change_scene_to_file()
# scene swaps between space (Main.tscn) and a planet's surface
# (PlanetSurface.tscn). Everything else in either scene gets torn down
# and rebuilt from scratch on each swap, so anything that needs to
# survive the trip has to live here instead.
var has_return_position: bool = false
Rework how a game starts: dev mode, a bare opening, and fleet growth Adds a Dev Mode entry to the main menu and takes the play-testing conveniences out of a normal game, which turn out to be the same change from two directions. DEV MODE. Banks 2000 of every resource, grants 500 research points, and plants one of each planet type in the chunks around the spawn. Built ON TOP of reset_new_game() rather than beside it, so a dev game is a normal game plus a stock rather than a second definition of "fresh game" to keep in sync. Offered only when running from Godot - OS.has_feature("editor"), the tag an export clears - as is the map editor. Hidden AND guarded: pressing either anyway is refused, because visibility is presentation and the rule is the rule. The menu now lays its stack out in code. Hiding two of four entries with scene-authored offsets would leave holes in a shipped build - the same failure the HUD columns had - so it measures the entries that actually exist and centres on them. A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both reset_new_game and the autoload's own default, so the two cannot disagree), and no gas giant guaranteed in the origin chunk. Both were play-testing conveniences that had become part of the real world's shape: every player got the same landmark in the same place. Play-testing wanted all four planet types anyway, and a normal game wanted none of them - different needs, now different mechanisms. WorldGen cannot read GameState back (an autoload is not resolvable as a global identifier that early, the hazard PlanetSetups.gd already works around), so GameState.dev_mode's setter PUSHES the flag down. World generation stays static and dependency-free, GameState stays the single source of truth, and dev planting is applied after every rng draw so a dev world and a normal one differ only in those four chunks. FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND how many ships you start with - which forced a new game to open with a full fleet, leaving the first ship crafter nothing to do. Split into BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening has two empty slots to fill. A third copy of the old value turned up in PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently degraded into "however many slots exist" once max_fleet_size() clamped it. The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron (70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game luxury but the ordinary way out of the opening, and pricing them as a luxury made the early game feel stuck. Armada follows at 90/2. Two new tiers, Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in one lane would be a long monotonous chain for a branch that only ever does one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size() clamps to its length and a doctrine past that would charge real resources for nothing; test_fleet_growth.gd asserts that invariant directly. TEST FALLOUT, all of it from the deliberate change rather than a defect: - Tests that silently inherited the old 5-ship default now ask for the fleet they need. Several had to crew it from their first frame rather than _initialize(), where the engine's deferred tree-entry finalization undoes it - the same hazard those tests already documented for set_process(false). - test_planet_landing_proximity and test_return_position_matches_planet take the FIRST chunk holding a planet and land there. The guaranteed gas giant had been masking that this can now be a black hole, which destroys ships on contact. They exclude it, and clear the asteroids the field already placed. - test_pivot_controls measured rotation with angle_difference, which wraps at +/-pi, so a hold that turned a full circle read as no rotation. Latent flakiness that only ever showed under load; it now reads the controller's unwrapped pivot_hold_amount. - test_research_points was failing on ore, not points: can_unlock() checks the ore price too, and a fresh game banks nothing now. - test_gas_system's "a reachable gas source near the spawn" premise moved to dev mode, which is where that guarantee now lives. Full suite green at 97/97. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
var surviving_ship_count: int = STARTING_SHIP_COUNT
var surviving_vessel_types: PackedStringArray = PackedStringArray()
var return_world_position: Vector2 = Vector2.ZERO
var return_camera_zoom: float = 1.0
var planet_name: String = ""
var planet_id: String = ""
2026-08-14 16:13:24 +02:00
var planet_definition_id: String = "rocky"
# #59: 1.0 is today's behaviour. Applied at the point of use (Fleet's cruise
# anchor, Ship's own max_speed/acceleration) rather than baked into stored
# stats, so a speed upgrade researched while boosted can't corrupt the base
# value - see Fleet.gd/Ship.gd for the multiply sites.
Stacking distorders, menu layouts, lab rewards, enemy detail, artillery cross DISTORDER STACKING. Each space-time distorder vessel past the first raises the fleet's speed ceiling by 1.0, so a second one is worth crewing instead of being dead weight. GameState gains max_fleet_speed_multiplier, pushed by Fleet whenever composition changes - the same direction as every other fleet-derived figure, since GameState does not reach into the scene tree to count ships. The energy drain is normalised against the CURRENT ceiling, so full boost costs the same per second however wide it is: extra vessels buy more speed, not cheaper speed. Losing one re-clamps an over-ceiling boost rather than leaving the fleet travelling faster than its crew can sustain. TRANSFER MENU. Rows overlapped, and the last one ran into the hint text. row_height is divided from the available space, but each row's contents used a fixed pixel ladder - glyph at +10, buttons at +88, selector at +142 - which needs about 244px. Three resources fit; the fourth cut rows to 180 and every one spilled 64px into the next. Every offset is now a fraction of the row, so it cannot overflow. Also dropped the per-row AMOUNT caption (four copies of a word the footer hint already explains) and moved each glyph and name to the TOP of its row, where they read as its heading rather than the next row's. HANGAR. Six vessel types in one row gave each about 100px for a 155px label, so the names ran together. Now a 3x2 grid per section, sized from the space between the first heading and the status line, using the empty lower half. LAB RESEARCH REWARD. A harder enemy lab is worth more research points, and how hard a lab is depends on the layout authored for it - how many capture points, how many defenders - which the planet TYPE cannot know. PlanetSetup carries research_point_reward, set in the map editor, overriding the planet type's flat rate. 0 means "no opinion", so every map authored before this keeps its old value. RESEARCH LAB BACKGROUND. Its own shader instead of the crater field with a red tint, which read as "rocky planet, tinted" rather than someone's facility: plated floor, seams, service conduits, lit junctions. Loop-free by construction (#68's lesson applied up front) - conduits stay continuous across cells by keying a horizontal run to cell.y and a vertical to cell.x, so neighbouring cells agree without any cell scanning its neighbours. ENEMY DETAIL. Every enemy was a single flat outline, which left the artillery and the turret spawner as the same octagon at two sizes - the silhouette carried no information about what the thing did. EnemyDefinition gains detail_lines and detail_rings as DATA, so adding an enemy still means adding a definition rather than editing Enemy.gd, and the four non-default hulls are redrawn: a long-barrelled sniper, a squat mortar, a hive with launch bays, a capital ship with spine and ribs. Three things drew an enemy from its definition - the enemy, the map editor's markers, and the palette buttons - each reimplementing it from hull_points alone, so the new detail appeared in the game and nowhere else and an editor marker stopped matching what it placed. They now share EnemyDefinitions.draw_definition(). ARTILLERY. The 1.8s charge-up before the tower fired arrived long before the threat was legible and mostly read as the tower being stuck. It fires immediately now; the warning is the shell you can see in the air plus a small cross on the ground at the impact point. Deliberately not a blast-radius ring - a large translucent area was easy to read as the explosion having already happened, where the cross says "move off this spot". MAIN MENU. No Quit in any build (a web build has nothing to quit to, and Android's back gesture already does it), a plain black background, and the game is "Spaceship" - in the title and in config/name, which is what drives the browser tab for the web export. change_scene_to_file() returns an error and otherwise fails silently, leaving the menu up, which is indistinguishable from an unpressed button; it is now checked and reported on screen. Full suite green at 99/99. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:54:46 +02:00
# The ceiling ONE distorder vessel reaches. Each ADDITIONAL one raises it by
# another 1.0 - see set_distorder_vessel_count(). Kept as the one-vessel
# figure rather than an absolute cap so the fader's meaning at a single
# vessel is unchanged.
const MAX_FLEET_SPEED_MULTIPLIER: float = 3.0
Stacking distorders, menu layouts, lab rewards, enemy detail, artillery cross DISTORDER STACKING. Each space-time distorder vessel past the first raises the fleet's speed ceiling by 1.0, so a second one is worth crewing instead of being dead weight. GameState gains max_fleet_speed_multiplier, pushed by Fleet whenever composition changes - the same direction as every other fleet-derived figure, since GameState does not reach into the scene tree to count ships. The energy drain is normalised against the CURRENT ceiling, so full boost costs the same per second however wide it is: extra vessels buy more speed, not cheaper speed. Losing one re-clamps an over-ceiling boost rather than leaving the fleet travelling faster than its crew can sustain. TRANSFER MENU. Rows overlapped, and the last one ran into the hint text. row_height is divided from the available space, but each row's contents used a fixed pixel ladder - glyph at +10, buttons at +88, selector at +142 - which needs about 244px. Three resources fit; the fourth cut rows to 180 and every one spilled 64px into the next. Every offset is now a fraction of the row, so it cannot overflow. Also dropped the per-row AMOUNT caption (four copies of a word the footer hint already explains) and moved each glyph and name to the TOP of its row, where they read as its heading rather than the next row's. HANGAR. Six vessel types in one row gave each about 100px for a 155px label, so the names ran together. Now a 3x2 grid per section, sized from the space between the first heading and the status line, using the empty lower half. LAB RESEARCH REWARD. A harder enemy lab is worth more research points, and how hard a lab is depends on the layout authored for it - how many capture points, how many defenders - which the planet TYPE cannot know. PlanetSetup carries research_point_reward, set in the map editor, overriding the planet type's flat rate. 0 means "no opinion", so every map authored before this keeps its old value. RESEARCH LAB BACKGROUND. Its own shader instead of the crater field with a red tint, which read as "rocky planet, tinted" rather than someone's facility: plated floor, seams, service conduits, lit junctions. Loop-free by construction (#68's lesson applied up front) - conduits stay continuous across cells by keying a horizontal run to cell.y and a vertical to cell.x, so neighbouring cells agree without any cell scanning its neighbours. ENEMY DETAIL. Every enemy was a single flat outline, which left the artillery and the turret spawner as the same octagon at two sizes - the silhouette carried no information about what the thing did. EnemyDefinition gains detail_lines and detail_rings as DATA, so adding an enemy still means adding a definition rather than editing Enemy.gd, and the four non-default hulls are redrawn: a long-barrelled sniper, a squat mortar, a hive with launch bays, a capital ship with spine and ribs. Three things drew an enemy from its definition - the enemy, the map editor's markers, and the palette buttons - each reimplementing it from hull_points alone, so the new detail appeared in the game and nowhere else and an editor marker stopped matching what it placed. They now share EnemyDefinitions.draw_definition(). ARTILLERY. The 1.8s charge-up before the tower fired arrived long before the threat was legible and mostly read as the tower being stuck. It fires immediately now; the warning is the shell you can see in the air plus a small cross on the ground at the impact point. Deliberately not a blast-radius ring - a large translucent area was easy to read as the explosion having already happened, where the cross says "move off this spot". MAIN MENU. No Quit in any build (a web build has nothing to quit to, and Android's back gesture already does it), a plain black background, and the game is "Spaceship" - in the title and in config/name, which is what drives the browser tab for the web export. change_scene_to_file() returns an error and otherwise fails silently, leaving the menu up, which is indistinguishable from an unpressed button; it is now checked and reported on screen. Full suite green at 99/99. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:54:46 +02:00
# How far the fader can actually go right now, given the fleet's current
# composition. Pushed by Fleet whenever that composition changes, the same
# direction as every other fleet-derived figure here - GameState does not
# reach into the scene tree to count ships.
var max_fleet_speed_multiplier: float = MAX_FLEET_SPEED_MULTIPLIER
var fleet_speed_multiplier: float = 1.0
# #62: energy cost of sustaining a boost, scaled by how far above 1.0 the
# multiplier is - a small boost costs proportionally less, full boost costs
# ENERGY_DRAIN_PER_SECOND. Sitting at exactly 1.0 costs nothing.
const ENERGY_DRAIN_PER_SECOND: float = 2.0
var _distorder_drain_progress: float = 0.0
# One-shot notice, consumed and cleared by SpeedFader.gd: true the instant
# a sustained boost is force-reset for running the fleet hold's energy
# to zero, so the control (which may not even be part of the current
# scene tree at that exact frame) can show why on its next _process().
var boost_depleted_notice: bool = false
func set_fleet_speed_multiplier(value: float) -> void:
Stacking distorders, menu layouts, lab rewards, enemy detail, artillery cross DISTORDER STACKING. Each space-time distorder vessel past the first raises the fleet's speed ceiling by 1.0, so a second one is worth crewing instead of being dead weight. GameState gains max_fleet_speed_multiplier, pushed by Fleet whenever composition changes - the same direction as every other fleet-derived figure, since GameState does not reach into the scene tree to count ships. The energy drain is normalised against the CURRENT ceiling, so full boost costs the same per second however wide it is: extra vessels buy more speed, not cheaper speed. Losing one re-clamps an over-ceiling boost rather than leaving the fleet travelling faster than its crew can sustain. TRANSFER MENU. Rows overlapped, and the last one ran into the hint text. row_height is divided from the available space, but each row's contents used a fixed pixel ladder - glyph at +10, buttons at +88, selector at +142 - which needs about 244px. Three resources fit; the fourth cut rows to 180 and every one spilled 64px into the next. Every offset is now a fraction of the row, so it cannot overflow. Also dropped the per-row AMOUNT caption (four copies of a word the footer hint already explains) and moved each glyph and name to the TOP of its row, where they read as its heading rather than the next row's. HANGAR. Six vessel types in one row gave each about 100px for a 155px label, so the names ran together. Now a 3x2 grid per section, sized from the space between the first heading and the status line, using the empty lower half. LAB RESEARCH REWARD. A harder enemy lab is worth more research points, and how hard a lab is depends on the layout authored for it - how many capture points, how many defenders - which the planet TYPE cannot know. PlanetSetup carries research_point_reward, set in the map editor, overriding the planet type's flat rate. 0 means "no opinion", so every map authored before this keeps its old value. RESEARCH LAB BACKGROUND. Its own shader instead of the crater field with a red tint, which read as "rocky planet, tinted" rather than someone's facility: plated floor, seams, service conduits, lit junctions. Loop-free by construction (#68's lesson applied up front) - conduits stay continuous across cells by keying a horizontal run to cell.y and a vertical to cell.x, so neighbouring cells agree without any cell scanning its neighbours. ENEMY DETAIL. Every enemy was a single flat outline, which left the artillery and the turret spawner as the same octagon at two sizes - the silhouette carried no information about what the thing did. EnemyDefinition gains detail_lines and detail_rings as DATA, so adding an enemy still means adding a definition rather than editing Enemy.gd, and the four non-default hulls are redrawn: a long-barrelled sniper, a squat mortar, a hive with launch bays, a capital ship with spine and ribs. Three things drew an enemy from its definition - the enemy, the map editor's markers, and the palette buttons - each reimplementing it from hull_points alone, so the new detail appeared in the game and nowhere else and an editor marker stopped matching what it placed. They now share EnemyDefinitions.draw_definition(). ARTILLERY. The 1.8s charge-up before the tower fired arrived long before the threat was legible and mostly read as the tower being stuck. It fires immediately now; the warning is the shell you can see in the air plus a small cross on the ground at the impact point. Deliberately not a blast-radius ring - a large translucent area was easy to read as the explosion having already happened, where the cross says "move off this spot". MAIN MENU. No Quit in any build (a web build has nothing to quit to, and Android's back gesture already does it), a plain black background, and the game is "Spaceship" - in the title and in config/name, which is what drives the browser tab for the web export. change_scene_to_file() returns an error and otherwise fails silently, leaving the menu up, which is indistinguishable from an unpressed button; it is now checked and reported on screen. Full suite green at 99/99. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:54:46 +02:00
fleet_speed_multiplier = clampf(value, 1.0, max_fleet_speed_multiplier)
# Every distorder vessel past the first widens the fleet's speed ceiling by
# 1.0, so stacking them is worth doing rather than the second one being dead
# weight. Zero vessels returns the ceiling to its one-vessel value; the
# multiplier itself is reset separately by Fleet when the last one is lost.
#
# Re-clamps the CURRENT multiplier, so losing a distorder vessel mid-boost
# pulls the fleet down to the new ceiling instead of leaving it travelling
# faster than its remaining crew can sustain.
func set_distorder_vessel_count(count: int) -> void:
max_fleet_speed_multiplier = MAX_FLEET_SPEED_MULTIPLIER + maxf(float(count) - 1.0, 0.0)
set_fleet_speed_multiplier(fleet_speed_multiplier)
# #63: currency gated behind conquering a research_lab planet - required by
# both progression paths (skill tree nodes, vessel/upgrade research) on top
# of their existing ore/resource costs.
signal research_points_changed(total: int)
var research_points: int = 0
Rework how a game starts: dev mode, a bare opening, and fleet growth Adds a Dev Mode entry to the main menu and takes the play-testing conveniences out of a normal game, which turn out to be the same change from two directions. DEV MODE. Banks 2000 of every resource, grants 500 research points, and plants one of each planet type in the chunks around the spawn. Built ON TOP of reset_new_game() rather than beside it, so a dev game is a normal game plus a stock rather than a second definition of "fresh game" to keep in sync. Offered only when running from Godot - OS.has_feature("editor"), the tag an export clears - as is the map editor. Hidden AND guarded: pressing either anyway is refused, because visibility is presentation and the rule is the rule. The menu now lays its stack out in code. Hiding two of four entries with scene-authored offsets would leave holes in a shipped build - the same failure the HUD columns had - so it measures the entries that actually exist and centres on them. A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both reset_new_game and the autoload's own default, so the two cannot disagree), and no gas giant guaranteed in the origin chunk. Both were play-testing conveniences that had become part of the real world's shape: every player got the same landmark in the same place. Play-testing wanted all four planet types anyway, and a normal game wanted none of them - different needs, now different mechanisms. WorldGen cannot read GameState back (an autoload is not resolvable as a global identifier that early, the hazard PlanetSetups.gd already works around), so GameState.dev_mode's setter PUSHES the flag down. World generation stays static and dependency-free, GameState stays the single source of truth, and dev planting is applied after every rng draw so a dev world and a normal one differ only in those four chunks. FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND how many ships you start with - which forced a new game to open with a full fleet, leaving the first ship crafter nothing to do. Split into BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening has two empty slots to fill. A third copy of the old value turned up in PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently degraded into "however many slots exist" once max_fleet_size() clamped it. The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron (70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game luxury but the ordinary way out of the opening, and pricing them as a luxury made the early game feel stuck. Armada follows at 90/2. Two new tiers, Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in one lane would be a long monotonous chain for a branch that only ever does one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size() clamps to its length and a doctrine past that would charge real resources for nothing; test_fleet_growth.gd asserts that invariant directly. TEST FALLOUT, all of it from the deliberate change rather than a defect: - Tests that silently inherited the old 5-ship default now ask for the fleet they need. Several had to crew it from their first frame rather than _initialize(), where the engine's deferred tree-entry finalization undoes it - the same hazard those tests already documented for set_process(false). - test_planet_landing_proximity and test_return_position_matches_planet take the FIRST chunk holding a planet and land there. The guaranteed gas giant had been masking that this can now be a black hole, which destroys ships on contact. They exclude it, and clear the asteroids the field already placed. - test_pivot_controls measured rotation with angle_difference, which wraps at +/-pi, so a hold that turned a full circle read as no rotation. Latent flakiness that only ever showed under load; it now reads the controller's unwrapped pivot_hold_amount. - test_research_points was failing on ore, not points: can_unlock() checks the ore price too, and a fresh game banks nothing now. - test_gas_system's "a reachable gas source near the spawn" premise moved to dev mode, which is where that guarantee now lives. Full suite green at 97/97. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
# Set only by start_dev_game(), which the main menu offers only when running
# from Godot. Makes WorldGen plant one of every planet type around the spawn.
# Cleared by reset_new_game(), so starting a normal game after a dev one
# cannot leave a dev world behind.
#
# The setter mirrors it into WorldGen, which cannot read it back: world
# generation is static and dependency-free by design. This stays the single
# place to set it - assigning dev_mode anywhere, tests included, reaches
# world generation.
var dev_mode: bool = false:
set(value):
dev_mode = value
WORLD_GEN.dev_planets_enabled = value
# Explored-chunk history for the world map's fog-of-war reveal. Lives
# here (not on ExplorationTracker, which gets destroyed and recreated on
# every scene swap) so exploration progress isn't lost every time the
# player lands on and leaves a planet.
var explored_chunks: Dictionary = {}
# --- Resources -------------------------------------------------------------
2026-08-14 20:32:58 +02:00
const RESOURCE_ORE: String = RESOURCE_DEFINITIONS.ORE_ID
const RESOURCE_GAS: String = RESOURCE_DEFINITIONS.GAS_ID
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>
2026-08-16 12:10:31 +02:00
const RESOURCE_ENERGY: String = RESOURCE_DEFINITIONS.ENERGY_ID
const RESOURCE_DARK_MATTER: String = RESOURCE_DEFINITIONS.DARK_MATTER_ID
2026-08-14 20:32:58 +02:00
const RESOURCE_TYPES: PackedStringArray = RESOURCE_DEFINITIONS.ORDER
# All resource-bearing locations use the same container. The old public
# names below are computed compatibility accessors, not separate storage.
Rework how a game starts: dev mode, a bare opening, and fleet growth Adds a Dev Mode entry to the main menu and takes the play-testing conveniences out of a normal game, which turn out to be the same change from two directions. DEV MODE. Banks 2000 of every resource, grants 500 research points, and plants one of each planet type in the chunks around the spawn. Built ON TOP of reset_new_game() rather than beside it, so a dev game is a normal game plus a stock rather than a second definition of "fresh game" to keep in sync. Offered only when running from Godot - OS.has_feature("editor"), the tag an export clears - as is the map editor. Hidden AND guarded: pressing either anyway is refused, because visibility is presentation and the rule is the rule. The menu now lays its stack out in code. Hiding two of four entries with scene-authored offsets would leave holes in a shipped build - the same failure the HUD columns had - so it measures the entries that actually exist and centres on them. A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both reset_new_game and the autoload's own default, so the two cannot disagree), and no gas giant guaranteed in the origin chunk. Both were play-testing conveniences that had become part of the real world's shape: every player got the same landmark in the same place. Play-testing wanted all four planet types anyway, and a normal game wanted none of them - different needs, now different mechanisms. WorldGen cannot read GameState back (an autoload is not resolvable as a global identifier that early, the hazard PlanetSetups.gd already works around), so GameState.dev_mode's setter PUSHES the flag down. World generation stays static and dependency-free, GameState stays the single source of truth, and dev planting is applied after every rng draw so a dev world and a normal one differ only in those four chunks. FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND how many ships you start with - which forced a new game to open with a full fleet, leaving the first ship crafter nothing to do. Split into BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening has two empty slots to fill. A third copy of the old value turned up in PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently degraded into "however many slots exist" once max_fleet_size() clamped it. The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron (70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game luxury but the ordinary way out of the opening, and pricing them as a luxury made the early game feel stuck. Armada follows at 90/2. Two new tiers, Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in one lane would be a long monotonous chain for a branch that only ever does one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size() clamps to its length and a doctrine past that would charge real resources for nothing; test_fleet_growth.gd asserts that invariant directly. TEST FALLOUT, all of it from the deliberate change rather than a defect: - Tests that silently inherited the old 5-ship default now ask for the fleet they need. Several had to crew it from their first frame rather than _initialize(), where the engine's deferred tree-entry finalization undoes it - the same hazard those tests already documented for set_process(false). - test_planet_landing_proximity and test_return_position_matches_planet take the FIRST chunk holding a planet and land there. The guaranteed gas giant had been masking that this can now be a black hole, which destroys ships on contact. They exclude it, and clear the asteroids the field already placed. - test_pivot_controls measured rotation with angle_difference, which wraps at +/-pi, so a hold that turned a full circle read as no rotation. Latent flakiness that only ever showed under load; it now reads the controller's unwrapped pivot_hold_amount. - test_research_points was failing on ore, not points: can_unlock() checks the ore price too, and a fresh game banks nothing now. - test_gas_system's "a reachable gas source near the spawn" premise moved to dev mode, which is where that guarantee now lives. Full suite green at 97/97. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
# Starts EMPTY. A new game used to open with 500 ore banked, which quietly
# skipped the opening of the game - the first mining run is what teaches the
# fleet hold, the return trip and the mothership. Dev mode (start_dev_game)
# is where a stocked start belongs.
var bank_inventory: RefCounted = INVENTORY.new()
2026-08-14 20:32:58 +02:00
var fleet_inventory: RefCounted = INVENTORY.new()
var rocks: int:
get: return bank_inventory.amount(RESOURCE_ORE)
set(value): bank_inventory.set_amount(RESOURCE_ORE, value)
var gas: int:
get: return bank_inventory.amount(RESOURCE_GAS)
set(value): bank_inventory.set_amount(RESOURCE_GAS, value)
var fleet_cargo: int:
get: return fleet_inventory.amount(RESOURCE_ORE)
set(value): fleet_inventory.set_amount(RESOURCE_ORE, value)
var fleet_gas_cargo: int:
get: return fleet_inventory.amount(RESOURCE_GAS)
set(value): fleet_inventory.set_amount(RESOURCE_GAS, value)
# Both return the amount actually accepted (#56: the fleet hold now has a
# finite total_capacity), so a collector can tell a full hold apart from a
# successful pickup instead of assuming everything offered was taken.
func collect_cargo(amount: int = 1) -> int:
return fleet_inventory.deposit(RESOURCE_ORE, amount)
func collect_gas_cargo(amount: int = 1) -> int:
return fleet_inventory.deposit(RESOURCE_GAS, amount)
2026-08-14 20:32:58 +02:00
func resource_types() -> PackedStringArray:
return RESOURCE_DEFINITIONS.ids()
func resource_definition(resource_type: String) -> Resource:
return RESOURCE_DEFINITIONS.get_definition(resource_type)
2026-08-13 22:17:24 +02:00
func resource_name(resource_type: String) -> String:
2026-08-14 20:32:58 +02:00
var definition: Resource = resource_definition(resource_type)
return String(definition.display_name) if definition != null else resource_type.capitalize()
func resource_color(resource_type: String) -> Color:
2026-08-14 20:32:58 +02:00
var definition: Resource = resource_definition(resource_type)
return definition.color if definition != null else Color.WHITE
func banked_resource(resource_type: String) -> int:
2026-08-14 20:32:58 +02:00
return bank_inventory.amount(resource_type)
func fleet_resource(resource_type: String) -> int:
2026-08-14 20:32:58 +02:00
return fleet_inventory.amount(resource_type)
# #60: consumed outright (ammunition), not moved to another inventory like
# transfer_resource_to_building/_fleet - returns the amount actually taken.
func withdraw_fleet_resource(resource_type: String, amount: int) -> int:
return fleet_inventory.withdraw(resource_type, amount)
# #61: generic counterparts, for resources (energy) with no dedicated
# fleet_cargo/fleet_gas_cargo-style property of their own. Deliberately NOT
# the same as deposit_resource_to_building() on a "bank" building - that
# deposits into the building's OWN per-record inventory, not bank_inventory
# itself (only building_resource()/building_inventory() special-case "bank"
# capability to redirect there).
func deposit_fleet_resource(resource_type: String, amount: int) -> int:
return fleet_inventory.deposit(resource_type, amount)
func deposit_bank_resource(resource_type: String, amount: int) -> int:
return bank_inventory.deposit(resource_type, amount)
# #56: total across every resource type in the hold, and the combined cap
# that limits it - a HUD readout wants both, not any one resource's amount.
func fleet_cargo_used() -> int:
return fleet_inventory.total()
func fleet_cargo_total_capacity() -> int:
return fleet_inventory.total_capacity
2026-08-14 20:32:58 +02:00
func _inventory_for_record(record: Dictionary) -> RefCounted:
var inventory: RefCounted = record.get("inventory") as RefCounted
if inventory == null:
inventory = INVENTORY.new()
record.inventory = inventory
# Compatibility migration for state made before #33. Erase each legacy
# field once absorbed so it cannot drift out of step with the inventory.
if record.has("stock"):
inventory.set_amount(RESOURCE_ORE, int(record.stock))
record.erase("stock")
if record.has("gas_stock"):
inventory.set_amount(RESOURCE_GAS, int(record.gas_stock))
record.erase("gas_stock")
return inventory
func building_inventory(id: int) -> RefCounted:
var building: Dictionary = find_building(id)
if building.is_empty():
2026-08-14 20:32:58 +02:00
return null
if building_has_capability(building, "bank"):
2026-08-14 20:32:58 +02:00
return bank_inventory
return _inventory_for_record(building)
func building_resource(id: int, resource_type: String) -> int:
var inventory: RefCounted = building_inventory(id)
return 0 if inventory == null else inventory.amount(resource_type)
func transfer_resource_to_building(id: int, resource_type: String, amount: int) -> int:
2026-08-14 20:32:58 +02:00
return fleet_inventory.transfer_to(building_inventory(id), resource_type, amount)
func transfer_resource_to_fleet(id: int, resource_type: String, amount: int) -> int:
2026-08-14 20:32:58 +02:00
var source: RefCounted = building_inventory(id)
return 0 if source == null else source.transfer_to(fleet_inventory, resource_type, amount)
# The batch size the transfer menu offers, next to "All". A batch rather
# than only all-or-nothing: the hold is the part that can be lost, and
# emptying the bank in one tap would strand research, which spends from it.
const CARGO_LOAD_BATCH: int = 25
# --- moving ore by hand, in either direction (#24) --------------------------
#
# One pair of primitives the transfer menu drives, rather than the two
# one-way special cases that used to live inside the skill and craft panels
# ("load ore" at the mothership, "store ore here" at a crafter).
#
# The mothership is deliberately not a special case in the UI: it is simply
# the building whose holdings ARE the bank. Depositing at one banks the ore,
# withdrawing from one draws on the bank, and every other building keeps its
# own stock (#16). That is what lets one menu serve every structure.
func building_holdings(id: int) -> int:
return building_resource(id, RESOURCE_ORE)
2026-08-13 22:17:24 +02:00
func building_gas_holdings(id: int) -> int:
return building_resource(id, RESOURCE_GAS)
2026-08-13 22:17:24 +02:00
func transfer_to_building(id: int, amount: int) -> int:
# Hold -> this building. Returns how much actually moved, which is capped
# by the hold: a caller asking for "all" passes a number too big on
# purpose and reads the result back.
return transfer_resource_to_building(id, RESOURCE_ORE, amount)
func transfer_to_fleet(id: int, amount: int) -> int:
# This building -> hold. Capped by what the building actually holds, so
# there is no overdraft in either direction.
return transfer_resource_to_fleet(id, RESOURCE_ORE, amount)
2026-08-13 22:17:24 +02:00
func transfer_gas_to_building(id: int, amount: int) -> int:
return transfer_resource_to_building(id, RESOURCE_GAS, amount)
2026-08-13 22:17:24 +02:00
func transfer_gas_to_fleet(id: int, amount: int) -> int:
return transfer_resource_to_fleet(id, RESOURCE_GAS, amount)
2026-08-13 22:17:24 +02:00
const MOTHERSHIP_COST: int = 20
const SHIP_CRAFTER_COST: int = 12
const SHIP_COST: int = 6
# --- Buildings -------------------------------------------------------------
# Player-placed structures, as [{"type": String, "position": Vector2}].
# Buildings exist only in space (Main.tscn) but are stored here so they
# survive the scene swap to a planet's surface and back.
const BUILDING_MOTHERSHIP: String = "mothership"
const BUILDING_SHIP_CRAFTER: String = "ship_crafter"
const BUILDING_COMMAND_CENTER: String = "command_center"
# Extractors are not placed in space like the others - they are built ON a
# planet's surface and tracked separately (see `extractors`).
const BUILDING_EXTRACTOR: String = "extractor"
Transport Hub building Forgejo #18, part 5 of #12. The building only - the vessels that do the hauling are #19. Placeable, costed at 30, gated behind a new "Freight Handling" research node hanging off Logistics, with its own info popup, build-menu glyph and map marker. Drawn as an octagonal dock ring with berth arms and a squared-off freight core: deliberately radial-but-flat so it does not read as a small mothership, which is the only structure with swept arms. Its stationed transports are NeonPiece children of one rotating holder, per #10 - the geometry is rasterised once and only the holder's rotation changes, so the traffic costs no redraws. Building._draw()'s match has a catch-all that renders anything unrecognised as a ship crafter, so a missing case is silent and looks almost right. The test checks for the hub's own _hub_traffic holder rather than trusting that something plausible appeared - which caught exactly that: a _ready() patch had silently not applied and the hub was rendering through the crafter branch. The build menu grew a third row. Every button, label and price moved: the old row occupied 332px against a 372px pitch, and three of those do not fit above a 1280px screen. Rows are now shorter (128px buttons, 40px price digits) at a 290px pitch, ending at y=1176. Rendered and looked at - the first attempt tightened the pitch alone and left every price overlapping the row beneath it, which no headless test would have seen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:45:07 +02:00
# Hosts the transport vessels that supply other buildings (#18). Built in
# space like the rest; it is the transporters (#19) that do the moving.
const BUILDING_TRANSPORT_HUB: String = "transport_hub"
# Where vessel types are researched and new ones unlocked (#5).
const BUILDING_SHIP_LAB: String = "ship_lab"
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>
2026-08-16 12:10:31 +02:00
# Converts gas into energy, both stored/moved through the building's own
# inventory exactly like every other building (#54).
const BUILDING_GAS_BURNER: String = "gas_burner"
const COMMAND_CENTER_COST: int = 15
const EXTRACTOR_COST: int = 25
Transport Hub building Forgejo #18, part 5 of #12. The building only - the vessels that do the hauling are #19. Placeable, costed at 30, gated behind a new "Freight Handling" research node hanging off Logistics, with its own info popup, build-menu glyph and map marker. Drawn as an octagonal dock ring with berth arms and a squared-off freight core: deliberately radial-but-flat so it does not read as a small mothership, which is the only structure with swept arms. Its stationed transports are NeonPiece children of one rotating holder, per #10 - the geometry is rasterised once and only the holder's rotation changes, so the traffic costs no redraws. Building._draw()'s match has a catch-all that renders anything unrecognised as a ship crafter, so a missing case is silent and looks almost right. The test checks for the hub's own _hub_traffic holder rather than trusting that something plausible appeared - which caught exactly that: a _ready() patch had silently not applied and the hub was rendering through the crafter branch. The build menu grew a third row. Every button, label and price moved: the old row occupied 332px against a 372px pitch, and three of those do not fit above a 1280px screen. Rows are now shorter (128px buttons, 40px price digits) at a 290px pitch, ending at y=1176. Rendered and looked at - the first attempt tightened the pitch alone and left every price overlapping the row beneath it, which no headless test would have seen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:45:07 +02:00
const TRANSPORT_HUB_COST: int = 30
const SHIP_LAB_COST: int = 35
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>
2026-08-16 12:10:31 +02:00
const GAS_BURNER_COST: int = 30
var buildings: Array = []
Skill tree, node-based building animation, bigger UI, and a 19x faster test suite Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:00:41 +02:00
# --- Skill tree ------------------------------------------------------------
# Researched at the mothership. Two things hang off it: which structures the
# player is allowed to build at all, and permanent upgrades that have no
# building of their own. The mothership itself is deliberately NOT in the
# tree - it is the thing you research AT, so gating it behind research would
# be a deadlock.
#
# `x` is a horizontal position on a 0..1 grid; the vertical position is NOT
# stored, it is derived from how many prerequisites deep the node is, so the
# tree always reads root-at-the-bottom and cannot drift out of step with the
# dependencies when nodes are added. `requires` is a single prerequisite id
# ("" for a root).
# #63: research_points (default 0 where omitted) is a SEPARATE currency from
# cost (ore), spent only from GameState.research_points - earned by
# conquering a research_lab planet, never bought. Balance, decided
# deliberately: shipyards and logistics stay at 0 so a fresh game can still
# reach a ship crafter and an extractor with zero research points (see the
# closing comment on #63 for the reasoning); every other node - none of them
# required for that opening - costs at least 1.
Skill tree, node-based building animation, bigger UI, and a 19x faster test suite Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:00:41 +02:00
const SKILL_TREE: Array = [
{
"id": "shipyards", "name": "Shipyards", "cost": 15, "requires": "",
"x": 0.5, "unlocks": BUILDING_SHIP_CRAFTER,
"desc": "Build ship crafters, and buy extra vessels at them.",
},
{
"id": "logistics", "name": "Logistics", "cost": 25, "requires": "shipyards",
"x": 0.18, "unlocks": BUILDING_EXTRACTOR,
"desc": "Build extractors on planets. They mine ore whether or not you are nearby.",
},
{
"id": "coordination", "name": "Coordination", "cost": 20, "requires": "shipyards",
"x": 0.50, "unlocks": BUILDING_COMMAND_CENTER, "research_points": 1,
Skill tree, node-based building animation, bigger UI, and a 19x faster test suite Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:00:41 +02:00
"desc": "Build command centers, where the fleet formation is arranged.",
},
Transport Hub building Forgejo #18, part 5 of #12. The building only - the vessels that do the hauling are #19. Placeable, costed at 30, gated behind a new "Freight Handling" research node hanging off Logistics, with its own info popup, build-menu glyph and map marker. Drawn as an octagonal dock ring with berth arms and a squared-off freight core: deliberately radial-but-flat so it does not read as a small mothership, which is the only structure with swept arms. Its stationed transports are NeonPiece children of one rotating holder, per #10 - the geometry is rasterised once and only the holder's rotation changes, so the traffic costs no redraws. Building._draw()'s match has a catch-all that renders anything unrecognised as a ship crafter, so a missing case is silent and looks almost right. The test checks for the hub's own _hub_traffic holder rather than trusting that something plausible appeared - which caught exactly that: a _ready() patch had silently not applied and the hub was rendering through the crafter branch. The build menu grew a third row. Every button, label and price moved: the old row occupied 332px against a 372px pitch, and three of those do not fit above a 1280px screen. Rows are now shorter (128px buttons, 40px price digits) at a 290px pitch, ending at y=1176. Rendered and looked at - the first attempt tightened the pitch alone and left every price overlapping the row beneath it, which no headless test would have seen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:45:07 +02:00
{
"id": "freight", "name": "Freight Handling", "cost": 40, "requires": "logistics",
"x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB, "research_points": 1,
2026-08-15 00:09:10 +02:00
"desc": "Build transport hubs. Each hub includes its own automatic transporters.",
Transport Hub building Forgejo #18, part 5 of #12. The building only - the vessels that do the hauling are #19. Placeable, costed at 30, gated behind a new "Freight Handling" research node hanging off Logistics, with its own info popup, build-menu glyph and map marker. Drawn as an octagonal dock ring with berth arms and a squared-off freight core: deliberately radial-but-flat so it does not read as a small mothership, which is the only structure with swept arms. Its stationed transports are NeonPiece children of one rotating holder, per #10 - the geometry is rasterised once and only the holder's rotation changes, so the traffic costs no redraws. Building._draw()'s match has a catch-all that renders anything unrecognised as a ship crafter, so a missing case is silent and looks almost right. The test checks for the hub's own _hub_traffic holder rather than trusting that something plausible appeared - which caught exactly that: a _ready() patch had silently not applied and the hub was rendering through the crafter branch. The build menu grew a third row. Every button, label and price moved: the old row occupied 332px against a 372px pitch, and three of those do not fit above a 1280px screen. Rows are now shorter (128px buttons, 40px price digits) at a 290px pitch, ending at y=1176. Rendered and looked at - the first attempt tightened the pitch alone and left every price overlapping the row beneath it, which no headless test would have seen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:45:07 +02:00
},
{
"id": "vessel_science", "name": "Vessel Science", "cost": 35, "requires": "shipyards",
"x": 0.82, "unlocks": BUILDING_SHIP_LAB, "research_points": 1,
"desc": "Build ship labs, where each vessel type is upgraded and new ones unlocked.",
},
{
"id": "freight_yards", "name": "Freight Yards", "cost": 80, "requires": "freight",
"x": 0.18, "hub_bonus": 1, "research_points": 2,
"desc": "Permanent: +1 transporter stationed at every hub, including ones already built.",
},
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>
2026-08-16 12:10:31 +02:00
{
"id": "power_systems", "name": "Power Systems", "cost": 45, "requires": "freight",
"x": 0.50, "unlocks": BUILDING_GAS_BURNER, "research_points": 2,
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>
2026-08-16 12:10:31 +02:00
"desc": "Build gas burners. Each converts gas into energy at its own site - hub transporters keep it stocked and collect what it makes.",
},
Skill tree, node-based building animation, bigger UI, and a 19x faster test suite Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:00:41 +02:00
{
Rework how a game starts: dev mode, a bare opening, and fleet growth Adds a Dev Mode entry to the main menu and takes the play-testing conveniences out of a normal game, which turn out to be the same change from two directions. DEV MODE. Banks 2000 of every resource, grants 500 research points, and plants one of each planet type in the chunks around the spawn. Built ON TOP of reset_new_game() rather than beside it, so a dev game is a normal game plus a stock rather than a second definition of "fresh game" to keep in sync. Offered only when running from Godot - OS.has_feature("editor"), the tag an export clears - as is the map editor. Hidden AND guarded: pressing either anyway is refused, because visibility is presentation and the rule is the rule. The menu now lays its stack out in code. Hiding two of four entries with scene-authored offsets would leave holes in a shipped build - the same failure the HUD columns had - so it measures the entries that actually exist and centres on them. A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both reset_new_game and the autoload's own default, so the two cannot disagree), and no gas giant guaranteed in the origin chunk. Both were play-testing conveniences that had become part of the real world's shape: every player got the same landmark in the same place. Play-testing wanted all four planet types anyway, and a normal game wanted none of them - different needs, now different mechanisms. WorldGen cannot read GameState back (an autoload is not resolvable as a global identifier that early, the hazard PlanetSetups.gd already works around), so GameState.dev_mode's setter PUSHES the flag down. World generation stays static and dependency-free, GameState stays the single source of truth, and dev planting is applied after every rng draw so a dev world and a normal one differ only in those four chunks. FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND how many ships you start with - which forced a new game to open with a full fleet, leaving the first ship crafter nothing to do. Split into BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening has two empty slots to fill. A third copy of the old value turned up in PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently degraded into "however many slots exist" once max_fleet_size() clamped it. The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron (70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game luxury but the ordinary way out of the opening, and pricing them as a luxury made the early game feel stuck. Armada follows at 90/2. Two new tiers, Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in one lane would be a long monotonous chain for a branch that only ever does one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size() clamps to its length and a doctrine past that would charge real resources for nothing; test_fleet_growth.gd asserts that invariant directly. TEST FALLOUT, all of it from the deliberate change rather than a defect: - Tests that silently inherited the old 5-ship default now ask for the fleet they need. Several had to crew it from their first frame rather than _initialize(), where the engine's deferred tree-entry finalization undoes it - the same hazard those tests already documented for set_process(false). - test_planet_landing_proximity and test_return_position_matches_planet take the FIRST chunk holding a planet and land there. The guaranteed gas giant had been masking that this can now be a black hole, which destroys ships on contact. They exclude it, and clear the asteroids the field already placed. - test_pivot_controls measured rotation with angle_difference, which wraps at +/-pi, so a hold that turned a full circle read as no rotation. Latent flakiness that only ever showed under load; it now reads the controller's unwrapped pivot_hold_amount. - test_research_points was failing on ore, not points: can_unlock() checks the ore price too, and a fresh game banks nothing now. - test_gas_system's "a reachable gas source near the spawn" premise moved to dev mode, which is where that guarantee now lives. Full suite green at 97/97. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
# Cheaper than it was (30 ore / 1 point): the fleet now starts at
# BASE_FLEET_SIZE 3 rather than 5, so the first two doctrines are no
# longer a mid-game luxury but the ordinary way out of the opening.
# Pricing them as a luxury made the early game feel stuck.
"id": "wing", "name": "Wing Doctrine", "cost": 15, "requires": "coordination",
"x": 0.82, "fleet_bonus": 1, "research_points": 1,
Skill tree, node-based building animation, bigger UI, and a 19x faster test suite Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:00:41 +02:00
"desc": "Permanent: +1 vessel in the fleet.",
},
{
# #70: was x=0.50 - the same lane freight's branch already occupies at
# this same depth (power_systems), so the two nodes were drawn
# directly on top of each other. Kept in line with wing (its own
# prerequisite, also x=0.82) instead, so the whole fleet-size
# doctrine branch now runs straight rather than zigzagging into
# someone else's lane.
Rework how a game starts: dev mode, a bare opening, and fleet growth Adds a Dev Mode entry to the main menu and takes the play-testing conveniences out of a normal game, which turn out to be the same change from two directions. DEV MODE. Banks 2000 of every resource, grants 500 research points, and plants one of each planet type in the chunks around the spawn. Built ON TOP of reset_new_game() rather than beside it, so a dev game is a normal game plus a stock rather than a second definition of "fresh game" to keep in sync. Offered only when running from Godot - OS.has_feature("editor"), the tag an export clears - as is the map editor. Hidden AND guarded: pressing either anyway is refused, because visibility is presentation and the rule is the rule. The menu now lays its stack out in code. Hiding two of four entries with scene-authored offsets would leave holes in a shipped build - the same failure the HUD columns had - so it measures the entries that actually exist and centres on them. A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both reset_new_game and the autoload's own default, so the two cannot disagree), and no gas giant guaranteed in the origin chunk. Both were play-testing conveniences that had become part of the real world's shape: every player got the same landmark in the same place. Play-testing wanted all four planet types anyway, and a normal game wanted none of them - different needs, now different mechanisms. WorldGen cannot read GameState back (an autoload is not resolvable as a global identifier that early, the hazard PlanetSetups.gd already works around), so GameState.dev_mode's setter PUSHES the flag down. World generation stays static and dependency-free, GameState stays the single source of truth, and dev planting is applied after every rng draw so a dev world and a normal one differ only in those four chunks. FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND how many ships you start with - which forced a new game to open with a full fleet, leaving the first ship crafter nothing to do. Split into BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening has two empty slots to fill. A third copy of the old value turned up in PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently degraded into "however many slots exist" once max_fleet_size() clamped it. The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron (70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game luxury but the ordinary way out of the opening, and pricing them as a luxury made the early game feel stuck. Armada follows at 90/2. Two new tiers, Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in one lane would be a long monotonous chain for a branch that only ever does one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size() clamps to its length and a doctrine past that would charge real resources for nothing; test_fleet_growth.gd asserts that invariant directly. TEST FALLOUT, all of it from the deliberate change rather than a defect: - Tests that silently inherited the old 5-ship default now ask for the fleet they need. Several had to crew it from their first frame rather than _initialize(), where the engine's deferred tree-entry finalization undoes it - the same hazard those tests already documented for set_process(false). - test_planet_landing_proximity and test_return_position_matches_planet take the FIRST chunk holding a planet and land there. The guaranteed gas giant had been masking that this can now be a black hole, which destroys ships on contact. They exclude it, and clear the asteroids the field already placed. - test_pivot_controls measured rotation with angle_difference, which wraps at +/-pi, so a hold that turned a full circle read as no rotation. Latent flakiness that only ever showed under load; it now reads the controller's unwrapped pivot_hold_amount. - test_research_points was failing on ore, not points: can_unlock() checks the ore price too, and a fresh game banks nothing now. - test_gas_system's "a reachable gas source near the spawn" premise moved to dev mode, which is where that guarantee now lives. Full suite green at 97/97. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
# Also cut (70 ore / 2 points), for the same reason as wing.
"id": "squadron", "name": "Squadron Doctrine", "cost": 35, "requires": "wing",
"x": 0.82, "fleet_bonus": 1, "research_points": 1,
Skill tree, node-based building animation, bigger UI, and a 19x faster test suite Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:00:41 +02:00
"desc": "Permanent: +1 vessel in the fleet.",
},
{
Rework how a game starts: dev mode, a bare opening, and fleet growth Adds a Dev Mode entry to the main menu and takes the play-testing conveniences out of a normal game, which turn out to be the same change from two directions. DEV MODE. Banks 2000 of every resource, grants 500 research points, and plants one of each planet type in the chunks around the spawn. Built ON TOP of reset_new_game() rather than beside it, so a dev game is a normal game plus a stock rather than a second definition of "fresh game" to keep in sync. Offered only when running from Godot - OS.has_feature("editor"), the tag an export clears - as is the map editor. Hidden AND guarded: pressing either anyway is refused, because visibility is presentation and the rule is the rule. The menu now lays its stack out in code. Hiding two of four entries with scene-authored offsets would leave holes in a shipped build - the same failure the HUD columns had - so it measures the entries that actually exist and centres on them. A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both reset_new_game and the autoload's own default, so the two cannot disagree), and no gas giant guaranteed in the origin chunk. Both were play-testing conveniences that had become part of the real world's shape: every player got the same landmark in the same place. Play-testing wanted all four planet types anyway, and a normal game wanted none of them - different needs, now different mechanisms. WorldGen cannot read GameState back (an autoload is not resolvable as a global identifier that early, the hazard PlanetSetups.gd already works around), so GameState.dev_mode's setter PUSHES the flag down. World generation stays static and dependency-free, GameState stays the single source of truth, and dev planting is applied after every rng draw so a dev world and a normal one differ only in those four chunks. FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND how many ships you start with - which forced a new game to open with a full fleet, leaving the first ship crafter nothing to do. Split into BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening has two empty slots to fill. A third copy of the old value turned up in PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently degraded into "however many slots exist" once max_fleet_size() clamped it. The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron (70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game luxury but the ordinary way out of the opening, and pricing them as a luxury made the early game feel stuck. Armada follows at 90/2. Two new tiers, Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in one lane would be a long monotonous chain for a branch that only ever does one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size() clamps to its length and a doctrine past that would charge real resources for nothing; test_fleet_growth.gd asserts that invariant directly. TEST FALLOUT, all of it from the deliberate change rather than a defect: - Tests that silently inherited the old 5-ship default now ask for the fleet they need. Several had to crew it from their first frame rather than _initialize(), where the engine's deferred tree-entry finalization undoes it - the same hazard those tests already documented for set_process(false). - test_planet_landing_proximity and test_return_position_matches_planet take the FIRST chunk holding a planet and land there. The guaranteed gas giant had been masking that this can now be a black hole, which destroys ships on contact. They exclude it, and clear the asteroids the field already placed. - test_pivot_controls measured rotation with angle_difference, which wraps at +/-pi, so a hold that turned a full circle read as no rotation. Latent flakiness that only ever showed under load; it now reads the controller's unwrapped pivot_hold_amount. - test_research_points was failing on ore, not points: can_unlock() checks the ore price too, and a fresh game banks nothing now. - test_gas_system's "a reachable gas source near the spawn" premise moved to dev mode, which is where that guarantee now lives. Full suite green at 97/97. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
"id": "armada", "name": "Armada Doctrine", "cost": 90, "requires": "squadron",
"x": 0.82, "fleet_bonus": 1, "research_points": 2,
Skill tree, node-based building animation, bigger UI, and a 19x faster test suite Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:00:41 +02:00
"desc": "Permanent: +1 vessel in the fleet.",
},
Rework how a game starts: dev mode, a bare opening, and fleet growth Adds a Dev Mode entry to the main menu and takes the play-testing conveniences out of a normal game, which turn out to be the same change from two directions. DEV MODE. Banks 2000 of every resource, grants 500 research points, and plants one of each planet type in the chunks around the spawn. Built ON TOP of reset_new_game() rather than beside it, so a dev game is a normal game plus a stock rather than a second definition of "fresh game" to keep in sync. Offered only when running from Godot - OS.has_feature("editor"), the tag an export clears - as is the map editor. Hidden AND guarded: pressing either anyway is refused, because visibility is presentation and the rule is the rule. The menu now lays its stack out in code. Hiding two of four entries with scene-authored offsets would leave holes in a shipped build - the same failure the HUD columns had - so it measures the entries that actually exist and centres on them. A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both reset_new_game and the autoload's own default, so the two cannot disagree), and no gas giant guaranteed in the origin chunk. Both were play-testing conveniences that had become part of the real world's shape: every player got the same landmark in the same place. Play-testing wanted all four planet types anyway, and a normal game wanted none of them - different needs, now different mechanisms. WorldGen cannot read GameState back (an autoload is not resolvable as a global identifier that early, the hazard PlanetSetups.gd already works around), so GameState.dev_mode's setter PUSHES the flag down. World generation stays static and dependency-free, GameState stays the single source of truth, and dev planting is applied after every rng draw so a dev world and a normal one differ only in those four chunks. FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND how many ships you start with - which forced a new game to open with a full fleet, leaving the first ship crafter nothing to do. Split into BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening has two empty slots to fill. A third copy of the old value turned up in PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently degraded into "however many slots exist" once max_fleet_size() clamped it. The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron (70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game luxury but the ordinary way out of the opening, and pricing them as a luxury made the early game feel stuck. Armada follows at 90/2. Two new tiers, Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in one lane would be a long monotonous chain for a branch that only ever does one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size() clamps to its length and a doctrine past that would charge real resources for nothing; test_fleet_growth.gd asserts that invariant directly. TEST FALLOUT, all of it from the deliberate change rather than a defect: - Tests that silently inherited the old 5-ship default now ask for the fleet they need. Several had to crew it from their first frame rather than _initialize(), where the engine's deferred tree-entry finalization undoes it - the same hazard those tests already documented for set_process(false). - test_planet_landing_proximity and test_return_position_matches_planet take the FIRST chunk holding a planet and land there. The guaranteed gas giant had been masking that this can now be a black hole, which destroys ships on contact. They exclude it, and clear the asteroids the field already placed. - test_pivot_controls measured rotation with angle_difference, which wraps at +/-pi, so a hold that turned a full circle read as no rotation. Latent flakiness that only ever showed under load; it now reads the controller's unwrapped pivot_hold_amount. - test_research_points was failing on ore, not points: can_unlock() checks the ore price too, and a fresh game banks nothing now. - test_gas_system's "a reachable gas source near the spawn" premise moved to dev mode, which is where that guarantee now lives. Full suite green at 97/97. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
{
# The last two tiers grant +2 rather than +1. Seven single-vessel
# nodes in one lane would be a long, monotonous chain for a branch
# that only ever does one thing; two larger, costlier steps carry the
# ceiling from 6 to 10 while keeping the branch a readable length.
"id": "battlegroup", "name": "Battlegroup Doctrine", "cost": 180, "requires": "armada",
"x": 0.82, "fleet_bonus": 2, "research_points": 4,
"desc": "Permanent: +2 vessels in the fleet.",
},
{
"id": "grand_fleet", "name": "Grand Fleet Doctrine", "cost": 320, "requires": "battlegroup",
"x": 0.82, "fleet_bonus": 2, "research_points": 6,
"desc": "Permanent: +2 vessels in the fleet.",
},
Skill tree, node-based building animation, bigger UI, and a 19x faster test suite Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:00:41 +02:00
]
Rework how a game starts: dev mode, a bare opening, and fleet growth Adds a Dev Mode entry to the main menu and takes the play-testing conveniences out of a normal game, which turn out to be the same change from two directions. DEV MODE. Banks 2000 of every resource, grants 500 research points, and plants one of each planet type in the chunks around the spawn. Built ON TOP of reset_new_game() rather than beside it, so a dev game is a normal game plus a stock rather than a second definition of "fresh game" to keep in sync. Offered only when running from Godot - OS.has_feature("editor"), the tag an export clears - as is the map editor. Hidden AND guarded: pressing either anyway is refused, because visibility is presentation and the rule is the rule. The menu now lays its stack out in code. Hiding two of four entries with scene-authored offsets would leave holes in a shipped build - the same failure the HUD columns had - so it measures the entries that actually exist and centres on them. A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both reset_new_game and the autoload's own default, so the two cannot disagree), and no gas giant guaranteed in the origin chunk. Both were play-testing conveniences that had become part of the real world's shape: every player got the same landmark in the same place. Play-testing wanted all four planet types anyway, and a normal game wanted none of them - different needs, now different mechanisms. WorldGen cannot read GameState back (an autoload is not resolvable as a global identifier that early, the hazard PlanetSetups.gd already works around), so GameState.dev_mode's setter PUSHES the flag down. World generation stays static and dependency-free, GameState stays the single source of truth, and dev planting is applied after every rng draw so a dev world and a normal one differ only in those four chunks. FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND how many ships you start with - which forced a new game to open with a full fleet, leaving the first ship crafter nothing to do. Split into BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening has two empty slots to fill. A third copy of the old value turned up in PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently degraded into "however many slots exist" once max_fleet_size() clamped it. The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron (70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game luxury but the ordinary way out of the opening, and pricing them as a luxury made the early game feel stuck. Armada follows at 90/2. Two new tiers, Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in one lane would be a long monotonous chain for a branch that only ever does one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size() clamps to its length and a doctrine past that would charge real resources for nothing; test_fleet_growth.gd asserts that invariant directly. TEST FALLOUT, all of it from the deliberate change rather than a defect: - Tests that silently inherited the old 5-ship default now ask for the fleet they need. Several had to crew it from their first frame rather than _initialize(), where the engine's deferred tree-entry finalization undoes it - the same hazard those tests already documented for set_process(false). - test_planet_landing_proximity and test_return_position_matches_planet take the FIRST chunk holding a planet and land there. The guaranteed gas giant had been masking that this can now be a black hole, which destroys ships on contact. They exclude it, and clear the asteroids the field already placed. - test_pivot_controls measured rotation with angle_difference, which wraps at +/-pi, so a hold that turned a full circle read as no rotation. Latent flakiness that only ever showed under load; it now reads the controller's unwrapped pivot_hold_amount. - test_research_points was failing on ore, not points: can_unlock() checks the ore price too, and a fresh game banks nothing now. - test_gas_system's "a reachable gas source near the spawn" premise moved to dev mode, which is where that guarantee now lives. Full suite green at 97/97. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
# How many SLOTS the fleet has before any doctrine is researched. Distinct
# from STARTING_SHIP_COUNT below: this is capacity, that is how many vessels
# are actually flying. The two used to be one constant, which forced a new
# game to open with a full fleet and left the first crafter with nothing to
# do - there was no room to put a vessel until a doctrine was researched.
const BASE_FLEET_SIZE: int = 3
# A new game opens with a SINGLE vessel. Building the fleet up is the early
# game, so the two empty slots are the first thing a ship crafter is for.
const STARTING_SHIP_COUNT: int = 1
Skill tree, node-based building animation, bigger UI, and a 19x faster test suite Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:00:41 +02:00
var unlocked: Array = []
func skill_node(id: String) -> Dictionary:
for node in SKILL_TREE:
if node.id == id:
return node
return {}
func skill_depth(id: String) -> int:
# How many prerequisites deep this node sits. Drives its height in the
# tree view: roots at the bottom, everything that depends on something
# above it.
var depth: int = 0
var node: Dictionary = skill_node(id)
while not node.is_empty() and node.requires != "":
depth += 1
node = skill_node(node.requires)
if depth > SKILL_TREE.size():
break # a cycle in the data would otherwise hang here
return depth
func max_skill_depth() -> int:
var deepest: int = 0
for node in SKILL_TREE:
deepest = maxi(deepest, skill_depth(node.id))
return deepest
func is_unlocked(id: String) -> bool:
return id in unlocked
func prerequisite_met(id: String) -> bool:
var node: Dictionary = skill_node(id)
if node.is_empty():
return false
return node.requires == "" or is_unlocked(node.requires)
func can_unlock(id: String) -> bool:
var node: Dictionary = skill_node(id)
return not is_unlocked(id) and prerequisite_met(id) and can_afford(int(node.cost)) \
and research_points >= skill_research_point_cost(id)
func skill_research_point_cost(id: String) -> int:
return int(skill_node(id).get("research_points", 0))
Skill tree, node-based building animation, bigger UI, and a 19x faster test suite Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:00:41 +02:00
func unlock(id: String) -> bool:
if not can_unlock(id):
return false
var node: Dictionary = skill_node(id)
if not spend(int(node.cost)):
Skill tree, node-based building animation, bigger UI, and a 19x faster test suite Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:00:41 +02:00
return false
var points_needed: int = skill_research_point_cost(id)
if points_needed > 0:
research_points -= points_needed
research_points_changed.emit(research_points)
Skill tree, node-based building animation, bigger UI, and a 19x faster test suite Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:00:41 +02:00
unlocked.append(id)
if int(node.get("hub_bonus", 0)) > 0:
2026-08-15 00:09:10 +02:00
_sync_hub_transporters()
Skill tree, node-based building animation, bigger UI, and a 19x faster test suite Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:00:41 +02:00
return true
func is_building_unlocked(type: String) -> bool:
for node in SKILL_TREE:
if node.get("unlocks", "") == type:
return is_unlocked(node.id)
return true
func research_for_building(type: String) -> String:
for node in SKILL_TREE:
if node.get("unlocks", "") == type:
return node.name
return ""
func max_fleet_size() -> int:
var n: int = BASE_FLEET_SIZE
for id in unlocked:
n += int(skill_node(id).get("fleet_bonus", 0))
return mini(n, DEFAULT_FORMATION.size())
func reset_progression() -> void:
unlocked.clear()
vessel_levels.clear()
Skill tree, node-based building animation, bigger UI, and a 19x faster test suite Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:00:41 +02:00
# Called from the main menu's Play button (#47): the autoload survives
# change_scene_to_file(), so without this a second playthrough in the same
# process would start from wherever the last one left off instead of a
# fresh game.
func reset_new_game() -> void:
has_return_position = false
Rework how a game starts: dev mode, a bare opening, and fleet growth Adds a Dev Mode entry to the main menu and takes the play-testing conveniences out of a normal game, which turn out to be the same change from two directions. DEV MODE. Banks 2000 of every resource, grants 500 research points, and plants one of each planet type in the chunks around the spawn. Built ON TOP of reset_new_game() rather than beside it, so a dev game is a normal game plus a stock rather than a second definition of "fresh game" to keep in sync. Offered only when running from Godot - OS.has_feature("editor"), the tag an export clears - as is the map editor. Hidden AND guarded: pressing either anyway is refused, because visibility is presentation and the rule is the rule. The menu now lays its stack out in code. Hiding two of four entries with scene-authored offsets would leave holes in a shipped build - the same failure the HUD columns had - so it measures the entries that actually exist and centres on them. A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both reset_new_game and the autoload's own default, so the two cannot disagree), and no gas giant guaranteed in the origin chunk. Both were play-testing conveniences that had become part of the real world's shape: every player got the same landmark in the same place. Play-testing wanted all four planet types anyway, and a normal game wanted none of them - different needs, now different mechanisms. WorldGen cannot read GameState back (an autoload is not resolvable as a global identifier that early, the hazard PlanetSetups.gd already works around), so GameState.dev_mode's setter PUSHES the flag down. World generation stays static and dependency-free, GameState stays the single source of truth, and dev planting is applied after every rng draw so a dev world and a normal one differ only in those four chunks. FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND how many ships you start with - which forced a new game to open with a full fleet, leaving the first ship crafter nothing to do. Split into BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening has two empty slots to fill. A third copy of the old value turned up in PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently degraded into "however many slots exist" once max_fleet_size() clamped it. The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron (70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game luxury but the ordinary way out of the opening, and pricing them as a luxury made the early game feel stuck. Armada follows at 90/2. Two new tiers, Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in one lane would be a long monotonous chain for a branch that only ever does one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size() clamps to its length and a doctrine past that would charge real resources for nothing; test_fleet_growth.gd asserts that invariant directly. TEST FALLOUT, all of it from the deliberate change rather than a defect: - Tests that silently inherited the old 5-ship default now ask for the fleet they need. Several had to crew it from their first frame rather than _initialize(), where the engine's deferred tree-entry finalization undoes it - the same hazard those tests already documented for set_process(false). - test_planet_landing_proximity and test_return_position_matches_planet take the FIRST chunk holding a planet and land there. The guaranteed gas giant had been masking that this can now be a black hole, which destroys ships on contact. They exclude it, and clear the asteroids the field already placed. - test_pivot_controls measured rotation with angle_difference, which wraps at +/-pi, so a hold that turned a full circle read as no rotation. Latent flakiness that only ever showed under load; it now reads the controller's unwrapped pivot_hold_amount. - test_research_points was failing on ore, not points: can_unlock() checks the ore price too, and a fresh game banks nothing now. - test_gas_system's "a reachable gas source near the spawn" premise moved to dev mode, which is where that guarantee now lives. Full suite green at 97/97. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
surviving_ship_count = STARTING_SHIP_COUNT
surviving_vessel_types = PackedStringArray()
return_world_position = Vector2.ZERO
return_camera_zoom = 1.0
planet_name = ""
planet_id = ""
planet_definition_id = "rocky"
fleet_speed_multiplier = 1.0
_distorder_drain_progress = 0.0
boost_depleted_notice = false
research_points = 0
Rework how a game starts: dev mode, a bare opening, and fleet growth Adds a Dev Mode entry to the main menu and takes the play-testing conveniences out of a normal game, which turn out to be the same change from two directions. DEV MODE. Banks 2000 of every resource, grants 500 research points, and plants one of each planet type in the chunks around the spawn. Built ON TOP of reset_new_game() rather than beside it, so a dev game is a normal game plus a stock rather than a second definition of "fresh game" to keep in sync. Offered only when running from Godot - OS.has_feature("editor"), the tag an export clears - as is the map editor. Hidden AND guarded: pressing either anyway is refused, because visibility is presentation and the rule is the rule. The menu now lays its stack out in code. Hiding two of four entries with scene-authored offsets would leave holes in a shipped build - the same failure the HUD columns had - so it measures the entries that actually exist and centres on them. A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both reset_new_game and the autoload's own default, so the two cannot disagree), and no gas giant guaranteed in the origin chunk. Both were play-testing conveniences that had become part of the real world's shape: every player got the same landmark in the same place. Play-testing wanted all four planet types anyway, and a normal game wanted none of them - different needs, now different mechanisms. WorldGen cannot read GameState back (an autoload is not resolvable as a global identifier that early, the hazard PlanetSetups.gd already works around), so GameState.dev_mode's setter PUSHES the flag down. World generation stays static and dependency-free, GameState stays the single source of truth, and dev planting is applied after every rng draw so a dev world and a normal one differ only in those four chunks. FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND how many ships you start with - which forced a new game to open with a full fleet, leaving the first ship crafter nothing to do. Split into BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening has two empty slots to fill. A third copy of the old value turned up in PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently degraded into "however many slots exist" once max_fleet_size() clamped it. The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron (70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game luxury but the ordinary way out of the opening, and pricing them as a luxury made the early game feel stuck. Armada follows at 90/2. Two new tiers, Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in one lane would be a long monotonous chain for a branch that only ever does one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size() clamps to its length and a doctrine past that would charge real resources for nothing; test_fleet_growth.gd asserts that invariant directly. TEST FALLOUT, all of it from the deliberate change rather than a defect: - Tests that silently inherited the old 5-ship default now ask for the fleet they need. Several had to crew it from their first frame rather than _initialize(), where the engine's deferred tree-entry finalization undoes it - the same hazard those tests already documented for set_process(false). - test_planet_landing_proximity and test_return_position_matches_planet take the FIRST chunk holding a planet and land there. The guaranteed gas giant had been masking that this can now be a black hole, which destroys ships on contact. They exclude it, and clear the asteroids the field already placed. - test_pivot_controls measured rotation with angle_difference, which wraps at +/-pi, so a hold that turned a full circle read as no rotation. Latent flakiness that only ever showed under load; it now reads the controller's unwrapped pivot_hold_amount. - test_research_points was failing on ore, not points: can_unlock() checks the ore price too, and a fresh game banks nothing now. - test_gas_system's "a reachable gas source near the spawn" premise moved to dev mode, which is where that guarantee now lives. Full suite green at 97/97. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
dev_mode = false
explored_chunks.clear()
Rework how a game starts: dev mode, a bare opening, and fleet growth Adds a Dev Mode entry to the main menu and takes the play-testing conveniences out of a normal game, which turn out to be the same change from two directions. DEV MODE. Banks 2000 of every resource, grants 500 research points, and plants one of each planet type in the chunks around the spawn. Built ON TOP of reset_new_game() rather than beside it, so a dev game is a normal game plus a stock rather than a second definition of "fresh game" to keep in sync. Offered only when running from Godot - OS.has_feature("editor"), the tag an export clears - as is the map editor. Hidden AND guarded: pressing either anyway is refused, because visibility is presentation and the rule is the rule. The menu now lays its stack out in code. Hiding two of four entries with scene-authored offsets would leave holes in a shipped build - the same failure the HUD columns had - so it measures the entries that actually exist and centres on them. A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both reset_new_game and the autoload's own default, so the two cannot disagree), and no gas giant guaranteed in the origin chunk. Both were play-testing conveniences that had become part of the real world's shape: every player got the same landmark in the same place. Play-testing wanted all four planet types anyway, and a normal game wanted none of them - different needs, now different mechanisms. WorldGen cannot read GameState back (an autoload is not resolvable as a global identifier that early, the hazard PlanetSetups.gd already works around), so GameState.dev_mode's setter PUSHES the flag down. World generation stays static and dependency-free, GameState stays the single source of truth, and dev planting is applied after every rng draw so a dev world and a normal one differ only in those four chunks. FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND how many ships you start with - which forced a new game to open with a full fleet, leaving the first ship crafter nothing to do. Split into BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening has two empty slots to fill. A third copy of the old value turned up in PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently degraded into "however many slots exist" once max_fleet_size() clamped it. The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron (70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game luxury but the ordinary way out of the opening, and pricing them as a luxury made the early game feel stuck. Armada follows at 90/2. Two new tiers, Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in one lane would be a long monotonous chain for a branch that only ever does one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size() clamps to its length and a doctrine past that would charge real resources for nothing; test_fleet_growth.gd asserts that invariant directly. TEST FALLOUT, all of it from the deliberate change rather than a defect: - Tests that silently inherited the old 5-ship default now ask for the fleet they need. Several had to crew it from their first frame rather than _initialize(), where the engine's deferred tree-entry finalization undoes it - the same hazard those tests already documented for set_process(false). - test_planet_landing_proximity and test_return_position_matches_planet take the FIRST chunk holding a planet and land there. The guaranteed gas giant had been masking that this can now be a black hole, which destroys ships on contact. They exclude it, and clear the asteroids the field already placed. - test_pivot_controls measured rotation with angle_difference, which wraps at +/-pi, so a hold that turned a full circle read as no rotation. Latent flakiness that only ever showed under load; it now reads the controller's unwrapped pivot_hold_amount. - test_research_points was failing on ore, not points: can_unlock() checks the ore price too, and a fresh game banks nothing now. - test_gas_system's "a reachable gas source near the spawn" premise moved to dev mode, which is where that guarantee now lives. Full suite green at 97/97. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
bank_inventory = INVENTORY.new()
fleet_inventory = INVENTORY.new()
buildings.clear()
next_building_id = 1
stored_vessels.clear()
vessel_deliveries.clear()
extractors.clear()
transporters.clear()
planet_capture.clear()
reset_progression()
reset_formation()
Rework how a game starts: dev mode, a bare opening, and fleet growth Adds a Dev Mode entry to the main menu and takes the play-testing conveniences out of a normal game, which turn out to be the same change from two directions. DEV MODE. Banks 2000 of every resource, grants 500 research points, and plants one of each planet type in the chunks around the spawn. Built ON TOP of reset_new_game() rather than beside it, so a dev game is a normal game plus a stock rather than a second definition of "fresh game" to keep in sync. Offered only when running from Godot - OS.has_feature("editor"), the tag an export clears - as is the map editor. Hidden AND guarded: pressing either anyway is refused, because visibility is presentation and the rule is the rule. The menu now lays its stack out in code. Hiding two of four entries with scene-authored offsets would leave holes in a shipped build - the same failure the HUD columns had - so it measures the entries that actually exist and centres on them. A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both reset_new_game and the autoload's own default, so the two cannot disagree), and no gas giant guaranteed in the origin chunk. Both were play-testing conveniences that had become part of the real world's shape: every player got the same landmark in the same place. Play-testing wanted all four planet types anyway, and a normal game wanted none of them - different needs, now different mechanisms. WorldGen cannot read GameState back (an autoload is not resolvable as a global identifier that early, the hazard PlanetSetups.gd already works around), so GameState.dev_mode's setter PUSHES the flag down. World generation stays static and dependency-free, GameState stays the single source of truth, and dev planting is applied after every rng draw so a dev world and a normal one differ only in those four chunks. FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND how many ships you start with - which forced a new game to open with a full fleet, leaving the first ship crafter nothing to do. Split into BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening has two empty slots to fill. A third copy of the old value turned up in PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently degraded into "however many slots exist" once max_fleet_size() clamped it. The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron (70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game luxury but the ordinary way out of the opening, and pricing them as a luxury made the early game feel stuck. Armada follows at 90/2. Two new tiers, Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in one lane would be a long monotonous chain for a branch that only ever does one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size() clamps to its length and a doctrine past that would charge real resources for nothing; test_fleet_growth.gd asserts that invariant directly. TEST FALLOUT, all of it from the deliberate change rather than a defect: - Tests that silently inherited the old 5-ship default now ask for the fleet they need. Several had to crew it from their first frame rather than _initialize(), where the engine's deferred tree-entry finalization undoes it - the same hazard those tests already documented for set_process(false). - test_planet_landing_proximity and test_return_position_matches_planet take the FIRST chunk holding a planet and land there. The guaranteed gas giant had been masking that this can now be a black hole, which destroys ships on contact. They exclude it, and clear the asteroids the field already placed. - test_pivot_controls measured rotation with angle_difference, which wraps at +/-pi, so a hold that turned a full circle read as no rotation. Latent flakiness that only ever showed under load; it now reads the controller's unwrapped pivot_hold_amount. - test_research_points was failing on ore, not points: can_unlock() checks the ore price too, and a fresh game banks nothing now. - test_gas_system's "a reachable gas source near the spawn" premise moved to dev mode, which is where that guarantee now lives. Full suite green at 97/97. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
# Amounts a dev game opens with: enough of everything to reach any part of
# the game immediately, without being so large that costs stop registering.
const DEV_RESOURCE_AMOUNT: int = 2000
const DEV_RESEARCH_POINTS: int = 500
# A new game stocked for play-testing: every resource banked, research points
# to spend, and one of each planet type next to the spawn (WorldGen reads
# dev_mode for that). Offered by the main menu only when running from Godot -
# see MainMenu.gd - so it cannot be reached in a shipped build.
#
# Deliberately built ON TOP of reset_new_game() rather than beside it, so a
# dev game is a normal game plus a stock rather than a second, separately
# maintained definition of what a fresh game is.
func start_dev_game() -> void:
reset_new_game()
dev_mode = true
for resource_type in resource_types():
bank_inventory.set_amount(resource_type, DEV_RESOURCE_AMOUNT)
research_points = DEV_RESEARCH_POINTS
research_points_changed.emit(research_points)
# --- building stock and production queue (#16) -----------------------------
# Since #12, a building cannot just deduct from the bank at the moment of
# purchase: what it produces has to be paid for out of resources physically
# delivered to it. So each building carries its own `stock`, and an order it
# cannot afford yet is QUEUED rather than refused, and completes by itself
# once the stock turns up.
#
# `id` exists because a building has to be referable across the space <->
# planet-surface scene swap, when every Building NODE has been destroyed and
# rebuilt. Position would nearly work and then fail on two buildings placed
# on the same spot.
var next_building_id: int = 1
2026-08-12 22:47:46 +02:00
# Finished vessels waiting at the mothership for the player to collect
# (#23), as a list of VESSEL_* types - a shield vessel must not come back as
# an interceptor. They are NOT handed to the fleet automatically: the player
# picks what to crew from the hangar, so a full fleet no longer stops a
# crafter from building.
#
# Production is simulated here, in the autoload, the same way extractor
# haulers are, so a build finishes whether or not the player is in space.
var stored_vessels: Array = []
2026-08-13 21:39:05 +02:00
# Finished crafter orders travel to the mothership before appearing in its
# hangar. State is authoritative here so delivery continues off-screen.
# {"type", "from", "to", "progress"}
var vessel_deliveries: Array = []
2026-08-14 22:30:19 +02:00
const VESSEL_DELIVERY_SPEED: float = 180.0
signal order_completed(building_id: int, type: String)
func add_building(type: String, at_position: Vector2) -> int:
var id: int = next_building_id
next_building_id += 1
buildings.append({
2026-08-14 20:32:58 +02:00
"id": id, "type": type, "position": at_position,
"inventory": INVENTORY.new(), "orders": [],
})
2026-08-15 00:09:10 +02:00
var building: Dictionary = buildings[-1]
if building_has_capability(building, "transport_hub"):
_provision_hub_transporters(building)
return id
func find_building(id: int) -> Dictionary:
for b in buildings:
if b.get("id", 0) == id:
return b
return {}
func building_has_capability(building: Dictionary, capability: String) -> bool:
if building.is_empty():
return false
var definition: Resource = BUILDING_DEFINITIONS.get_definition(String(building.type))
return definition != null and capability in definition.capabilities
func building_logistics_profile(building: Dictionary) -> Resource:
if building.is_empty():
return null
var definition: Resource = BUILDING_DEFINITIONS.get_definition(String(building.type))
return null if definition == null else definition.logistics
# --- vessel types and their upgrades (#5) ---------------------------------
2026-08-15 00:09:10 +02:00
# Combat vessels can join the player fleet. Transporter metadata lives in the
# same definition catalog so the lab can upgrade its cargo and travel stats,
# but transporters themselves belong to hubs and are never production orders.
const VESSEL_INTERCEPTOR: String = "interceptor"
const VESSEL_SHIELD: String = "shield"
2026-08-15 04:45:15 +02:00
const VESSEL_GAS_BOMBER: String = "gas_bomber"
const VESSEL_TRANSPORTER: String = "transporter"
const VESSEL_CARRIER: String = "carrier"
Add laser ship vessel and its skill shot (#58) A manually aimed instant beam, modelled on the gas bomber's press-drag- release aiming (GasBombController/VesselGasBomberComponent) but firing the moment the touch releases - no projectile travel time. Damage, beam_range and cooldown are resolved per ship from base_stats/upgrades (VesselLaserComponent.apply_stats(), the same pattern VesselWeaponComponent uses) rather than hardcoded in the controller, so the new damage/range/cooldown ship-lab upgrades actually reach them. Cooldown lives on the component (per ship), not the controller (per fleet) - two laser ships give two shots, the exact bug already fixed for bombers in eb79efd. LaserController.gd is reused unmodified by both Main.tscn and PlanetSurface.tscn (relative ../Fleet, ../Camera2D, ../UI/LaserButton paths, matching GasBombController), so it works identically in space and on a landed surface. Wired into GameController's touch/drag first-refusal chain alongside the bomb controller. The crafter, hangar and ship-lab menus were already generic over VesselDefinitions.fleet()/.all() - only the per-vessel Main.tscn nodes are hand-placed, not the logic - so this repacks those rows to 5/5/6 columns and extends BuildController's onready arrays. No new signal handlers were needed. Also fixes test_vessel_composition.gd's hardcoded fleet-catalog size again (4 -> 5) now that laser is a fifth fleet-eligible vessel. Note for later tickets: a brand-new class_name script (LaserController) resolves fine as a static type when its own file is the godot -s entry point, but the identical type hint fails to parse ("Could not find type") when the script holding it is only reached indirectly - e.g. Main.tscn loaded from inside a _screenshot_tool.gd preview. Untyped Node + duck-typed calls sidesteps it; GameController.gd's laser_controller field documents this in place.
2026-08-16 17:29:53 +02:00
const VESSEL_LASER: String = "laser"
const VESSEL_DISTORDER: String = "distorder"
# Compatibility constants for callers and old tests. Runtime metadata is
# resolved from VesselDefinition below.
const SHIELD_COST: int = 14
2026-08-13 22:04:21 +02:00
const LAB_RESEARCH_TIME: float = 8.0
2026-08-12 22:47:46 +02:00
# Test seam. Headless tests need the timed path - queued, started, finishing
# - without sitting through a real 10-second build each, and a suite that
# slow stops being run. Left at 1.0 in the game.
var build_time_scale: float = 1.0
func build_time(product: String) -> float:
var definition: Resource = VESSEL_DEFINITIONS.get_definition(product)
return (definition.build_time if definition != null else 10.0) * build_time_scale
2026-08-12 22:47:46 +02:00
2026-08-13 22:04:21 +02:00
func lab_research_time() -> float:
return LAB_RESEARCH_TIME * build_time_scale
# Compatibility constants. VesselDefinition owns the live base figures.
const SHIP_BASE_DAMAGE: int = 1
const SHIP_BASE_COOLDOWN: float = 0.5
const SHIP_BASE_RANGE: float = 320.0
const SHIP_BASE_SPEED: float = 460.0
const SHIP_BASE_ACCEL: float = 450.0
const SHIELD_BASE_CHARGES: int = 1
const SHIELD_BASE_RECHARGE: float = 6.0
var vessel_levels: Dictionary = {}
signal vessel_upgraded(upgrade_id: String)
func has_ship_lab() -> bool:
for b in buildings:
if building_has_capability(b, "vessel_research"):
return true
return false
func shield_vessel_unlocked() -> bool:
return vessel_is_unlocked(VESSEL_SHIELD)
const UNLOCK_SHIELD_VESSEL: String = "unlock_shield"
const UNLOCK_SHIELD_COST: int = 45
func unlock_shield_vessel() -> bool:
return unlock_vessel(VESSEL_SHIELD)
func vessel_definition(vessel: String) -> Resource:
return VESSEL_DEFINITIONS.get_definition(vessel)
func vessel_name(vessel: String) -> String:
var definition: Resource = vessel_definition(vessel)
return definition.display_name if definition != null else vessel.capitalize()
func vessel_has_capability(vessel: String, capability: String) -> bool:
var definition: Resource = vessel_definition(vessel)
return definition != null and definition.has_capability(capability)
2026-08-15 00:09:10 +02:00
func vessel_is_fleet_eligible(vessel: String) -> bool:
var definition: Resource = vessel_definition(vessel)
return definition != null and definition.fleet_eligible
func vessel_is_unlocked(vessel: String) -> bool:
var definition: Resource = vessel_definition(vessel)
if definition == null:
return false
return definition.unlock_id == "" or int(vessel_levels.get(definition.unlock_id, 0)) > 0
func vessel_for_unlock(unlock_id: String) -> Resource:
for definition in VESSEL_DEFINITIONS.all():
if definition.unlock_id == unlock_id:
return definition
return null
func unlock_vessel(vessel: String) -> bool:
var definition: Resource = vessel_definition(vessel)
if definition == null or definition.unlock_id == "" or vessel_is_unlocked(vessel) or not has_ship_lab():
return false
2026-08-15 04:45:15 +02:00
var costs: Dictionary = research_costs(definition.unlock_id)
var points_needed: int = int(definition.research_point_cost)
Pay for everything from the bank and the fleet hold, not just buildings Construction already pooled the two; research, ship-lab upgrades and vessel unlocks still charged the bank alone. That split was arbitrary from the player's side - the resources are a couple of taps away from being banked either way, so refusing a purchase over where they happen to be sitting was ceremony. Worse, it was inconsistent: the same ore could buy a mothership and not a skill node. So the pooling moved down into can_afford/spend themselves, and the construction-specific pair is gone. can_afford_resources now pools per resource type, and a new spend_resources() charges bank first and hold for the shortfall, settling affordability in full before taking anything - a multi-resource price must not charge for the half it can cover and then fail. unlock_vessel goes through it instead of withdrawing from the bank directly. The skill menu's "you have %d" quoted the bank, which would have told the player they could not afford something they could. Ammo stays out of it. AimedFleetAbilityController reads the fleet hold directly, and should: a gas bomb is fired from what the ships are physically carrying, which is a fact about where the matter is rather than a convenience about where the money is. test_build_uses_fleet_hold became test_payments_use_fleet_hold and now walks every route - skill tree, ship lab, vessel research, multi-resource atomicity, and ammo staying unpooled. Two of its own setup steps were being silently clamped by the fleet hold's capacity, which is what made an affordable price look unaffordable; capacity is not what those checks are about, so it is taken out of the way explicitly. Full suite 106/106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:15:00 +02:00
if research_points < points_needed or not spend_resources(costs):
return false
if points_needed > 0:
research_points -= points_needed
research_points_changed.emit(research_points)
vessel_levels[definition.unlock_id] = 1
vessel_upgraded.emit(definition.unlock_id)
return true
func upgrade_def(id: String) -> Dictionary:
for definition in VESSEL_DEFINITIONS.all():
for upgrade in definition.upgrades:
if upgrade.upgrade_id == id:
return {"id": upgrade.upgrade_id, "vessel": definition.definition_id,
"name": upgrade.display_name, "desc": upgrade.description,
"cost": upgrade.base_cost, "max_level": upgrade.max_level,
"icon": upgrade.icon, "resource": upgrade}
return {}
func upgrades_for(vessel: String) -> Array:
var out: Array = []
var definition: Resource = vessel_definition(vessel)
if definition == null:
return out
for upgrade in definition.upgrades:
out.append({"id": upgrade.upgrade_id, "vessel": vessel,
"name": upgrade.display_name, "desc": upgrade.description,
"cost": upgrade.base_cost, "max_level": upgrade.max_level,
"icon": upgrade.icon, "resource": upgrade})
return out
func upgrade_level(id: String) -> int:
return int(vessel_levels.get(id, 0))
func upgrade_maxed(id: String) -> bool:
var d: Dictionary = upgrade_def(id)
return d.is_empty() or upgrade_level(id) >= int(d.max_level)
func upgrade_cost(id: String) -> int:
var d: Dictionary = upgrade_def(id)
if d.is_empty():
return 0
return int(d.cost) * (upgrade_level(id) + 1)
func can_buy_upgrade(id: String) -> bool:
return has_ship_lab() and not upgrade_maxed(id) and can_afford(upgrade_cost(id)) \
and research_points >= research_point_cost(id)
func buy_upgrade(id: String) -> bool:
if not can_buy_upgrade(id):
return false
if not spend(upgrade_cost(id)):
return false
var points_needed: int = research_point_cost(id)
if points_needed > 0:
research_points -= points_needed
research_points_changed.emit(research_points)
vessel_levels[id] = upgrade_level(id) + 1
vessel_upgraded.emit(id)
return true
func vessel_stat(vessel: String, stat: String) -> float:
var definition: Resource = vessel_definition(vessel)
if definition == null:
return 0.0
var value: float = float(definition.base_stats.get(stat, 0.0))
for upgrade in definition.upgrades:
var level: int = upgrade_level(upgrade.upgrade_id)
value += float(upgrade.additive_modifiers.get(stat, 0.0)) * level
if upgrade.multiplicative_modifiers.has(stat):
value *= pow(float(upgrade.multiplicative_modifiers[stat]), level)
if definition.minimum_stats.has(stat):
value = maxf(value, float(definition.minimum_stats[stat]))
return value
func ship_damage(vessel: String) -> int: return int(vessel_stat(vessel, "damage"))
func ship_fire_cooldown(vessel: String) -> float: return vessel_stat(vessel, "fire_cooldown")
func ship_fire_range(vessel: String) -> float: return vessel_stat(vessel, "fire_range")
func ship_max_speed(vessel: String) -> float: return vessel_stat(vessel, "max_speed")
func ship_acceleration(vessel: String) -> float: return vessel_stat(vessel, "acceleration")
func shield_max_charges() -> int:
var definition: Resource = VESSEL_DEFINITIONS.with_capability("shield")
return int(vessel_stat(definition.definition_id, "shield_charges"))
func shield_recharge_time() -> float:
var definition: Resource = VESSEL_DEFINITIONS.with_capability("shield")
return vessel_stat(definition.definition_id, "shield_recharge")
# #56: sums cargo_capacity over ships that actually declare the "cargo"
# capability, so combat specialists (no capability, whatever their stat
# dict says) contribute nothing - data-driven rather than a vessel-id list.
# Takes the composition as an argument rather than reading it off a live
# Fleet node: GameState has no fixed reference to "the" Fleet (space and a
# planet surface each own a separate one), so the caller passes its own
# Fleet.vessel_types().
func fleet_cargo_capacity(vessel_types: PackedStringArray) -> int:
var total: int = 0
for vessel_type in vessel_types:
if vessel_has_capability(vessel_type, "cargo"):
total += int(vessel_stat(vessel_type, "cargo_capacity"))
return total
func sync_fleet_cargo_capacity(vessel_types: PackedStringArray) -> void:
fleet_inventory.total_capacity = fleet_cargo_capacity(vessel_types)
func transporter_load_limit() -> int:
var definition: Resource = VESSEL_DEFINITIONS.with_capability("transport")
return int(vessel_stat(definition.definition_id, "cargo_capacity"))
func transporter_speed() -> float:
var definition: Resource = VESSEL_DEFINITIONS.with_capability("transport")
return vessel_stat(definition.definition_id, "transport_speed")
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
const PRODUCT_SHIP: String = "interceptor"
func product_cost(product: String) -> int:
2026-08-15 04:45:15 +02:00
return int(product_costs(product).get(RESOURCE_ORE, 0))
func product_costs(product: String) -> Dictionary:
var definition: Resource = vessel_definition(product)
2026-08-15 04:45:15 +02:00
if definition == null:
return {}
if not definition.build_resource_costs.is_empty():
return definition.build_resource_costs.duplicate()
return {RESOURCE_ORE: definition.build_cost} if definition.build_cost > 0 else {}
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
func can_produce(type: String) -> bool:
var definition: Resource = BUILDING_DEFINITIONS.get_definition(type)
return definition != null and "production" in definition.capabilities
func building_stock(id: int) -> int:
2026-08-14 20:32:58 +02:00
return building_resource(id, RESOURCE_ORE)
func pending_orders(id: int) -> int:
var b: Dictionary = find_building(id)
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
return 0 if b.is_empty() else (b.get("orders", []) as Array).size()
func pending_orders_of(id: int, product: String) -> int:
var b: Dictionary = find_building(id)
if b.is_empty():
return 0
var n: int = 0
for o in b.get("orders", []):
2026-08-12 22:47:46 +02:00
if String(o.product) == product:
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
n += 1
return n
2026-08-12 22:47:46 +02:00
func order_list(id: int) -> Array:
# What the queue panel draws: product, and how far along the one being
# built is. Everything behind it reads as simply waiting.
var out: Array = []
var b: Dictionary = find_building(id)
if b.is_empty():
return out
for o in b.get("orders", []):
var product: String = String(o.product)
out.append({
"product": product,
"started": bool(o.started),
"progress": clampf(float(o.elapsed) / maxf(build_time(product), 0.001), 0.0, 1.0),
})
return out
func cancel_order(id: int, index: int) -> bool:
# Refunds whatever was actually taken: a build that has started has its
# ore in the building already, one still waiting never paid.
var b: Dictionary = find_building(id)
if b.is_empty():
return false
var orders: Array = b.get("orders", [])
if index < 0 or index >= orders.size():
return false
var order: Dictionary = orders[index]
if bool(order.started):
2026-08-15 04:45:15 +02:00
var inventory: RefCounted = _inventory_for_record(b)
var costs: Dictionary = product_costs(String(order.product))
for resource_type in costs:
inventory.deposit(String(resource_type), int(costs[resource_type]))
2026-08-12 22:47:46 +02:00
orders.remove_at(index)
return true
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
func outstanding_cost(id: int) -> int:
2026-08-15 04:45:15 +02:00
return outstanding_resource_cost(id, RESOURCE_ORE)
func outstanding_costs(id: int) -> Dictionary:
var result: Dictionary = {}
for resource_type in resource_types():
var need: int = outstanding_resource_cost(id, resource_type)
if need > 0:
result[resource_type] = need
return result
func outstanding_resource_cost(id: int, resource_type: String) -> int:
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
# What this building still needs delivered to clear everything queued.
var b: Dictionary = find_building(id)
if b.is_empty():
return 0
var need: int = 0
for o in b.get("orders", []):
2026-08-12 22:47:46 +02:00
if not bool(o.started):
2026-08-15 04:45:15 +02:00
need += int(product_costs(String(o.product)).get(resource_type, 0))
2026-08-14 22:46:25 +02:00
for order in b.get("research_orders", []):
if not bool(order.started):
2026-08-15 04:45:15 +02:00
need += int(research_costs(String(order.id)).get(resource_type, 0))
return maxi(need - _inventory_for_record(b).amount(resource_type), 0)
func deposit_to_building(id: int, amount: int) -> int:
2026-08-15 04:45:15 +02:00
return deposit_resource_to_building(id, RESOURCE_ORE, amount)
func deposit_resource_to_building(id: int, resource_type: String, amount: int) -> int:
if amount <= 0:
return 0
var b: Dictionary = find_building(id)
if b.is_empty():
return 0
2026-08-15 04:45:15 +02:00
return _inventory_for_record(b).deposit(resource_type, amount)
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
func queue_order(id: int, product: String = PRODUCT_SHIP) -> bool:
var b: Dictionary = find_building(id)
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
if b.is_empty() or not can_produce(b.type):
return false
var definition: Resource = vessel_definition(product)
2026-08-15 00:09:10 +02:00
if definition == null or not definition.fleet_eligible \
or not vessel_is_unlocked(product):
return false
2026-08-12 22:47:46 +02:00
(b.orders as Array).append({"product": product, "elapsed": 0.0, "started": false})
return true
2026-08-12 22:47:46 +02:00
func _advance_production(delta: float) -> void:
for b in buildings:
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
if not can_produce(b.type):
continue
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
var orders: Array = b.get("orders", [])
2026-08-12 22:47:46 +02:00
if orders.is_empty():
continue
# ONE at a time, in order. A queue the player can watch and cancel
# has to have a thing being built and things waiting behind it -
# clearing the whole queue the instant the ore lands (which is what
# this did before #23) leaves nothing to see or cancel.
var order: Dictionary = orders[0]
var product: String = String(order.product)
2026-08-15 04:45:15 +02:00
var costs: Dictionary = product_costs(product)
2026-08-14 20:32:58 +02:00
var inventory: RefCounted = _inventory_for_record(b)
var definition: Resource = vessel_definition(product)
2026-08-12 22:47:46 +02:00
if not bool(order.started):
2026-08-15 04:45:15 +02:00
var affordable := true
for resource_type in costs:
if inventory.amount(String(resource_type)) < int(costs[resource_type]):
affordable = false
break
if not affordable:
2026-08-12 22:47:46 +02:00
continue
2026-08-15 04:45:15 +02:00
for resource_type in costs:
inventory.withdraw(String(resource_type), int(costs[resource_type]))
2026-08-12 22:47:46 +02:00
order.started = true
order.elapsed = float(order.elapsed) + delta
if float(order.elapsed) < build_time(product):
continue
orders.pop_front()
2026-08-15 00:09:10 +02:00
# A finished combat hull launches from the crafter and physically flies
# to the mothership hangar. Hub transporters are provisioned by hubs and
# never enter this production path.
var mothership: Variant = get_mothership_position()
if mothership == null:
stored_vessels.append(product)
2026-08-12 22:47:46 +02:00
else:
2026-08-15 00:09:10 +02:00
vessel_deliveries.append({"type": product, "from": b.position,
"to": mothership, "progress": 0.0})
2026-08-12 22:47:46 +02:00
order_completed.emit(int(b.id), product)
2026-08-13 22:04:21 +02:00
func queue_lab_research(lab_id: int, upgrade_id: String) -> bool:
var lab: Dictionary = find_building(lab_id)
if not building_has_capability(lab, "vessel_research") or upgrade_id == "":
2026-08-13 22:04:21 +02:00
return false
var orders: Array = lab.get("research_orders", [])
var unlock_definition: Resource = vessel_for_unlock(upgrade_id)
if not orders.is_empty() or (unlock_definition == null and upgrade_maxed(upgrade_id)):
2026-08-13 22:04:21 +02:00
return false
orders.append({"id": upgrade_id, "elapsed": 0.0, "started": false})
lab.research_orders = orders
return true
func lab_research_order(lab_id: int) -> Dictionary:
var lab: Dictionary = find_building(lab_id)
var orders: Array = lab.get("research_orders", [])
return {} if orders.is_empty() else orders[0]
2026-08-14 22:46:25 +02:00
func research_cost(id: String) -> int:
2026-08-15 04:45:15 +02:00
return int(research_costs(id).get(RESOURCE_ORE, 0))
func research_point_cost(id: String) -> int:
var unlock_definition: Resource = vessel_for_unlock(id)
if unlock_definition != null:
return int(unlock_definition.research_point_cost)
var d: Dictionary = upgrade_def(id)
return int(d.resource.research_point_cost) if not d.is_empty() else 0
2026-08-15 04:45:15 +02:00
func research_costs(id: String) -> Dictionary:
2026-08-14 22:46:25 +02:00
var unlock_definition: Resource = vessel_for_unlock(id)
2026-08-15 04:45:15 +02:00
if unlock_definition != null:
if not unlock_definition.unlock_resource_costs.is_empty():
return unlock_definition.unlock_resource_costs.duplicate()
return {RESOURCE_ORE: unlock_definition.unlock_cost} if unlock_definition.unlock_cost > 0 else {}
var cost: int = upgrade_cost(id)
return {RESOURCE_ORE: cost} if cost > 0 else {}
2026-08-14 22:46:25 +02:00
2026-08-13 22:04:21 +02:00
func _advance_lab_research(delta: float) -> void:
for lab in buildings:
if not building_has_capability(lab, "vessel_research"):
2026-08-13 22:04:21 +02:00
continue
var orders: Array = lab.get("research_orders", [])
if orders.is_empty():
continue
var order: Dictionary = orders[0]
var id: String = String(order.id)
var unlock_definition: Resource = vessel_for_unlock(id)
2026-08-15 04:45:15 +02:00
var costs: Dictionary = research_costs(id)
2026-08-14 20:32:58 +02:00
var inventory: RefCounted = _inventory_for_record(lab)
2026-08-13 22:04:21 +02:00
if not bool(order.started):
2026-08-15 04:45:15 +02:00
var affordable := true
for resource_type in costs:
if inventory.amount(String(resource_type)) < int(costs[resource_type]):
affordable = false
break
# #63: research points are a fleet-wide currency, not delivered
# to the lab like ore/gas - checked and spent here too so a
# shortfall stalls the order the same way an undelivered
# resource does, rather than letting it start regardless.
var points_needed: int = research_point_cost(id)
if points_needed > research_points:
affordable = false
2026-08-15 04:45:15 +02:00
if not affordable:
2026-08-13 22:04:21 +02:00
continue
2026-08-15 04:45:15 +02:00
for resource_type in costs:
inventory.withdraw(String(resource_type), int(costs[resource_type]))
if points_needed > 0:
research_points -= points_needed
research_points_changed.emit(research_points)
2026-08-13 22:04:21 +02:00
order.started = true
order.elapsed = float(order.elapsed) + delta
if float(order.elapsed) < lab_research_time():
continue
if unlock_definition != null:
2026-08-13 22:04:21 +02:00
vessel_levels[id] = 1
else:
vessel_levels[id] = upgrade_level(id) + 1
vessel_upgraded.emit(id)
orders.pop_front()
2026-08-12 22:47:46 +02:00
func claim_stored_vessel(index: int) -> String:
# Hands one finished vessel over to be crewed. Returns its type, or ""
# when there is nothing there - the caller decides whether the fleet
# actually has room.
if index < 0 or index >= stored_vessels.size():
return ""
return String(stored_vessels[index])
func take_stored_vessel(index: int) -> String:
var product: String = claim_stored_vessel(index)
if product == "":
return ""
stored_vessels.remove_at(index)
return product
func has_mothership() -> bool:
return get_mothership_position() != null
func has_building_of_type(type: String) -> bool:
for building in buildings:
if building.type == type:
return true
return false
func remove_buildings_of_type(type: String) -> int:
var kept: Array = []
var removed_hub_ids: Array = []
var removed: int = 0
for b in buildings:
if b.type == type:
removed += 1
if building_has_capability(b, "transport_hub"):
removed_hub_ids.append(int(b.id))
else:
kept.append(b)
buildings = kept
if not removed_hub_ids.is_empty():
2026-08-15 00:09:10 +02:00
var kept_transporters: Array = []
for transporter in transporters:
if int(transporter.get("hub_id", 0)) not in removed_hub_ids:
2026-08-15 00:09:10 +02:00
kept_transporters.append(transporter)
transporters = kept_transporters
return removed
func get_mothership_position() -> Variant:
# Where a wiped-out fleet gets rebuilt. Returns null when no mothership
# has been built yet, so the caller can fall back to the world origin.
for b in buildings:
if building_has_capability(b, "bank"):
return b.position
return null
# --- Fleet formation -------------------------------------------------------
# Where each vessel sits relative to the fleet centre, editable at a command
# centre. Lives here (not as a const on Fleet) so a layout the player has
# arranged survives the trip to a planet's surface and back, and so a
# rebuilt fleet keeps their arrangement.
Skill tree, node-based building animation, bigger UI, and a 19x faster test suite Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:00:41 +02:00
# Slots beyond BASE_FLEET_SIZE only become usable once the matching fleet
# capacity upgrade is researched, but they are defined up front so the wedge
# keeps a deliberate shape as it grows rather than having positions invented
# at unlock time.
Rework how a game starts: dev mode, a bare opening, and fleet growth Adds a Dev Mode entry to the main menu and takes the play-testing conveniences out of a normal game, which turn out to be the same change from two directions. DEV MODE. Banks 2000 of every resource, grants 500 research points, and plants one of each planet type in the chunks around the spawn. Built ON TOP of reset_new_game() rather than beside it, so a dev game is a normal game plus a stock rather than a second definition of "fresh game" to keep in sync. Offered only when running from Godot - OS.has_feature("editor"), the tag an export clears - as is the map editor. Hidden AND guarded: pressing either anyway is refused, because visibility is presentation and the rule is the rule. The menu now lays its stack out in code. Hiding two of four entries with scene-authored offsets would leave holes in a shipped build - the same failure the HUD columns had - so it measures the entries that actually exist and centres on them. A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both reset_new_game and the autoload's own default, so the two cannot disagree), and no gas giant guaranteed in the origin chunk. Both were play-testing conveniences that had become part of the real world's shape: every player got the same landmark in the same place. Play-testing wanted all four planet types anyway, and a normal game wanted none of them - different needs, now different mechanisms. WorldGen cannot read GameState back (an autoload is not resolvable as a global identifier that early, the hazard PlanetSetups.gd already works around), so GameState.dev_mode's setter PUSHES the flag down. World generation stays static and dependency-free, GameState stays the single source of truth, and dev planting is applied after every rng draw so a dev world and a normal one differ only in those four chunks. FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND how many ships you start with - which forced a new game to open with a full fleet, leaving the first ship crafter nothing to do. Split into BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening has two empty slots to fill. A third copy of the old value turned up in PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently degraded into "however many slots exist" once max_fleet_size() clamped it. The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron (70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game luxury but the ordinary way out of the opening, and pricing them as a luxury made the early game feel stuck. Armada follows at 90/2. Two new tiers, Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in one lane would be a long monotonous chain for a branch that only ever does one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size() clamps to its length and a doctrine past that would charge real resources for nothing; test_fleet_growth.gd asserts that invariant directly. TEST FALLOUT, all of it from the deliberate change rather than a defect: - Tests that silently inherited the old 5-ship default now ask for the fleet they need. Several had to crew it from their first frame rather than _initialize(), where the engine's deferred tree-entry finalization undoes it - the same hazard those tests already documented for set_process(false). - test_planet_landing_proximity and test_return_position_matches_planet take the FIRST chunk holding a planet and land there. The guaranteed gas giant had been masking that this can now be a black hole, which destroys ships on contact. They exclude it, and clear the asteroids the field already placed. - test_pivot_controls measured rotation with angle_difference, which wraps at +/-pi, so a hold that turned a full circle read as no rotation. Latent flakiness that only ever showed under load; it now reads the controller's unwrapped pivot_hold_amount. - test_research_points was failing on ore, not points: can_unlock() checks the ore price too, and a fresh game banks nothing now. - test_gas_system's "a reachable gas source near the spawn" premise moved to dev mode, which is where that guarantee now lives. Full suite green at 97/97. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
# Ten slots, because max_fleet_size() caps itself on this array's length -
# so the wedge has to be long enough for every doctrine to be worth buying.
# The last pair continues the V rather than starting a new shape.
const DEFAULT_FORMATION: Array = [
Vector2(0, -48), Vector2(-30, -8), Vector2(30, -8), Vector2(-60, 32), Vector2(60, 32),
Skill tree, node-based building animation, bigger UI, and a 19x faster test suite Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:00:41 +02:00
Vector2(-90, 72), Vector2(90, 72), Vector2(0, 56),
Rework how a game starts: dev mode, a bare opening, and fleet growth Adds a Dev Mode entry to the main menu and takes the play-testing conveniences out of a normal game, which turn out to be the same change from two directions. DEV MODE. Banks 2000 of every resource, grants 500 research points, and plants one of each planet type in the chunks around the spawn. Built ON TOP of reset_new_game() rather than beside it, so a dev game is a normal game plus a stock rather than a second definition of "fresh game" to keep in sync. Offered only when running from Godot - OS.has_feature("editor"), the tag an export clears - as is the map editor. Hidden AND guarded: pressing either anyway is refused, because visibility is presentation and the rule is the rule. The menu now lays its stack out in code. Hiding two of four entries with scene-authored offsets would leave holes in a shipped build - the same failure the HUD columns had - so it measures the entries that actually exist and centres on them. A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both reset_new_game and the autoload's own default, so the two cannot disagree), and no gas giant guaranteed in the origin chunk. Both were play-testing conveniences that had become part of the real world's shape: every player got the same landmark in the same place. Play-testing wanted all four planet types anyway, and a normal game wanted none of them - different needs, now different mechanisms. WorldGen cannot read GameState back (an autoload is not resolvable as a global identifier that early, the hazard PlanetSetups.gd already works around), so GameState.dev_mode's setter PUSHES the flag down. World generation stays static and dependency-free, GameState stays the single source of truth, and dev planting is applied after every rng draw so a dev world and a normal one differ only in those four chunks. FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND how many ships you start with - which forced a new game to open with a full fleet, leaving the first ship crafter nothing to do. Split into BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening has two empty slots to fill. A third copy of the old value turned up in PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently degraded into "however many slots exist" once max_fleet_size() clamped it. The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron (70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game luxury but the ordinary way out of the opening, and pricing them as a luxury made the early game feel stuck. Armada follows at 90/2. Two new tiers, Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in one lane would be a long monotonous chain for a branch that only ever does one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size() clamps to its length and a doctrine past that would charge real resources for nothing; test_fleet_growth.gd asserts that invariant directly. TEST FALLOUT, all of it from the deliberate change rather than a defect: - Tests that silently inherited the old 5-ship default now ask for the fleet they need. Several had to crew it from their first frame rather than _initialize(), where the engine's deferred tree-entry finalization undoes it - the same hazard those tests already documented for set_process(false). - test_planet_landing_proximity and test_return_position_matches_planet take the FIRST chunk holding a planet and land there. The guaranteed gas giant had been masking that this can now be a black hole, which destroys ships on contact. They exclude it, and clear the asteroids the field already placed. - test_pivot_controls measured rotation with angle_difference, which wraps at +/-pi, so a hold that turned a full circle read as no rotation. Latent flakiness that only ever showed under load; it now reads the controller's unwrapped pivot_hold_amount. - test_research_points was failing on ore, not points: can_unlock() checks the ore price too, and a fresh game banks nothing now. - test_gas_system's "a reachable gas source near the spawn" premise moved to dev mode, which is where that guarantee now lives. Full suite green at 97/97. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
Vector2(-120, 112), Vector2(120, 112),
]
var formation: Array = DEFAULT_FORMATION.duplicate()
func reset_formation() -> void:
formation = DEFAULT_FORMATION.duplicate()
func rotate_formation(radians: float) -> void:
# Pivots every slot about the fleet centre, keeping the shape intact
# and only changing which way it faces.
for i in range(formation.size()):
formation[i] = formation[i].rotated(radians)
# --- Extractors ------------------------------------------------------------
# Extractors mine into their OWN local stock. They do not own a magical
# hauler: ore becomes spendable only when a hub-stationed transporter picks
# up a full load and physically delivers it to the mothership. Entries are
# {"position", "planet_id", "planet_name", "resource_type", "inventory", "mining_progress"}.
Recentre a released stick, slow extractors again, and unbreak the lab layout Both sticks float - the base jumps to wherever the finger lands, so a player does not have to hit an exact spot on a screen they cannot feel. Releasing put only the KNOB back onto the base and left the base parked wherever that touch had been, so an idle stick sat off-centre in a different place every time and read as a control still in use. Release now returns base and knob to the middle of the rect. Extractors produce at 0.5 ore per second, halved again: at 1.0 they still outran the rest of the economy. An extractor now pays back its 25-ore cost in fifty seconds rather than twelve. The ship lab and the crafter were both overlapping themselves. Enlarging the shared resource table to 136px (so it could breathe and show bigger type) pushed its bottom edge 34px into the vessel row beneath it in one and the status text in the other - my regression, visible immediately on a real screen and not at all in any test. The lab's second row of upgrade cards was also already running into the detail text below it, by 13px, before any of this. Both menus are re-spaced end to end rather than nudged, since moving one collision into another is how the second one got there. Two tests carried waits tuned to an extraction rate that has now changed twice. Both derive them from EXTRACTOR_RATE instead, so the next balance pass does not report a broken extractor that is working perfectly - the same fix test_extractor_income already needed one round ago, in the file next to it. Suite 112/112. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:31:46 +02:00
# Ore per second, per extractor.
#
# Halved once alongside the planet-density change in WorldGen (roughly 2.5x as
# many planets to build on, so holding the rate would have multiplied passive
# income rather than moving where it comes from), and halved again because it
# still outran the rest of the economy. At 0.5 an extractor pays back its own
# 25-ore cost in fifty seconds and yields 30 a minute after that, so a
# single lucky early site cannot carry a game and expanding is what grows one.
const EXTRACTOR_RATE: float = 0.5
var extractors: Array = []
func has_extractor_at(planet_key: String) -> bool:
for e in extractors:
# Old saves have no planet_id; their name remains a migration fallback.
if String(e.get("planet_id", e.get("planet_name", ""))) == planet_key:
return true
return false
func extractor_index_at(planet_key: String) -> int:
for i in range(extractors.size()):
var extractor: Dictionary = extractors[i]
if String(extractor.get("planet_id", extractor.get("planet_name", ""))) == planet_key:
return i
return -1
signal extractor_added(planet_name: String)
func add_extractor(planet_name: String, at_position: Vector2, resource_type: String = "ore",
planet_key: String = "") -> void:
var stable_id: String = planet_key if planet_key != "" else planet_name
extractors.append({
"position": at_position,
"planet_id": stable_id,
"planet_name": planet_name,
2026-08-13 21:07:35 +02:00
"resource_type": resource_type,
2026-08-14 20:32:58 +02:00
"inventory": INVENTORY.new(),
"mining_progress": 0.0,
})
# An extractor is built from SPACE, with the fleet parked on the planet -
# so the Planet node is already in the tree with its _ready() long since
# run. Without this it kept showing no derrick until it happened to
# despawn and respawn, which reads as the build having done nothing.
extractor_added.emit(stable_id)
func extractor_stock(index: int) -> int:
if index < 0 or index >= extractors.size():
return 0
2026-08-14 20:32:58 +02:00
var extractor: Dictionary = extractors[index]
return _extractor_inventory(extractor).amount(String(extractor.get("resource_type", RESOURCE_ORE)))
func extractor_resource(index: int, resource_type: String) -> int:
if index < 0 or index >= extractors.size():
return 0
return _extractor_inventory(extractors[index]).amount(resource_type)
func transfer_resource_to_extractor(index: int, resource_type: String, amount: int) -> int:
if index < 0 or index >= extractors.size():
return 0
return fleet_inventory.transfer_to(_extractor_inventory(extractors[index]), resource_type, amount)
func transfer_resource_from_extractor(index: int, resource_type: String, amount: int) -> int:
if index < 0 or index >= extractors.size():
return 0
return _extractor_inventory(extractors[index]).transfer_to(fleet_inventory, resource_type, amount)
2026-08-14 20:32:58 +02:00
func _extractor_inventory(extractor: Dictionary) -> RefCounted:
var inventory: RefCounted = extractor.get("inventory") as RefCounted
if inventory == null:
inventory = INVENTORY.new()
extractor.inventory = inventory
# Old extractor state had one stock field whose material was described by
# resource_type. Absorb it into that key on first access.
if extractor.has("stock"):
inventory.set_amount(String(extractor.get("resource_type", RESOURCE_ORE)), int(extractor.stock))
extractor.erase("stock")
return inventory
# --- building logistics and resource conversion ---------------------------
func building_logistics_deficit(id: int, resource_type: String) -> int:
var building: Dictionary = find_building(id)
var profile: Resource = building_logistics_profile(building)
if profile == null:
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>
2026-08-16 12:10:31 +02:00
return 0
var target: int = int(profile.input_buffers.get(resource_type, 0))
return maxi(0, target - building_resource(id, resource_type))
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>
2026-08-16 12:10:31 +02:00
func building_requested_resource(id: int, resource_type: String) -> int:
return maxi(outstanding_resource_cost(id, resource_type),
building_logistics_deficit(id, resource_type))
func _collectable_building_resource(building: Dictionary, minimum_amount: int) -> String:
var profile: Resource = building_logistics_profile(building)
if profile == null:
return ""
for resource_type in profile.collected_outputs:
if building_resource(int(building.id), resource_type) >= minimum_amount:
return resource_type
return ""
func _advance_resource_converters(delta: float) -> void:
for building in buildings:
var profile: Resource = building_logistics_profile(building)
if profile == null or profile.conversion_input == "" \
or profile.conversion_output == "" or profile.conversion_rate <= 0.0 \
or profile.conversion_yield <= 0:
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>
2026-08-16 12:10:31 +02:00
continue
var inventory: RefCounted = _inventory_for_record(building)
if inventory.amount(profile.conversion_input) <= 0:
continue
var progress: float = float(building.get("conversion_progress", 0.0)) \
+ delta * profile.conversion_rate
var whole: int = mini(int(floor(progress)), inventory.amount(profile.conversion_input))
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>
2026-08-16 12:10:31 +02:00
if whole > 0:
inventory.withdraw(profile.conversion_input, whole)
inventory.deposit(profile.conversion_output, whole * profile.conversion_yield)
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>
2026-08-16 12:10:31 +02:00
progress -= whole
building.conversion_progress = progress
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>
2026-08-16 12:10:31 +02:00
func _advance_distorder_drain(delta: float) -> void:
if fleet_speed_multiplier <= 1.0:
# Nothing owed at 1.0x - and no stale fractional progress should
# carry over into a LATER, unrelated boost.
_distorder_drain_progress = 0.0
return
Stacking distorders, menu layouts, lab rewards, enemy detail, artillery cross DISTORDER STACKING. Each space-time distorder vessel past the first raises the fleet's speed ceiling by 1.0, so a second one is worth crewing instead of being dead weight. GameState gains max_fleet_speed_multiplier, pushed by Fleet whenever composition changes - the same direction as every other fleet-derived figure, since GameState does not reach into the scene tree to count ships. The energy drain is normalised against the CURRENT ceiling, so full boost costs the same per second however wide it is: extra vessels buy more speed, not cheaper speed. Losing one re-clamps an over-ceiling boost rather than leaving the fleet travelling faster than its crew can sustain. TRANSFER MENU. Rows overlapped, and the last one ran into the hint text. row_height is divided from the available space, but each row's contents used a fixed pixel ladder - glyph at +10, buttons at +88, selector at +142 - which needs about 244px. Three resources fit; the fourth cut rows to 180 and every one spilled 64px into the next. Every offset is now a fraction of the row, so it cannot overflow. Also dropped the per-row AMOUNT caption (four copies of a word the footer hint already explains) and moved each glyph and name to the TOP of its row, where they read as its heading rather than the next row's. HANGAR. Six vessel types in one row gave each about 100px for a 155px label, so the names ran together. Now a 3x2 grid per section, sized from the space between the first heading and the status line, using the empty lower half. LAB RESEARCH REWARD. A harder enemy lab is worth more research points, and how hard a lab is depends on the layout authored for it - how many capture points, how many defenders - which the planet TYPE cannot know. PlanetSetup carries research_point_reward, set in the map editor, overriding the planet type's flat rate. 0 means "no opinion", so every map authored before this keeps its old value. RESEARCH LAB BACKGROUND. Its own shader instead of the crater field with a red tint, which read as "rocky planet, tinted" rather than someone's facility: plated floor, seams, service conduits, lit junctions. Loop-free by construction (#68's lesson applied up front) - conduits stay continuous across cells by keying a horizontal run to cell.y and a vertical to cell.x, so neighbouring cells agree without any cell scanning its neighbours. ENEMY DETAIL. Every enemy was a single flat outline, which left the artillery and the turret spawner as the same octagon at two sizes - the silhouette carried no information about what the thing did. EnemyDefinition gains detail_lines and detail_rings as DATA, so adding an enemy still means adding a definition rather than editing Enemy.gd, and the four non-default hulls are redrawn: a long-barrelled sniper, a squat mortar, a hive with launch bays, a capital ship with spine and ribs. Three things drew an enemy from its definition - the enemy, the map editor's markers, and the palette buttons - each reimplementing it from hull_points alone, so the new detail appeared in the game and nowhere else and an editor marker stopped matching what it placed. They now share EnemyDefinitions.draw_definition(). ARTILLERY. The 1.8s charge-up before the tower fired arrived long before the threat was legible and mostly read as the tower being stuck. It fires immediately now; the warning is the shell you can see in the air plus a small cross on the ground at the impact point. Deliberately not a blast-radius ring - a large translucent area was easy to read as the explosion having already happened, where the cross says "move off this spot". MAIN MENU. No Quit in any build (a web build has nothing to quit to, and Android's back gesture already does it), a plain black background, and the game is "Spaceship" - in the title and in config/name, which is what drives the browser tab for the web export. change_scene_to_file() returns an error and otherwise fails silently, leaving the menu up, which is indistinguishable from an unpressed button; it is now checked and reported on screen. Full suite green at 99/99. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:54:46 +02:00
# Normalized against the CURRENT ceiling, so "full boost" always costs
# ENERGY_DRAIN_PER_SECOND however many distorder vessels widened it -
# a bigger fleet buys more speed, not cheaper speed.
var rate: float = (fleet_speed_multiplier - 1.0) / maxf(max_fleet_speed_multiplier - 1.0, 0.001) \
* ENERGY_DRAIN_PER_SECOND
_distorder_drain_progress += rate * delta
var whole: int = int(floor(_distorder_drain_progress))
if whole <= 0:
return
_distorder_drain_progress -= whole
var available: int = fleet_inventory.amount(RESOURCE_ENERGY)
if available >= whole:
fleet_inventory.withdraw(RESOURCE_ENERGY, whole)
return
# Ran out mid-drain: take whatever is left (down to exactly zero) and
# force the boost back to its default, rather than let the fleet keep
# travelling fast on credit.
fleet_inventory.withdraw(RESOURCE_ENERGY, available)
fleet_speed_multiplier = 1.0
_distorder_drain_progress = 0.0
boost_depleted_notice = true
# Planet conquest: a planet must have every one of its capture points held
# before an extractor can be built there (see BuildController's placement
# gates). Keyed by the same stable planet_id as `extractors`, entirely
# separate from it - a planet can be conquered long before anyone builds an
# extractor on it, or never get one at all.
var planet_capture: Dictionary = {}
signal planet_conquered(planet_id: String)
Stacking distorders, menu layouts, lab rewards, enemy detail, artillery cross DISTORDER STACKING. Each space-time distorder vessel past the first raises the fleet's speed ceiling by 1.0, so a second one is worth crewing instead of being dead weight. GameState gains max_fleet_speed_multiplier, pushed by Fleet whenever composition changes - the same direction as every other fleet-derived figure, since GameState does not reach into the scene tree to count ships. The energy drain is normalised against the CURRENT ceiling, so full boost costs the same per second however wide it is: extra vessels buy more speed, not cheaper speed. Losing one re-clamps an over-ceiling boost rather than leaving the fleet travelling faster than its crew can sustain. TRANSFER MENU. Rows overlapped, and the last one ran into the hint text. row_height is divided from the available space, but each row's contents used a fixed pixel ladder - glyph at +10, buttons at +88, selector at +142 - which needs about 244px. Three resources fit; the fourth cut rows to 180 and every one spilled 64px into the next. Every offset is now a fraction of the row, so it cannot overflow. Also dropped the per-row AMOUNT caption (four copies of a word the footer hint already explains) and moved each glyph and name to the TOP of its row, where they read as its heading rather than the next row's. HANGAR. Six vessel types in one row gave each about 100px for a 155px label, so the names ran together. Now a 3x2 grid per section, sized from the space between the first heading and the status line, using the empty lower half. LAB RESEARCH REWARD. A harder enemy lab is worth more research points, and how hard a lab is depends on the layout authored for it - how many capture points, how many defenders - which the planet TYPE cannot know. PlanetSetup carries research_point_reward, set in the map editor, overriding the planet type's flat rate. 0 means "no opinion", so every map authored before this keeps its old value. RESEARCH LAB BACKGROUND. Its own shader instead of the crater field with a red tint, which read as "rocky planet, tinted" rather than someone's facility: plated floor, seams, service conduits, lit junctions. Loop-free by construction (#68's lesson applied up front) - conduits stay continuous across cells by keying a horizontal run to cell.y and a vertical to cell.x, so neighbouring cells agree without any cell scanning its neighbours. ENEMY DETAIL. Every enemy was a single flat outline, which left the artillery and the turret spawner as the same octagon at two sizes - the silhouette carried no information about what the thing did. EnemyDefinition gains detail_lines and detail_rings as DATA, so adding an enemy still means adding a definition rather than editing Enemy.gd, and the four non-default hulls are redrawn: a long-barrelled sniper, a squat mortar, a hive with launch bays, a capital ship with spine and ribs. Three things drew an enemy from its definition - the enemy, the map editor's markers, and the palette buttons - each reimplementing it from hull_points alone, so the new detail appeared in the game and nowhere else and an editor marker stopped matching what it placed. They now share EnemyDefinitions.draw_definition(). ARTILLERY. The 1.8s charge-up before the tower fired arrived long before the threat was legible and mostly read as the tower being stuck. It fires immediately now; the warning is the shell you can see in the air plus a small cross on the ground at the impact point. Deliberately not a blast-radius ring - a large translucent area was easy to read as the explosion having already happened, where the cross says "move off this spot". MAIN MENU. No Quit in any build (a web build has nothing to quit to, and Android's back gesture already does it), a plain black background, and the game is "Spaceship" - in the title and in config/name, which is what drives the browser tab for the web export. change_scene_to_file() returns an error and otherwise fails silently, leaving the menu up, which is indistinguishable from an unpressed button; it is now checked and reported on screen. Full suite green at 99/99. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:54:46 +02:00
func register_capture_points(planet_key: String, total: int, definition_id: String = "",
reward_override: int = 0) -> void:
# Idempotent: landing again on a planet must not wipe points already
# captured on a previous visit.
if planet_capture.has(planet_key):
return
var captured: Array = []
captured.resize(total)
captured.fill(false)
# A planet demanding zero points is vacuously already held (#55: a
# black hole's "void" profile has no authored PlanetSetup at all, so
# this is the only registration it ever gets) - every planet WITH real
# points still starts unconquered, exactly as before. The planet definition
# supplies an optional conquest reward, avoiding planet-id special cases in
# the capture system.
var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id)
var point_reward: int = 0 if definition == null else int(definition.conquest_research_points)
Stacking distorders, menu layouts, lab rewards, enemy detail, artillery cross DISTORDER STACKING. Each space-time distorder vessel past the first raises the fleet's speed ceiling by 1.0, so a second one is worth crewing instead of being dead weight. GameState gains max_fleet_speed_multiplier, pushed by Fleet whenever composition changes - the same direction as every other fleet-derived figure, since GameState does not reach into the scene tree to count ships. The energy drain is normalised against the CURRENT ceiling, so full boost costs the same per second however wide it is: extra vessels buy more speed, not cheaper speed. Losing one re-clamps an over-ceiling boost rather than leaving the fleet travelling faster than its crew can sustain. TRANSFER MENU. Rows overlapped, and the last one ran into the hint text. row_height is divided from the available space, but each row's contents used a fixed pixel ladder - glyph at +10, buttons at +88, selector at +142 - which needs about 244px. Three resources fit; the fourth cut rows to 180 and every one spilled 64px into the next. Every offset is now a fraction of the row, so it cannot overflow. Also dropped the per-row AMOUNT caption (four copies of a word the footer hint already explains) and moved each glyph and name to the TOP of its row, where they read as its heading rather than the next row's. HANGAR. Six vessel types in one row gave each about 100px for a 155px label, so the names ran together. Now a 3x2 grid per section, sized from the space between the first heading and the status line, using the empty lower half. LAB RESEARCH REWARD. A harder enemy lab is worth more research points, and how hard a lab is depends on the layout authored for it - how many capture points, how many defenders - which the planet TYPE cannot know. PlanetSetup carries research_point_reward, set in the map editor, overriding the planet type's flat rate. 0 means "no opinion", so every map authored before this keeps its old value. RESEARCH LAB BACKGROUND. Its own shader instead of the crater field with a red tint, which read as "rocky planet, tinted" rather than someone's facility: plated floor, seams, service conduits, lit junctions. Loop-free by construction (#68's lesson applied up front) - conduits stay continuous across cells by keying a horizontal run to cell.y and a vertical to cell.x, so neighbouring cells agree without any cell scanning its neighbours. ENEMY DETAIL. Every enemy was a single flat outline, which left the artillery and the turret spawner as the same octagon at two sizes - the silhouette carried no information about what the thing did. EnemyDefinition gains detail_lines and detail_rings as DATA, so adding an enemy still means adding a definition rather than editing Enemy.gd, and the four non-default hulls are redrawn: a long-barrelled sniper, a squat mortar, a hive with launch bays, a capital ship with spine and ribs. Three things drew an enemy from its definition - the enemy, the map editor's markers, and the palette buttons - each reimplementing it from hull_points alone, so the new detail appeared in the game and nowhere else and an editor marker stopped matching what it placed. They now share EnemyDefinitions.draw_definition(). ARTILLERY. The 1.8s charge-up before the tower fired arrived long before the threat was legible and mostly read as the tower being stuck. It fires immediately now; the warning is the shell you can see in the air plus a small cross on the ground at the impact point. Deliberately not a blast-radius ring - a large translucent area was easy to read as the explosion having already happened, where the cross says "move off this spot". MAIN MENU. No Quit in any build (a web build has nothing to quit to, and Android's back gesture already does it), a plain black background, and the game is "Spaceship" - in the title and in config/name, which is what drives the browser tab for the web export. change_scene_to_file() returns an error and otherwise fails silently, leaving the menu up, which is indistinguishable from an unpressed button; it is now checked and reported on screen. Full suite green at 99/99. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:54:46 +02:00
# A map may be worth more than its planet type's flat rate - a harder
# lab should pay better, and only the authored layout knows how hard it
# is. Zero means the setup did not express an opinion.
if reward_override > 0:
point_reward = reward_override
planet_capture[planet_key] = {"captured": captured, "conquered": total <= 0,
"research_point_reward": point_reward, "research_awarded": false}
func capture_state(planet_key: String) -> Dictionary:
return planet_capture.get(planet_key, {"captured": [], "conquered": false})
func is_point_captured(planet_key: String, index: int) -> bool:
var captured: Array = capture_state(planet_key).get("captured", [])
if index < 0 or index >= captured.size():
return false
return bool(captured[index])
func mark_point_captured(planet_key: String, index: int) -> void:
if not planet_capture.has(planet_key):
return
var state: Dictionary = planet_capture[planet_key]
var captured: Array = state.captured
if index < 0 or index >= captured.size():
return
captured[index] = true
if state.conquered:
return
if captured.all(func(c): return bool(c)):
state.conquered = true
planet_conquered.emit(planet_key)
# #63: definition-authored research points, exactly once per planet.
# state.conquered flipping above already makes a second call to this
# whole function a no-op via the early return above it, but
# research_awarded is its own explicit guard on the award itself
# regardless of how conquest was reached.
var reward: int = int(state.get("research_point_reward", 0))
if reward > 0 and not bool(state.get("research_awarded", false)):
state.research_awarded = true
research_points += reward
research_points_changed.emit(research_points)
func is_planet_conquered(planet_key: String) -> bool:
return bool(capture_state(planet_key).get("conquered", false))
func captured_point_count(planet_key: String) -> int:
var captured: Array = capture_state(planet_key).get("captured", [])
var count: int = 0
for c in captured:
if c:
count += 1
return count
func total_capture_points(planet_key: String) -> int:
return capture_state(planet_key).get("captured", []).size()
func _process(delta: float) -> void:
# Runs in the autoload, so mining and transport continue while the player
# is on a planet's surface or nowhere near either.
2026-08-12 22:47:46 +02:00
_advance_production(delta)
2026-08-13 22:04:21 +02:00
_advance_lab_research(delta)
2026-08-13 21:39:05 +02:00
_advance_vessel_deliveries(delta)
_advance_resource_converters(delta)
_advance_distorder_drain(delta)
for e in extractors:
e.mining_progress = float(e.get("mining_progress", 0.0)) + delta * EXTRACTOR_RATE
var whole: int = int(floor(float(e.mining_progress)))
if whole > 0:
2026-08-14 20:32:58 +02:00
_extractor_inventory(e).deposit(String(e.get("resource_type", RESOURCE_ORE)), whole)
e.mining_progress = float(e.mining_progress) - whole
_advance_transporters(delta)
2026-08-13 21:39:05 +02:00
func vessel_delivery_position(delivery: Dictionary) -> Vector2:
return (delivery.from as Vector2).lerp(delivery.to as Vector2,
clampf(float(delivery.progress), 0.0, 1.0))
func _advance_vessel_deliveries(delta: float) -> void:
for i in range(vessel_deliveries.size() - 1, -1, -1):
var delivery: Dictionary = vessel_deliveries[i]
var distance: float = (delivery.from as Vector2).distance_to(delivery.to as Vector2)
2026-08-14 22:30:19 +02:00
var delivery_speed: float = vessel_stat(String(delivery.type), "delivery_speed")
if delivery_speed <= 0.0:
delivery_speed = VESSEL_DELIVERY_SPEED
delivery.progress = float(delivery.progress) + delta / maxf(distance / delivery_speed, 0.001)
2026-08-13 21:39:05 +02:00
if float(delivery.progress) >= 1.0:
stored_vessels.append(String(delivery.type))
vessel_deliveries.remove_at(i)
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
# --- transporters (#19) ---------------------------------------------------
# Vessels stationed at a transport hub that fetch ore from the mothership and
# deliver it to whichever building is waiting on it.
#
# Simulated HERE rather than by any node, exactly like the extractor haulers
# above and for the same reason: a route the player is nowhere near still has
# to run. `progress` drives both the drawn position and the moment the ore
# actually moves, so what you see and what happens cannot drift apart.
#
# Entries are {"hub_id", "state", "target_id", "carrying", "progress",
# "from", "to"}.
2026-08-15 00:09:10 +02:00
# How many transporters each hub owns. Raised for EVERY hub - built already
# or not - by research, and provisioned automatically rather than crafted.
const BASE_HUB_CAPACITY: int = 2
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
const TRANSPORTER_IDLE: String = "idle"
const TRANSPORTER_TO_MOTHERSHIP: String = "to_mothership"
const TRANSPORTER_TO_BUILDING: String = "to_building"
const TRANSPORTER_RETURNING: String = "returning"
const TRANSPORTER_TO_EXTRACTOR: String = "to_extractor"
const TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR: String = "extractor_to_mothership"
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>
2026-08-16 12:10:31 +02:00
# Collecting an output resource (energy) directly from a regular building's
# own inventory - a gas burner, not an extractor - and banking it. Reuses
# `target_id` exactly like the ordinary supply path does, rather than
# `extractor_index`: the source is a building, already addressable that way.
const TRANSPORTER_TO_BUILDING_PICKUP: String = "to_building_pickup"
const TRANSPORTER_TO_MOTHERSHIP_FROM_BUILDING: String = "building_to_mothership"
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
var transporters: Array = []
func hub_capacity() -> int:
# Per hub.
var n: int = BASE_HUB_CAPACITY
for id in unlocked:
n += int(skill_node(id).get("hub_bonus", 0))
return n
func hub_count() -> int:
var n: int = 0
for b in buildings:
if building_has_capability(b, "transport_hub"):
n += 1
return n
2026-08-15 00:09:10 +02:00
func _add_hub_transporter(hub: Dictionary) -> void:
transporters.append({
"hub_id": int(hub.id), "state": TRANSPORTER_IDLE, "target_id": 0,
"extractor_index": -1, "carrying": 0, "resource_type": RESOURCE_ORE,
"progress": 1.0, "from": hub.position, "to": hub.position,
})
2026-08-15 00:09:10 +02:00
func _provision_hub_transporters(hub: Dictionary) -> void:
var stationed: int = 0
for transporter in transporters:
if int(transporter.get("hub_id", 0)) == int(hub.id):
stationed += 1
while stationed < hub_capacity():
_add_hub_transporter(hub)
stationed += 1
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
2026-08-15 00:09:10 +02:00
func _sync_hub_transporters() -> void:
for hub in buildings:
if building_has_capability(hub, "transport_hub"):
_provision_hub_transporters(hub)
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
func transporter_position(t: Dictionary) -> Vector2:
return (t.from as Vector2).lerp(t.to as Vector2, clampf(t.progress, 0.0, 1.0))
func _hub_position(hub_id: int) -> Variant:
var hub: Dictionary = find_building(hub_id)
return null if hub.is_empty() else hub.position
func _begin_leg(t: Dictionary, from: Vector2, to: Vector2, state: String) -> void:
t.from = from
t.to = to
t.state = state
t.progress = 0.0
func _transporter_inbound_to(building_id: int) -> bool:
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>
2026-08-16 12:10:31 +02:00
# Delivery-purpose states only (#54): a building can have BOTH a
# transporter delivering it something (gas, for a burner) and a
# separate one collecting something from it (energy) at once, since
# they are two different transporters and two different legs. Matching
# on target_id alone, regardless of state, would make either purpose
# block the other from ever being dispatched to the same building.
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
for t in transporters:
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>
2026-08-16 12:10:31 +02:00
if (t.state == TRANSPORTER_TO_MOTHERSHIP or t.state == TRANSPORTER_TO_BUILDING) \
and int(t.target_id) == building_id:
return true
return false
func _transporter_inbound_for_pickup(building_id: int) -> bool:
for t in transporters:
if (t.state == TRANSPORTER_TO_BUILDING_PICKUP or t.state == TRANSPORTER_TO_MOTHERSHIP_FROM_BUILDING) \
and int(t.target_id) == building_id:
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
return true
return false
func _transporter_inbound_to_extractor(index: int) -> bool:
for t in transporters:
if t.state == TRANSPORTER_TO_EXTRACTOR \
or t.state == TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR:
if int(t.get("extractor_index", -1)) == index:
return true
return false
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
func _dispatch_transporters() -> void:
var mothership: Variant = get_mothership_position()
if mothership == null:
return # nowhere to load from
2026-08-15 10:41:18 +02:00
# Supply already-queued production and research before collecting passive
# extractor output. Extractors can refill forever, so servicing them first
# can otherwise consume every transporter each time one becomes idle and
# permanently starve a partly supplied order.
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
for b in buildings:
2026-08-15 04:45:15 +02:00
var needed_resource := ""
for resource_type in resource_types():
var need: int = building_requested_resource(int(b.id), resource_type)
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>
2026-08-16 12:10:31 +02:00
if need > 0 and bank_inventory.amount(resource_type) > 0:
2026-08-15 04:45:15 +02:00
needed_resource = resource_type
break
if needed_resource == "":
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
continue
# One at a time per building: without this every idle transporter
# would be sent after the same shortfall and they would all arrive
2026-08-15 10:41:18 +02:00
# carrying resources nobody needs any more.
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
if _transporter_inbound_to(int(b.id)):
continue
var chosen: Dictionary = {}
var best_d: float = INF
for t in transporters:
if t.state != TRANSPORTER_IDLE:
continue
var here: Vector2 = transporter_position(t)
var d: float = here.distance_squared_to(b.position)
if d < best_d:
best_d = d
chosen = t
if chosen.is_empty():
return # every transporter is busy
chosen.target_id = int(b.id)
2026-08-15 04:45:15 +02:00
chosen.resource_type = needed_resource
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
_begin_leg(chosen, transporter_position(chosen), mothership, TRANSPORTER_TO_MOTHERSHIP)
2026-08-15 10:41:18 +02:00
# A distant extractor is worth visiting only once it has a full hold ready.
# This keeps the transporter system meaningful: mining is local until a
# vessel physically carries a researched-sized load home.
var load: int = transporter_load_limit()
for i in range(extractors.size()):
var extractor: Dictionary = extractors[i]
var resource_id: String = String(extractor.get("resource_type", RESOURCE_ORE))
if _extractor_inventory(extractor).amount(resource_id) < load or _transporter_inbound_to_extractor(i):
continue
var chosen_extractor: Dictionary = {}
var best_extractor_distance: float = INF
for t in transporters:
if t.state != TRANSPORTER_IDLE:
continue
var d: float = transporter_position(t).distance_squared_to(extractor.position)
if d < best_extractor_distance:
best_extractor_distance = d
chosen_extractor = t
if chosen_extractor.is_empty():
return
chosen_extractor.extractor_index = i
chosen_extractor.resource_type = resource_id
chosen_extractor.target_id = 0
_begin_leg(chosen_extractor, transporter_position(chosen_extractor), extractor.position,
TRANSPORTER_TO_EXTRACTOR)
# Collect building outputs last: queued deliveries and extractor collection
# both outrank resources that are merely ready for pickup.
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>
2026-08-16 12:10:31 +02:00
for b in buildings:
var pickup_resource: String = _collectable_building_resource(b, load)
if pickup_resource == "" or _transporter_inbound_for_pickup(int(b.id)):
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>
2026-08-16 12:10:31 +02:00
continue
var chosen_pickup: Dictionary = {}
var best_pickup_distance: float = INF
for t in transporters:
if t.state != TRANSPORTER_IDLE:
continue
var d: float = transporter_position(t).distance_squared_to(b.position)
if d < best_pickup_distance:
best_pickup_distance = d
chosen_pickup = t
if chosen_pickup.is_empty():
return
chosen_pickup.target_id = int(b.id)
chosen_pickup.resource_type = pickup_resource
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>
2026-08-16 12:10:31 +02:00
chosen_pickup.extractor_index = -1
_begin_leg(chosen_pickup, transporter_position(chosen_pickup), b.position,
TRANSPORTER_TO_BUILDING_PICKUP)
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
func _advance_transporters(delta: float) -> void:
2026-08-15 00:09:10 +02:00
_sync_hub_transporters()
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
if transporters.is_empty():
return
_dispatch_transporters()
for t in transporters:
if t.state == TRANSPORTER_IDLE:
continue
var distance: float = (t.from as Vector2).distance_to(t.to as Vector2)
var leg: float = maxf(distance / transporter_speed(), 0.0001)
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
t.progress += delta / leg
if t.progress < 1.0:
continue
t.progress = 1.0
_arrive(t)
func _arrive(t: Dictionary) -> void:
var here: Vector2 = t.to
var hub_pos: Variant = _hub_position(int(t.hub_id))
match t.state:
TRANSPORTER_TO_MOTHERSHIP:
var target: Dictionary = find_building(int(t.target_id))
2026-08-15 04:45:15 +02:00
var resource_type: String = String(t.get("resource_type", RESOURCE_ORE))
var want: int = building_requested_resource(int(t.target_id), resource_type)
2026-08-15 04:45:15 +02:00
var take: int = mini(mini(want, bank_inventory.amount(resource_type)), transporter_load_limit())
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
if take <= 0 or target.is_empty():
# Nothing to carry, or the building is gone. Go home empty.
t.target_id = 0
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
return
2026-08-15 04:45:15 +02:00
bank_inventory.withdraw(resource_type, take)
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
t.carrying = take
_begin_leg(t, here, target.position, TRANSPORTER_TO_BUILDING)
TRANSPORTER_TO_BUILDING:
2026-08-15 04:45:15 +02:00
# The cargo moves at exactly the moment the vessel arrives.
var resource_type: String = String(t.get("resource_type", RESOURCE_ORE))
if deposit_resource_to_building(int(t.target_id), resource_type, int(t.carrying)) <= 0:
# Building demolished in transit - put the cargo back rather
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
# than quietly destroying it.
2026-08-15 04:45:15 +02:00
bank_inventory.deposit(resource_type, int(t.carrying))
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
t.carrying = 0
t.target_id = 0
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
TRANSPORTER_TO_EXTRACTOR:
var extractor_index: int = int(t.get("extractor_index", -1))
if extractor_index < 0 or extractor_index >= extractors.size():
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
return
var extractor: Dictionary = extractors[extractor_index]
var load: int = transporter_load_limit()
2026-08-14 20:32:58 +02:00
var resource_id: String = String(extractor.get("resource_type", RESOURCE_ORE))
var extractor_inventory: RefCounted = _extractor_inventory(extractor)
if extractor_inventory.amount(resource_id) < load:
t.extractor_index = -1
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
return
2026-08-14 20:32:58 +02:00
extractor_inventory.withdraw(resource_id, load)
t.carrying = load
2026-08-14 20:32:58 +02:00
t.resource_type = resource_id
_begin_leg(t, here, get_mothership_position(), TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR)
TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR:
2026-08-13 21:07:35 +02:00
# Bank only on arrival; cargo retains the extractor's resource type.
2026-08-14 20:32:58 +02:00
bank_inventory.deposit(String(t.get("resource_type", RESOURCE_ORE)), int(t.carrying))
t.carrying = 0
t.extractor_index = -1
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
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>
2026-08-16 12:10:31 +02:00
TRANSPORTER_TO_BUILDING_PICKUP:
var source: Dictionary = find_building(int(t.target_id))
var resource_type: String = String(t.get("resource_type", RESOURCE_ENERGY))
var take: int = 0 if source.is_empty() \
else mini(building_resource(int(t.target_id), resource_type), transporter_load_limit())
if take <= 0 or source.is_empty():
t.target_id = 0
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
return
_inventory_for_record(source).withdraw(resource_type, take)
t.carrying = take
_begin_leg(t, here, get_mothership_position(), TRANSPORTER_TO_MOTHERSHIP_FROM_BUILDING)
TRANSPORTER_TO_MOTHERSHIP_FROM_BUILDING:
bank_inventory.deposit(String(t.get("resource_type", RESOURCE_ENERGY)), int(t.carrying))
t.carrying = 0
t.target_id = 0
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:14:27 +02:00
TRANSPORTER_RETURNING:
t.state = TRANSPORTER_IDLE
t.progress = 1.0
Pay for everything from the bank and the fleet hold, not just buildings Construction already pooled the two; research, ship-lab upgrades and vessel unlocks still charged the bank alone. That split was arbitrary from the player's side - the resources are a couple of taps away from being banked either way, so refusing a purchase over where they happen to be sitting was ceremony. Worse, it was inconsistent: the same ore could buy a mothership and not a skill node. So the pooling moved down into can_afford/spend themselves, and the construction-specific pair is gone. can_afford_resources now pools per resource type, and a new spend_resources() charges bank first and hold for the shortfall, settling affordability in full before taking anything - a multi-resource price must not charge for the half it can cover and then fail. unlock_vessel goes through it instead of withdrawing from the bank directly. The skill menu's "you have %d" quoted the bank, which would have told the player they could not afford something they could. Ammo stays out of it. AimedFleetAbilityController reads the fleet hold directly, and should: a gas bomb is fired from what the ships are physically carrying, which is a fact about where the matter is rather than a convenience about where the money is. test_build_uses_fleet_hold became test_payments_use_fleet_hold and now walks every route - skill tree, ship lab, vessel research, multi-resource atomicity, and ammo staying unpooled. Two of its own setup steps were being silently clamped by the fleet hold's capacity, which is what made an affordable price look unaffordable; capacity is not what those checks are about, so it is taken out of the way explicitly. Full suite 106/106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:15:00 +02:00
# --- paying for things ------------------------------------------------------
#
# EVERY payment draws on the bank AND on what the ships are carrying. The
# player can already move resources between the two by hand at any building
# that takes them, so refusing a purchase that a couple of taps would have
# made affordable was pure ceremony.
#
# In one case it was worse than ceremony. A fresh game starts with an empty
# bank, ore is mined into the fleet hold, and the only way to bank it is to
# deliver it to a mothership - so charging construction to the bank alone
# made the first mothership cost ore that could not yet exist. Mine forever,
# build nothing.
#
# Bank first, hold second. The hold is also the fleet's mining capacity, so
# leaving it as full as possible means the next trip out can still carry
# something back.
#
# The one deliberate exception is AMMO - see AimedFleetAbilityController,
# which checks fleet_resource() directly. A gas bomb is fired from what the
# ships are physically carrying; that is a fact about where the matter is,
# not a convenience about where the money is.
Pay for everything from the bank and the fleet hold, not just buildings Construction already pooled the two; research, ship-lab upgrades and vessel unlocks still charged the bank alone. That split was arbitrary from the player's side - the resources are a couple of taps away from being banked either way, so refusing a purchase over where they happen to be sitting was ceremony. Worse, it was inconsistent: the same ore could buy a mothership and not a skill node. So the pooling moved down into can_afford/spend themselves, and the construction-specific pair is gone. can_afford_resources now pools per resource type, and a new spend_resources() charges bank first and hold for the shortfall, settling affordability in full before taking anything - a multi-resource price must not charge for the half it can cover and then fail. unlock_vessel goes through it instead of withdrawing from the bank directly. The skill menu's "you have %d" quoted the bank, which would have told the player they could not afford something they could. Ammo stays out of it. AimedFleetAbilityController reads the fleet hold directly, and should: a gas bomb is fired from what the ships are physically carrying, which is a fact about where the matter is rather than a convenience about where the money is. test_build_uses_fleet_hold became test_payments_use_fleet_hold and now walks every route - skill tree, ship lab, vessel research, multi-resource atomicity, and ammo staying unpooled. Two of its own setup steps were being silently clamped by the fleet hold's capacity, which is what made an affordable price look unaffordable; capacity is not what those checks are about, so it is taken out of the way explicitly. Full suite 106/106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:15:00 +02:00
func available_resource(resource_type: String) -> int:
return bank_inventory.amount(resource_type) + fleet_inventory.amount(resource_type)
2026-08-15 04:45:15 +02:00
Pay for everything from the bank and the fleet hold, not just buildings Construction already pooled the two; research, ship-lab upgrades and vessel unlocks still charged the bank alone. That split was arbitrary from the player's side - the resources are a couple of taps away from being banked either way, so refusing a purchase over where they happen to be sitting was ceremony. Worse, it was inconsistent: the same ore could buy a mothership and not a skill node. So the pooling moved down into can_afford/spend themselves, and the construction-specific pair is gone. can_afford_resources now pools per resource type, and a new spend_resources() charges bank first and hold for the shortfall, settling affordability in full before taking anything - a multi-resource price must not charge for the half it can cover and then fail. unlock_vessel goes through it instead of withdrawing from the bank directly. The skill menu's "you have %d" quoted the bank, which would have told the player they could not afford something they could. Ammo stays out of it. AimedFleetAbilityController reads the fleet hold directly, and should: a gas bomb is fired from what the ships are physically carrying, which is a fact about where the matter is rather than a convenience about where the money is. test_build_uses_fleet_hold became test_payments_use_fleet_hold and now walks every route - skill tree, ship lab, vessel research, multi-resource atomicity, and ammo staying unpooled. Two of its own setup steps were being silently clamped by the fleet hold's capacity, which is what made an affordable price look unaffordable; capacity is not what those checks are about, so it is taken out of the way explicitly. Full suite 106/106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:15:00 +02:00
func available_ore() -> int:
return available_resource(RESOURCE_ORE)
Share one backdrop between game and editor, and fix three input/economy bugs The setup editor and the landed surface drew different backgrounds for the same planet. Each scene carried its own hand-written ShaderMaterial, and only the game side ever swapped in a profile's shader, so a gas giant authored in the editor showed recoloured craters while the game showed cloud bands. Both now instance one SurfaceBackground.tscn and configure it through one method, and every profile names its own shader - the old "null means keep whatever the scene already had" convention was the drift, since each scene "already had" something different. The shared material is resource_local_to_scene, or two live surfaces would repaint each other. The gas giant's bands broke into disjoint segments once per cell: the second turbulence octave doubled only the INTEGER half of a lattice coordinate, which is a different point in every cell rather than the same point at twice the frequency. Measured, the old form steps 0.153 at a cell boundary against 0.0002 now. A second discontinuity went with it - the camera cell index arrives pre-wrapped, so hashing it raw rearranged the whole sky every 32 cells; hashes are now wrapped per octave and the field is exactly periodic. Bands also gained low-frequency octaves and per-row differential drift. WorldMap swallowed every touch in _input(), which runs before the GUI system, so CloseButton never saw one: the map could be opened and not closed. Android and desktop hid it behind Godot's emulated mouse event, which this never swallowed; a phone browser gives no such second chance. Its test had been closing the map by calling the method the button was failing to reach. Construction now draws on the fleet hold as well as the bank. With an empty starting bank and ore bankable only at a mothership, charging construction to the bank alone made the first mothership cost ore that could not yet exist. Also: a conquered planet no longer re-spawns its garrison (guarded on having capture points, since a setup with none registers as vacuously conquered); planets keep the map they drew, with the candidate list sorted rather than left in filesystem order and assignments pruned rather than cleared on rescan; and the map editor lists setups at a tappable size and can delete one, as an explicit armed mode rather than a tap away from destroying work. Full suite 106/106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:16:38 +02:00
Pay for everything from the bank and the fleet hold, not just buildings Construction already pooled the two; research, ship-lab upgrades and vessel unlocks still charged the bank alone. That split was arbitrary from the player's side - the resources are a couple of taps away from being banked either way, so refusing a purchase over where they happen to be sitting was ceremony. Worse, it was inconsistent: the same ore could buy a mothership and not a skill node. So the pooling moved down into can_afford/spend themselves, and the construction-specific pair is gone. can_afford_resources now pools per resource type, and a new spend_resources() charges bank first and hold for the shortfall, settling affordability in full before taking anything - a multi-resource price must not charge for the half it can cover and then fail. unlock_vessel goes through it instead of withdrawing from the bank directly. The skill menu's "you have %d" quoted the bank, which would have told the player they could not afford something they could. Ammo stays out of it. AimedFleetAbilityController reads the fleet hold directly, and should: a gas bomb is fired from what the ships are physically carrying, which is a fact about where the matter is rather than a convenience about where the money is. test_build_uses_fleet_hold became test_payments_use_fleet_hold and now walks every route - skill tree, ship lab, vessel research, multi-resource atomicity, and ammo staying unpooled. Two of its own setup steps were being silently clamped by the fleet hold's capacity, which is what made an affordable price look unaffordable; capacity is not what those checks are about, so it is taken out of the way explicitly. Full suite 106/106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:15:00 +02:00
func can_afford(cost: int) -> bool:
return available_ore() >= cost
Share one backdrop between game and editor, and fix three input/economy bugs The setup editor and the landed surface drew different backgrounds for the same planet. Each scene carried its own hand-written ShaderMaterial, and only the game side ever swapped in a profile's shader, so a gas giant authored in the editor showed recoloured craters while the game showed cloud bands. Both now instance one SurfaceBackground.tscn and configure it through one method, and every profile names its own shader - the old "null means keep whatever the scene already had" convention was the drift, since each scene "already had" something different. The shared material is resource_local_to_scene, or two live surfaces would repaint each other. The gas giant's bands broke into disjoint segments once per cell: the second turbulence octave doubled only the INTEGER half of a lattice coordinate, which is a different point in every cell rather than the same point at twice the frequency. Measured, the old form steps 0.153 at a cell boundary against 0.0002 now. A second discontinuity went with it - the camera cell index arrives pre-wrapped, so hashing it raw rearranged the whole sky every 32 cells; hashes are now wrapped per octave and the field is exactly periodic. Bands also gained low-frequency octaves and per-row differential drift. WorldMap swallowed every touch in _input(), which runs before the GUI system, so CloseButton never saw one: the map could be opened and not closed. Android and desktop hid it behind Godot's emulated mouse event, which this never swallowed; a phone browser gives no such second chance. Its test had been closing the map by calling the method the button was failing to reach. Construction now draws on the fleet hold as well as the bank. With an empty starting bank and ore bankable only at a mothership, charging construction to the bank alone made the first mothership cost ore that could not yet exist. Also: a conquered planet no longer re-spawns its garrison (guarded on having capture points, since a setup with none registers as vacuously conquered); planets keep the map they drew, with the candidate list sorted rather than left in filesystem order and assignments pruned rather than cleared on rescan; and the map editor lists setups at a tappable size and can delete one, as an explicit armed mode rather than a tap away from destroying work. Full suite 106/106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:16:38 +02:00
Pay for everything from the bank and the fleet hold, not just buildings Construction already pooled the two; research, ship-lab upgrades and vessel unlocks still charged the bank alone. That split was arbitrary from the player's side - the resources are a couple of taps away from being banked either way, so refusing a purchase over where they happen to be sitting was ceremony. Worse, it was inconsistent: the same ore could buy a mothership and not a skill node. So the pooling moved down into can_afford/spend themselves, and the construction-specific pair is gone. can_afford_resources now pools per resource type, and a new spend_resources() charges bank first and hold for the shortfall, settling affordability in full before taking anything - a multi-resource price must not charge for the half it can cover and then fail. unlock_vessel goes through it instead of withdrawing from the bank directly. The skill menu's "you have %d" quoted the bank, which would have told the player they could not afford something they could. Ammo stays out of it. AimedFleetAbilityController reads the fleet hold directly, and should: a gas bomb is fired from what the ships are physically carrying, which is a fact about where the matter is rather than a convenience about where the money is. test_build_uses_fleet_hold became test_payments_use_fleet_hold and now walks every route - skill tree, ship lab, vessel research, multi-resource atomicity, and ammo staying unpooled. Two of its own setup steps were being silently clamped by the fleet hold's capacity, which is what made an affordable price look unaffordable; capacity is not what those checks are about, so it is taken out of the way explicitly. Full suite 106/106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:15:00 +02:00
func can_afford_resources(costs: Dictionary) -> bool:
for resource_type in costs:
if available_resource(String(resource_type)) < int(costs[resource_type]):
return false
return true
Share one backdrop between game and editor, and fix three input/economy bugs The setup editor and the landed surface drew different backgrounds for the same planet. Each scene carried its own hand-written ShaderMaterial, and only the game side ever swapped in a profile's shader, so a gas giant authored in the editor showed recoloured craters while the game showed cloud bands. Both now instance one SurfaceBackground.tscn and configure it through one method, and every profile names its own shader - the old "null means keep whatever the scene already had" convention was the drift, since each scene "already had" something different. The shared material is resource_local_to_scene, or two live surfaces would repaint each other. The gas giant's bands broke into disjoint segments once per cell: the second turbulence octave doubled only the INTEGER half of a lattice coordinate, which is a different point in every cell rather than the same point at twice the frequency. Measured, the old form steps 0.153 at a cell boundary against 0.0002 now. A second discontinuity went with it - the camera cell index arrives pre-wrapped, so hashing it raw rearranged the whole sky every 32 cells; hashes are now wrapped per octave and the field is exactly periodic. Bands also gained low-frequency octaves and per-row differential drift. WorldMap swallowed every touch in _input(), which runs before the GUI system, so CloseButton never saw one: the map could be opened and not closed. Android and desktop hid it behind Godot's emulated mouse event, which this never swallowed; a phone browser gives no such second chance. Its test had been closing the map by calling the method the button was failing to reach. Construction now draws on the fleet hold as well as the bank. With an empty starting bank and ore bankable only at a mothership, charging construction to the bank alone made the first mothership cost ore that could not yet exist. Also: a conquered planet no longer re-spawns its garrison (guarded on having capture points, since a setup with none registers as vacuously conquered); planets keep the map they drew, with the candidate list sorted rather than left in filesystem order and assignments pruned rather than cleared on rescan; and the map editor lists setups at a tappable size and can delete one, as an explicit armed mode rather than a tap away from destroying work. Full suite 106/106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:16:38 +02:00
Pay for everything from the bank and the fleet hold, not just buildings Construction already pooled the two; research, ship-lab upgrades and vessel unlocks still charged the bank alone. That split was arbitrary from the player's side - the resources are a couple of taps away from being banked either way, so refusing a purchase over where they happen to be sitting was ceremony. Worse, it was inconsistent: the same ore could buy a mothership and not a skill node. So the pooling moved down into can_afford/spend themselves, and the construction-specific pair is gone. can_afford_resources now pools per resource type, and a new spend_resources() charges bank first and hold for the shortfall, settling affordability in full before taking anything - a multi-resource price must not charge for the half it can cover and then fail. unlock_vessel goes through it instead of withdrawing from the bank directly. The skill menu's "you have %d" quoted the bank, which would have told the player they could not afford something they could. Ammo stays out of it. AimedFleetAbilityController reads the fleet hold directly, and should: a gas bomb is fired from what the ships are physically carrying, which is a fact about where the matter is rather than a convenience about where the money is. test_build_uses_fleet_hold became test_payments_use_fleet_hold and now walks every route - skill tree, ship lab, vessel research, multi-resource atomicity, and ammo staying unpooled. Two of its own setup steps were being silently clamped by the fleet hold's capacity, which is what made an affordable price look unaffordable; capacity is not what those checks are about, so it is taken out of the way explicitly. Full suite 106/106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:15:00 +02:00
func spend(cost: int) -> bool:
return spend_resources({RESOURCE_ORE: cost})
Share one backdrop between game and editor, and fix three input/economy bugs The setup editor and the landed surface drew different backgrounds for the same planet. Each scene carried its own hand-written ShaderMaterial, and only the game side ever swapped in a profile's shader, so a gas giant authored in the editor showed recoloured craters while the game showed cloud bands. Both now instance one SurfaceBackground.tscn and configure it through one method, and every profile names its own shader - the old "null means keep whatever the scene already had" convention was the drift, since each scene "already had" something different. The shared material is resource_local_to_scene, or two live surfaces would repaint each other. The gas giant's bands broke into disjoint segments once per cell: the second turbulence octave doubled only the INTEGER half of a lattice coordinate, which is a different point in every cell rather than the same point at twice the frequency. Measured, the old form steps 0.153 at a cell boundary against 0.0002 now. A second discontinuity went with it - the camera cell index arrives pre-wrapped, so hashing it raw rearranged the whole sky every 32 cells; hashes are now wrapped per octave and the field is exactly periodic. Bands also gained low-frequency octaves and per-row differential drift. WorldMap swallowed every touch in _input(), which runs before the GUI system, so CloseButton never saw one: the map could be opened and not closed. Android and desktop hid it behind Godot's emulated mouse event, which this never swallowed; a phone browser gives no such second chance. Its test had been closing the map by calling the method the button was failing to reach. Construction now draws on the fleet hold as well as the bank. With an empty starting bank and ore bankable only at a mothership, charging construction to the bank alone made the first mothership cost ore that could not yet exist. Also: a conquered planet no longer re-spawns its garrison (guarded on having capture points, since a setup with none registers as vacuously conquered); planets keep the map they drew, with the candidate list sorted rather than left in filesystem order and assignments pruned rather than cleared on rescan; and the map editor lists setups at a tappable size and can delete one, as an explicit armed mode rather than a tap away from destroying work. Full suite 106/106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:16:38 +02:00
Pay for everything from the bank and the fleet hold, not just buildings Construction already pooled the two; research, ship-lab upgrades and vessel unlocks still charged the bank alone. That split was arbitrary from the player's side - the resources are a couple of taps away from being banked either way, so refusing a purchase over where they happen to be sitting was ceremony. Worse, it was inconsistent: the same ore could buy a mothership and not a skill node. So the pooling moved down into can_afford/spend themselves, and the construction-specific pair is gone. can_afford_resources now pools per resource type, and a new spend_resources() charges bank first and hold for the shortfall, settling affordability in full before taking anything - a multi-resource price must not charge for the half it can cover and then fail. unlock_vessel goes through it instead of withdrawing from the bank directly. The skill menu's "you have %d" quoted the bank, which would have told the player they could not afford something they could. Ammo stays out of it. AimedFleetAbilityController reads the fleet hold directly, and should: a gas bomb is fired from what the ships are physically carrying, which is a fact about where the matter is rather than a convenience about where the money is. test_build_uses_fleet_hold became test_payments_use_fleet_hold and now walks every route - skill tree, ship lab, vessel research, multi-resource atomicity, and ammo staying unpooled. Two of its own setup steps were being silently clamped by the fleet hold's capacity, which is what made an affordable price look unaffordable; capacity is not what those checks are about, so it is taken out of the way explicitly. Full suite 106/106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:15:00 +02:00
func spend_resources(costs: Dictionary) -> bool:
# Affordability is settled in full BEFORE anything is taken. Charging for
# part of a purchase that then fails is worse than refusing it, and with
# several resource types in one price that is a real possibility rather
# than a theoretical one.
if not can_afford_resources(costs):
Share one backdrop between game and editor, and fix three input/economy bugs The setup editor and the landed surface drew different backgrounds for the same planet. Each scene carried its own hand-written ShaderMaterial, and only the game side ever swapped in a profile's shader, so a gas giant authored in the editor showed recoloured craters while the game showed cloud bands. Both now instance one SurfaceBackground.tscn and configure it through one method, and every profile names its own shader - the old "null means keep whatever the scene already had" convention was the drift, since each scene "already had" something different. The shared material is resource_local_to_scene, or two live surfaces would repaint each other. The gas giant's bands broke into disjoint segments once per cell: the second turbulence octave doubled only the INTEGER half of a lattice coordinate, which is a different point in every cell rather than the same point at twice the frequency. Measured, the old form steps 0.153 at a cell boundary against 0.0002 now. A second discontinuity went with it - the camera cell index arrives pre-wrapped, so hashing it raw rearranged the whole sky every 32 cells; hashes are now wrapped per octave and the field is exactly periodic. Bands also gained low-frequency octaves and per-row differential drift. WorldMap swallowed every touch in _input(), which runs before the GUI system, so CloseButton never saw one: the map could be opened and not closed. Android and desktop hid it behind Godot's emulated mouse event, which this never swallowed; a phone browser gives no such second chance. Its test had been closing the map by calling the method the button was failing to reach. Construction now draws on the fleet hold as well as the bank. With an empty starting bank and ore bankable only at a mothership, charging construction to the bank alone made the first mothership cost ore that could not yet exist. Also: a conquered planet no longer re-spawns its garrison (guarded on having capture points, since a setup with none registers as vacuously conquered); planets keep the map they drew, with the candidate list sorted rather than left in filesystem order and assignments pruned rather than cleared on rescan; and the map editor lists setups at a tappable size and can delete one, as an explicit armed mode rather than a tap away from destroying work. Full suite 106/106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:16:38 +02:00
return false
Pay for everything from the bank and the fleet hold, not just buildings Construction already pooled the two; research, ship-lab upgrades and vessel unlocks still charged the bank alone. That split was arbitrary from the player's side - the resources are a couple of taps away from being banked either way, so refusing a purchase over where they happen to be sitting was ceremony. Worse, it was inconsistent: the same ore could buy a mothership and not a skill node. So the pooling moved down into can_afford/spend themselves, and the construction-specific pair is gone. can_afford_resources now pools per resource type, and a new spend_resources() charges bank first and hold for the shortfall, settling affordability in full before taking anything - a multi-resource price must not charge for the half it can cover and then fail. unlock_vessel goes through it instead of withdrawing from the bank directly. The skill menu's "you have %d" quoted the bank, which would have told the player they could not afford something they could. Ammo stays out of it. AimedFleetAbilityController reads the fleet hold directly, and should: a gas bomb is fired from what the ships are physically carrying, which is a fact about where the matter is rather than a convenience about where the money is. test_build_uses_fleet_hold became test_payments_use_fleet_hold and now walks every route - skill tree, ship lab, vessel research, multi-resource atomicity, and ammo staying unpooled. Two of its own setup steps were being silently clamped by the fleet hold's capacity, which is what made an affordable price look unaffordable; capacity is not what those checks are about, so it is taken out of the way explicitly. Full suite 106/106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:15:00 +02:00
for resource_type in costs:
var id: String = String(resource_type)
var owed: int = int(costs[resource_type])
owed -= bank_inventory.withdraw(id, owed)
if owed > 0:
fleet_inventory.withdraw(id, owed)
Share one backdrop between game and editor, and fix three input/economy bugs The setup editor and the landed surface drew different backgrounds for the same planet. Each scene carried its own hand-written ShaderMaterial, and only the game side ever swapped in a profile's shader, so a gas giant authored in the editor showed recoloured craters while the game showed cloud bands. Both now instance one SurfaceBackground.tscn and configure it through one method, and every profile names its own shader - the old "null means keep whatever the scene already had" convention was the drift, since each scene "already had" something different. The shared material is resource_local_to_scene, or two live surfaces would repaint each other. The gas giant's bands broke into disjoint segments once per cell: the second turbulence octave doubled only the INTEGER half of a lattice coordinate, which is a different point in every cell rather than the same point at twice the frequency. Measured, the old form steps 0.153 at a cell boundary against 0.0002 now. A second discontinuity went with it - the camera cell index arrives pre-wrapped, so hashing it raw rearranged the whole sky every 32 cells; hashes are now wrapped per octave and the field is exactly periodic. Bands also gained low-frequency octaves and per-row differential drift. WorldMap swallowed every touch in _input(), which runs before the GUI system, so CloseButton never saw one: the map could be opened and not closed. Android and desktop hid it behind Godot's emulated mouse event, which this never swallowed; a phone browser gives no such second chance. Its test had been closing the map by calling the method the button was failing to reach. Construction now draws on the fleet hold as well as the bank. With an empty starting bank and ore bankable only at a mothership, charging construction to the bank alone made the first mothership cost ore that could not yet exist. Also: a conquered planet no longer re-spawns its garrison (guarded on having capture points, since a setup with none registers as vacuously conquered); planets keep the map they drew, with the candidate list sorted rather than left in filesystem order and assignments pruned rather than cleared on rescan; and the map editor lists setups at a tappable size and can delete one, as an explicit armed mode rather than a tap away from destroying work. Full suite 106/106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:16:38 +02:00
return true