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
|
|
|
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
|
#138: structures and doctrines cost more than rocks, and more the deeper they are
Both halves of the ticket, in the shape vessels have used since gas bombers
and laser ships: a definition carries an ore figure and, when it wants more
than ore, a dictionary that IS the price. `building_costs()` and
`skill_costs()` answer it the way `product_costs()` already did, so there is
one question with one answer whatever is being bought, and every payment
path now goes through `spend_resources` rather than `spend`.
Seven of ten structures and thirteen of seventeen doctrines now want gas,
energy or dark matter alongside their rocks, and the totals climb with tree
depth - structures 16/31/58/62/96 by depth, doctrines 15/30/73/96/143/258/530.
The constraint that shaped the table is REACHABILITY, and it is the reason
some prices stayed ore-only. Energy exists only where a gas burner stands,
and a gas burner needs Power Systems - so nothing at or before that depth
may be priced in energy, or it is a wall with no way through. Dark matter is
rarer still and appears once, on the deepest node in the game. The extractor
stays rocks-only despite its depth allowing gas, because the quest chain
reaches it before it has sent the player for any.
test_progressive_costs checks those as properties rather than transcribing
the numbers, so rebalancing a price costs nothing while breaking the shape
fails: nothing unreachable at its own depth, prices rising with depth, the
ore field agreeing with the dictionary (repairs quote the field alone, since
a repair drone carries ore and nothing else), and the quest chain never
asking for a structure it has not funded.
Two things cleaned up on the way:
- The per-building cost constants in GameState are gone. Nothing but tests
used them, and they were a second copy of a number the definitions already
hold - exactly what would drift once a price gained a resource.
- research_for_building() returns a display NAME, and the id-keyed half of
the tree wants an id. That mix-up is silent - skill_depth() of a name finds
no node and answers 0, so a deep structure reads as an ungated one, which
is how the first draft of the test let an unreachable price through.
research_node_for_building() is the id, and the name is expressed from it.
PriceTag no longer rebuilds its labels when re-told the same price; it
refreshes the held figures instead. The craft menu re-states prices every
frame and the build menu now does too, so that was a dozen Labels being torn
down and rebuilt sixty times a second.
2026-08-28 15:30:30 +02:00
|
|
|
# #138: the deep doctrines want gas and energy too.
|
|
|
|
|
game_state.bank_inventory.set_amount(GameState.RESOURCE_GAS, 5000)
|
|
|
|
|
game_state.bank_inventory.set_amount(GameState.RESOURCE_ENERGY, 5000)
|
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
|
|
|
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
|