spaceship/tests/test_spacial_workshop.gd
Loïc Wisniewski ec7a8f1f17 Cover the last batch of tickets with tests
The previous ten tickets shipped with almost no coverage - only #109, #113,
#115 and #117 touched a test. That was a gap, not a decision: everything
before it had its behaviour pinned, and #116 in particular added a whole
subsystem nothing was watching.

Four new files and one extended case:

  test_spacial_workshop        #116 - the repair economy end to end: the
                               cost of a repair scales with the damage, the
                               workshop asks logistics for that ore and
                               stops at its buffer, a drone spends when it
                               LEAVES so two cannot double-charge one wall,
                               and a target levelled in flight sends its
                               load home rather than swallowing it
  test_desktop_controls        #118/#121 - both driven through the real
                               routing, because the routing is what was
                               missing; a test calling handle_mouse_button()
                               itself would have passed against both reports
  test_enemy_bomber            #114 - it flies, it faces where it flies, its
                               bombs arrive, and it bombs structures with no
                               fleet in sight
  test_menu_polish             #111/#112/#119/#120 - what each panel shows
  test_wave_system (case G)    #110 - the dev button, and that it does not
                               exist outside dev mode

One assertion worth recording: the craft caption is checked as an EXACT
match against the vessel's display name rather than by hunting for resource
words in it. The first version failed on "Gas Bomber" - a vessel named after
a resource, and always will be. What #111 removed was the price spelled out
underneath, not the name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 12:10:31 +02:00

202 lines
9.2 KiB
GDScript

