spaceship/tests/test_planning_center.gd
Loïc Wisniewski f77596395b Plan every craftable vessel, and stop restating the point cost
#221: the planning centre listed only fleet-eligible hulls, so the two
vessels that most need replacing were the two it could not plan - #216 made
transporters and repair drones wear out, which is what a standing order is
for. It offers everything a crafter can build now.

That needed the census to see them: it counted the fleet, the hangar,
garrisons and transit, but not vessels standing at a hub or a workshop. An
order for a transporter would never have seen the ones already built, and
the crafters would have gone on making more forever.

#222: the ship lab's detail block restated the selected upgrade's research
point cost, which its own card shows a few pixels higher. Removed, with the
parameter and the call-site lookup that fed it.

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

265 lines
11 KiB
GDScript

extends SceneTree
# Headless test: run with
# godot --headless -s res://tests/test_planning_center.gd
# Forgejo #106 and #107 (#98a, #98b): the Planning Center.
#
# "let the player tell the system how many ships of a give type should
# always be available over the whole game (fleet + storage + outposts) ...
# the planning center automatically manage the ship craft building queues
# to ensure that the needed vessels are produced to fullfill the
# constraints."
#
# Covers:
# A. the census counts a vessel exactly once wherever it is - flying,
# stored, stationed at an outpost, or in the air between any two
# B. a shortfall is queued at a crafter, and queued vessels count, so it
# does not re-order the same shortfall every tick
# C. it tops up rather than rewriting: an order placed by hand survives,
# and the planner appends beside it
# D. it stops when the target is met, and starts again when one is lost
# E. nothing happens without a planning centre, but the plan is remembered
# across losing and rebuilding one
# F. the panel drives the real targets through the real buttons
var game_state: Node
var scene: Node
var controller: Node
var menu: Control
var panel: Control
var crafter_id: int = 0
var outpost_id: int = 0
var stage: int = 0
var elapsed: float = 0.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.stored_vessels.clear()
game_state.vessel_deliveries.clear()
game_state.vessel_targets.clear()
game_state.fleet_vessel_types = PackedStringArray()
game_state.reset_progression()
game_state.reset_waves()
game_state.unlocked.append_array(["shipyards", "coordination", "forward_bases",
"vessel_science", "logistics_command"])
func _fail(message: String) -> bool:
print("TEST_PLANNING_CENTER: FAIL (%s)" % message)
quit(1)
return true
func _queued_at_crafter() -> int:
return game_state.pending_orders_of(crafter_id, GameState.VESSEL_INTERCEPTOR)
func _state_cases() -> bool:
game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO)
crafter_id = game_state.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(400.0, 0.0))
outpost_id = game_state.add_building(GameState.BUILDING_OUTPOST, Vector2(-900.0, 0.0))
var interceptor: String = GameState.VESSEL_INTERCEPTOR
# --- A. one vessel, one count, wherever it is ------------------------
if game_state.vessel_census(interceptor) != 0:
return _fail("an empty game counts %d vessels" % game_state.vessel_census(interceptor))
# --- #221: support craft are plannable too --------------------------
# #216 made transporters and repair drones ordinary craft that wear out
# and need replacing, which is exactly what a standing order is for -
# and they were the one thing the planning centre could not plan.
var plannable: PackedStringArray = game_state.planned_vessel_types()
for support in [GameState.VESSEL_TRANSPORTER, GameState.REPAIR_DRONE]:
if not plannable.has(support):
return _fail("'%s' cannot be planned, so a standing order cannot replace one" % support)
# And the census has to SEE them where they live, or an order for one
# could never be satisfied and the crafters would build forever.
var hub_id: int = game_state.add_building(GameState.BUILDING_TRANSPORT_HUB,
Vector2(1200.0, 0.0))
load("res://tests/_staff.gd").staff(game_state, hub_id, 1)
if game_state.vessel_census(GameState.VESSEL_TRANSPORTER) != 1:
return _fail("a transporter standing at a hub is counted %d times, expected once" \
% game_state.vessel_census(GameState.VESSEL_TRANSPORTER))
game_state.remove_buildings_of_type(GameState.BUILDING_TRANSPORT_HUB)
print("support craft are plannable, and counted where they stand")
game_state.fleet_vessel_types = PackedStringArray([interceptor, interceptor])
game_state.stored_vessels.append(interceptor)
game_state.set_garrison_desired(outpost_id, interceptor, 1)
game_state.garrison_crew(outpost_id).append(interceptor)
game_state.vessel_deliveries.append({"type": interceptor, "from": Vector2.ZERO,
"to": Vector2(400.0, 0.0), "progress": 0.0, "to_outpost": 0})
if game_state.vessel_count_in_fleet(interceptor) != 2:
return _fail("the fleet reads %d" % game_state.vessel_count_in_fleet(interceptor))
if game_state.vessel_count_in_hangar(interceptor) != 1:
return _fail("the hangar reads %d" % game_state.vessel_count_in_hangar(interceptor))
if game_state.vessel_count_in_garrisons(interceptor) != 1:
return _fail("the garrisons read %d" % game_state.vessel_count_in_garrisons(interceptor))
if game_state.vessel_count_in_transit(interceptor) != 1:
return _fail("in transit reads %d" % game_state.vessel_count_in_transit(interceptor))
if game_state.vessel_census(interceptor) != 5:
return _fail("2 flying + 1 stored + 1 stationed + 1 moving counted as %d" \
% game_state.vessel_census(interceptor))
print("census: 2 flying, 1 stored, 1 stationed, 1 moving = %d" \
% game_state.vessel_census(interceptor))
# --- E (first half). no centre, no action ---------------------------
game_state.set_vessel_target(interceptor, 8)
for i in range(20):
game_state._advance_planning()
if _queued_at_crafter() != 0:
return _fail("orders were placed with no planning centre built")
print("with no planning centre, a standing order does nothing")
game_state.add_building(GameState.BUILDING_PLANNING_CENTER, Vector2(900.0, 0.0))
if not game_state.has_planning_center():
return _fail("the planning centre was built but is not recognised")
# --- B. the shortfall is queued, once ------------------------------
if game_state.vessel_shortfall(interceptor) != 3:
return _fail("a target of 8 against 5 reads a shortfall of %d" \
% game_state.vessel_shortfall(interceptor))
for i in range(20):
game_state._advance_planning()
if _queued_at_crafter() != 3:
return _fail("20 passes at a shortfall of 3 queued %d orders" % _queued_at_crafter())
if game_state.vessel_shortfall(interceptor) != 0:
return _fail("the shortfall is still %d with 3 on the slipway" \
% game_state.vessel_shortfall(interceptor))
print("a shortfall of 3 queued exactly 3, and 20 more passes added none")
# --- C. hand-placed orders are left alone ---------------------------
game_state.queue_order(crafter_id, GameState.VESSEL_SHIELD)
var by_hand: int = game_state.pending_orders_of(crafter_id, GameState.VESSEL_SHIELD)
game_state.set_vessel_target(interceptor, 10)
for i in range(20):
game_state._advance_planning()
if game_state.pending_orders_of(crafter_id, GameState.VESSEL_SHIELD) != by_hand:
return _fail("the planner cancelled a shield the player ordered by hand")
if _queued_at_crafter() != 5:
return _fail("raising the target to 10 left %d interceptors queued" % _queued_at_crafter())
print("a hand-placed shield survives; the planner appended beside it")
# --- D. it stops, and it reacts to a loss ---------------------------
game_state.set_vessel_target(interceptor, 5)
var queued_before: int = _queued_at_crafter()
for i in range(20):
game_state._advance_planning()
if _queued_at_crafter() != queued_before:
return _fail("lowering the target changed the queue by %d" \
% (_queued_at_crafter() - queued_before))
# Losing one from the fleet is a real shortfall again.
game_state.set_vessel_target(interceptor, 12)
var before_loss: int = _queued_at_crafter()
for i in range(20):
game_state._advance_planning()
var after_top_up: int = _queued_at_crafter()
game_state.fleet_vessel_types = PackedStringArray([interceptor])
for i in range(20):
game_state._advance_planning()
if _queued_at_crafter() <= after_top_up:
return _fail("losing a vessel did not put a replacement on the slipway")
print("target met -> nothing queued; a vessel lost -> %d queued (was %d)" \
% [_queued_at_crafter() - after_top_up, before_loss])
# --- E (second half). the plan outlives the building ----------------
for b in game_state.buildings:
if game_state.building_has_capability(b, "planning"):
game_state.destroy_building(int(b.id))
break
if game_state.has_planning_center():
return _fail("the planning centre survived being destroyed")
if game_state.vessel_target(interceptor) != 12:
return _fail("losing the building erased the plan")
var frozen: int = _queued_at_crafter()
game_state.fleet_vessel_types = PackedStringArray()
for i in range(20):
game_state._advance_planning()
if _queued_at_crafter() != frozen:
return _fail("the planner kept working after its building was destroyed")
print("destroyed: the plan is remembered, and nothing acts on it")
return false
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if _state_cases():
return true
game_state.buildings.clear()
game_state.vessel_targets.clear()
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
controller = scene.get_node("BuildController")
menu = scene.get_node("UI/PlanningMenu")
panel = scene.get_node("UI/PlanningMenu/Panel")
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.3:
return false
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
scene.get_node("BuildingField").place(GameState.BUILDING_PLANNING_CENTER,
Vector2(900.0, 0.0))
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.2:
return false
# --- F. the real panel, through the real buttons -------------
var node: Node2D = null
for b in scene.get_node("BuildingField").spawned:
if is_instance_valid(b) and b.building_type == GameState.BUILDING_PLANNING_CENTER:
node = b
if node == null:
return _fail("no planning centre node was placed")
if node.primary_action() != "planning":
return _fail("its primary action is \"%s\"" % node.primary_action())
controller.current_building = node
controller._on_action_pressed()
if not menu.visible:
return _fail("the planning centre did not open its menu")
var row: Dictionary = {}
for candidate in panel._rows:
if String(candidate.type) == GameState.VESSEL_INTERCEPTOR:
row = candidate
if row.is_empty():
return _fail("the panel has no row for the interceptor")
row.plus.pressed.emit()
row.plus.pressed.emit()
row.plus.pressed.emit()
if game_state.vessel_target(GameState.VESSEL_INTERCEPTOR) != 3:
return _fail("three presses of + set a target of %d" \
% game_state.vessel_target(GameState.VESSEL_INTERCEPTOR))
row.minus.pressed.emit()
if game_state.vessel_target(GameState.VESSEL_INTERCEPTOR) != 2:
return _fail("- did not lower the target")
if not row.count.text.ends_with("/2"):
return _fail("the row reads \"%s\" - it should show have over want" % row.count.text)
# The note is what makes an unmet plan legible: it says where the
# vessels actually are.
if row.note.text.strip_edges() == "" and game_state.vessel_census(
GameState.VESSEL_INTERCEPTOR) > 0:
return _fail("vessels exist but the row says nothing about where they are")
print("the panel drives the real plan: + + + - leaves \"%s\" (%s)" \
% [row.count.text, row.note.text])
var status: String = scene.get_node("UI/PlanningMenu/StatusLabel").text
if status.strip_edges() == "":
return _fail("the planning menu reports nothing at all")
print("status: \"%s\"" % status)
print("TEST_PLANNING_CENTER: PASS")
quit(0)
return true
return false