Repair a broken working tree, and make every crafter press report

The crafter buttons doing nothing was not the crafter: Ship.gd was left
mid-edit and failed to parse (undeclared SHIELD_COLOR/SHIELD_RADIUS, and
a spawn() that only exists on the concrete VFX subclass). A script that
does not compile takes Main.tscn's whole node tree with it, so every
button in the game was dead. The project directory is the one being
played from, so an unfinished edit there is a broken build - finish or
revert before stopping.

With that fixed, the real gap underneath: an order the crafter cannot
pay for is ACCEPTED and queued (#16), which is indistinguishable from a
dead button. Ordering now reports either "building now" or "queued - N
more ore must be delivered here", and the transient message is rendered
LAST so it is the first line read rather than buried under the standing
fleet-capacity note.

test_crafter_feedback covers all three presses and asserts the message
survives the per-frame refresh, which is the specific way this looked
broken before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-11 22:50:09 +02:00
commit 004808f067
6 changed files with 413 additions and 14 deletions

View file

@ -564,17 +564,22 @@ func _refresh_craft_status() -> void:
craft_interceptor_button.queue_redraw()
# One statement of the fleet count, either way round: the capacity line
# replaces the plain readout rather than being bolted in front of it.
if room:
craft_status.text += " Fleet %d/%d" % [fleet.ships.size(), GameState.max_fleet_size()]
else:
var queued_ships: int = GameState.pending_orders_of(craft_building_id, GameState.PRODUCT_SHIP)
var owed: int = GameState.ships_owed + queued_ships
var pending_note: String = "" if owed == 0 else " (%d already on order)" % owed
craft_status.text = "No room in the fleet: %d/%d%s - research capacity at the mothership\n%s" % [
fleet.ships.size(), GameState.max_fleet_size(), pending_note, craft_status.text
]
# LAST, so a message about what just happened is the first line the
# player reads rather than being buried under the standing state.
if craft_message != "":
if Time.get_ticks_msec() < craft_message_until:
craft_status.text = "%s\n%s" % [craft_message, craft_status.text]
else:
craft_message = ""
if room:
craft_status.text += " Fleet %d/%d" % [fleet.ships.size(), GameState.max_fleet_size()]
else:
craft_status.text = "Fleet full %d/%d - research capacity at the mothership\n%s" % [
fleet.ships.size(), GameState.max_fleet_size(), craft_status.text
]
func _on_craft_interceptor() -> void:
@ -588,9 +593,11 @@ func _on_craft_interceptor() -> void:
_say("Fleet is full (%d slots)" % GameState.max_fleet_size())
return
# No affordability check: an order the crafter cannot pay for yet is
# QUEUED, and completes by itself once ore is delivered here (#16).
# QUEUED, and completes by itself once ore is delivered here (#16). But
# queueing silently looks identical to a dead button, so it says which
# happened and what the crafter is still short of.
GameState.queue_order(craft_building_id)
_refresh_craft_status()
_report_order(GameState.PRODUCT_SHIP)
func _refresh_load_ore() -> void:
@ -604,6 +611,16 @@ func _on_load_ore() -> void:
_refresh_load_ore()
func _report_order(product: String) -> void:
var short: int = GameState.outstanding_cost(craft_building_id)
if short > 0:
_say("%s queued - %d more ore must be delivered here" % [
GameState.VESSEL_NAMES.get(product, product), short
])
else:
_say("%s ordered - building now" % GameState.VESSEL_NAMES.get(product, product))
func _on_store_ore() -> void:
if craft_building_id == 0:
return

View file

@ -252,6 +252,7 @@ func max_fleet_size() -> int:
func reset_progression() -> void:
unlocked.clear()
vessel_levels.clear()
# --- building stock and production queue (#16) -----------------------------
@ -294,6 +295,176 @@ func find_building(id: int) -> Dictionary:
return {}
# --- vessel types and their upgrades (#5) ---------------------------------
# The three things that can be flying around. The first and last double as
# production order ids, so a vessel type and the order that builds it are the
# same string rather than two that have to be kept in step.
const VESSEL_INTERCEPTOR: String = "interceptor"
const VESSEL_SHIELD: String = "shield"
const VESSEL_TRANSPORTER: String = "transporter"
const VESSEL_NAMES: Dictionary = {
VESSEL_INTERCEPTOR: "Interceptor",
VESSEL_SHIELD: "Shield Vessel",
VESSEL_TRANSPORTER: "Transporter",
}
const SHIELD_COST: int = 14
# Researched at the ship lab, per vessel type. Separate from SKILL_TREE on
# purpose: that gates what the player may build at all and is a strategic
# spine, while these are repeatable tuning on a vessel you already have.
# `cost` is the FIRST level; each level after costs cost * (level + 1), so
# the third point in something is not the same price as the first.
const VESSEL_UPGRADES: Array = [
{
"id": "int_damage", "vessel": VESSEL_INTERCEPTOR, "name": "Heavy Slugs",
"cost": 30, "max_level": 3, "desc": "+1 damage per shot.",
},
{
"id": "int_rate", "vessel": VESSEL_INTERCEPTOR, "name": "Autoloader",
"cost": 25, "max_level": 3, "desc": "Fires 15% faster per level.",
},
{
"id": "int_range", "vessel": VESSEL_INTERCEPTOR, "name": "Targeting Array",
"cost": 20, "max_level": 3, "desc": "+60 firing range per level.",
},
{
"id": "int_speed", "vessel": VESSEL_INTERCEPTOR, "name": "Thrusters",
"cost": 25, "max_level": 3, "desc": "+40 top speed, +60 acceleration per level.",
},
{
"id": "shd_hits", "vessel": VESSEL_SHIELD, "name": "Layered Emitters",
"cost": 60, "max_level": 2, "desc": "+1 hit absorbed before the shield drops.",
},
{
"id": "shd_recharge", "vessel": VESSEL_SHIELD, "name": "Fast Cycling",
"cost": 35, "max_level": 3, "desc": "Shield returns 1.2s sooner per level.",
},
{
"id": "shd_speed", "vessel": VESSEL_SHIELD, "name": "Thrusters",
"cost": 25, "max_level": 3, "desc": "+40 top speed, +60 acceleration per level.",
},
{
"id": "trn_capacity", "vessel": VESSEL_TRANSPORTER, "name": "Expanded Hold",
"cost": 30, "max_level": 3, "desc": "Carries 8 more ore per supply run.",
},
{
"id": "trn_speed", "vessel": VESSEL_TRANSPORTER, "name": "Freight Engines",
"cost": 30, "max_level": 3, "desc": "+70 speed on supply runs.",
},
]
# Base figures every vessel starts from, before anything is researched.
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
const TRANSPORTER_BASE_LOAD: int = 20
const TRANSPORTER_BASE_SPEED: float = 340.0
var vessel_levels: Dictionary = {}
signal vessel_upgraded(upgrade_id: String)
func upgrade_def(id: String) -> Dictionary:
for u in VESSEL_UPGRADES:
if u.id == id:
return u
return {}
func upgrades_for(vessel: String) -> Array:
var out: Array = []
for u in VESSEL_UPGRADES:
if u.vessel == vessel:
out.append(u)
return out
func upgrade_level(id: String) -> int:
return int(vessel_levels.get(id, 0))
func upgrade_maxed(id: String) -> bool:
var d: Dictionary = upgrade_def(id)
return d.is_empty() or upgrade_level(id) >= int(d.max_level)
func upgrade_cost(id: String) -> int:
var d: Dictionary = upgrade_def(id)
if d.is_empty():
return 0
return int(d.cost) * (upgrade_level(id) + 1)
func can_buy_upgrade(id: String) -> bool:
return not upgrade_maxed(id) and can_afford(upgrade_cost(id))
func buy_upgrade(id: String) -> bool:
if not can_buy_upgrade(id):
return false
if not spend(upgrade_cost(id)):
return false
vessel_levels[id] = upgrade_level(id) + 1
vessel_upgraded.emit(id)
return true
# --- what those upgrades actually come to ---------------------------------
# Read live rather than baked into a vessel when it is built, so researching
# something improves the ships already flying instead of only the next ones.
func ship_damage(vessel: String) -> int:
return SHIP_BASE_DAMAGE + (upgrade_level("int_damage") if vessel == VESSEL_INTERCEPTOR else 0)
func ship_fire_cooldown(vessel: String) -> float:
if vessel != VESSEL_INTERCEPTOR:
return SHIP_BASE_COOLDOWN
return SHIP_BASE_COOLDOWN * pow(0.85, float(upgrade_level("int_rate")))
func ship_fire_range(vessel: String) -> float:
return SHIP_BASE_RANGE + (60.0 * upgrade_level("int_range") if vessel == VESSEL_INTERCEPTOR else 0.0)
func _speed_levels(vessel: String) -> int:
if vessel == VESSEL_SHIELD:
return upgrade_level("shd_speed")
return upgrade_level("int_speed")
func ship_max_speed(vessel: String) -> float:
return SHIP_BASE_SPEED + 40.0 * _speed_levels(vessel)
func ship_acceleration(vessel: String) -> float:
return SHIP_BASE_ACCEL + 60.0 * _speed_levels(vessel)
func shield_max_charges() -> int:
return SHIELD_BASE_CHARGES + upgrade_level("shd_hits")
func shield_recharge_time() -> float:
return maxf(2.0, SHIELD_BASE_RECHARGE - 1.2 * upgrade_level("shd_recharge"))
func transporter_load_limit() -> int:
return TRANSPORTER_BASE_LOAD + 8 * upgrade_level("trn_capacity")
func transporter_speed() -> float:
return TRANSPORTER_BASE_SPEED + 70.0 * upgrade_level("trn_speed")
const PRODUCT_SHIP: String = "interceptor"
const PRODUCT_TRANSPORTER: String = "transporter"
@ -303,6 +474,8 @@ const TRANSPORTER_COST: int = 10
func product_cost(product: String) -> int:
if product == PRODUCT_TRANSPORTER:
return TRANSPORTER_COST
if product == VESSEL_SHIELD:
return SHIELD_COST
return SHIP_COST
@ -704,7 +877,7 @@ func _advance_transporters(delta: float) -> void:
if t.state == TRANSPORTER_IDLE:
continue
var distance: float = (t.from as Vector2).distance_to(t.to as Vector2)
var leg: float = maxf(distance / TRANSPORTER_SPEED, 0.0001)
var leg: float = maxf(distance / transporter_speed(), 0.0001)
t.progress += delta / leg
if t.progress < 1.0:
continue
@ -719,7 +892,7 @@ func _arrive(t: Dictionary) -> void:
TRANSPORTER_TO_MOTHERSHIP:
var target: Dictionary = find_building(int(t.target_id))
var want: int = outstanding_cost(int(t.target_id))
var take: int = mini(want, rocks)
var take: int = mini(mini(want, rocks), 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

94
Ship.gd
View file

@ -17,6 +17,21 @@ signal destroyed
@export var idle_rescan_interval: float = 0.25
@export var projectile_damage: int = 1
# Which kind of vessel this is (GameState.VESSEL_*). Drives both its
# researched statistics and whether it carries a shield (#5).
@export var vessel_type: String = "interceptor"
# --- shield (#5) ----------------------------------------------------------
const SHIELD_COLOR: Color = Color(0.45, 0.8, 1.0)
const SHIELD_RADIUS: float = 30.0
# A shield vessel survives a hit that would destroy any other ship, and is
# then defenceless until it cycles back up. Personal, not a bubble: it
# protects the vessel carrying it and nothing else.
var shield_charges: int = 0
var shield_recharge_left: float = 0.0
var _shield_ring: Node2D = null
# Neon colours, assigned by Fleet on spawn so a scene can tint its fleet.
# The lunar surface is a bright grey, where the default near-white core all
# but vanishes - PlanetSurface.tscn passes a dark core there. See NeonDraw.
@ -46,6 +61,10 @@ func _ready() -> void:
move_target = position
add_to_group("player_ships")
area_entered.connect(_on_area_entered)
if vessel_type == GameState.VESSEL_SHIELD:
_build_shield_ring()
shield_charges = GameState.shield_max_charges()
apply_vessel_stats()
queue_redraw()
@ -53,7 +72,49 @@ func set_move_target(world_pos: Vector2) -> void:
move_target = world_pos
func apply_vessel_stats() -> void:
# Read from GameState rather than baked in at spawn, so researching an
# upgrade improves the ships already flying and not just the next one
# built. Fleet calls this on every vessel when something is bought.
max_speed = GameState.ship_max_speed(vessel_type)
acceleration = GameState.ship_acceleration(vessel_type)
fire_range = GameState.ship_fire_range(vessel_type)
fire_cooldown = GameState.ship_fire_cooldown(vessel_type)
projectile_damage = GameState.ship_damage(vessel_type)
if vessel_type == GameState.VESSEL_SHIELD:
# Topping up a live shield with researched capacity, without
# refilling one that is currently down and cycling.
var was_up: bool = shield_charges > 0
shield_charges = GameState.shield_max_charges() if was_up else shield_charges
_update_shield_visual()
func has_shield() -> bool:
return vessel_type == GameState.VESSEL_SHIELD and shield_charges > 0
func _update_shield_visual() -> void:
if _shield_ring == null:
return
# visible only - per #10 the ring is rasterised once and never redrawn to
# turn it on and off.
_shield_ring.visible = has_shield()
func _tick_shield(delta: float) -> void:
if vessel_type != GameState.VESSEL_SHIELD:
return
if shield_charges >= GameState.shield_max_charges():
return
shield_recharge_left -= delta
if shield_recharge_left <= 0.0:
shield_charges += 1
shield_recharge_left = GameState.shield_recharge_time()
_update_shield_visual()
func _physics_process(delta: float) -> void:
_tick_shield(delta)
var to_target: Vector2 = move_target - position
var distance: float = to_target.length()
@ -122,10 +183,35 @@ func _find_nearest_enemy() -> Node2D:
func _on_area_entered(area: Area2D) -> void:
if area.is_in_group("enemies") or area.is_in_group("asteroids"):
ShipExplosionVFX.spawn(get_tree().root, position)
destroyed.emit()
queue_free()
if not (area.is_in_group("enemies") or area.is_in_group("asteroids")):
return
if has_shield():
# Absorbed. The thing that hit it is still destroyed - a shield stops
# the vessel dying, it does not make the rock harmless - but the
# vessel survives and is defenceless until the shield cycles back.
shield_charges -= 1
shield_recharge_left = GameState.shield_recharge_time()
_update_shield_visual()
if area.is_in_group("asteroids") and is_instance_valid(area):
area.queue_free()
return
ShipExplosionVFX.spawn(get_tree().root, position)
destroyed.emit()
queue_free()
func _build_shield_ring() -> void:
# Its own child, drawn once, toggled with `visible` (#10). A shield that
# came and went by redrawing the ship would re-tessellate the hull every
# time it cycled.
_shield_ring = NeonPiece.create(self, Vector2.ZERO, SHIELD_COLOR)
var points: PackedVector2Array = PackedVector2Array()
for i in range(14):
var a: float = (float(i) / 14.0) * TAU
points.append(Vector2(cos(a), sin(a)) * SHIELD_RADIUS)
_shield_ring.add_polygon(points, 0.7)
_shield_ring.queue_redraw()
_shield_ring.visible = false
func _draw() -> void:

View file

@ -0,0 +1 @@
uid://b88pj64m6kpjn

View file

@ -0,0 +1,121 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_crafter_feedback.gd
# Every press in the ship crafter must say what happened. Reported twice from
# play-testing as "the buttons do nothing".
#
# There is no silent-failure path left to press: storing with an empty hold,
# ordering something the crafter cannot pay for yet, and storing successfully
# all report. The queue in particular is invisible by nature - an order that
# cannot be afforded is accepted and waits (#16), which looks exactly like a
# dead button unless it says so.
#
# Verifies:
# 1. storing with an empty hold explains where ore comes from
# 2. ordering against an empty stock says it is queued AND how much more
# ore the building needs
# 3. a successful store reports the amount
# 4. the message survives the per-frame status refresh - it used to be
# overwritten on the very next frame, which is what made a working
# button look dead
# 5. the message is the FIRST line, not buried under the standing state
var game_state: Node
var scene: Node
var controller: Node
var crafter_id: int = 0
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.transporters.clear()
game_state.reset_progression()
game_state.unlocked = ["shipyards", "coordination", "wing"]
game_state.rocks = 100
game_state.fleet_cargo = 0
game_state.ships_owed = 0
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
controller = scene.get_node("BuildController")
func _first_line() -> String:
return controller.craft_status.text.split("\n")[0]
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
scene.get_node("BuildingField").place(GameState.BUILDING_SHIP_CRAFTER, Vector2(500.0, 0.0))
crafter_id = int(game_state.buildings[-1].id)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.3:
return false
controller.craft_building_id = crafter_id
controller.craft_menu.visible = true
controller._refresh_craft_status()
# --- 1. store with an empty hold ----------------------------
controller._on_store_ore()
var empty_msg: String = _first_line()
if "carrying nothing" not in empty_msg:
print("got: %s" % empty_msg)
print("TEST_CRAFTER_FEEDBACK: FAIL (storing with an empty hold said nothing useful)")
quit(1)
return true
print("empty hold -> %s" % empty_msg)
# --- 2. order against an empty stock ------------------------
controller._on_craft_interceptor()
var queued_msg: String = _first_line()
if "queued" not in queued_msg.to_lower() or "more ore" not in queued_msg:
print("got: %s" % queued_msg)
print("TEST_CRAFTER_FEEDBACK: FAIL (a queued order did not say it was queued and what it still needs)")
quit(1)
return true
if game_state.pending_orders(crafter_id) != 1:
print("TEST_CRAFTER_FEEDBACK: FAIL (the order was not actually queued)")
quit(1)
return true
print("unaffordable order -> %s" % queued_msg)
# --- 3. a successful store ----------------------------------
game_state.withdraw_to_fleet(30)
controller._on_store_ore()
var stored_msg: String = _first_line()
if "Stored" not in stored_msg:
print("got: %s" % stored_msg)
print("TEST_CRAFTER_FEEDBACK: FAIL (a successful store did not report)")
quit(1)
return true
print("successful store -> %s" % stored_msg)
elapsed = 0.0
stage = 2
return false
2:
# --- 4 + 5. it survives the refresh, and stays first --------
if elapsed < 0.5:
return false
controller._refresh_craft_status()
var still: String = _first_line()
if "Stored" not in still:
print("first line half a second later: %s" % still)
print("TEST_CRAFTER_FEEDBACK: FAIL (the message was overwritten by the per-frame refresh)")
quit(1)
return true
print("half a second later, still first: %s" % still)
print("TEST_CRAFTER_FEEDBACK: PASS")
quit(0)
return true
return false

View file

@ -0,0 +1 @@
uid://gr4k23bre8b6