spaceship/core/GameState.gd
Claude 840f9aa9a0 #224: refuse to build a structure on top of another
BuildingField.place() dropped a structure at the fleet centroid with no
overlap check of any kind, so they could be stacked.

It reads worse than it looks. BuildingField decides which structure the
fleet is standing on by testing the centroid against interact_radius and
returning the FIRST match, so two structures inside one another make that a
question of list order rather than of where the player flew - you could fly
to your workshop and open the crafter.

The separation is interact_radius itself (150) rather than a new number,
because that is exactly the circle the ambiguity comes from. Buildings draw
to a radius of roughly 60, so this leaves a visible gap rather than letting
them touch.

Gated in three places: the info popup (the button greys out and says which
structure is in the way), _confirm_build, and _try_place - the preview goes
stale as the fleet keeps moving.

Ordering matters in one case. A unique structure replaces itself by
demolishing the old one BEFORE placing the new one, so a placement refused
afterwards would leave the player with neither. The check runs before the
demolition and excludes the structure being replaced. Case D pins it.

Two existing tests encoded the old permissive behaviour and now fly clear
first; neither is about placement:

  test_build_info_popup builds every menu option from one position, so
  each structure blocked the next
  test_payments_use_fleet_hold builds a mothership on the ship lab that
  an earlier case injects at the origin

Also fixes a bug from 37e2e89: Sfx.warm() called add_child() from _ready(),
which Godot refuses outright - it printed an error and never pre-built the
pool, defeating the point of warming it. Deferred, like Music.ensure().

173/173.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-09 12:57:28 +02:00