extends SceneTree
# Headless test: run with
# godot --headless -s res://tests/test_spacial_workshop.gd
# Forgejo #116: the Spacial Workshop and its repair drones.
#
# Modelled on the transport hub, so most of what it does is already
# understood: drones belong to a workshop, are provisioned when it is built,
# are never a production order, and are upgraded at a ship lab. What is new
# is what a repair COSTS - a share of the building's own price, in
# proportion to the health it is missing.
#
# Covers:
# A. the building is in the catalog behind its own research, with its own
# look, and it provisions drones the moment it is placed
# B. the drone is a catalogued vessel that CANNOT be crafted, and carries
# its own upgrades
# C. a full repair costs a share of the building's price scaled by the
# damage: undamaged is free, half-wrecked is about half
# D. the workshop asks hub logistics for the ore its repairs will need,
# and stops asking past its buffer
# E. a drone flies out, spends ore, and puts structure back
# F. it will not go with an empty till, and two drones never chase the
# same wall
# G. a building demolished mid-flight gets its ore back rather than
# swallowing it
# H. the berth upgrade reaches workshops already standing
var game_state: Node
var workshop_id: int = 0
var crafter_id: int = 0
func _initialize() -> void:
# GameState is an autoload: a bare GameState.<instance member> does not
# compile in a SceneTree script, so the node is fetched by name instead.
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.transporters.clear()
game_state.repair_drones.clear()
game_state.reset_progression()
game_state.reset_waves()
func _fail(message: String) -> bool:
print("TEST_SPACIAL_WORKSHOP: FAIL (%s)" % message)
quit(1)
return true
func _drones_of(id: int) -> Array:
var out: Array = []
for drone in game_state.repair_drones:
if int(drone.get("workshop_id", 0)) == id:
out.append(drone)
return out
# Runs the autoload's own repair clock without waiting out the real seconds.
func _simulate(seconds: float) -> void:
var step: float = 1.0 / 60.0
for i in range(int(seconds / step)):
game_state._advance_repairs(step)
func _process(_delta: float) -> bool:
var definitions: Script = load("res://buildings/BuildingDefinitions.gd")
# --- A. catalogued, researched, and it brings its own drones ---------
var workshop_def: Resource = definitions.get_definition(GameState.BUILDING_WORKSHOP)
if workshop_def == null:
return _fail("no \"spacial_workshop\" definition in the catalog")
if workshop_def.visual_scene == null or workshop_def.menu_button_name == "":
return _fail("the workshop has no visual or no build-menu entry")
for other in definitions.all():
if other.definition_id != GameState.BUILDING_WORKSHOP \
and other.menu_icon == workshop_def.menu_icon:
return _fail("the workshop shares its icon with the %s" % other.definition_id)
if game_state.is_building_unlocked(GameState.BUILDING_WORKSHOP):
return _fail("the workshop is buildable before its node is researched")
var research: String = ""
for node in GameState.SKILL_TREE:
if node.get("unlocks", "") == GameState.BUILDING_WORKSHOP:
research = String(node.id)
if research == "":
return _fail("nothing in the skill tree unlocks the workshop")
game_state.unlocked.append(research)
game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO)
workshop_id = game_state.add_building(GameState.BUILDING_WORKSHOP, Vector2(600.0, 0.0))
if _drones_of(workshop_id).size() != GameState.BASE_WORKSHOP_DRONES:
return _fail("a new workshop stationed %d drones, expected %d" \
% [_drones_of(workshop_id).size(), GameState.BASE_WORKSHOP_DRONES])
print("workshop researched at \"%s\", arrives with %d drones" \
% [research, GameState.BASE_WORKSHOP_DRONES])
# --- B. the drone is a vessel, but not a product ---------------------
var drone_def: Resource = game_state.vessel_definition(GameState.REPAIR_DRONE)
if drone_def == null:
return _fail("no repair_drone in the vessel catalog")
if bool(drone_def.fleet_eligible):
return _fail("the repair drone is fleet-eligible - it would be craftable")
if drone_def.upgrades.is_empty():
return _fail("the repair drone carries no ship-lab upgrades")
if game_state.queue_order(workshop_id, GameState.REPAIR_DRONE):
return _fail("a repair drone was accepted as a production order")
print("repair drone: %d lab upgrades, not craftable" % drone_def.upgrades.size())
# --- C. what a repair costs -----------------------------------------
crafter_id = game_state.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(900.0, 0.0))
var price: int = int(definitions.get_definition(GameState.BUILDING_SHIP_CRAFTER).cost)
if game_state.repair_cost(crafter_id) != 0:
return _fail("an undamaged crafter costs %d to repair" % game_state.repair_cost(crafter_id))
var full: int = game_state.building_max_hp(GameState.BUILDING_SHIP_CRAFTER)
game_state.damage_building(crafter_id, full / 2)
var half_cost: int = game_state.repair_cost(crafter_id)
if absf(float(half_cost) - float(price) * 0.5) > float(price) * 0.12:
return _fail("a half-wrecked crafter costs %d of its %d price, expected about half" \
% [half_cost, price])
print("crafter at %d%% health: %d ore of its %d price" \
% [int(game_state.building_health_fraction(crafter_id) * 100.0), half_cost, price])
# --- D. and it asks for that ore ------------------------------------
var asked: int = game_state.building_requested_resource(workshop_id, GameState.RESOURCE_ORE)
if asked <= 0:
return _fail("a workshop with a wrecked crafter beside it asks for no ore")
if asked > GameState.WORKSHOP_ORE_BUFFER:
return _fail("it asked for %d ore, past its %d buffer" \
% [asked, GameState.WORKSHOP_ORE_BUFFER])
print("workshop asks hub logistics for %d ore" % asked)
# --- F (first half). an empty till sends nobody ----------------------
_simulate(4.0)
for drone in _drones_of(workshop_id):
if String(drone.state) != GameState.DRONE_IDLE:
return _fail("a drone left with no ore in the workshop")
if game_state.building_health_fraction(crafter_id) > 0.51:
return _fail("the crafter was repaired out of an empty workshop")
print("empty till: every drone stayed home")
# --- E. paid for, it flies and mends --------------------------------
game_state.deposit_resource_to_building(workshop_id, GameState.RESOURCE_ORE, 60)
var ore_before: int = game_state.building_resource(workshop_id, GameState.RESOURCE_ORE)
var hp_before: int = game_state.building_hp(crafter_id)
game_state._advance_repairs(0.016)
var flying: int = 0
for drone in _drones_of(workshop_id):
if String(drone.state) == GameState.DRONE_OUTBOUND:
flying += 1
if flying != 1:
return _fail("%d drones set off for one damaged crafter" % flying)
# --- F (second half). the ore leaves with the drone ------------------
if game_state.building_resource(workshop_id, GameState.RESOURCE_ORE) >= ore_before:
return _fail("a drone left without the ore being taken")
_simulate(30.0)
var hp_after: int = game_state.building_hp(crafter_id)
if hp_after <= hp_before:
return _fail("the drone arrived and the crafter is still at %d hp" % hp_after)
print("drone spent %d ore and put %d structure back" \
% [ore_before - game_state.building_resource(workshop_id, GameState.RESOURCE_ORE),
hp_after - hp_before])
# --- G. a destination that stops existing ---------------------------
game_state.building_inventory(workshop_id).set_amount(GameState.RESOURCE_ORE, 60)
var doomed: int = game_state.add_building(GameState.BUILDING_SHIP_LAB, Vector2(-900.0, 0.0))
game_state.damage_building(doomed, game_state.building_max_hp(GameState.BUILDING_SHIP_LAB) / 2)
game_state._advance_repairs(0.016)
var carrying: int = 0
for drone in game_state.repair_drones:
if String(drone.state) == GameState.DRONE_OUTBOUND and int(drone.target_id) == doomed:
carrying = int(drone.carrying)
if carrying <= 0:
return _fail("nothing was dispatched to the damaged lab")
var till_before: int = game_state.building_resource(workshop_id, GameState.RESOURCE_ORE)
game_state.destroy_building(doomed)
_simulate(30.0)
if game_state.building_resource(workshop_id, GameState.RESOURCE_ORE) != till_before + carrying:
return _fail("a drone whose target was levelled under it lost its %d ore" % carrying)
print("target levelled in flight: %d ore came home" % carrying)
# --- H. the upgrade reaches standing workshops ----------------------
var bonus_id: String = ""
for node in GameState.SKILL_TREE:
if int(node.get("workshop_bonus", 0)) > 0:
bonus_id = String(node.id)
if bonus_id == "":
return _fail("no skill node raises the drone count")
var before_count: int = _drones_of(workshop_id).size()
game_state.rocks = 5000
game_state.research_points = 20
if not game_state.unlock(bonus_id):
return _fail("could not research \"%s\"" % bonus_id)
if _drones_of(workshop_id).size() != before_count + 1:
return _fail("a workshop built before the upgrade still has %d drones" \
% _drones_of(workshop_id).size())
print("\"%s\" raises an already-standing workshop from %d to %d drones" \
% [bonus_id, before_count, _drones_of(workshop_id).size()])
print("TEST_SPACIAL_WORKSHOP: PASS")
quit(0)
return true