spaceship/core/GameState.gd
Loïc Wisniewski 11773a63cf Put every research branch back in its own lane
#219 deleted the eight-node doctrine lane, which left the ship-lab branch
zigzagging between two lanes with nothing beside it - the same fault #70
fixed for squadron, where a chain that changed lane at depth collided with
somebody else's node. The tree runs as five straight lanes again: supply,
industry, spine, ground, vessels. No two nodes share a lane at one depth,
and every only-child stays in its parent's.

Fleet size is deliberately absent, which is why the widest lane is empty:
#219 moved it out of research and onto the Fleet Office. Said in the code so
the gap reads as a decision rather than as something dropped.

Also corrects wording #216 had made untrue: hubs no longer come with their
own transporters, and the two capacity doctrines buy a BERTH, not the vessel
to fill it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-05 21:10:35 +02:00

3998 lines
153 KiB
GDScript3

extends Node
const INVENTORY = preload("res://resources/Inventory.gd")
const RESOURCE_DEFINITIONS = preload("res://resources/ResourceDefinitions.gd")
const BUILDING_DEFINITIONS = preload("res://buildings/BuildingDefinitions.gd")
const VESSEL_DEFINITIONS = preload("res://vessels/VesselDefinitions.gd")
const PLANET_DEFINITIONS = preload("res://planets/PlanetDefinitions.gd")
# 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://world/WorldGen.gd")
const QUESTS = preload("res://core/Quests.gd")
const CATALOG_CHECK = preload("res://core/CatalogCheck.gd")
# 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
var surviving_ship_count: int = STARTING_SHIP_COUNT
var surviving_vessel_types: PackedStringArray = PackedStringArray()
# #106: a mirror of what is flying, pushed by Fleet._sync_composition_state()
# whenever the composition changes. GameState cannot see scene nodes, and the
# census the Planning Center works from has to include the fleet.
var fleet_vessel_types: PackedStringArray = PackedStringArray()
# Where the fleet is, written by Fleet each frame. Only #158 needs it: a
# building may draw on the hold when the hold is actually there, and this
# autoload has no other way to know.
var fleet_position: Vector2 = Vector2.ZERO
# #122: the prologue is running. Set by reset_new_game() so it plays once at
# the start of a NEW game and never on a resumed one, and cleared by the
# director when the prologue hands over.
#
# It is a flag rather than a separate scene because the prologue DIRECTS the
# real game: the real fleet, the real enemies, the real asteroid field. That
# is also what makes "then the game starts" cost nothing - the director stops
# steering and there is nothing left to load.
var intro_active: bool = false
# #173: what the startup catalog check found, if anything. Empty in a healthy
# build; see CatalogCheck for why a build can be unhealthy without anything
# else noticing.
var catalog_faults: PackedStringArray = PackedStringArray()
# #124: the breach takes the wheel. Movement input is ignored while this is
# set, so the fleet goes where the script sends it rather than fighting the
# player for the last few seconds of the prologue.
var intro_locks_movement: bool = false
# Short in-world reveals (currently the boss arrival) use their own lock so
# they do not pretend the prologue is active merely to steer the fleet for a
# few seconds.
var encounter_locks_movement: bool = false
# #123: the prologue's convoy is bigger than any fleet the player can field
# yet - it is not their fleet, it is the one they are about to lose. An
# explicit override rather than a fake doctrine, so nothing about the
# player's own progression has to be bent to fit a cutscene.
var intro_fleet_size: int = 0
# --- what the player is actually holding (#171) -----------------------------
#
# "The web export should auto detect keyboard mouse or screen and remove the
# touch screen controls if not needed."
#
# DisplayServer.is_touchscreen_available() is the wrong question in a browser
# and always was. It answers "could a touch arrive", and a desktop browser on
# a laptop with a touch-capable panel - or one that merely reports
# navigator.maxTouchPoints - says yes. So the thumb sticks and the pivot pair
# sat over a screen being played with a mouse, and mouse aiming, which the
# same query gates, was switched off on the machine it exists for.
#
# The right question is what the player LAST actually used, which is a fact
# rather than a capability, and it is asked here rather than in a controller
# because two of them need the same answer: GameController shows or hides the
# on-screen controls, and MouseCombatController decides whether a click fires.
# One answer, so they can never disagree.
#
# The catch is that Godot can manufacture the other kind of event from each
# one - the pointing/emulate_* settings in project.godot turn taps into clicks
# and clicks into taps. Taken at face value a phone would look like it had a
# mouse the instant a finger touched the glass. So a touch SUPPRESSES mouse
# evidence for a moment afterwards: a manufactured click always follows its
# touch within the same frame, well inside the window, while a player who
# genuinely puts a finger down and then reaches for the mouse takes far longer
# than this to do it. Correct whether that emulation is on or off - with it
# off there is simply nothing for the window to suppress.
const POINTER_TOUCH_GRACE_MSEC: int = 1000
# Seeded from the display's own answer so the very first frame is sensible on
# a phone, and immediately overwritten by the first real input either way.
var pointer_is_touch: bool = DisplayServer.is_touchscreen_available()
# When a touch was last seen, in engine milliseconds. -1 for never. Public so
# a test can age it out rather than sleeping for a second.
var last_touch_msec: int = -1
func note_touch_input() -> void:
last_touch_msec = Time.get_ticks_msec()
pointer_is_touch = true
# A mouse or a key. Ignored while a touch is still recent, because that is
# what a manufactured event looks like.
func note_pointer_input() -> void:
if last_touch_msec >= 0 \
and Time.get_ticks_msec() - last_touch_msec < POINTER_TOUCH_GRACE_MSEC:
return
pointer_is_touch = false
var return_world_position: Vector2 = Vector2.ZERO
var return_camera_zoom: float = 1.0
var planet_name: String = ""
var planet_id: String = ""
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.
# The ceiling ONE Energy Propulser reaches. Every DOUBLING of them 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
# 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:
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.
# #77: the ceiling grows with the LOGARITHM of how many propulsers fly, not
# linearly. Each one used to add a flat 1.0, so a fleet that could afford six
# of them simply drove at six times cruise and the vessel stopped being a
# choice. Base-2, which makes the rule one sentence: every DOUBLING of
# propulsers buys another 1.0.
#
# 1 -> 3.0 2 -> 4.0 4 -> 5.0 8 -> 6.0
#
# Two propulsers are worth exactly what they were worth before, so nothing a
# player has already built is devalued; it is the fifth and sixth that stop
# paying for themselves.
func set_distorder_vessel_count(count: int) -> void:
if count <= 1:
max_fleet_speed_multiplier = MAX_FLEET_SPEED_MULTIPLIER
return
max_fleet_speed_multiplier = MAX_FLEET_SPEED_MULTIPLIER \
+ log(float(count)) / log(2.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
# 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_unlimited_resources: bool = false
# #202: every research and production price answers zero while this is on.
# A separate switch from the one above rather than a stronger version of it -
# unlimited resources tests whether a price can be PAID, and this tests what
# happens when there is no price at all, which is a different play-test.
var dev_free_costs: bool = false
var dev_mode: bool = false:
set(value):
dev_mode = value
if not value:
dev_unlimited_resources = false
dev_free_costs = false
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 = {}
# The scanner's latest unconquered destination. Unlike explored_chunks this
# is one intentional navigation target, shared by the map and flight HUD.
var scanner_target: Dictionary = {}
# #207: where the prologue's convoy is headed. Set by IntroDirector while the
# World Ship is still something to fly TOWARD, and cleared the moment the
# breach takes that away. Kept beside the scanner's target because the HUD
# asks one question - "where am I going" - and only ever wants one answer.
var intro_destination: Dictionary = {}
# The four ordinary destinations teach the scanner enough signatures to
# identify the hostile boss world. Kept explicit so adding a sixth authored
# planet later cannot silently move the progression gate.
const SCANNER_SURVEY_TYPES: PackedStringArray = [PLANET_DEFINITIONS.ROCKY_ID,
PLANET_DEFINITIONS.GAS_GIANT_ID, PLANET_DEFINITIONS.BLACK_HOLE_ID,
PLANET_DEFINITIONS.RESEARCH_LAB_ID]
# --- Resources -------------------------------------------------------------
const RESOURCE_ORE: String = RESOURCE_DEFINITIONS.ORE_ID
const RESOURCE_GAS: String = RESOURCE_DEFINITIONS.GAS_ID
const RESOURCE_ENERGY: String = RESOURCE_DEFINITIONS.ENERGY_ID
const RESOURCE_DARK_MATTER: String = RESOURCE_DEFINITIONS.DARK_MATTER_ID
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.
# 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()
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)
func resource_types() -> PackedStringArray:
return RESOURCE_DEFINITIONS.ids()
func resource_definition(resource_type: String) -> Resource:
return RESOURCE_DEFINITIONS.get_definition(resource_type)
func resource_name(resource_type: String) -> String:
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:
var definition: Resource = resource_definition(resource_type)
return definition.color if definition != null else Color.WHITE
func banked_resource(resource_type: String) -> int:
return bank_inventory.amount(resource_type)
func fleet_resource(resource_type: String) -> int:
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).
# #210: anything that reaches the fleet's own hold is heard - a mined rock, a
# transporter's delivery, a load lifted off a planet. The bank is deliberately
# silent: that is a number moving in a building the player is not standing in.
const PICKUP_SOUND_INTERVAL_MS: int = 90
var _pickup_sound_at: int = 0
func deposit_fleet_resource(resource_type: String, amount: int) -> int:
var accepted: int = fleet_inventory.deposit(resource_type, amount)
if accepted > 0:
_play_pickup_sound()
return accepted
# One unit at a time is how mining arrives, so a blip per unit would be a
# machine-gun. A burst inside the window is heard once; anything slower than
# it stays a separate sound.
func _play_pickup_sound() -> void:
var now: int = Time.get_ticks_msec()
if now - _pickup_sound_at < PICKUP_SOUND_INTERVAL_MS:
return
_pickup_sound_at = now
Sfx.play(get_tree(), Sfx.PICKUP)
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
func _inventory_for_record(record: Dictionary) -> RefCounted:
var inventory: RefCounted = record.get("inventory") as RefCounted
if inventory == null:
inventory = INVENTORY.new()
record.inventory = inventory
# An outpost holds exactly what its garrison can use, and the garrison
# changes. Re-applied on every access rather than when the crew changes,
# because there is no single place a crew change goes through - a vessel
# arriving, one being released and one being shot down are three.
if is_outpost(record):
# Every resource gets a ceiling, not only the ones a magazine wants:
# an outpost is not a depot, and ore parked at one has nothing to do
# there and no transporter coming for it.
var capacities: Dictionary = {}
var magazines: Dictionary = garrison_magazines(int(record.id))
for resource_type in resource_types():
capacities[resource_type] = int(magazines.get(resource_type, 0))
inventory.resource_capacities = capacities
# 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():
return null
if building_has_capability(building, "bank"):
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:
return fleet_inventory.transfer_to(building_inventory(id), resource_type, amount)
func transfer_resource_to_fleet(id: int, resource_type: String, amount: int) -> int:
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)
func building_gas_holdings(id: int) -> int:
return building_resource(id, RESOURCE_GAS)
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)
func transfer_gas_to_building(id: int, amount: int) -> int:
return transfer_resource_to_building(id, RESOURCE_GAS, amount)
func transfer_gas_to_fleet(id: int, amount: int) -> int:
return transfer_resource_to_fleet(id, RESOURCE_GAS, amount)
const SHIP_COST: int = 6
# #138: the per-building cost constants that used to sit here are gone. A
# structure's price lives in its own BuildingDefinition and is read through
# building_costs(), which is the only thing that knows about the resources
# beyond ore that some of them now want. Two places holding the same number
# is how they end up disagreeing.
# --- 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"
# #102: a forward position the fleet can garrison, researched early because
# it is the answer to waves going after what you have built.
const BUILDING_OUTPOST: String = "outpost"
# #106: where standing orders for the whole fleet are kept.
const BUILDING_PLANNING_CENTER: String = "planning_center"
# #116: repairs what the waves knock down.
const BUILDING_WORKSHOP: String = "spacial_workshop"
# 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"
# 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"
# 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"
# #219: the structure that pays for a berth in the fleet.
const BUILDING_FLEET_OFFICE: String = "fleet_office"
# A map-revealing structure, not a passive sensor bonus. Its menu searches
# the same deterministic WorldGen catalogue that PlanetField spawns from.
const BUILDING_PLANET_SCANNER: String = "planet_scanner"
var buildings: Array = []
# --- 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.
# LANES. A chain keeps one x as it rises, and a branch fans out once, at the
# node it branches from. That rule is why squadron was moved in #70 - it sat
# in someone else's lane at the same depth and the two nodes were drawn on top
# of each other - and #219 broke it again by deleting the whole doctrine lane,
# leaving the ship-lab branch zigzagging between 0.82 and 0.94 with nothing
# beside it.
#
# 0.10 supply logistics -> freight -> freight_yards
# 0.30 industry power_systems -> field_engineering -> drone_bays
# 0.50 spine shipyards -> coordination -> deep_space_scanning
# 0.70 ground forward_bases -> garrison_bays
# 0.90 vessels vessel_science -> logistics_command
#
# Fleet size is deliberately absent: #219 moved it out of research entirely
# and onto the Fleet Office, which is why the widest lane is now empty.
const SKILL_TREE: Array = [
{
"id": "shipyards", "name": "Shipyards", "cost": 15, "requires": "",
"x": 0.50, "unlocks": BUILDING_SHIP_CRAFTER,
"desc": "Build ship crafters, and buy extra vessels at them.",
},
{
"id": "logistics", "name": "Logistics", "cost": 25, "requires": "shipyards",
"x": 0.10, "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,
"desc": "Build command centers, where the fleet formation is arranged.",
},
{
# Early on purpose (#97: "pretty early in the tech tree"). It hangs off
# coordination rather than off a deep branch, so the answer to waves
# attacking your structures is available at about the time the waves
# start being able to hurt them.
"id": "forward_bases", "name": "Forward Bases", "cost": 30, "requires": "coordination",
"costs": {"ore": 30, "gas": 12},
"x": 0.70, "unlocks": BUILDING_OUTPOST, "research_points": 1,
"desc": "Build outposts: forward positions vessels can be stationed at, to hold ground you are not standing on.",
},
{
# The same shape as freight_yards for hubs: a permanent bonus that
# applies to outposts already standing, not only to new ones, so
# researching it never means demolishing and rebuilding.
"id": "garrison_bays", "name": "Garrison Bays", "cost": 110, "requires": "forward_bases",
"costs": {"ore": 110, "gas": 35},
"x": 0.70, "outpost_bonus": 1, "research_points": 2,
"icon": 40,
"desc": "Permanent: +1 berth at every outpost, including ones already built.",
},
{
# Deep on purpose, unlike forward_bases: standing orders are a thing
# you want once the fleet is large enough that keeping track of it by
# hand has become work, and it has shipyards behind it because it
# manages what shipyards do.
"id": "logistics_command", "name": "Logistics Command", "cost": 140,
"costs": {"ore": 140, "gas": 40},
"requires": "vessel_science", "x": 0.90,
"unlocks": BUILDING_PLANNING_CENTER, "research_points": 3,
"desc": "Build a planning center: set how many of each vessel should always exist, and the crafters are kept working until they do.",
},
{
# Behind power_systems rather than off the fleet branch: a workshop
# is infrastructure that looks after infrastructure, and it should
# arrive after the player has some worth looking after.
"id": "field_engineering", "name": "Field Engineering", "cost": 95,
"costs": {"ore": 95, "gas": 35, "energy": 20},
"requires": "power_systems", "x": 0.30,
"unlocks": BUILDING_WORKSHOP, "research_points": 2,
"desc": "Build spacial workshops. Their drones fly out and repair damaged structures, spending ore delivered to the workshop.",
},
{
# Same shape as freight_yards and garrison_bays: a permanent bonus
# that reaches workshops already standing.
"id": "drone_bays", "name": "Drone Bays", "cost": 150,
"costs": {"ore": 150, "gas": 50, "energy": 35},
"requires": "field_engineering", "x": 0.30,
"workshop_bonus": 1, "research_points": 2, "icon": 43,
"desc": "Permanent: +1 berth at every spacial workshop, including ones already built. The drone to fill it is still yours to build.",
},
{
"id": "freight", "name": "Freight Handling", "cost": 40, "requires": "logistics",
"costs": {"ore": 40, "gas": 15},
"x": 0.10, "unlocks": BUILDING_TRANSPORT_HUB, "research_points": 1,
"desc": "Build transport hubs. Transporters are built at a ship crafter and assigned to a hub, which then runs its routes without you.",
},
{
"id": "vessel_science", "name": "Vessel Science", "cost": 35, "requires": "shipyards",
"costs": {"ore": 35, "gas": 10},
"x": 0.90, "unlocks": BUILDING_SHIP_LAB, "research_points": 1,
"desc": "Build ship labs, where each vessel type is upgraded and new ones unlocked.",
},
{
"id": "deep_space_scanning", "name": "Deep-Space Scanning", "cost": 50,
"costs": {"ore": 50, "gas": 15},
"requires": "coordination", "x": 0.50,
"unlocks": BUILDING_PLANET_SCANNER, "research_points": 1,
"desc": "Build a planet scanner and reveal the nearest world of a chosen type.",
},
{
"id": "freight_yards", "name": "Freight Yards", "cost": 80, "requires": "freight",
"costs": {"ore": 80, "gas": 30},
"x": 0.10, "hub_bonus": 1, "research_points": 2,
"icon": 12,
"desc": "Permanent: +1 berth at every hub, including ones already built. The transporter to fill it is still yours to build.",
},
{
"id": "power_systems", "name": "Power Systems", "cost": 45, "requires": "freight",
"costs": {"ore": 45, "gas": 35},
"x": 0.30, "unlocks": BUILDING_GAS_BURNER, "research_points": 2,
"desc": "Build gas burners. Each converts gas into energy at its own site - hub transporters keep it stocked and collect what it makes.",
},
]
# How many SLOTS the fleet has before a single Fleet Office is built.
#
# #219 replaced the fleet-size doctrines with a structure that has to be
# supplied: every berth past the first is a Fleet Office standing somewhere,
# paying for the vessel in it. So this is 1 - the hull the survivors came out
# of the breach with, which the ticket exempts from needing an office at all.
#
# Distinct from STARTING_SHIP_COUNT below: this is capacity, that is how many
# vessels are actually flying.
const BASE_FLEET_SIZE: int = 1
# 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
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)
# What one doctrine costs. Same rule as buildings and vessels (#138): the
# node's own dictionary when it has one, its ore figure otherwise.
#
# `cost` stays the ore figure either way rather than being folded into the
# dictionary and deleted, so the tree's own display - and everything that
# ever compared two nodes by price - keeps working on a plain number.
func skill_costs(id: String) -> Dictionary:
var node: Dictionary = skill_node(id)
if node.is_empty() or costs_are_free():
return {}
var costs: Dictionary = node.get("costs", {})
if not costs.is_empty():
return costs.duplicate()
return {RESOURCE_ORE: int(node.cost)} if int(node.cost) > 0 else {}
func can_unlock(id: String, payment_position: Variant = null) -> bool:
var node: Dictionary = skill_node(id)
return not is_unlocked(id) and prerequisite_met(id) \
and can_afford_resources(skill_costs(id), payment_position) \
and research_points >= skill_research_point_cost(id)
func skill_research_point_cost(id: String) -> int:
return 0 if costs_are_free() else int(skill_node(id).get("research_points", 0))
func unlock(id: String, payment_position: Variant = null) -> bool:
if not can_unlock(id, payment_position):
return false
var node: Dictionary = skill_node(id)
if not spend_resources(skill_costs(id), payment_position):
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)
unlocked.append(id)
return true
# #112: the mark drawn inside a skill node. A node that unlocks a structure
# borrows that structure's own menu icon, so the tree and the build menu
# cannot disagree about what a shipyard looks like; the rest - doctrines and
# permanent bonuses, which have no building to point at - carry an explicit
# one. -1 means "no mark", and the node is drawn hollow as it always was.
func skill_node_icon(node: Dictionary) -> int:
if node.is_empty():
return -1
var unlocks: String = String(node.get("unlocks", ""))
if unlocks != "":
var definition: Resource = building_definition(unlocks)
if definition != null:
return int(definition.menu_icon)
return int(node.get("icon", -1))
func is_building_unlocked(type: String) -> bool:
for node in SKILL_TREE:
if node.get("unlocks", "") == type:
return is_unlocked(node.id)
return true
# The doctrine that unlocks a structure, BY ID - the handle everything else
# in the tree is keyed on (skill_depth, skill_costs, is_unlocked).
func research_node_for_building(type: String) -> String:
for node in SKILL_TREE:
if node.get("unlocks", "") == type:
return String(node.id)
return ""
# The same thing by NAME, which is what a message shows the player. Kept
# apart from the id above because the two are easy to mix up and the mistake
# is silent: skill_depth() of a display name finds no node and answers 0, so
# a deep structure reads as an ungated one.
func research_for_building(type: String) -> String:
var id: String = research_node_for_building(type)
return "" if id == "" else String(skill_node(id).name)
# #219: every berth past the first is a Fleet Office standing somewhere, and
# an office only counts while it can still pay for the vessel in it. A starved
# one has already had its vessel destroyed; leaving it counting would let the
# player rebuild straight into a berth nothing is supplying.
signal fleet_upkeep_failed(slot_index: int, vessel_type: String)
# How often an office is charged, and how much of the vessel's own build price
# it charges. Derived from the build cost rather than authored per vessel: a
# hull that was expensive to make is expensive to keep, which is the rule the
# ticket asks for and needs no second table to fall out of step with.
const FLEET_OFFICE_UPKEEP_SECONDS: float = 25.0
const FLEET_OFFICE_UPKEEP_DIVISOR: int = 8
# Offices in the order they were built, which is the order they are matched to
# the fleet: the first vessel needs none, the second is kept by the first
# office, and so on.
func fleet_offices() -> Array:
var offices: Array = []
for building in buildings:
if String(building.get("type", "")) == BUILDING_FLEET_OFFICE:
offices.append(building)
return offices
func supplied_fleet_office_count() -> int:
var n: int = 0
for office in fleet_offices():
if not bool(office.get("starved", false)):
n += 1
return n
# What one berth costs per charge, for the vessel actually sitting in it. An
# empty answer means this vessel is free to keep - which is true of nothing
# the crafter builds, and is what an unknown type falls back to.
func fleet_office_upkeep(vessel_type: String) -> Dictionary:
var upkeep: Dictionary = {}
for resource_type in product_costs(vessel_type):
var share: int = int(ceil(float(product_costs(vessel_type)[resource_type])
/ float(FLEET_OFFICE_UPKEEP_DIVISOR)))
if share > 0:
upkeep[resource_type] = share
return upkeep
# The vessel an office is answerable for, or "" when the fleet is smaller than
# the berths built for it - an office with nothing in it costs nothing.
func fleet_office_vessel(slot_index: int) -> String:
if slot_index < 0 or slot_index >= fleet_vessel_types.size():
return ""
return String(fleet_vessel_types[slot_index])
func _advance_fleet_office_upkeep(delta: float) -> void:
var offices: Array = fleet_offices()
for i in range(offices.size()):
var office: Dictionary = offices[i]
# Office i keeps the (i + 1)th vessel: the first one needs no office.
var vessel_type: String = fleet_office_vessel(i + BASE_FLEET_SIZE)
if vessel_type == "":
# Nothing to keep. The clock is not advanced either, so a berth
# that stood empty does not present a bill the moment it is used.
#
# An empty berth is IDLE, not starved, including one that just
# lost its vessel: an office is only failing while there is
# something in it to fail. Holding it disabled would leave the
# player unable to use the berth they paid for, with no way back
# - it can never pay for a vessel it is not allowed to hold.
office.upkeep_progress = 0.0
office.starved = false
continue
var progress: float = float(office.get("upkeep_progress", 0.0)) + delta
if progress < FLEET_OFFICE_UPKEEP_SECONDS:
office.upkeep_progress = progress
continue
office.upkeep_progress = progress - FLEET_OFFICE_UPKEEP_SECONDS
var upkeep: Dictionary = fleet_office_upkeep(vessel_type)
var inventory: RefCounted = _inventory_for_record(office)
var affordable: bool = true
for resource_type in upkeep:
if inventory.amount(String(resource_type)) < int(upkeep[resource_type]):
affordable = false
break
if affordable:
for resource_type in upkeep:
inventory.withdraw(String(resource_type), int(upkeep[resource_type]))
# A restocked office goes back to work on its own: the player
# fixing their supply line should not also have to rebuild it.
office.starved = false
continue
office.starved = true
say_quest_line(("A Fleet Office has run dry. The %s berthed there is gone - "
+ "nothing is reaching it, and a berth nobody supplies keeps nobody.")
% vessel_name(vessel_type))
fleet_upkeep_failed.emit(i + BASE_FLEET_SIZE, vessel_type)
func max_fleet_size() -> int:
if intro_fleet_size > 0:
return mini(intro_fleet_size, DEFAULT_FORMATION.size())
return mini(BASE_FLEET_SIZE + supplied_fleet_office_count(),
DEFAULT_FORMATION.size())
func reset_progression() -> void:
unlocked.clear()
vessel_levels.clear()
# 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:
intro_active = true
intro_locks_movement = false
encounter_locks_movement = false
intro_fleet_size = 0
has_return_position = false
surviving_ship_count = STARTING_SHIP_COUNT
surviving_vessel_types = PackedStringArray()
fleet_vessel_types = PackedStringArray()
fleet_position = Vector2.ZERO
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
dev_unlimited_resources = false
dev_free_costs = false
dev_mode = false
explored_chunks.clear()
scanner_target.clear()
intro_destination.clear()
bank_inventory = INVENTORY.new()
fleet_inventory = INVENTORY.new()
buildings.clear()
next_building_id = 1
stored_vessels.clear()
vessel_deliveries.clear()
extractors.clear()
transporters.clear()
repair_drones.clear()
planet_capture.clear()
vessel_targets.clear()
reset_waves()
reset_quests(true)
reset_progression()
reset_formation()
func scanner_can_find_boss() -> bool:
for definition_id in SCANNER_SURVEY_TYPES:
if not visited_planet_kinds.has(definition_id):
return false
return true
func scanner_available_planet_types() -> PackedStringArray:
var available: PackedStringArray = SCANNER_SURVEY_TYPES.duplicate()
if scanner_can_find_boss():
available.append(PLANET_DEFINITIONS.BOSS_ID)
return available
# Finds the nearest unconquered generated planet of this type, reveals its
# neighbourhood on the persistent map, and retains it as the flight target.
# Rings bound the work, but candidates are compared by their actual generated
# world position: the first planet in the first matching chunk ring is not
# necessarily the physically closest one when local positions differ.
func scan_for_planet(definition_id: String, origin_world_position: Vector2) -> Dictionary:
if not structure_exists(BUILDING_PLANET_SCANNER) \
or definition_id not in scanner_available_planet_types():
return {}
var origin: Vector2i = WORLD_GEN.chunk_coords(origin_world_position)
var best: Dictionary = {}
var best_distance: float = INF
for radius in range(0, 257):
if radius == 0:
var center: Dictionary = _scanner_candidate_at(origin,
definition_id, radius)
if not center.is_empty():
best = center
best_distance = origin_world_position.distance_to(
Vector2(center.world_position))
else:
# Only the perimeter is new at each radius. Every matching candidate
# on it is considered before deciding whether the search can stop.
for x in range(-radius, radius + 1):
for y in [-radius, radius]:
var horizontal: Dictionary = _scanner_candidate_at(
origin + Vector2i(x, y), definition_id, radius)
var horizontal_distance: float = INF if horizontal.is_empty() else \
origin_world_position.distance_to(Vector2(horizontal.world_position))
if horizontal_distance < best_distance:
best = horizontal
best_distance = horizontal_distance
for y in range(-radius + 1, radius):
for x in [-radius, radius]:
var vertical: Dictionary = _scanner_candidate_at(
origin + Vector2i(x, y), definition_id, radius)
var vertical_distance: float = INF if vertical.is_empty() else \
origin_world_position.distance_to(Vector2(vertical.world_position))
if vertical_distance < best_distance:
best = vertical
best_distance = vertical_distance
if not best.is_empty() and best_distance <= _scanner_unsearched_distance(
origin, origin_world_position, radius):
return _accept_scanner_target(best)
return _accept_scanner_target(best) if not best.is_empty() else {}
func _scanner_candidate_at(chunk: Vector2i, definition_id: String,
distance_chunks: int) -> Dictionary:
if not WORLD_GEN.has_planet_at_chunk(chunk):
return {}
var data: Dictionary = WORLD_GEN.planet_data_at_chunk(chunk)
if String(data.get("definition_id", "")) != definition_id \
or is_planet_conquered(String(data.get("planet_id", ""))):
return {}
data["chunk"] = chunk
data["world_position"] = WORLD_GEN.chunk_origin(chunk) + Vector2(data.local_pos)
data["distance_chunks"] = distance_chunks
return data
func _scanner_unsearched_distance(origin_chunk: Vector2i,
origin_world_position: Vector2, radius: int) -> float:
var top_left: Vector2 = WORLD_GEN.chunk_origin(
origin_chunk - Vector2i(radius, radius))
var bottom_right: Vector2 = WORLD_GEN.chunk_origin(
origin_chunk + Vector2i(radius + 1, radius + 1))
return minf(minf(origin_world_position.x - top_left.x,
bottom_right.x - origin_world_position.x), minf(
origin_world_position.y - top_left.y,
bottom_right.y - origin_world_position.y))
func _accept_scanner_target(data: Dictionary) -> Dictionary:
var chunk := Vector2i(data.chunk)
for reveal_y in range(-1, 2):
for reveal_x in range(-1, 2):
explored_chunks[chunk + Vector2i(reveal_x, reveal_y)] = true
scanner_target = data.duplicate(true)
return data
# What the flight HUD should point at. The prologue's destination wins while
# it exists: during those beats the fleet has no scanner and exactly one place
# to be, and afterwards there is no World Ship left to point at.
func active_guidance_target() -> Dictionary:
if intro_active:
return intro_destination
return active_scanner_target()
func active_scanner_target() -> Dictionary:
if scanner_target.is_empty() \
or is_planet_conquered(String(scanner_target.get("planet_id", ""))):
return {}
return scanner_target
# 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
const DEV_UNLIMITED_AMOUNT: int = 999999
# A post-prologue 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. Dev mode is for iterating on the game itself, not replaying the long
# scripted introduction before every check.
#
# 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
intro_active = false
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)
# Development toggles deliberately live beside the state they affect. The
# menu is only a front-end; scene swaps cannot turn an infinite bank off or
# forget which doctrines were exposed.
func set_dev_unlimited_resources(enabled: bool) -> bool:
if enabled and not dev_mode:
return false
dev_unlimited_resources = enabled
if enabled:
_refill_dev_resources()
return true
func set_dev_free_costs(enabled: bool) -> bool:
if enabled and not dev_mode:
return false
dev_free_costs = enabled
return true
# Asked by every price below, and by the menus that display them. The dev_mode
# term is not redundant: the setter refuses outside a dev game and the dev_mode
# setter clears the flag, so this is the third guard rather than the first, and
# it is the one that holds if a save or a test sets the field directly.
func costs_are_free() -> bool:
return dev_mode and dev_free_costs
func _refill_dev_resources() -> void:
for resource_type in resource_types():
if bank_inventory.amount(resource_type) < DEV_UNLIMITED_AMOUNT:
bank_inventory.set_amount(resource_type, DEV_UNLIMITED_AMOUNT)
if research_points < DEV_UNLIMITED_AMOUNT:
research_points = DEV_UNLIMITED_AMOUNT
research_points_changed.emit(research_points)
func dev_unlock_all_skills() -> int:
if not dev_mode:
return 0
var added: int = 0
for node in SKILL_TREE:
var id: String = String(node.id)
if id in unlocked:
continue
unlocked.append(id)
added += 1
return added
# --- 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
# 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 = []
# 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 = []
const VESSEL_DELIVERY_SPEED: float = 180.0
signal order_completed(building_id: int, type: String)
# #99: fired after the record is already gone, so a listener that reacts by
# asking GameState about the world sees the world without it.
signal building_destroyed(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({
"id": id, "type": type, "position": at_position,
"inventory": INVENTORY.new(), "orders": [],
"hp": building_max_hp(type),
})
return id
func find_building(id: int) -> Dictionary:
for b in buildings:
if b.get("id", 0) == id:
return b
return {}
# --- what a structure can take before it comes down (#99) ------------------
#
# Health lives on the RECORD, not on the Building node. Main.tscn is torn
# down and rebuilt on every trip to a planet surface, so damage kept on a
# node would heal itself the moment the player landed - the same reason the
# buildings themselves live here.
# What one structure costs, in every resource it wants.
#
# #138: the same shape product_costs() already answers for vessels - the
# dictionary when a definition carries one, the ore figure otherwise - so
# there is one question with one answer whatever is being bought.
func building_costs(type: String) -> Dictionary:
var definition: Resource = building_definition(type)
if definition == null or costs_are_free():
return {}
if not definition.build_resource_costs.is_empty():
return definition.build_resource_costs.duplicate()
return {RESOURCE_ORE: int(definition.cost)} if int(definition.cost) > 0 else {}
func building_definition(type: String) -> Resource:
return BUILDING_DEFINITIONS.get_definition(type)
func building_max_hp(type: String) -> int:
var definition: Resource = building_definition(type)
return 1 if definition == null else maxi(int(definition.max_hp), 1)
func building_hp(id: int) -> int:
var b: Dictionary = find_building(id)
if b.is_empty():
return 0
# Records written before #99 carry no "hp" at all. Reading that as zero
# would present every structure in an existing save as rubble, so a
# missing field means undamaged.
return int(b.get("hp", building_max_hp(String(b.type))))
func building_health_fraction(id: int) -> float:
var b: Dictionary = find_building(id)
if b.is_empty():
return 0.0
return clampf(float(building_hp(id)) / float(building_max_hp(String(b.type))), 0.0, 1.0)
# The mothership is where the bank, the hangar and research all live, and it
# is where a wiped-out fleet is rebuilt. Losing it would delete the player's
# savings and their stored vessels with no route back, so it bottoms out at
# one hit point instead of dying. Keyed on the capability rather than on the
# type: whatever building holds the bank is the one that cannot be lost.
# #159: "a building with no life should explode" - and nothing is exempt any
# more. The mothership used to floor at 1hp, so a battered one sat there
# reading as dead and never came down.
#
# That exemption was there to stop a soft-lock, and the soft-lock turns out
# not to exist. Losing the structure does not lose what is in it: the bank is
# GameState.bank_inventory and the hangar is stored_vessels, both of which
# outlive any building. And rebuilding is always available - the mothership
# is the one structure research never gates, it costs exactly what a single
# interceptor's hold carries, and ore comes from asteroids, which do not run
# out. So the way back is always mine, fly, rebuild.
#
# Kept as a function rather than deleted because the QUESTION is still a real
# one - something may well need to be un-killable later - and a caller asking
# it is clearer than a caller that has forgotten it was ever asked.
func building_is_indestructible(_building: Dictionary) -> bool:
return false
# Returns true when this killed the building.
func damage_building(id: int, amount: int) -> bool:
if amount <= 0:
return false
var b: Dictionary = find_building(id)
if b.is_empty():
return false
var floor_hp: int = 1 if building_is_indestructible(b) else 0
b.hp = maxi(building_hp(id) - amount, floor_hp)
if int(b.hp) > 0:
return false
return destroy_building(id)
func repair_building(id: int, amount: int) -> int:
if amount <= 0:
return 0
var b: Dictionary = find_building(id)
if b.is_empty():
return 0
var before: int = building_hp(id)
b.hp = mini(before + amount, building_max_hp(String(b.type)))
return int(b.hp) - before
func destroy_building(id: int) -> bool:
var b: Dictionary = find_building(id)
if b.is_empty() or building_is_indestructible(b):
return false
var type: String = String(b.type)
var was_hub: bool = building_has_capability(b, "transport_hub")
buildings.erase(b)
if was_hub:
# Its transporters belonged to it and have nowhere to go home to.
var kept: Array = []
for transporter in transporters:
if int(transporter.get("hub_id", 0)) != id:
kept.append(transporter)
transporters = kept
# Everything else keyed on a building id already copes with the building
# being gone: a transporter mid-delivery puts its cargo back rather than
# destroying it (see _arrive), and find_building simply returns empty.
building_destroyed.emit(id, type)
return true
# --- outpost garrisons (#103) ----------------------------------------------
#
# Two separate things live on an outpost's record, and conflating them was
# the first thing to get wrong here:
#
# garrison what the player ASKED for - {vessel_type: count}. Set in
# the outpost's own menu and otherwise untouched.
# garrison_crew who is actually THERE - a list of vessel types. #104
# fills it from the mothership; nothing else writes it.
#
# Keeping the order as a wish rather than as an inventory is what lets an
# outpost ask for a vessel nobody has yet and simply wait, instead of the
# request evaporating.
const GARRISON_BASE_SLOTS: int = 2
# Which vessels can hold a position. #97: "Only ships that attack can be
# assigned" - so a shield vessel, a hauler or an engine block cannot, however
# much the player might want the shield out there. Written as capabilities
# rather than as a list of types, so a new armed vessel is eligible the day
# it is added and an unarmed one never is by accident.
#
# #105 shipped with "weapon" only, because firing the laser and the gas bomb
# lived in the player's controllers rather than in the vessels' own
# components - a garrisoned laser would have held its position and never
# shot. Both effects now live in the world as LaserBeamEffect/GasBombEffect
# and both components expose the same fire_ability() seam, so a garrison can
# pull either trigger and the list is no longer a apology for a missing
# feature.
const GARRISON_CAPABILITIES: PackedStringArray = ["weapon", "laser", "gas_bomber"]
func outpost_slot_capacity() -> int:
var n: int = GARRISON_BASE_SLOTS
for id in unlocked:
n += int(skill_node(id).get("outpost_bonus", 0))
return n
func vessel_can_garrison(vessel_type: String) -> bool:
var definition: Resource = vessel_definition(vessel_type)
if definition == null or not definition.fleet_eligible:
return false
for capability in GARRISON_CAPABILITIES:
if capability in definition.capabilities:
return true
return false
func garrison_vessel_types() -> PackedStringArray:
var out: PackedStringArray = PackedStringArray()
for definition in VESSEL_DEFINITIONS.all():
if vessel_can_garrison(String(definition.definition_id)):
out.append(String(definition.definition_id))
return out
func is_outpost(building: Dictionary) -> bool:
return building_has_capability(building, "garrison")
# #163: how many vessels are actually standing at outposts, across all of
# them. Counts the CREW rather than what was asked for - a vessel still in
# transit has not started holding anything yet.
func garrisoned_vessel_count() -> int:
var total: int = 0
for building in buildings:
if is_outpost(building):
total += garrison_crew(int(building.id)).size()
return total
func garrison_desired(id: int) -> Dictionary:
var b: Dictionary = find_building(id)
if b.is_empty() or not is_outpost(b):
return {}
if not b.has("garrison"):
b.garrison = {}
return b.garrison
func garrison_desired_total(id: int) -> int:
var n: int = 0
for count in garrison_desired(id).values():
n += int(count)
return n
func garrison_desired_of(id: int, vessel_type: String) -> int:
return int(garrison_desired(id).get(vessel_type, 0))
# Returns the count actually set, so the caller can show what happened when
# the request was clamped rather than silently doing something else.
func set_garrison_desired(id: int, vessel_type: String, count: int) -> int:
var b: Dictionary = find_building(id)
if b.is_empty() or not is_outpost(b) or not vessel_can_garrison(vessel_type):
return 0
var wanted: Dictionary = garrison_desired(id)
var others: int = garrison_desired_total(id) - int(wanted.get(vessel_type, 0))
var room: int = maxi(outpost_slot_capacity() - others, 0)
var final_count: int = clampi(count, 0, room)
if final_count <= 0:
wanted.erase(vessel_type)
else:
wanted[vessel_type] = final_count
return final_count
func adjust_garrison_desired(id: int, vessel_type: String, delta: int) -> int:
return set_garrison_desired(id, vessel_type,
garrison_desired_of(id, vessel_type) + delta)
func garrison_crew(id: int) -> Array:
var b: Dictionary = find_building(id)
if b.is_empty() or not is_outpost(b):
return []
if not b.has("garrison_crew"):
b.garrison_crew = []
return b.garrison_crew
# --- what a garrison keeps beside it ---------------------------------------
#
# An outpost stores ammunition for the vessels actually stationed there: 50
# gas for each gas bomber, 50 energy for each laser. The numbers come off the
# VESSEL definitions, so the outpost never learns which vessel needs what -
# add an armed vessel with a magazine and its outpost storage follows.
#
# This is a CAPACITY, not a stock. Hub transporters fill it the same way they
# fill a gas burner's buffer (see building_logistics_deficit), and the
# garrison AI spends from it.
func garrison_magazine_capacity(id: int, resource_type: String) -> int:
var total: int = 0
for vessel_type in garrison_crew(id):
var definition: Resource = vessel_definition(String(vessel_type))
if definition == null:
continue
if String(definition.garrison_ammo_resource) != resource_type:
continue
total += int(definition.garrison_ammo_capacity)
return total
# Every resource this outpost wants held, as {resource_type: capacity}.
func garrison_magazines(id: int) -> Dictionary:
var out: Dictionary = {}
for vessel_type in garrison_crew(id):
var definition: Resource = vessel_definition(String(vessel_type))
if definition == null:
continue
var resource_type: String = String(definition.garrison_ammo_resource)
if resource_type == "" or int(definition.garrison_ammo_capacity) <= 0:
continue
out[resource_type] = int(out.get(resource_type, 0)) \
+ int(definition.garrison_ammo_capacity)
return out
# Spends from the outpost's own store. Returns false when it cannot pay in
# full - a half-priced shot would be worse than no shot.
func spend_garrison_ammo(id: int, resource_type: String, amount: int) -> bool:
if amount <= 0:
return true
var inventory: RefCounted = building_inventory(id)
if inventory == null or inventory.amount(resource_type) < amount:
return false
inventory.withdraw(resource_type, amount)
return true
func garrison_crew_of(id: int, vessel_type: String) -> int:
var n: int = 0
for crewed in garrison_crew(id):
if String(crewed) == vessel_type:
n += 1
return n
# What this outpost is still short of, as {vessel_type: count}. The one
# question #104 has to answer every tick, so it is asked here rather than
# recomputed by whoever needs it.
func garrison_shortfall(id: int) -> Dictionary:
var out: Dictionary = {}
for vessel_type in garrison_desired(id):
# What is already on its way counts. Without this the dispatcher
# would send the whole hangar to fill one berth, because the crew
# does not grow until the first arrival lands.
var missing: int = int(garrison_desired(id)[vessel_type]) \
- garrison_crew_of(id, String(vessel_type)) \
- garrison_inbound_of(id, String(vessel_type))
if missing > 0:
out[vessel_type] = missing
return out
func garrison_inbound_of(id: int, vessel_type: String) -> int:
var n: int = 0
for delivery in vessel_deliveries:
if int(delivery.get("to_outpost", 0)) == id \
and String(delivery.type) == vessel_type:
n += 1
return n
func garrison_inbound(id: int) -> int:
var n: int = 0
for delivery in vessel_deliveries:
if int(delivery.get("to_outpost", 0)) == id:
n += 1
return n
func outpost_ids() -> Array:
var out: Array = []
for b in buildings:
if is_outpost(b):
out.append(int(b.id))
return out
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) ---------------------------------
# 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"
const VESSEL_GAS_BOMBER: String = "gas_bomber"
const VESSEL_TRANSPORTER: String = "transporter"
const VESSEL_CARRIER: String = "carrier"
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
const LAB_RESEARCH_TIME: float = 8.0
# 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
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)
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, payment_position: Variant = null) -> 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
var costs: Dictionary = research_costs(definition.unlock_id)
# Through the accessor, not off the definition: this used to be the one
# place a research price was read straight from its file, which #202's
# free-costs switch would have left charging full price here alone.
var points_needed: int = research_point_cost(definition.unlock_id)
if research_points < points_needed or not spend_resources(costs, payment_position):
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() or costs_are_free():
return 0
return int(d.cost) * (upgrade_level(id) + 1)
func can_buy_upgrade(id: String, payment_position: Variant = null) -> bool:
return has_ship_lab() and not upgrade_maxed(id) \
and can_afford(upgrade_cost(id), payment_position) \
and research_points >= research_point_cost(id)
func buy_upgrade(id: String, payment_position: Variant = null) -> bool:
if not can_buy_upgrade(id, payment_position):
return false
if not spend(upgrade_cost(id), payment_position):
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")
# #217: what a Burn Drive multiplies that by while there is energy aboard.
# 1.0 until the upgrade is researched, which is also the flag for whether a
# transporter should go looking for fuel at all - an unupgraded one has no
# use for it and must never detour.
func transporter_fuel_boost() -> float:
var definition: Resource = VESSEL_DEFINITIONS.with_capability("transport")
return maxf(vessel_stat(definition.definition_id, "fuel_boost"), 1.0)
func transporter_fuel_capacity() -> int:
var definition: Resource = VESSEL_DEFINITIONS.with_capability("transport")
return int(vessel_stat(definition.definition_id, "fuel_capacity"))
func transporter_burns_energy() -> bool:
return transporter_fuel_boost() > 1.0 and transporter_fuel_capacity() > 0
# The speed THIS transporter is making: the base crawl, multiplied while it
# still has something in the tank.
func transporter_leg_speed(t: Dictionary) -> float:
if int(t.get("fuel", 0)) > 0:
return transporter_speed() * transporter_fuel_boost()
return transporter_speed()
const PRODUCT_SHIP: String = "interceptor"
func product_cost(product: String) -> int:
return int(product_costs(product).get(RESOURCE_ORE, 0))
func product_costs(product: String) -> Dictionary:
var definition: Resource = vessel_definition(product)
if definition == null or costs_are_free():
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 {}
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:
return building_resource(id, RESOURCE_ORE)
func pending_orders(id: int) -> int:
var b: Dictionary = find_building(id)
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", []):
if String(o.product) == product:
n += 1
return n
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):
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]))
orders.remove_at(index)
return true
func outstanding_cost(id: int) -> int:
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:
# 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", []):
if not bool(o.started):
need += int(product_costs(String(o.product)).get(resource_type, 0))
for order in b.get("research_orders", []):
if not bool(order.started):
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:
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
return _inventory_for_record(b).deposit(resource_type, amount)
func queue_order(id: int, product: String = PRODUCT_SHIP) -> bool:
var b: Dictionary = find_building(id)
if b.is_empty() or not can_produce(b.type):
return false
var definition: Resource = vessel_definition(product)
# #216: support craft are orderable here too. They never join the fleet,
# so "can the crafter make it" is its own question now.
if definition == null or not vessel_is_unlocked(product) \
or not (definition.fleet_eligible or definition.support_craftable):
return false
(b.orders as Array).append({"product": product, "elapsed": 0.0, "started": false})
return true
# What a building can actually pay a local cost with (#158).
#
# #12 made production cost resources physically DELIVERED to the building
# rather than money out of the bank, and that stands: the bank is still not
# raided from here. What #158 adds is the fleet's own hold - the one other
# place resources physically are. A hull that flew in carrying gas can hand
# it over, and making the player park, open the transfer menu and move it
# across by hand was ceremony rather than a decision.
#
# But only WHERE THE FLEET IS, and that qualifier is the whole design.
# Without it a crafter on the far side of the map reaches into the hold and
# spends ore the player withdrew to carry somewhere else - which is exactly
# the complaint that got automatic banking at the mothership removed in #24,
# arriving from the other direction. test_manual_supply_route caught it
# doing precisely that.
#
# The range is Building.interact_radius, the same distance that decides
# whether the transfer button is offered at all. So the rule is not a hidden
# one: if you could hand the cargo over by hand, the building can take it.
const LOCAL_PAY_RANGE: float = 150.0
func fleet_is_at(building: Dictionary) -> bool:
if building.is_empty():
return false
return fleet_can_pay_at(building.position)
func fleet_can_pay_at(payment_position: Variant) -> bool:
return payment_position is Vector2 \
and fleet_position.distance_to(payment_position as Vector2) <= LOCAL_PAY_RANGE
func local_payment_available(building: Dictionary, inventory: RefCounted,
resource_type: String) -> int:
var here: int = 0 if inventory == null else inventory.amount(resource_type)
return here + (fleet_inventory.amount(resource_type) if fleet_is_at(building) else 0)
func can_pay_locally(building: Dictionary, inventory: RefCounted,
costs: Dictionary) -> bool:
for resource_type in costs:
if local_payment_available(building, inventory, String(resource_type)) \
< int(costs[resource_type]):
return false
return true
# All or nothing, settled in full before anything is taken - a part-charged
# order that then fails is worse than one that never started. The building's
# own stock goes first, so what was hauled in is spent before the hold the
# player is still flying around with.
func pay_locally(building: Dictionary, inventory: RefCounted,
costs: Dictionary) -> bool:
if not can_pay_locally(building, inventory, costs):
return false
var at_hand: bool = fleet_is_at(building)
for resource_type in costs:
var id: String = String(resource_type)
var owed: int = int(costs[resource_type])
if inventory != null:
owed -= inventory.withdraw(id, owed)
if owed > 0 and at_hand:
fleet_inventory.withdraw(id, owed)
return true
func _advance_production(delta: float) -> void:
for b in buildings:
if not can_produce(b.type):
continue
var orders: Array = b.get("orders", [])
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)
var costs: Dictionary = product_costs(product)
var inventory: RefCounted = _inventory_for_record(b)
var definition: Resource = vessel_definition(product)
if not bool(order.started):
# #158: the crafter's own stock, or the hold that flew in.
if not pay_locally(b, inventory, costs):
continue
order.started = true
order.elapsed = float(order.elapsed) + delta
if float(order.elapsed) < build_time(product):
continue
orders.pop_front()
# A finished hull launches from the crafter and physically flies to the
# mothership hangar - support craft included since #216, which is what
# _advance_station_dispatch() then draws on to staff a hub or a
# workshop.
var mothership: Variant = get_mothership_position()
if mothership == null:
stored_vessels.append(product)
else:
vessel_deliveries.append({"type": product, "from": b.position,
"to": mothership, "progress": 0.0})
order_completed.emit(int(b.id), product)
# --- crewing an outpost from the mothership (#104) --------------------------
#
# "the game should transfer, whenever available, ships from the mothership to
# the outpost to always fullfill the outpost conditions if possible."
#
# Vessels come from the HANGAR - stored_vessels - and never from the fleet.
# The fleet is what the player is flying; taking a vessel out of it to crew a
# building would be the game undoing the player's own arrangement. "Whenever
# available" therefore means "when one is sitting at the mothership", and an
# outpost asking for a hull nobody has built simply waits.
#
# They travel rather than teleporting, down the same vessel_deliveries path a
# finished crafter order already uses to reach the mothership.
func _garrison_still_wants(id: int, vessel_type: String) -> bool:
var b: Dictionary = find_building(id)
if b.is_empty() or not is_outpost(b):
return false
return garrison_crew_of(id, vessel_type) < garrison_desired_of(id, vessel_type)
func _take_stored_vessel(vessel_type: String) -> bool:
var index: int = stored_vessels.find(vessel_type)
if index < 0:
return false
stored_vessels.remove_at(index)
return true
func _advance_garrison_dispatch() -> void:
var mothership: Variant = get_mothership_position()
if mothership == null:
return
for b in buildings:
if not is_outpost(b):
continue
var id: int = int(b.id)
# Over strength first: an order the player has just lowered should
# release the vessel back to the hangar rather than stranding it.
_return_surplus_garrison(b)
for vessel_type in garrison_shortfall(id):
var needed: int = int(garrison_shortfall(id)[vessel_type])
for i in range(needed):
if not _take_stored_vessel(String(vessel_type)):
break
vessel_deliveries.append({
"type": String(vessel_type), "from": mothership,
"to": b.position, "progress": 0.0, "to_outpost": id,
})
func _return_surplus_garrison(b: Dictionary) -> void:
var id: int = int(b.id)
var mothership: Variant = get_mothership_position()
if mothership == null:
return
var crew: Array = garrison_crew(id)
for i in range(crew.size() - 1, -1, -1):
var vessel_type: String = String(crew[i])
if garrison_crew_of(id, vessel_type) <= garrison_desired_of(id, vessel_type):
continue
crew.remove_at(i)
vessel_deliveries.append({
"type": vessel_type, "from": b.position, "to": mothership,
"progress": 0.0, "to_outpost": 0,
})
# --- standing orders, and the census behind them (#106) --------------------
#
# "how many ships of a give type should always be available over the whole
# game (fleet + storage + outposts)".
#
# The targets are GLOBAL, not per building. There is one plan for the whole
# game; a second Planning Center is a second place to read and edit the same
# plan, not a competing one. Storing them per building would mean two
# centres could hold contradictory orders and the crafters would be told to
# satisfy both.
#
# The census is the harder half, and the rule is that every vessel is
# counted exactly once, wherever it happens to be at that instant - flying
# in the fleet, parked in the hangar, stationed at an outpost, or in the air
# between any two of those. Miss the in-flight ones and the planner queues a
# replacement for a vessel that is merely en route.
var vessel_targets: Dictionary = {}
func has_planning_center() -> bool:
for b in buildings:
if building_has_capability(b, "planning"):
return true
return false
func vessel_target(vessel_type: String) -> int:
return int(vessel_targets.get(vessel_type, 0))
func set_vessel_target(vessel_type: String, count: int) -> int:
if not vessel_is_fleet_eligible(vessel_type):
return 0
var final_count: int = maxi(count, 0)
if final_count == 0:
vessel_targets.erase(vessel_type)
else:
vessel_targets[vessel_type] = final_count
return final_count
func adjust_vessel_target(vessel_type: String, delta: int) -> int:
return set_vessel_target(vessel_type, vessel_target(vessel_type) + delta)
func planned_vessel_types() -> PackedStringArray:
var out: PackedStringArray = PackedStringArray()
for definition in VESSEL_DEFINITIONS.all():
if bool(definition.fleet_eligible):
out.append(String(definition.definition_id))
return out
# How many of this type exist anywhere. Broken out per place rather than
# summed inline so the census can be read - and tested - one location at a
# time.
func vessel_census(vessel_type: String) -> int:
return vessel_count_in_fleet(vessel_type) \
+ vessel_count_in_hangar(vessel_type) \
+ vessel_count_in_garrisons(vessel_type) \
+ vessel_count_in_transit(vessel_type)
func vessel_count_in_fleet(vessel_type: String) -> int:
var n: int = 0
for flying in fleet_vessel_types:
if String(flying) == vessel_type:
n += 1
return n
func vessel_count_in_hangar(vessel_type: String) -> int:
var n: int = 0
for stored in stored_vessels:
if String(stored) == vessel_type:
n += 1
return n
func vessel_count_in_garrisons(vessel_type: String) -> int:
var n: int = 0
for id in outpost_ids():
n += garrison_crew_of(id, vessel_type)
return n
# Both directions of flight: a finished hull on its way to the mothership,
# and a vessel being moved to or from an outpost. All of them are vessels
# that exist and will land somewhere.
func vessel_count_in_transit(vessel_type: String) -> int:
var n: int = 0
for delivery in vessel_deliveries:
if String(delivery.type) == vessel_type:
n += 1
return n
# Ordered but not yet built. Kept separate from the census on purpose: these
# do not exist yet, so a readout has to be able to say "4 of 6, 2 building"
# rather than claiming six.
func vessel_count_on_order(vessel_type: String) -> int:
var n: int = 0
for b in buildings:
if not can_produce(String(b.type)):
continue
for order in b.get("orders", []):
if String(order.product) == vessel_type:
n += 1
return n
func vessel_shortfall(vessel_type: String) -> int:
return maxi(vessel_target(vessel_type)
- vessel_census(vessel_type) - vessel_count_on_order(vessel_type), 0)
# --- the planner working the crafters (#107) -------------------------------
#
# "the planning center automatically manage the ship craft building queues to
# ensure that the needed vessels are produced to fullfill the constraints."
#
# Three rules keep this from fighting the player:
#
# It TOPS UP, never rewrites. Orders placed by hand are left exactly where
# they are; the planner only ever appends, and only for a type that is
# genuinely short. Cancelling one of the player's orders to make room for
# one of its own would be the game arguing with them.
#
# Everything counts. A vessel already queued, already flying to the
# mothership, or already stationed at an outpost is a vessel that exists or
# shortly will - see vessel_shortfall(). Without that it re-queues the same
# shortfall every tick and buries the player in hulls.
#
# Nothing happens without a planning centre. Destroy it and the standing
# orders stop being acted on, though they are remembered, so rebuilding
# picks up where it left off rather than starting from a blank plan.
func _crafter_for_planning() -> Dictionary:
# The shortest queue, so orders spread across the crafters that exist
# rather than piling onto whichever happens to be first in the list.
var best: Dictionary = {}
var best_queue: int = 0
for b in buildings:
if not can_produce(String(b.type)):
continue
var queued: int = (b.get("orders", []) as Array).size()
if best.is_empty() or queued < best_queue:
best = b
best_queue = queued
return best
func _advance_planning() -> void:
if vessel_targets.is_empty() or not has_planning_center():
return
for vessel_type in vessel_targets:
var type_id: String = String(vessel_type)
if not vessel_is_unlocked(type_id):
continue
var missing: int = vessel_shortfall(type_id)
if missing <= 0:
continue
var crafter: Dictionary = _crafter_for_planning()
if crafter.is_empty():
return
# ONE per pass, not the whole shortfall. The queue is spread across
# crafters by _crafter_for_planning(), and asking for the whole
# shortfall at once would put it all on the shortest queue before
# that had a chance to change.
queue_order(int(crafter.id), type_id)
# --- the spacial workshop and its repair drones (#116) ---------------------
#
# Modelled on the transport hub, deliberately and closely: drones belong to a
# workshop rather than to the player, are provisioned when it is built, are
# never a production order, and can be upgraded at a ship lab like any other
# vessel. Anything that already knows how hub transporters behave knows how
# these do.
#
# What a repair COSTS is the one new idea: a share of the building's own
# price, in proportion to how much of its health is gone. Levelling a crafter
# and rebuilding it costs its full price; patching one up after a wave costs
# a fraction, and the fraction is the damage.
const BASE_WORKSHOP_DRONES: int = 2
const REPAIR_DRONE: String = "repair_drone"
# What a drone puts back per trip, before upgrades.
const BASE_REPAIR_AMOUNT: int = 25
const DRONE_IDLE: String = "idle"
const DRONE_OUTBOUND: String = "outbound"
const DRONE_RETURNING: String = "returning"
var repair_drones: Array = []
func workshop_drone_capacity() -> int:
var n: int = BASE_WORKSHOP_DRONES
for id in unlocked:
n += int(skill_node(id).get("workshop_bonus", 0))
return n
func has_workshop() -> bool:
for b in buildings:
if building_has_capability(b, "repair"):
return true
return false
func workshop_ids() -> Array:
var out: Array = []
for b in buildings:
if building_has_capability(b, "repair"):
out.append(int(b.id))
return out
# The ore a full repair of this building would take: its own price, scaled by
# the share of its health that is missing. A structure at full health costs
# nothing, one levelled to a sliver costs nearly what it did to build.
func repair_cost(id: int) -> int:
var b: Dictionary = find_building(id)
if b.is_empty():
return 0
var definition: Resource = building_definition(String(b.type))
if definition == null:
return 0
var missing: float = 1.0 - building_health_fraction(id)
return int(ceil(float(definition.cost) * missing))
func repair_amount() -> int:
return int(vessel_stat(REPAIR_DRONE, "repair_amount"))
# What one drone-load of repair costs, for a building that needs one.
func repair_trip_cost(id: int) -> int:
var b: Dictionary = find_building(id)
if b.is_empty():
return 0
var definition: Resource = building_definition(String(b.type))
if definition == null:
return 0
var per_point: float = float(definition.cost) / float(maxi(building_max_hp(String(b.type)), 1))
var putting_back: int = mini(repair_amount(),
building_max_hp(String(b.type)) - building_hp(id))
return maxi(int(ceil(per_point * float(putting_back))), 1) if putting_back > 0 else 0
func _damaged_buildings() -> Array:
var out: Array = []
for b in buildings:
if building_health_fraction(int(b.id)) < 0.999:
out.append(b)
return out
# #116: "Required resources are sent to the spacial workshop". The workshop
# asks hub logistics for the ore its outstanding repairs will need, exactly
# as a gas burner asks for gas - see building_logistics_deficit.
func workshop_ore_demand(id: int) -> int:
var b: Dictionary = find_building(id)
if b.is_empty() or not building_has_capability(b, "repair"):
return 0
var needed: int = 0
for damaged in _damaged_buildings():
needed += repair_cost(int(damaged.id))
return mini(needed, WORKSHOP_ORE_BUFFER)
# A ceiling, so one badly-mauled base does not pull the entire bank into a
# single workshop and starve everything else.
const WORKSHOP_ORE_BUFFER: int = 120
func _add_workshop_drone(workshop: Dictionary) -> void:
repair_drones.append({
"workshop_id": int(workshop.id), "state": DRONE_IDLE, "target_id": 0,
"from": workshop.position, "to": workshop.position, "progress": 1.0,
"carrying": 0, "repairs_left": DRONE_REPAIRS,
})
func repair_drone_position(drone: Dictionary) -> Vector2:
return (drone.from as Vector2).lerp(drone.to as Vector2,
clampf(float(drone.progress), 0.0, 1.0))
func _drone_inbound_to(building_id: int) -> bool:
for drone in repair_drones:
if String(drone.state) == DRONE_OUTBOUND and int(drone.target_id) == building_id:
return true
return false
func _advance_repairs(delta: float) -> void:
if repair_drones.is_empty():
return
var speed: float = vessel_stat(REPAIR_DRONE, "transport_speed")
# Duplicated because a drone that runs out of work is removed from the
# list below, and #216 made that reachable from inside this loop.
for drone in repair_drones.duplicate():
var workshop: Dictionary = find_building(int(drone.workshop_id))
if workshop.is_empty():
continue
if String(drone.state) == DRONE_IDLE:
_dispatch_drone(drone, workshop)
continue
var distance: float = (drone.from as Vector2).distance_to(drone.to as Vector2)
drone.progress = float(drone.progress) + delta / maxf(distance / maxf(speed, 1.0), 0.001)
if float(drone.progress) < 1.0:
continue
drone.progress = 1.0
if String(drone.state) == DRONE_OUTBOUND:
# The ore was taken when it left, so a building demolished in
# transit does not silently consume it - it goes home with the
# load and the deposit below puts it back.
var target: Dictionary = find_building(int(drone.target_id))
if not target.is_empty():
repair_building(int(drone.target_id), repair_amount())
else:
deposit_resource_to_building(int(drone.workshop_id), RESOURCE_ORE,
int(drone.carrying))
drone.carrying = 0
drone.target_id = 0
_send_drone(drone, workshop.position, DRONE_RETURNING)
else:
drone.state = DRONE_IDLE
# #216: counted where the run ENDS, so a drone is never retired
# with a building half repaired behind it.
drone.repairs_left = int(drone.get("repairs_left", DRONE_REPAIRS)) - 1
if int(drone.repairs_left) <= 0:
_retire_drone(drone)
func _dispatch_drone(drone: Dictionary, workshop: Dictionary) -> void:
var inventory: RefCounted = building_inventory(int(workshop.id))
if inventory == null:
return
var best: Dictionary = {}
var best_health: float = 1.0
for damaged in _damaged_buildings():
var id: int = int(damaged.id)
# One drone per building at a time: two arriving at the same wall
# would spend twice the ore to put back one drone-load of health.
if _drone_inbound_to(id):
continue
var cost: int = repair_trip_cost(id)
if cost <= 0 or inventory.amount(RESOURCE_ORE) < cost:
continue
var health: float = building_health_fraction(id)
if best.is_empty() or health < best_health:
best = damaged
best_health = health
if best.is_empty():
return
# Worst first: the structure closest to coming down is the one a repair
# is worth most on.
var spend: int = repair_trip_cost(int(best.id))
inventory.withdraw(RESOURCE_ORE, spend)
drone.carrying = spend
drone.target_id = int(best.id)
drone.from = workshop.position
_send_drone(drone, best.position, DRONE_OUTBOUND)
func _send_drone(drone: Dictionary, to: Vector2, state: String) -> void:
drone.from = repair_drone_position(drone)
drone.to = to
drone.progress = 0.0
drone.state = state
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 == "":
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)):
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]
func research_cost(id: String) -> int:
return int(research_costs(id).get(RESOURCE_ORE, 0))
func research_point_cost(id: String) -> int:
if costs_are_free():
return 0
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
func research_costs(id: String) -> Dictionary:
if costs_are_free():
return {}
var unlock_definition: Resource = vessel_for_unlock(id)
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 {}
func _advance_lab_research(delta: float) -> void:
for lab in buildings:
if not building_has_capability(lab, "vessel_research"):
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)
var costs: Dictionary = research_costs(id)
var inventory: RefCounted = _inventory_for_record(lab)
if not bool(order.started):
# #158: the lab's own stock, or the hold that flew in.
var affordable: bool = can_pay_locally(lab, inventory, costs)
# #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
if not affordable:
continue
if not pay_locally(lab, inventory, costs):
continue
if points_needed > 0:
research_points -= points_needed
research_points_changed.emit(research_points)
order.started = true
order.elapsed = float(order.elapsed) + delta
if float(order.elapsed) < lab_research_time():
continue
if unlock_definition != null:
vessel_levels[id] = 1
else:
vessel_levels[id] = upgrade_level(id) + 1
vessel_upgraded.emit(id)
orders.pop_front()
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
# Whether a structure of this type exists ANYWHERE, however it is stored.
#
# #145: extractors are not in `buildings`. They are placed onto a planet from
# space and live in `extractors`, with their own position, their own stock
# and their own id - so has_building_of_type("extractor") is false no matter
# how many the player has built. The quest that asks for one (#133) was
# asking a question that could never be answered yes, and no amount of
# building extractors was going to finish it.
#
# Anything asking "does the player have one of these" wants THIS, and
# has_building_of_type() only where the answer really is about the
# `buildings` list itself.
func structure_exists(type: String) -> bool:
if type == BUILDING_EXTRACTOR:
return not extractors.is_empty()
return has_building_of_type(type)
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():
var kept_transporters: Array = []
for transporter in transporters:
if int(transporter.get("hub_id", 0)) not in removed_hub_ids:
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.
# 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.
# Twenty 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.
# Each further pair continues the same V rather than starting a new shape:
# the rows step 30 out and 40 back, which is the rule the first ten already
# followed, so the wedge that arrives at twenty is the one that left at three.
const DEFAULT_FORMATION: Array = [
Vector2(0, -48), Vector2(-30, -8), Vector2(30, -8), Vector2(-60, 32), Vector2(60, 32),
Vector2(-90, 72), Vector2(90, 72), Vector2(0, 56),
Vector2(-120, 112), Vector2(120, 112),
Vector2(-150, 152), Vector2(150, 152), Vector2(-180, 192), Vector2(180, 192),
Vector2(-210, 232), Vector2(210, 232), Vector2(-240, 272), Vector2(240, 272),
Vector2(-270, 312), Vector2(270, 312),
]
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"}.
# 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,
"resource_type": resource_type,
"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
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)
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 target: int = 0
var profile: Resource = building_logistics_profile(building)
if profile != null:
target = int(profile.input_buffers.get(resource_type, 0))
# An outpost has no logistics profile at all - what it wants held depends
# on who is stationed there, which is not something a definition can
# state. Taking the larger of the two keeps the static case untouched.
target = maxi(target, garrison_magazine_capacity(id, resource_type))
# #116: and a workshop asks for the ore its outstanding repairs will
# take, which likewise depends on the world rather than on a definition.
if resource_type == RESOURCE_ORE:
target = maxi(target, workshop_ore_demand(id))
return maxi(0, target - building_resource(id, resource_type))
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 ""
# #78: a converter can be switched off. A gas burner turns gas the player may
# want for bombs into energy they may not need yet, and until now it did that
# relentlessly - the only way to stop it was to demolish it. Paused is stored
# on the building record like every other piece of its state.
func is_building_paused(id: int) -> bool:
return bool(find_building(id).get("paused", false))
func set_building_paused(id: int, paused: bool) -> void:
var building: Dictionary = find_building(id)
if building.is_empty():
return
building.paused = paused
building_paused_changed.emit(id, paused)
func toggle_building_paused(id: int) -> bool:
var paused: bool = not is_building_paused(id)
set_building_paused(id, paused)
return paused
signal building_paused_changed(id: int, paused: bool)
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:
continue
if bool(building.get("paused", false)):
# Progress is left exactly where it was rather than reset, so
# switching a burner off and on again does not throw away the
# fraction of a unit it had already worked through.
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))
if whole > 0:
inventory.withdraw(profile.conversion_input, whole)
inventory.deposit(profile.conversion_output, whole * profile.conversion_yield)
progress -= whole
building.conversion_progress = progress
func _distorder_boost_in_use() -> bool:
for fleet in get_tree().get_nodes_in_group("player_fleet"):
if fleet.has_method("is_using_speed_boost") and fleet.is_using_speed_boost():
return true
return false
func _advance_distorder_drain(delta: float, boost_in_use: bool = true) -> 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
if not boost_in_use:
# The fader is a selected cruise speed, not an always-running machine.
# Keep any already-earned fraction so stopping cannot erase a nearly
# complete unit of cost, but accrue nothing while the fleet is parked.
return
# 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.
# #205: the per-second figure is the Propulser's own stat now, so a Flux
# Regulator makes sustained boost cheaper. ENERGY_DRAIN_PER_SECOND remains
# what the definition declares, and what this falls back to.
var rate: float = (fleet_speed_multiplier - 1.0) / maxf(max_fleet_speed_multiplier - 1.0, 0.001) \
* vessel_stat(VESSEL_DISTORDER, "boost_drain")
_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 is held once its garrison is dead, and only then
# can an extractor 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.
#
# #211 replaced capture points - stand in a ring, wait out a timer, once per
# point - with the fight itself. Taking a planet is now exactly "kill what is
# defending it", which is what players were doing anyway before walking to
# the rings.
var planet_capture: Dictionary = {}
# #212: carries the research points the conquest actually paid, so whatever
# announces it does not have to reconstruct the award from the definition and
# the setup's override and get a different answer.
signal planet_conquered(planet_id: String, research_points_awarded: int)
# Declares what is defending a planet. Called every time the surface is
# composed, because the garrison is re-placed every time the player lands:
# the count has to start again with the enemies it counts, or a planet left
# half-cleared would fall to a single kill on the next visit.
#
# A planet already conquered is left alone entirely - its garrison is dead
# and stays dead.
func register_planet_garrison(planet_key: String, enemy_count: int,
definition_id: String = "", reward_override: int = 0) -> void:
var existing: Dictionary = planet_capture.get(planet_key, {})
if bool(existing.get("conquered", false)):
return
# A planet defending itself with nothing 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). The planet definition supplies an
# optional conquest reward, avoiding planet-id special cases here.
var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id)
var point_reward: int = 0 if definition == null else int(definition.conquest_research_points)
# 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] = {
"remaining": enemy_count,
"total": enemy_count,
"conquered": enemy_count <= 0,
# Carried across landings: the reward is paid once, and a planet
# re-registered on a second visit must not become payable again.
"research_point_reward": point_reward,
"research_awarded": bool(existing.get("research_awarded", false)),
}
func capture_state(planet_key: String) -> Dictionary:
return planet_capture.get(planet_key,
{"remaining": 0, "total": 0, "conquered": false})
# How much of the garrison is still standing, and how big it was. Both are
# for the surface readout; conquest itself is decided below.
func garrison_remaining(planet_key: String) -> int:
return int(capture_state(planet_key).get("remaining", 0))
func garrison_total(planet_key: String) -> int:
return int(capture_state(planet_key).get("total", 0))
# One defender down. Called from the enemy's own destroyed signal, so it
# counts kills by whatever means - a beam, a bomb, a ramming hull.
func mark_garrison_enemy_destroyed(planet_key: String) -> void:
if not planet_capture.has(planet_key):
return
var state: Dictionary = planet_capture[planet_key]
if bool(state.conquered):
return
state.remaining = maxi(int(state.get("remaining", 0)) - 1, 0)
if int(state.remaining) <= 0:
state.conquered = true
# #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))
var awarded: int = 0
if reward > 0 and not bool(state.get("research_awarded", false)):
state.research_awarded = true
awarded = reward
research_points += reward
research_points_changed.emit(research_points)
# Emitted AFTER the award, so a listener that reports the reward is
# reporting one that has already been paid (#212).
planet_conquered.emit(planet_key, awarded)
func is_planet_conquered(planet_key: String) -> bool:
return bool(capture_state(planet_key).get("conquered", false))
# --- waves (#100) ----------------------------------------------------------
#
# #97 replaces the roaming spawner - one enemy every few seconds, forever -
# with waves: a long quiet, a warning, then an attack. The CLOCK lives here
# rather than in the space scene for the usual reason, that Main.tscn is
# destroyed on every landing; a wave must not be cancelled, nor restarted
# from zero, by a trip to a planet.
#
# What is deliberately NOT here is the spawning. GameState cannot see the
# world, so EnemySpawner reads the phase and puts the enemies in it. That
# also settles what happens if the wave turns while the player is on a
# surface: the phase goes to ACTIVE with nothing to spawn it, and the wave
# is waiting in space when they come back.
const WAVE_PHASE_PEACE: String = "peace"
const WAVE_PHASE_WARNING: String = "warning"
const WAVE_PHASE_ACTIVE: String = "active"
const WAVE_PEACE_SECONDS: float = 50.0
# How long the alert is up before anything arrives. Long enough to fly home
# and take a position, short enough that it is a warning and not a chore.
const WAVE_WARNING_SECONDS: float = 9.0
# A wave that wandered off and never died must not stall the next one for
# ever. The director normally ends the phase by clearing the wave.
const WAVE_MAX_SECONDS: float = 240.0
var wave_number: int = 0
var wave_phase: String = WAVE_PHASE_PEACE
var wave_timer: float = 0.0
# Set when a wave turns ACTIVE, cleared by whoever puts it in the world.
# Without it a director arriving mid-wave (the player just came back from a
# planet) could not tell "this wave still owes you enemies" from "this wave
# is already out there and half dead".
var wave_pending: bool = false
func reset_waves() -> void:
wave_number = 0
wave_phase = WAVE_PHASE_PEACE
wave_timer = 0.0
wave_pending = false
func wave_is_incoming() -> bool:
return wave_phase == WAVE_PHASE_WARNING
# Deliberately NOT a remaining-seconds accessor. #97: "No timer should be
# visible" - so the one number that could be rendered as a countdown is not
# offered to the UI at all, and the alert cannot drift into being a clock.
func wave_warning_pulse() -> float:
if wave_phase != WAVE_PHASE_WARNING:
return 0.0
return 0.5 + 0.5 * sin(wave_timer * 7.0)
# Strength is a function of the FLEET, per #97 - a bigger fleet is a bigger
# target, so growing it raises the stakes rather than trivialising them.
# The wave number is a slower second term, so a player who never grows still
# meets escalation rather than the same two chasers all game.
func wave_strength(fleet_size: int) -> int:
# The flat term matters: without it the opening wave against a one-vessel
# fleet is a single chaser, which reads as the system not working rather
# than as an easy first wave.
return 3 + maxi(fleet_size, 1) * 3 + wave_number * 2
# Spent most-expensive-first, so a large budget buys heavier enemies rather
# than an ever-larger swarm of chasers.
#
# Two gates, not one. min_strength was meant to keep the openings honest, but
# strength counts the wave NUMBER as well, so a player who had grown nothing
# still met lancers and then snipers simply by surviving - #218's "the first
# waves are too complex".
#
# min_fleet is the gate that actually holds: an enemy type stays out of the
# roster until the fleet is big enough to have earned it. At BASE_FLEET_SIZE
# the player meets chasers and nothing else, however long they take, and each
# vessel past the starting three opens exactly one new kind of trouble.
const WAVE_ROSTER: Array = [
{"id": "artillery", "cost": 9, "min_strength": 26, "min_fleet": 6},
{"id": "sniper", "cost": 6, "min_strength": 16, "min_fleet": 5},
{"id": "lancer", "cost": 4, "min_strength": 12, "min_fleet": 4},
{"id": "chaser", "cost": 3, "min_strength": 0, "min_fleet": 0},
]
const WAVE_MAX_ENEMIES: int = 18
func wave_composition(fleet_size: int) -> PackedStringArray:
var budget: int = wave_strength(fleet_size)
var strength: int = budget
var out: PackedStringArray = PackedStringArray()
for entry in WAVE_ROSTER:
if strength < int(entry.min_strength) \
or fleet_size < int(entry.get("min_fleet", 0)):
continue
var cost: int = int(entry.cost)
# The cheapest tier is last and takes whatever is left, so the budget
# is always spent rather than rounded away.
var share: int = budget if String(entry.id) == "chaser" else budget / 2
while share >= cost and budget >= cost and out.size() < WAVE_MAX_ENEMIES:
out.append(String(entry.id))
budget -= cost
share -= cost
if out.is_empty():
out.append("chaser")
return out
func advance_waves(delta: float) -> void:
wave_timer += delta
match wave_phase:
WAVE_PHASE_PEACE:
if wave_timer >= WAVE_PEACE_SECONDS:
wave_phase = WAVE_PHASE_WARNING
wave_timer = 0.0
WAVE_PHASE_WARNING:
if wave_timer >= WAVE_WARNING_SECONDS:
wave_phase = WAVE_PHASE_ACTIVE
wave_timer = 0.0
wave_number += 1
wave_pending = true
WAVE_PHASE_ACTIVE:
if wave_timer >= WAVE_MAX_SECONDS:
wave_cleared()
# #110: put a wave on the board now, for testing. Skips the peace AND the
# warning: the point of the button is to see the wave, and waiting out a
# nine-second alert every time would make it useless as a tool.
func force_wave_now() -> void:
wave_phase = WAVE_PHASE_ACTIVE
wave_timer = 0.0
wave_number += 1
wave_pending = true
func wave_cleared() -> void:
wave_phase = WAVE_PHASE_PEACE
wave_timer = 0.0
wave_pending = false
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.
if dev_mode and dev_unlimited_resources:
_refill_dev_resources()
advance_waves(delta)
_advance_production(delta)
_advance_lab_research(delta)
_advance_vessel_deliveries(delta)
_advance_garrison_dispatch()
_advance_station_dispatch()
_advance_planning()
_advance_repairs(delta)
_advance_resource_converters(delta)
_advance_fleet_office_upkeep(delta)
_advance_distorder_drain(delta, _distorder_boost_in_use())
_advance_quests()
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:
_extractor_inventory(e).deposit(String(e.get("resource_type", RESOURCE_ORE)), whole)
e.mining_progress = float(e.mining_progress) - whole
_advance_transporters(delta)
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)
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)
if float(delivery.progress) < 1.0:
continue
vessel_deliveries.remove_at(i)
# #104: a vessel bound for an outpost joins its crew. If the outpost
# was levelled while it was in the air, or the order was cancelled
# under it, it lands in the hangar instead - the vessel is never
# destroyed by its destination going away, the same rule a
# transporter mid-delivery follows with its cargo.
var outpost_id: int = int(delivery.get("to_outpost", 0))
var station_id: int = int(delivery.get("to_station", 0))
if outpost_id != 0 and _garrison_still_wants(outpost_id, String(delivery.type)):
garrison_crew(outpost_id).append(String(delivery.type))
elif station_id != 0 and _station_still_wants(station_id):
# #216: same rule as an outpost's - a hub levelled or an order
# cancelled while the vessel was in the air lands it in the
# hangar rather than destroying it.
_station_arrived(station_id)
else:
stored_vessels.append(String(delivery.type))
# --- 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"}.
# 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
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"
# 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"
# #217: fetching its own fuel. A leg of its own rather than a flag on the
# others, so a transporter that is refuelling is visibly doing that and can
# never be dispatched onto a cargo run halfway there.
const TRANSPORTER_TO_FUEL: String = "to_fuel"
const TRANSPORTER_TO_MOTHERSHIP_FROM_BUILDING: String = "building_to_mothership"
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
# #216: a hub used to conjure its own transporters, up to capacity, for free
# and forever. They are BUILT now - ordered at a ship crafter, flown out from
# the mothership, and worn out by the work they do - so this only ever adds
# one that has actually arrived.
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,
"trips_left": TRANSPORTER_TRIPS, "fuel": 0, "fuel_source_id": 0,
})
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:
# 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.
for t in transporters:
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:
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
# #217: the nearest place with energy in it, as {position, id} - id 0 meaning
# the bank at the mothership, which is where most of it sits. Nearest to the
# transporter itself rather than to its hub: it is the one making the trip.
func _nearest_energy_source(from: Vector2) -> Dictionary:
var best: Dictionary = {}
var best_distance: float = INF
var mothership: Variant = get_mothership_position()
if mothership != null and bank_inventory.amount(RESOURCE_ENERGY) > 0:
best = {"position": mothership, "id": 0}
best_distance = from.distance_squared_to(mothership)
for b in buildings:
if building_has_capability(b, "bank"):
continue # already considered, as the bank above
if building_resource(int(b.id), RESOURCE_ENERGY) <= 0:
continue
var distance: float = from.distance_squared_to(b.position)
if distance < best_distance:
best_distance = distance
best = {"position": b.position, "id": int(b.id)}
return best
# An empty tank is worth a detour only once a Burn Drive is researched, and
# only while something has energy to give. Runs before ordinary dispatch so a
# transporter tops up between jobs rather than crawling through the next one.
func _refuel_idle_transporters() -> void:
if not transporter_burns_energy():
return
for t in transporters:
if t.state != TRANSPORTER_IDLE or int(t.get("fuel", 0)) > 0:
continue
var here: Vector2 = transporter_position(t)
var source: Dictionary = _nearest_energy_source(here)
if source.is_empty():
continue
t.fuel_source_id = int(source.id)
_begin_leg(t, here, Vector2(source.position), TRANSPORTER_TO_FUEL)
func _dispatch_transporters() -> void:
var mothership: Variant = get_mothership_position()
if mothership == null:
return # nowhere to load from
_refuel_idle_transporters()
# 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.
for b in buildings:
var needed_resource := ""
for resource_type in resource_types():
var need: int = building_requested_resource(int(b.id), resource_type)
if need > 0 and bank_inventory.amount(resource_type) > 0:
needed_resource = resource_type
break
if needed_resource == "":
continue
# One at a time per building: without this every idle transporter
# would be sent after the same shortfall and they would all arrive
# carrying resources nobody needs any more.
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)
chosen.resource_type = needed_resource
_begin_leg(chosen, transporter_position(chosen), mothership, TRANSPORTER_TO_MOTHERSHIP)
# 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.
for b in buildings:
var pickup_resource: String = _collectable_building_resource(b, load)
if pickup_resource == "" or _transporter_inbound_for_pickup(int(b.id)):
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
chosen_pickup.extractor_index = -1
_begin_leg(chosen_pickup, transporter_position(chosen_pickup), b.position,
TRANSPORTER_TO_BUILDING_PICKUP)
func _advance_transporters(delta: float) -> void:
if transporters.is_empty():
return
_dispatch_transporters()
# Duplicated for the same reason as the drones: a spent transporter is
# removed from this list on arrival.
for t in transporters.duplicate():
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_leg_speed(t), 0.0001)
t.progress += delta / leg
if t.progress < 1.0:
continue
t.progress = 1.0
# #217: a unit of energy per leg flown under boost, spent on arrival
# so a leg is either paid for in full or not flown under boost at all.
# Refuelling legs are not charged - a transporter would otherwise burn
# the fuel it is on its way to collect.
if int(t.get("fuel", 0)) > 0 and String(t.state) != TRANSPORTER_TO_FUEL:
t.fuel = int(t.fuel) - 1
_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))
var resource_type: String = String(t.get("resource_type", RESOURCE_ORE))
var want: int = building_requested_resource(int(t.target_id), resource_type)
var take: int = mini(mini(want, bank_inventory.amount(resource_type)), transporter_load_limit())
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
bank_inventory.withdraw(resource_type, take)
t.carrying = take
_begin_leg(t, here, target.position, TRANSPORTER_TO_BUILDING)
TRANSPORTER_TO_BUILDING:
# 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
# than quietly destroying it.
bank_inventory.deposit(resource_type, 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_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()
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
extractor_inventory.withdraw(resource_id, load)
t.carrying = load
t.resource_type = resource_id
_begin_leg(t, here, get_mothership_position(), TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR)
TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR:
# Bank only on arrival; cargo retains the extractor's resource type.
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)
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_TO_FUEL:
# Whatever is actually there, up to the tank. A store drained by
# something else while this was in the air simply means a shorter
# fill, not a wasted trip.
var tank: int = transporter_fuel_capacity() - int(t.get("fuel", 0))
var source_id: int = int(t.get("fuel_source_id", 0))
var drawn: int = 0
if source_id == 0:
drawn = mini(tank, bank_inventory.amount(RESOURCE_ENERGY))
bank_inventory.withdraw(RESOURCE_ENERGY, drawn)
else:
drawn = mini(tank, building_resource(source_id, RESOURCE_ENERGY))
var store: RefCounted = building_inventory(source_id)
if store != null:
store.withdraw(RESOURCE_ENERGY, drawn)
else:
drawn = 0
t.fuel = int(t.get("fuel", 0)) + drawn
t.fuel_source_id = 0
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
TRANSPORTER_RETURNING:
t.state = TRANSPORTER_IDLE
t.progress = 1.0
# #216: a round trip is what wears one out, counted where the trip
# ENDS. Counting at dispatch would retire a vessel with its cargo
# still in the air.
t.trips_left = int(t.get("trips_left", TRANSPORTER_TRIPS)) - 1
if int(t.trips_left) <= 0:
_retire_transporter(t)
# --- staffing hubs and workshops (#216) ------------------------------------
#
# Transporters and repair drones used to appear with their building, free and
# permanent. They are ordinary craft now: ordered at a ship crafter, parked in
# the mothership hangar, flown out to whichever structure asked for them, and
# worn out by the work they do. What a structure HAS and what it was ASKED to
# have are separate, exactly as they are for an outpost garrison (#104), so a
# hub waiting on a hull it has not built yet reads as waiting rather than as
# broken.
#
# How much work one has in it before it is spent. Round numbers rather than a
# curve: the point the ticket makes is that they run out and have to be
# replaced, and a player has to be able to hold "about twenty runs" in their
# head while deciding whether to build another.
const TRANSPORTER_TRIPS: int = 20
const DRONE_REPAIRS: int = 12
# Both already exist above - VESSEL_TRANSPORTER with the other vessel ids,
# REPAIR_DRONE with the workshop - so they are used rather than redeclared.
# Which vessel staffs this structure, or "" for one that is not staffed.
func station_vessel_type(building: Dictionary) -> String:
if building_has_capability(building, "transport_hub"):
return VESSEL_TRANSPORTER
if building_has_capability(building, "repair"):
return REPAIR_DRONE
return ""
func station_capacity(building: Dictionary) -> int:
match station_vessel_type(building):
VESSEL_TRANSPORTER: return hub_capacity()
REPAIR_DRONE: return workshop_drone_capacity()
return 0
func station_desired(id: int) -> int:
var building: Dictionary = find_building(id)
if building.is_empty():
return 0
return mini(int(building.get("staff_desired", 0)), station_capacity(building))
# Returns what was actually set, so a caller can show a clamped request rather
# than silently doing something else - same contract as set_garrison_desired.
func set_station_desired(id: int, count: int) -> int:
var building: Dictionary = find_building(id)
if building.is_empty() or station_vessel_type(building) == "":
return 0
building.staff_desired = clampi(count, 0, station_capacity(building))
return int(building.staff_desired)
# How many are actually standing there. Counts the crew, not the order: one
# still in the air has not started working yet.
func station_crew(id: int) -> int:
var building: Dictionary = find_building(id)
var n: int = 0
match station_vessel_type(building):
VESSEL_TRANSPORTER:
for transporter in transporters:
if int(transporter.get("hub_id", 0)) == id:
n += 1
REPAIR_DRONE:
for drone in repair_drones:
if int(drone.get("workshop_id", 0)) == id:
n += 1
return n
# What is left in each of them, worst first, for the readout in the building.
# A structure whose crew is nearly spent should look nearly spent.
func station_condition(id: int) -> Array:
var building: Dictionary = find_building(id)
var left: Array = []
match station_vessel_type(building):
VESSEL_TRANSPORTER:
for transporter in transporters:
if int(transporter.get("hub_id", 0)) == id:
left.append(int(transporter.get("trips_left", TRANSPORTER_TRIPS)))
REPAIR_DRONE:
for drone in repair_drones:
if int(drone.get("workshop_id", 0)) == id:
left.append(int(drone.get("repairs_left", DRONE_REPAIRS)))
left.sort()
return left
func station_work_capacity(id: int) -> int:
var building: Dictionary = find_building(id)
match station_vessel_type(building):
VESSEL_TRANSPORTER: return TRANSPORTER_TRIPS
REPAIR_DRONE: return DRONE_REPAIRS
return 0
# One in the air, so a structure short of crew does not order a second hull
# for the same berth while the first is still on its way.
func _station_inbound(id: int) -> int:
var n: int = 0
for delivery in vessel_deliveries:
if int(delivery.get("to_station", 0)) == id:
n += 1
return n
func _advance_station_dispatch() -> void:
var mothership: Variant = get_mothership_position()
if mothership == null:
return
for building in buildings:
var vessel_type: String = station_vessel_type(building)
if vessel_type == "":
continue
var id: int = int(building.id)
var short: int = station_desired(id) - station_crew(id) - _station_inbound(id)
while short > 0 and _take_stored_vessel(vessel_type):
vessel_deliveries.append({"type": vessel_type, "from": mothership,
"to": building.position, "progress": 0.0, "to_station": id})
short -= 1
func _station_still_wants(id: int) -> bool:
var building: Dictionary = find_building(id)
if building.is_empty() or station_vessel_type(building) == "":
return false
return station_crew(id) < station_desired(id)
func _station_arrived(id: int) -> void:
var building: Dictionary = find_building(id)
match station_vessel_type(building):
VESSEL_TRANSPORTER: _add_hub_transporter(building)
REPAIR_DRONE: _add_workshop_drone(building)
# A spent vessel is GONE, not returned to the hangar: it was used up. The
# structure is short again, and the dispatch above will send another as soon
# as the player has built one.
func _retire_transporter(t: Dictionary) -> void:
var index: int = transporters.find(t)
if index >= 0:
transporters.remove_at(index)
func _retire_drone(drone: Dictionary) -> void:
var index: int = repair_drones.find(drone)
if index >= 0:
repair_drones.remove_at(index)
# --- quests (#129, #131) ---------------------------------------------------
# The chain lives in Quests.gd; what lives here is how far along it the
# player is, and the answer to "is this one done yet".
#
# Progress LATCHES, and it latches for free: the chain is walked one quest
# at a time, and the moment a goal is met the index moves on and never comes
# back. So collecting five ore and then spending it cannot un-complete the
# quest that asked for five ore - by the time it is spent, nothing is
# asking any more. That is why goals are allowed to be phrased against
# current holdings rather than against a running total nobody would keep in
# step.
#
# It sits in GameState with everything else that has to survive the
# Main.tscn <-> PlanetSurface.tscn swap - a quest completed on a surface
# must still be completed after take-off.
signal quest_started(id: String)
signal quest_completed(id: String)
# Whether this playthrough has a quest chain at all. Armed by
# reset_new_game(), which is the only door into a real game (the main menu's
# Play, and start_dev_game on top of it) - exactly the gate intro_active
# already uses. Without it, every scene ever instantiated would open on the
# first quest's briefing, including the ones in the test suite that only
# want a world to look at.
var quests_armed: bool = false
var quest_index: int = 0
# Whether the current quest has been ANNOUNCED. Kept apart from the index so
# a quest that is already satisfied the moment it starts still gets a frame
# on screen with its own name on it, rather than being skipped silently.
var quest_announced: bool = false
# Lines the chain wants said, waiting for something that can say them.
#
# Queued here rather than emitted straight at a banner because quests
# advance in this autoload, which runs on the planet surface too - where
# there is no narration banner at all. A line emitted there would simply be
# lost. QuestDirector drains this whenever it is alive, so a quest completed
# underground is read out on the way back up.
var quest_messages: Array = []
# Which planets have been LANDED ON, and which kinds of world they were.
# Distinct from explored_chunks, which is only about having flown past.
var visited_planets: Dictionary = {}
var visited_planet_kinds: Dictionary = {}
# #173: the catalog is checked once, here, in every build - not only in the
# test suite. The editor can quietly strip a property out of a .tres between
# the last test run and the export, and the shipped result is a game that
# looks fine and cannot mine. push_error rather than a crash: the game is
# still playable in whatever way survived, and on the web these lines reach
# the on-page log web_shell.html mirrors from the console, which is the only
# error a phone can read.
func _ready() -> void:
catalog_faults = CATALOG_CHECK.faults()
if catalog_faults.is_empty():
return
push_error("The definition catalog is damaged - see core/CatalogCheck.gd. %d fault(s):" \
% catalog_faults.size())
for fault in catalog_faults:
push_error(" catalog: %s" % fault)
func reset_quests(armed: bool = false) -> void:
quests_armed = armed
quest_index = 0
quest_announced = false
quest_messages.clear()
visited_planets.clear()
visited_planet_kinds.clear()
# Called when the fleet actually sets down on a world.
func note_planet_visited(id: String, definition_id: String) -> void:
if id != "":
visited_planets[id] = true
if definition_id != "":
visited_planet_kinds[definition_id] = true
func quest_count() -> int:
return QUESTS.count()
func quest_chain_complete() -> bool:
return quest_index >= QUESTS.count()
# The quest the player is on, or {} once the chain is done.
func active_quest() -> Dictionary:
return QUESTS.at(quest_index)
# One quest by position in the chain, for anything listing what is behind the
# player (#140). Out of range answers {} rather than erroring.
func quest_at(index: int) -> Dictionary:
return QUESTS.at(index)
# How far along one quest is, as (have, need). One line per goal kind, and
# every one of them is answered from state this autoload already holds - a
# goal that needed a node to ask would be a goal that stops working on a
# planet surface.
func quest_progress(quest: Dictionary) -> Vector2i:
if quest.is_empty():
return Vector2i.ZERO
var goal: Dictionary = quest.get("goal", {})
match String(goal.get("kind", "")):
QUESTS.GOAL_RESOURCE:
var want: int = int(goal.get("amount", 1))
return Vector2i(mini(available_resource(String(goal.get("resource",
RESOURCE_ORE))), want), want)
QUESTS.GOAL_BUILDING:
return Vector2i(1 if structure_exists(String(goal.get("building", ""))) else 0, 1)
QUESTS.GOAL_FLEET_VESSEL:
var need: int = int(goal.get("count", 1))
return Vector2i(mini(vessel_count_in_fleet(String(goal.get("vessel", ""))),
need), need)
QUESTS.GOAL_VESSEL_UNLOCKED:
return Vector2i(1 if vessel_is_unlocked(String(goal.get("vessel", ""))) else 0, 1)
QUESTS.GOAL_RESEARCH:
return Vector2i(1 if is_unlocked(String(goal.get("node", ""))) else 0, 1)
QUESTS.GOAL_GARRISONED:
var stationed: int = int(goal.get("count", 1))
return Vector2i(mini(garrisoned_vessel_count(), stationed), stationed)
QUESTS.GOAL_BANKED:
var banked: int = int(goal.get("amount", 1))
return Vector2i(mini(banked_resource(String(goal.get("resource",
RESOURCE_ORE))), banked), banked)
QUESTS.GOAL_EXPLORE:
var sectors: int = int(goal.get("sectors", 1))
return Vector2i(mini(explored_chunks.size(), sectors), sectors)
QUESTS.GOAL_PLANET_VISITS:
var visits: int = int(goal.get("count", 1))
return Vector2i(mini(visited_planets.size(), visits), visits)
QUESTS.GOAL_PLANET_KINDS:
var kinds: int = int(goal.get("count", 1))
return Vector2i(mini(visited_planet_kinds.size(), kinds), kinds)
QUESTS.GOAL_CONQUERED:
var wanted: int = int(goal.get("count", 1))
var held: int = 0
for key in planet_capture:
if bool(planet_capture[key].get("conquered", false)):
held += 1
return Vector2i(mini(held, wanted), wanted)
# An unknown goal kind must not be quietly "already done" - that would
# run the whole chain off in one frame.
return Vector2i(0, 1)
func quest_goal_met(quest: Dictionary) -> bool:
if quest.is_empty():
return false
var progress: Vector2i = quest_progress(quest)
return progress.x >= progress.y
func say_quest_line(text: String) -> void:
if text.strip_edges() == "":
return
quest_messages.append({"line": text, "speaker": QUESTS.SPEAKER})
# Hands over every line waiting to be said, and empties the queue. Drained
# rather than read so two directors could never read the same line twice.
func take_quest_messages() -> Array:
var taken: Array = quest_messages.duplicate()
quest_messages.clear()
return taken
# Ends the current quest whether or not its goal was met. The chain's only
# way forward, used by _advance_quests below and by the tests.
#
# #167: `announce` is what a SKIPPED quest turns off. The index still moves
# and quest_completed still fires - the quest is credited exactly as if it
# had been played - but nothing is queued for the banner to read out.
func complete_active_quest(announce: bool = true) -> bool:
var quest: Dictionary = active_quest()
if quest.is_empty():
return false
if announce:
say_quest_line(String(quest.get("done", "")))
quest_index += 1
quest_announced = false
quest_completed.emit(String(quest.id))
return true
# #167: a quest handed out with its goal already met, credited in silence.
#
# Both signals still fire, in the order they would have fired in had it been
# played, so anything watching the chain sees an ordinary start-then-finish
# rather than a quest that was never begun. What it does NOT do is speak:
# neither the brief nor the answer reaches quest_messages, so the game is
# never stopped twice to be told about something already done.
func skip_active_quest() -> bool:
var quest: Dictionary = active_quest()
if quest.is_empty():
return false
quest_started.emit(String(quest.id))
return complete_active_quest(false)
func _advance_quests() -> void:
# The prologue is already talking, and the fleet in it is not the
# player's. The chain starts when the prologue hands over.
if not quests_armed or intro_active or quest_chain_complete():
return
var quest: Dictionary = active_quest()
if not quest_announced:
# #167: the chain hands out teaching quests, and by the time some of
# them come up the player has already done the thing - the extractor
# is down, the hangar is crewed. Announcing one of those stops the
# game to explain a task, then stops it again to congratulate them
# on a task they never did. So an unimportant quest whose goal is
# already met is banked without a word (see Quests.is_important);
# it still shows up as done in the objectives panel, which is where
# the player looks for what they have been credited with.
if quest_goal_met(quest) and not QUESTS.is_important(quest):
skip_active_quest()
# One a frame. Several already-met quests in a row are cleared
# over the next few frames rather than in a loop here, which
# keeps this function's shape - one step per call - and keeps a
# malformed chain from spinning inside a single tick.
return
quest_announced = true
say_quest_line(String(quest.get("brief", "")))
quest_started.emit(String(quest.id))
# Deliberately no completion check this frame: a quest whose goal
# happens to be met already still gets read out before it closes.
return
if quest_goal_met(quest):
complete_active_quest()
# --- paying for things ------------------------------------------------------
#
# Payments draw on the bank and may also draw on what the ships are carrying
# when the caller supplies the concrete place being paid. The player can move
# resources between the two by hand, so using nearby cargo avoids ceremony;
# #180 makes "nearby" part of the API instead of an assumption in the caller.
#
# 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, nearby hold second. A call with no position is deliberately
# bank-only: missing context must fail safe instead of teleporting cargo.
#
# 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.
func available_resource(resource_type: String) -> int:
return bank_inventory.amount(resource_type) + fleet_inventory.amount(resource_type)
# What can pay at one concrete place. Banked resources remain globally
# available; cargo in the fleet hold joins them only when the fleet is close
# enough to physically hand it to the building or construction site (#180).
# A null position deliberately means "no local payer", so a forgotten context
# can never reach into the hold from across the map.
func available_resource_at(resource_type: String, payment_position: Variant = null) -> int:
var amount: int = bank_inventory.amount(resource_type)
if fleet_can_pay_at(payment_position):
amount += fleet_inventory.amount(resource_type)
return amount
func available_ore() -> int:
return available_resource(RESOURCE_ORE)
func can_afford(cost: int, payment_position: Variant = null) -> bool:
return available_resource_at(RESOURCE_ORE, payment_position) >= cost
func can_afford_resources(costs: Dictionary, payment_position: Variant = null) -> bool:
for resource_type in costs:
if available_resource_at(String(resource_type), payment_position) \
< int(costs[resource_type]):
return false
return true
func spend(cost: int, payment_position: Variant = null) -> bool:
return spend_resources({RESOURCE_ORE: cost}, payment_position)
func spend_resources(costs: Dictionary, payment_position: Variant = null) -> 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, payment_position):
return false
var fleet_can_pay: bool = fleet_can_pay_at(payment_position)
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 and fleet_can_pay:
fleet_inventory.withdraw(id, owed)
return true