spaceship/tests/test_research_points.gd
Loïc Wisniewski 209169206b #108: the prologue - the convoy, the ambush, and the breach
Landed as one piece across #122, #123 and #124: the frame is useless without
a script, and the script has nowhere to run without the frame.

The director drives the REAL scene rather than being a scene of its own. It
wants the real fleet, the real enemies and the real asteroid field, and it
makes "then the game starts" cost nothing - the director stops steering,
clears the flag, and what is left running is the ordinary game.

The script is a list of beats, each with a deadline as well as its own
completion test, so a beat whose condition is never met - a player who
refuses to fly at the ambush - runs out rather than wedging the prologue.
skip() ends the whole thing at any point and lands in exactly the state
playing it through does, so the game has one way to start rather than two
that have to be kept in step. The test pins that equivalence.

"No action button available, only movements" is enforced where the buttons
are decided, not by the director reaching in: the ability controllers set
their own visibility every frame and would have put them straight back, and
the map button belongs to the map. The joystick, the pivot and the fader
stay.

The convoy is ten hulls where a new game's fleet is one, so it needs an
explicit fleet-size override - a plain number rather than a fake doctrine,
because nothing about the player's own progression should be bent to fit a
cutscene. It is carriers rather than transporters: transporters belong to
hubs and are not fleet-eligible, so a convoy of them would be silently
filtered down to its escorts.

The ambush is chasers and only chasers. "Easy enough" is the requirement,
and a sniper against a convoy the player cannot yet fight properly is
neither easy nor a good first impression.

The breach takes the wheel through one check in GameController, covering the
stick, the keyboard and the released-again path together - three separate
gates would be three chances for one of them to keep steering. The shake is
written to camera.offset, never to its position, because GameController owns
where the camera is and would smooth a shake away.

The vessel that survives is chosen as the interceptor rather than whichever
happened to be first in the list, and the prologue ends on exactly a new
game's opening position.

Also: PlanetAtmosphereController no longer spawns the opening fleet while
the prologue is arming, and the tests that are about what the GAME starts
from now say so by turning the prologue off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 20:59:12 +02:00

171 lines
9.1 KiB
GDScript

extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_research_points.gd
# Forgejo #63: a rare enemy planet type that yields no resources but grants
# one research point when conquered - a required currency for mid/late
# skill-tree nodes and vessel/lab research, gated alongside their existing
# ore/resource costs.
#
# Covers:
# A. research labs generate deterministically and are rare
# (structural) the definition yields no resources and its surface has no
# resource provider at all
# F. a fresh game can still reach a ship crafter and an extractor with
# zero research points
# D. a research-point-gated skill node is refused while unaffordable, and
# explains why
# E. conquering a research_lab awards what its MAP says it is worth, once.
# The reward used to be a flat 1 on the planet definition; it now comes
# entirely from the authored setup, so a hard lab can pay more than an
# easy one and the value is editable in the map editor. The planet type
# contributing nothing of its own is part of what is checked (guarded
# even against a redundant re-mark), emits the signal once, and a
# non-research_lab planet's conquest never awards one
# D continued: unlocking spends the points and the balance updates
# G. the ship-lab research path gates and spends research points the
# same way, independent of the skill tree
var game_state: Node
func _initialize() -> void:
game_state = root.get_node("GameState")
# --- A. deterministic and rare -------------------------------------------
var lab_count: int = 0
var total_planets: int = 0
for x in range(-40, 40):
for y in range(-40, 40):
var chunk: Vector2i = Vector2i(x, y)
if not WorldGen.has_planet_at_chunk(chunk):
continue
total_planets += 1
var first: Dictionary = WorldGen.planet_data_at_chunk(chunk)
var second: Dictionary = WorldGen.planet_data_at_chunk(chunk)
if first.definition_id != second.definition_id or first.local_pos != second.local_pos:
return _fail("planet_data_at_chunk(%s) is not deterministic" % chunk)
if first.definition_id == "research_lab":
lab_count += 1
print("scanned %d planet chunks: %d research labs" % [total_planets, lab_count])
if lab_count == 0:
return _fail("no research labs generated across a wide scan - the rare outcome never fires")
if lab_count * 3 >= total_planets:
return _fail("research labs (%d of %d) are not rare" % [lab_count, total_planets])
print("research labs are deterministic and rare (%d of %d planet chunks)" % [lab_count, total_planets])
# --- structural: yields nothing at all ------------------------------------
var lab_def: Resource = load("res://planets/PlanetDefinitions.gd").get_definition("research_lab")
if String(lab_def.resource_id) != "":
return _fail("research_lab planet's resource_id is '%s', expected empty" % lab_def.resource_id)
var profile: Resource = load("res://surface/PlanetSurfaceProfiles.gd").get_profile("research_lab")
if profile.features_scene != null:
return _fail("research_lab surface profile has a features_scene - it must yield nothing")
print("research_lab yields no resource id and no surface feature provider")
# --- F. a fresh game still reaches a crafter and an extractor -------------
game_state.reset_new_game()
# #108: reset_new_game() arms the prologue, and the prologue opens with a
# convoy rather than the game's own starting fleet. This test is about
# what the GAME starts from, which is where the prologue hands over -
# see test_intro_scene.gd for the prologue itself.
game_state.intro_active = false
# can_unlock() also checks the ore price, and a new game now banks
# nothing - the opening mining run is the point. Stock the ore so this
# checks what it says it checks: reachability at ZERO RESEARCH POINTS.
game_state.rocks = 500
if not game_state.can_unlock("shipyards"):
return _fail("shipyards is not reachable with 0 research points")
game_state.unlock("shipyards")
if not game_state.can_unlock("logistics"):
return _fail("logistics is not reachable with 0 research points after shipyards")
game_state.unlock("logistics")
print("fresh game reaches a ship crafter (shipyards) and an extractor (logistics) at 0 research points")
# --- D. a points-gated node is refused and explains why -------------------
if game_state.can_unlock("coordination"):
return _fail("coordination (costs research points) was unlockable with 0 research points")
print("a research-point-gated node (coordination) correctly refused at 0 points")
# --- E. earning exactly one point, once, with the signal ------------------
var totals_seen: Array = []
game_state.research_points_changed.connect(func(total): totals_seen.append(total))
game_state.planet_capture.clear()
# The planet type itself must no longer carry a reward - that is the
# delegation. If it crept back, a lab would pay its map's value PLUS a
# hidden one, or pay something even with no map authored.
var lab_definition: Resource = load("res://planets/PlanetDefinitions.gd").get_definition("research_lab")
if int(lab_definition.conquest_research_points) != 0:
return _fail("the research_lab planet type still grants %d point(s) of its own - the reward belongs to the authored map now" \
% int(lab_definition.conquest_research_points))
game_state.register_capture_points("test_lab_no_reward", 1, "research_lab")
game_state.mark_point_captured("test_lab_no_reward", 0)
if game_state.research_points != 0:
return _fail("a lab whose map states no reward still awarded %d" % game_state.research_points)
print("the planet type grants nothing by itself; an unpriced map awards nothing")
# What an authored map is worth is passed through as the override, exactly
# as PlanetSetupHost does from the setup's research_point_reward.
game_state.register_capture_points("test_lab_planet", 2, "research_lab", 1)
if game_state.is_planet_conquered("test_lab_planet"):
return _fail("a lab with 2 real capture points started conquered")
game_state.mark_point_captured("test_lab_planet", 0)
if game_state.research_points != 0:
return _fail("a research point was awarded before every point was captured")
game_state.mark_point_captured("test_lab_planet", 1)
if game_state.research_points != 1:
return _fail("conquering a research_lab did not award the 1 point its map is worth: %d" % game_state.research_points)
if totals_seen != [1]:
return _fail("research_points_changed did not fire exactly once with the new total: %s" % [totals_seen])
print("conquering a research_lab awarded exactly what its map is worth, once")
game_state.mark_point_captured("test_lab_planet", 0) # already captured - redundant re-mark
if game_state.research_points != 1:
return _fail("re-marking an already-captured point re-awarded a research point: %d" % game_state.research_points)
print("double-award guarded against")
game_state.register_capture_points("test_rocky_planet", 1, "rocky")
game_state.mark_point_captured("test_rocky_planet", 0)
if game_state.research_points != 1:
return _fail("conquering a non-research_lab planet awarded a research point: %d" % game_state.research_points)
print("a non-research_lab planet's conquest never awards a point")
# --- D continued: unlocking spends the points, balance updates ------------
if not game_state.can_unlock("coordination"):
return _fail("coordination is still not unlockable with 1 research point and enough ore")
if not game_state.unlock("coordination"):
return _fail("unlock(coordination) failed despite can_unlock() being true")
if game_state.research_points != 0:
return _fail("unlocking a 1-point node did not spend the point: %d remaining" % game_state.research_points)
print("unlocking a research-point-gated skill node spent the point, balance now 0")
# --- G. the ship-lab research path gates and spends the same currency -----
var carrier_def: Resource = game_state.vessel_definition(GameState.VESSEL_CARRIER)
var point_cost: int = int(carrier_def.research_point_cost)
if point_cost <= 0:
return _fail("carrier production data has no research-point cost")
game_state.vessel_levels.erase(carrier_def.unlock_id)
var lab_id: int = game_state.add_building(GameState.BUILDING_SHIP_LAB, Vector2.ZERO)
if not game_state.queue_lab_research(lab_id, carrier_def.unlock_id):
return _fail("could not queue carrier research")
game_state.deposit_resource_to_building(lab_id, GameState.RESOURCE_ORE, 1000)
game_state._advance_lab_research(100.0)
if game_state.vessel_is_unlocked(GameState.VESSEL_CARRIER):
return _fail("carrier research completed despite 0 research points")
print("lab research correctly stalled on a research-point shortfall")
game_state.research_points = point_cost
game_state._advance_lab_research(100.0)
if not game_state.vessel_is_unlocked(GameState.VESSEL_CARRIER):
return _fail("carrier research did not complete once research points were available")
if game_state.research_points != 0:
return _fail("lab research did not spend the research points: %d remaining" % game_state.research_points)
print("lab research spent the research points once affordable, and completed")
print("TEST_RESEARCH_POINTS: PASS")
quit(0)
func _fail(message: String) -> void:
print("TEST_RESEARCH_POINTS: FAIL (%s)" % message)
quit(1)