#106/#107 (#98a/#98b): the Planning Center
Landed together: a board of standing orders nothing acts on is a notepad,
and a planner with nowhere to read the plan from has nothing to do.
The targets are GLOBAL, not per building. There is one plan for the whole
game, and a second planning centre is another place to read and edit it
rather than a competing one - per-building targets would let two centres
hold contradictory orders and tell the crafters to satisfy both.
The census is the harder half. Every vessel is counted exactly once wherever
it happens to be: 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. The
fleet is the one place GameState cannot see, so Fleet pushes its composition
on every change, alongside the cargo capacity and distorder count that
already went that way.
Three rules keep the planner from fighting the player. It tops up and never
rewrites, so an order placed by hand stays exactly where it is. Everything
counts, including what is already queued, or it re-queues the same shortfall
every tick and buries the player in hulls. And nothing happens without a
planning centre - though the plan is remembered when one is destroyed, so
rebuilding picks up where it left off.
Vessels on order are counted separately from the census on purpose: they do
not exist yet, so the readout can say "4 of 6, 2 building" rather than
claiming six. The per-row note says where the vessels actually are, because
a plan can look unmet while everything it asked for is sitting at an
outpost.
The build menu goes to three columns past eight entries. #54 already
tightened its rows to 223px to fit a fourth, explicitly to avoid a
scrollable menu, and a ninth structure needs a fifth row that will not fit
at any spacing a button, a label and a cost can share. Three columns take
nine into three rows - fewer than it uses today, not more.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:21:52 +02:00
|
|
|
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))
|
2026-09-05 23:13:23 +02:00
|
|
|
|
|
|
|
|
# --- #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")
|
#106/#107 (#98a/#98b): the Planning Center
Landed together: a board of standing orders nothing acts on is a notepad,
and a planner with nowhere to read the plan from has nothing to do.
The targets are GLOBAL, not per building. There is one plan for the whole
game, and a second planning centre is another place to read and edit it
rather than a competing one - per-building targets would let two centres
hold contradictory orders and tell the crafters to satisfy both.
The census is the harder half. Every vessel is counted exactly once wherever
it happens to be: 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. The
fleet is the one place GameState cannot see, so Fleet pushes its composition
on every change, alongside the cargo capacity and distorder count that
already went that way.
Three rules keep the planner from fighting the player. It tops up and never
rewrites, so an order placed by hand stays exactly where it is. Everything
counts, including what is already queued, or it re-queues the same shortfall
every tick and buries the player in hulls. And nothing happens without a
planning centre - though the plan is remembered when one is destroyed, so
rebuilding picks up where it left off.
Vessels on order are counted separately from the census on purpose: they do
not exist yet, so the readout can say "4 of 6, 2 building" rather than
claiming six. The per-row note says where the vessels actually are, because
a plan can look unmet while everything it asked for is sitting at an
outpost.
The build menu goes to three columns past eight entries. #54 already
tightened its rows to 223px to fit a fourth, explicitly to avoid a
scrollable menu, and a ninth structure needs a fifth row that will not fit
at any spacing a button, a label and a cost can share. Three columns take
nine into three rows - fewer than it uses today, not more.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:21:52 +02:00
|
|
|
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
|