4218 lines
164 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.08 supply logistics -> freight -> freight_yards
# 0.25 industry power_systems -> field_engineering -> drone_bays
# 0.42 spine shipyards -> coordination -> deep_space_scanning
# 0.58 fleet berth_authority
# 0.75 ground forward_bases -> garrison_bays
# 0.92 vessels vessel_science -> logistics_command
const SKILL_TREE: Array = [
{
"id": "shipyards", "name": "Shipyards", "cost": 15, "requires": "",
"x": 0.42, "unlocks": BUILDING_SHIP_CRAFTER,
"desc": "Build ship crafters, and buy extra vessels at them.",
},
{
# Fleet size is a researched thing again, but only the STRUCTURE is:
# the office still has to be built and supplied, which is what #219
# moved it to. Ore only, and no research points: points come solely
# from conquering a planet, and conquering one needs a fleet, so
# pricing this in them would lock the fleet at its opening hull
# behind a fight that hull has to win first.
"id": "berth_authority", "name": "Berth Authority", "cost": 20,
"requires": "shipyards",
"x": 0.58, "unlocks": BUILDING_FLEET_OFFICE,
"desc": "Build Fleet Offices. Each holds one more berth in the fleet, and has to be kept supplied or the vessel in it is lost.",
},
{
"id": "logistics", "name": "Logistics", "cost": 25, "requires": "shipyards",
"x": 0.08, "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.42, "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.75, "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.75, "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.92,
"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.25,
"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.25,
"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.08, "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.92, "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.42,
"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.08, "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.25, "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
# #220: and each berth costs more than the one before it. A flat per-vessel
# charge means a big fleet is only linearly harder to keep, so the tenth hull
# is no more of a decision than the second; compounding makes every addition
# weigh against the whole fleet behind it.
#
# Deliberately shallow - the ticket asks for a slow one. At 1.12 the thirteenth
# berth costs about three and a half times the second, and the twentieth about
# seven and a half. A steeper rate reaches "unaffordable" while the player is
# still learning what the fleet is for.
const FLEET_UPKEEP_GROWTH: float = 1.12
# #223: how many charges an office is kept stocked for. Enough that the tax
# is always payable while the bank has anything - a transporter round trip is
# well inside four charge intervals - and no more, so an office does not sit
# on resources the rest of the base needs. Below this it can starve with ore
# in the bank; far above it, offices become a hoard.
const FLEET_OFFICE_STOCK_CHARGES: int = 4
# 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.
# What one berth costs per charge. `slot_index` is the fleet position it
# keeps, so the first berth past the free one pays the base rate and every
# berth after it pays the compounded one (#220).
func fleet_office_upkeep(vessel_type: String, slot_index: int = BASE_FLEET_SIZE) -> Dictionary:
var upkeep: Dictionary = {}
var scale: float = fleet_upkeep_scale(slot_index)
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) * scale))
if share > 0:
upkeep[resource_type] = share
return upkeep
# The multiplier on a berth at this fleet position. The free hull and the
# first paid berth both sit at 1.0, so the curve starts where the charging
# starts rather than partway up it.
func fleet_upkeep_scale(slot_index: int) -> float:
return pow(FLEET_UPKEEP_GROWTH, maxi(slot_index - BASE_FLEET_SIZE, 0))
# The whole bill, berth by berth, for the readout #220 asks for: what each
# one is keeping, what it is charged, and what that is per minute. Computed
# here rather than in the menu so the numbers shown are the numbers charged -
# a readout that recomputes them separately can disagree with the loop that
# actually takes the resources.
func fleet_upkeep_breakdown() -> Dictionary:
var berths: Array = []
var per_minute: Dictionary = {}
var charges_per_minute: float = 60.0 / FLEET_OFFICE_UPKEEP_SECONDS
var offices: Array = fleet_offices()
for i in range(offices.size()):
var slot_index: int = i + BASE_FLEET_SIZE
var vessel_type: String = fleet_office_vessel(slot_index)
if vessel_type == "":
continue # a berth standing empty is charged nothing
var costs: Dictionary = fleet_office_upkeep(vessel_type, slot_index)
berths.append({
"slot": slot_index + 1,
"vessel_type": vessel_type,
"scale": fleet_upkeep_scale(slot_index),
"costs": costs,
"starved": bool(offices[i].get("starved", false)),
})
for resource_type in costs:
per_minute[resource_type] = float(per_minute.get(resource_type, 0.0)) \
+ float(costs[resource_type]) * charges_per_minute
return {
"berths": berths,
"per_minute": per_minute,
"charge_seconds": FLEET_OFFICE_UPKEEP_SECONDS,
"divisor": FLEET_OFFICE_UPKEEP_DIVISOR,
"growth": FLEET_UPKEEP_GROWTH,
}
# 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.
# What this office should hold of one resource: enough charges of whatever is
# in its berth to keep paying while a transporter is on its way, and nothing
# it will never spend. An office keeping an interceptor asks for no gas at
# all, where a flat buffer had it hauling gas it could not use.
#
# An empty berth asks for nothing beyond the small floor its definition
# declares - which is what keeps a newly crewed berth payable before its
# first delivery arrives.
func fleet_office_stock_target(id: int, resource_type: String) -> int:
var offices: Array = fleet_offices()
for i in range(offices.size()):
if int(offices[i].get("id", 0)) != id:
continue
var slot_index: int = i + BASE_FLEET_SIZE
var vessel_type: String = fleet_office_vessel(slot_index)
if vessel_type == "":
return 0
var upkeep: Dictionary = fleet_office_upkeep(vessel_type, slot_index)
return int(upkeep.get(resource_type, 0)) * FLEET_OFFICE_STOCK_CHARGES
return 0
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,
i + BASE_FLEET_SIZE)
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()
# The chain has to have SENT the player after it. The tentacle world is a
# destination the story hands over, not something to find while scanning for
# ore - so the scanner carries its signature without offering it until the
# quest that names it is live.
#
# The four survey signatures are still required. They are implied by the
# chain reaching that quest, but the rule is about what the SCANNER has
# learned, and it should read that way whatever order the two arrive in.
func scanner_can_find_boss() -> bool:
if not quest_reached("tentacles"):
return false
for definition_id in SCANNER_SURVEY_TYPES:
if not visited_planet_kinds.has(definition_id):
return false
return true
# Has the chain arrived at this quest - handed it out, or gone past it?
func quest_reached(quest_id: String) -> bool:
var at: int = QUESTS.index_of(quest_id)
return at >= 0 and quest_index >= at
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
# What a ship crafter can actually be asked for right now. #216 made support
# craft orderable at all; this is the narrower question of whether THIS one
# can be ordered today.
#
# A support vessel is only worth building once there is somewhere to put it:
# a transporter with no hub and a repair drone with no workshop have nowhere
# to be assigned, so they would sit in the hangar as spent resources. The
# structure that uses one is therefore part of its availability, the same way
# research is for a fleet hull.
func vessel_can_be_crafted(vessel: String) -> bool:
var definition: Resource = vessel_definition(vessel)
if definition == null or not vessel_is_unlocked(vessel):
return false
if not (definition.fleet_eligible or definition.support_craftable):
return false
match vessel:
VESSEL_TRANSPORTER: return hub_count() > 0
REPAIR_DRONE: return has_workshop()
return true
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
if not vessel_can_be_crafted(product):
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:
# Plannable means CRAFTABLE, not fleet-eligible. #221 listed the support
# craft in the planning centre and left this guard reading the old rule,
# so those two rows appeared with buttons that silently did nothing -
# the worst shape a refusal can take.
if not planned_vessel_types().has(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)
# #221: everything a crafter can be asked for, not just the fleet hulls.
# #216 made transporters and repair drones ordinary craft that wear out and
# have to be replaced, which is precisely the kind of vessel a standing order
# is for - they were the one thing the planning centre could not plan.
func planned_vessel_types() -> PackedStringArray:
var out: PackedStringArray = PackedStringArray()
for definition in VESSEL_DEFINITIONS.craftable():
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_at_stations(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
# #221: transporters standing at hubs and drones at workshops. Without these
# a standing order for one could never be satisfied - the census would never
# see the vessels it had already built, and the crafters would keep making
# more forever.
func vessel_count_at_stations(vessel_type: String) -> int:
var n: int = 0
if vessel_type == VESSEL_TRANSPORTER:
n += transporters.size()
elif vessel_type == REPAIR_DRONE:
n += repair_drones.size()
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)
# Not merely unlocked: orderable. A standing order for a transporter
# with no hub standing would be re-offered to a crafter every pass
# and refused every time.
if not vessel_can_be_crafted(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
# Space structures may not be stacked (#224). The distance is Building's own
# interact_radius, rather than a new number: that is the circle BuildingField
# tests the fleet centroid against to decide which structure you are standing
# on, and it returns the FIRST match - so two structures inside one another
# make that a question of list order rather than of where you flew.
const MIN_BUILDING_SEPARATION: float = 150.0
# The structure a new one at this position would sit on top of, or an empty
# dictionary when the spot is clear. ignore_type skips a type that is about
# to be demolished, which is how the unique structures replace themselves -
# without it a mothership could never be rebuilt where the old one stood.
func building_blocking(at_position: Vector2, ignore_type: String = "") -> Dictionary:
for building in buildings:
if ignore_type != "" and String(building.get("type", "")) == ignore_type:
continue
var at: Vector2 = building.get("position", Vector2.ZERO)
if at.distance_to(at_position) < MIN_BUILDING_SEPARATION:
return building
return {}
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))
# #223: a Fleet Office asks for what the vessel in ITS berth is charged,
# which no definition can state - the berth's occupant decides it, and
# since #220 so does how far down the fleet that berth sits.
target = maxi(target, fleet_office_stock_target(id, resource_type))
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)),
# Which kind of world this was. A quest can then ask for a research
# lab specifically - "any planet" and "the one worth research data"
# are different lessons - and the record is the only place that
# survives leaving the surface.
"definition_id": definition_id if definition_id != "" \
else String(existing.get("definition_id", "")),
}
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_VESSEL_BUILT:
# Counted ANYWHERE, not just in the fleet: a transporter never
# joins one, and the step being taught here is "you built it".
var built: int = int(goal.get("count", 1))
return Vector2i(mini(vessel_census(String(goal.get("vessel", ""))), built), built)
QUESTS.GOAL_BUILDING_STOCK:
# The fullest one of its kind, not the total: the lesson is that
# a SINGLE office has to be stocked, and summing across three
# half-empty ones would credit it without teaching it.
var stocked: int = int(goal.get("amount", 1))
var resource_type: String = String(goal.get("resource", RESOURCE_ORE))
var wanted_type: String = String(goal.get("building", ""))
var fullest: int = 0
for building in buildings:
if String(building.get("type", "")) == wanted_type:
fullest = maxi(fullest, building_resource(int(building.id), resource_type))
return Vector2i(mini(fullest, stocked), stocked)
QUESTS.GOAL_STATION_CREW:
var crewed: int = int(goal.get("count", 1))
var capability: String = String(goal.get("capability", "transport_hub"))
var standing: int = 0
for building in buildings:
if building_has_capability(building, capability):
standing += station_crew(int(building.id))
return Vector2i(mini(standing, crewed), crewed)
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_WORLD_VISITED:
var kind: String = String(goal.get("definition", ""))
return Vector2i(1 if visited_planet_kinds.has(kind) else 0, 1)
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))
# An optional kind: "take a planet" and "take a research lab" are
# different asks, and the second is how a player learns where
# research data comes from in quantity.
var of_kind: String = String(goal.get("definition", ""))
var held: int = 0
for key in planet_capture:
var record: Dictionary = planet_capture[key]
if not bool(record.get("conquered", false)):
continue
if of_kind != "" and String(record.get("definition_id", "")) != of_kind:
continue
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