spaceship/tests/test_quest_chain.gd
Loïc Wisniewski 4c920f10b0 #157, #158, #159: pay from the hold you flew in, and nothing is exempt from coming down
#157 was already satisfied - second_hull is quest 7 and extractor is quest
11 - so this pins the property rather than adding a quest: nothing costing
more than a single hold may be asked for before the chain has grown the
fleet or taught banking. It fails if either is ever moved behind it.

#158 - a building's local costs may now be paid out of the fleet's hold.
#12 made production cost resources physically DELIVERED rather than money
out of the bank, and that stands: the bank is still not raided here. The
hold is simply the other place resources physically are, and making the
player park, open the transfer menu and move cargo across by hand was
ceremony rather than a decision.

But only WHERE THE FLEET IS, and that qualifier is the whole design. The
first draft had no range on it and test_manual_supply_route caught it
immediately: a crafter three thousand units away reached into the hold and
spent ore the player had just withdrawn to carry somewhere else - which is
exactly the complaint that got automatic banking removed in #24, arriving
from the other direction. The range is Building.interact_radius, the same
distance that decides whether the transfer button appears, so the rule is
not a hidden one: if you could hand the cargo over, the building can take
it.

#159 - the mothership floored at 1hp, so a battered one sat there reading as
dead and never exploded. That exemption existed to prevent a soft-lock, and
the soft-lock turns out not to exist: the bank is GameState.bank_inventory
and the hangar is stored_vessels, both of which outlive any building; the
mothership is the one structure research never gates; it costs exactly what
a single interceptor's hold carries; and ore comes from asteroids, which do
not run out. So the way back is always mine, fly, rebuild - and that is what
test_building_health now asserts, in place of the exemption that used to
stand in for it.
2026-08-29 15:51:34 +02:00

522 lines
23 KiB
GDScript

extends SceneTree
# Headless test: run with
# godot --headless -s res://tests/test_quest_chain.gd
# Forgejo #129 (#131, #132, #133): the quest chain.
#
# The chain is a campaign, so most of what can go wrong with it is not a
# crash - it is a quest that cannot be finished, one that finishes itself,
# or one that asks for something the player has no way to reach yet. Those
# are what is checked here, against the real catalogue rather than a fixture:
# a chain that passes with test data and strands the player is exactly the
# failure this is for.
#
# Covers:
# A. the model: a fresh game is on the first quest, and every entry in the
# catalogue is well formed and reachable
# B. no quest counts as done before it is done, and every goal kind is
# answerable
# C. progress LATCHES - collecting and then spending does not un-complete
# D. a quest is announced before it can complete, so an already-satisfied
# goal still gets read out
# E. the whole chain can be walked, and the ore quest really is finished by
# collecting ore
# F. #133 - the mothership quest's answer is that the World Ship is out of
# reach, and exploration is what follows
# G. research points only come from conquest, so no quest may ask for a
# doctrine that costs them before the conquest quest
# H. quests do not run during the prologue
# P. #142 - enough rocks are asked for before the first structure is
# Q. #141 - the first structure that needs a doctrine has that doctrine as
# a quest of its own, so research is taught rather than assumed
# R. #145 - every structure quest is actually finishable: building the
# thing it asks for, the way the game builds it, completes it
# S. #157 - nothing costing more than a single hold is asked for before
# the chain has grown the fleet or taught banking
# I. lines survive having nobody to say them: a quest finished with no
# banner alive is read out by the next one that is
# J. a planet surface carries the chain too - a quest finished down there
# is announced down there, not on the way back up, and the objective is
# reachable from a button there as well
# K. landing is recorded, both the planet and the kind of world it was
const QUESTS = preload("res://core/Quests.gd")
var game_state: Node
var stage: int = 0
var elapsed: float = 0.0
var spoke: bool = false
var scene: Node
var panel: Control
var quest_button: Control
var narration: Control
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.reset_new_game()
game_state.intro_active = false
func _fail(message: String) -> bool:
print("TEST_QUEST_CHAIN: FAIL (%s)" % message)
quit(1)
return true
# Which skill-tree nodes cost research points, which only conquest supplies.
func _costs_research_points(id: String) -> bool:
return int(game_state.skill_node(id).get("research_points", 0)) > 0
func _catalogue_cases() -> bool:
# --- A. the catalogue is well formed ---------------------------------
if QUESTS.count() < 8:
return _fail("a chain of %d is not a campaign" % QUESTS.count())
var seen: Dictionary = {}
for i in range(QUESTS.count()):
var quest: Dictionary = QUESTS.at(i)
for field in ["id", "title", "objective", "goal", "brief", "done"]:
if not quest.has(field):
return _fail("quest %d has no %s" % [i, field])
if field != "goal" and String(quest[field]).strip_edges() == "":
return _fail("quest %s has an empty %s" % [quest.id, field])
if seen.has(quest.id):
return _fail("two quests share the id %s" % quest.id)
seen[quest.id] = true
# Every goal must be one this build can actually answer. An unknown
# kind reads as never-done and would strand the player there.
var progress: Vector2i = game_state.quest_progress(quest)
if progress.y <= 0:
return _fail("quest %s has a goal of zero, so it is already done" % quest.id)
# --- B. and nothing is complete on a fresh game ------------------
if game_state.quest_goal_met(quest):
return _fail("quest %s counts as done on a brand new game" % quest.id)
# The things a goal names must exist.
var goal: Dictionary = quest.goal
match String(goal.kind):
QUESTS.GOAL_BUILDING:
if game_state.building_definition(String(goal.building)) == null:
return _fail("quest %s asks for a building that does not exist: %s" \
% [quest.id, goal.building])
QUESTS.GOAL_FLEET_VESSEL, QUESTS.GOAL_VESSEL_UNLOCKED:
if game_state.vessel_definition(String(goal.vessel)) == null:
return _fail("quest %s asks for a vessel that does not exist: %s" \
% [quest.id, goal.vessel])
QUESTS.GOAL_RESOURCE:
if not (String(goal.resource) in game_state.resource_types()):
return _fail("quest %s asks for a resource that does not exist: %s" \
% [quest.id, goal.resource])
QUESTS.GOAL_RESEARCH:
if game_state.skill_node(String(goal.node)).is_empty():
return _fail("quest %s asks to research a doctrine that does not exist: %s" \
% [quest.id, goal.node])
print("catalogue: %d quests, all well formed and none already done" % QUESTS.count())
# --- F. the hinge ----------------------------------------------------
var mothership: Dictionary = QUESTS.by_id("mothership")
if mothership.is_empty():
return _fail("there is no mothership quest - #129 turns on it")
var answer: String = String(mothership.done).to_lower()
if not ("world ship" in answer):
return _fail("the mothership quest's answer never mentions the World Ship")
if not ("further" in answer or "far" in answer or "carry" in answer):
return _fail("the mothership quest does not say the World Ship is out of reach")
# And what follows it is a search.
var after: Dictionary = QUESTS.at(QUESTS.CHAIN.find(mothership) + 1)
if String(after.goal.kind) != QUESTS.GOAL_EXPLORE:
return _fail("the quest after the mothership is %s, not exploration" % after.id)
print("the mothership answers with distance, and exploration follows it")
# --- R. #145: every structure quest can actually be finished ---------
# The extractor quest could not be. Extractors are placed onto a planet
# from space and live in their own array, not in `buildings`, so the
# question the goal asked ("is there a building of this type") was one
# that could never be answered yes however many were built.
#
# Checked by BUILDING each one the way the game builds it and watching
# the goal flip, rather than by knowing which array it lands in - the
# knowing is what got this wrong in the first place.
for i in range(QUESTS.count()):
var build_quest: Dictionary = QUESTS.at(i)
if String(build_quest.goal.kind) != QUESTS.GOAL_BUILDING:
continue
var type: String = String(build_quest.goal.building)
game_state.buildings.clear()
game_state.extractors.clear()
if game_state.quest_goal_met(build_quest):
return _fail("quest %s counts as done with nothing built at all" % build_quest.id)
if type == GameState.BUILDING_EXTRACTOR:
game_state.add_extractor("Quest Test", Vector2.ZERO, GameState.RESOURCE_ORE,
"quest:costs")
else:
game_state.add_building(type, Vector2.ZERO)
if not game_state.quest_goal_met(build_quest):
return _fail("building a %s does not finish quest %s - it cannot be completed at all" \
% [type, build_quest.id])
game_state.buildings.clear()
game_state.extractors.clear()
print("every structure quest is finished by building the thing it asks for")
# --- P. #142: enough rocks before anything is asked to be built ------
# The first structure the chain asks for used to arrive straight after a
# five-ore errand, costing four times that, with nothing in between
# saying so.
var first_build: int = -1
for i in range(QUESTS.count()):
if String(QUESTS.at(i).goal.kind) == QUESTS.GOAL_BUILDING:
first_build = i
break
if first_build <= 0:
return _fail("the chain opens on a build quest, with nothing gathered first")
var build_type: String = String(QUESTS.at(first_build).goal.building)
var build_price: int = int(game_state.building_costs(build_type).get(GameState.RESOURCE_ORE, 0))
var gathered: int = 0
for i in range(first_build):
var goal_before: Dictionary = QUESTS.at(i).goal
if String(goal_before.kind) == QUESTS.GOAL_RESOURCE \
and String(goal_before.resource) == GameState.RESOURCE_ORE:
gathered = maxi(gathered, int(goal_before.amount))
if gathered < build_price:
return _fail("the chain asks for a %s costing %d ore, having only ever asked for %d" \
% [build_type, build_price, gathered])
# ...and the pile has to FIT. Before the first structure exists there is
# nowhere to bank anything, so the fleet's own hold is the only store
# there is - and a pile larger than it would be a quest that cannot be
# finished at all. It currently fits exactly, with no slack, so this is
# the assertion that says so out loud rather than leaving it to be
# discovered by a player.
var starting: PackedStringArray = PackedStringArray()
for i in range(GameState.STARTING_SHIP_COUNT):
starting.append(GameState.VESSEL_INTERCEPTOR)
var hold: int = game_state.fleet_cargo_capacity(starting)
if gathered > hold:
return _fail("the chain asks for %d ore before anything can bank it, and the starting fleet holds %d" \
% [gathered, hold])
print("%d ore is asked for before the %s that costs %d, and the opening hold carries %d" \
% [gathered, build_type, build_price, hold])
# --- S. #157: a second hull before anything one hull cannot pay for --
# A structure costing more than a single hold cannot be bought in one
# trip, so the chain has to have grown the fleet - or taught banking -
# before it asks for one. Both are in front of the extractor today; this
# fails if either is ever moved behind it.
var one_hold: int = game_state.fleet_cargo_capacity(
PackedStringArray([GameState.VESSEL_INTERCEPTOR]))
var grew_fleet: int = -1
var taught_banking: int = -1
for i in range(QUESTS.count()):
var goal_s: Dictionary = QUESTS.at(i).goal
if String(goal_s.kind) == QUESTS.GOAL_FLEET_VESSEL and int(goal_s.count) > 1:
grew_fleet = i if grew_fleet < 0 else grew_fleet
if String(goal_s.kind) == QUESTS.GOAL_BANKED:
taught_banking = i if taught_banking < 0 else taught_banking
if String(goal_s.kind) != QUESTS.GOAL_BUILDING:
continue
var price: int = int(game_state.building_costs(String(goal_s.building)).get(
GameState.RESOURCE_ORE, 0))
if price <= one_hold:
continue
if grew_fleet < 0 and taught_banking < 0:
return _fail("quest %s wants a %s at %d ore, more than the %d one hull carries, and the chain has neither grown the fleet nor taught banking yet" \
% [QUESTS.at(i).id, goal_s.building, price, one_hold])
if grew_fleet < 0:
return _fail("no quest ever asks for a second hull")
print("a second hull at %d and banking at %d, both before anything costing more than one %d-unit hold" \
% [grew_fleet, taught_banking, one_hold])
# --- Q. #141: research is taught before it is assumed ----------------
# The first structure in the chain that needs a doctrine must have that
# doctrine as a quest of its own first. Only the FIRST one: once the
# player has been into the tree, a mention in a brief is enough, and
# demanding a research quest per building would double the chain.
var taught: Dictionary = {}
var checked: bool = false
for i in range(QUESTS.count()):
var goal_here: Dictionary = QUESTS.at(i).goal
if String(goal_here.kind) == QUESTS.GOAL_RESEARCH:
taught[String(goal_here.node)] = true
continue
if checked or String(goal_here.kind) != QUESTS.GOAL_BUILDING:
continue
var gate: String = String(game_state.research_node_for_building(String(goal_here.building)))
if gate == "":
continue
checked = true
if not taught.has(gate):
return _fail("the chain asks for a %s, gated behind \"%s\", without ever sending anyone to research it" \
% [goal_here.building, gate])
print("the first gated structure (%s) has its doctrine \"%s\" as a quest of its own" \
% [goal_here.building, gate])
if not checked:
return _fail("no quest asks for a structure that needs research at all")
# --- G. nothing asks for points before conquest ----------------------
var conquest_at: int = -1
for i in range(QUESTS.count()):
if String(QUESTS.at(i).goal.kind) == QUESTS.GOAL_CONQUERED:
conquest_at = i
break
if conquest_at < 0:
return _fail("no quest ever conquers a planet, so research points are unreachable")
for i in range(conquest_at):
var quest: Dictionary = QUESTS.at(i)
if String(quest.goal.kind) != QUESTS.GOAL_BUILDING:
continue
var research: String = game_state.research_for_building(String(quest.goal.building))
if research != "" and _costs_research_points(research):
return _fail("quest %s needs %s, which costs research points, before any planet is taken" \
% [quest.id, research])
print("no quest needs research points before the conquest quest at %d" % conquest_at)
# --- H. not during the prologue --------------------------------------
game_state.reset_new_game()
if not game_state.intro_active:
return _fail("a new game is not in the prologue")
game_state._advance_quests()
if game_state.quest_announced or not game_state.quest_messages.is_empty():
return _fail("a quest spoke up during the prologue")
game_state.intro_active = false
print("the chain holds its tongue while the prologue runs")
# --- D. announced before it can complete -----------------------------
game_state.bank_inventory.deposit(GameState.RESOURCE_ORE, 500)
game_state._advance_quests()
if game_state.quest_index != 0:
return _fail("the first quest completed before it was ever announced")
if game_state.quest_messages.is_empty():
return _fail("the first quest was not announced")
var brief: String = String(game_state.take_quest_messages()[0].line)
if brief != String(QUESTS.at(0).brief):
return _fail("the first quest announced something other than its own brief")
game_state._advance_quests()
if game_state.quest_index != 1:
return _fail("a satisfied quest did not complete on the frame after it was announced")
print("a quest is read out first, then completed: \"%s\"" % brief.substr(0, 48))
# --- C. and it stays complete ----------------------------------------
game_state.bank_inventory.withdraw(GameState.RESOURCE_ORE, 500)
if game_state.available_resource(GameState.RESOURCE_ORE) != 0:
return _fail("the test failed to spend the ore it just banked")
game_state._advance_quests()
if game_state.quest_index < 1:
return _fail("spending the ore put the chain back on the ore quest")
print("spending the ore did not un-complete the quest that asked for it")
# --- E. the whole chain walks ----------------------------------------
var guard: int = 0
while not game_state.quest_chain_complete() and guard < QUESTS.count() * 4:
guard += 1
game_state.complete_active_quest()
if not game_state.quest_chain_complete():
return _fail("the chain would not walk to its end")
if not game_state.active_quest().is_empty():
return _fail("there is still an active quest after the chain ended")
game_state._advance_quests()
print("the chain walks to the end and stops there")
return false
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if _catalogue_cases():
return true
# --- E (second half). the ore quest, really collected --------
game_state.reset_new_game()
game_state.intro_active = false
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
current_scene = scene
panel = scene.get_node("UI/QuestPanel")
quest_button = scene.get_node("UI/QuestButton")
narration = scene.get_node("UI/NarrationBanner")
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.3:
return false
# The opening brief is up, which has stopped the game (#128).
if not narration.is_speaking():
return _fail("the first quest never reached the banner")
if not paused:
return _fail("a quest line did not stop the game")
print("the first quest arrives as a dialog and stops the game")
while narration.dismiss():
pass
if paused:
return _fail("acknowledging every line left the game stopped")
# --- the HUD ------------------------------------------------
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.3:
return false
# #140: the objective lives behind a button next to the map's,
# not on the HUD - it used to sit on top of the wave button.
if panel.visible:
return _fail("the objective panel is open before anyone pressed anything")
if not quest_button.visible:
return _fail("there is no quest button to press")
var map_button: Control = scene.get_node("UI/MapButton")
if quest_button.get_rect().intersects(map_button.get_rect()):
return _fail("the quest button is drawn on top of the map button")
var wave_button: Control = scene.get_node("UI/DevWaveButton")
if quest_button.get_rect().intersects(wave_button.get_rect()):
return _fail("the quest button is drawn on top of the wave button")
if absf(quest_button.get_rect().get_center().y - map_button.get_rect().get_center().y) > 1.0:
return _fail("the quest button does not sit beside the map button")
panel.open()
if not panel.visible:
return _fail("pressing the quest button opened nothing")
var quest: Dictionary = game_state.active_quest()
if panel.quest_label.text != String(quest.title):
return _fail("the panel shows \"%s\", the quest is \"%s\"" \
% [panel.quest_label.text, quest.title])
if panel.objective_label.text != String(quest.objective):
return _fail("the panel does not show the objective")
if panel.progress != Vector2i(0, 5):
return _fail("the panel reads %s, expected 0 of 5" % panel.progress)
print("quest button beside the map, clear of the wave button; panel: \"%s\" - %s (%d/%d)" \
% [panel.quest_label.text, panel.objective_label.text,
panel.progress.x, panel.progress.y])
panel.close()
# Collect what the quest asked for, the way the game does.
game_state.collect_cargo(5)
elapsed = 0.0
stage = 3
return false
3:
if elapsed < 0.3:
return false
# #142: five ore finishes the first errand and starts the
# stockpile, rather than jumping straight to a structure costing
# four times what was just asked for.
if String(game_state.active_quest().get("id", "")) != "stockpile":
return _fail("collecting 5 ore did not finish the first ore quest (on %s)" \
% game_state.active_quest().get("id", "<none>"))
print("5 ore done; the chain asks for the pile before the mothership")
while narration.dismiss():
pass
game_state.collect_cargo(15)
elapsed = 0.0
stage = 31
return false
31:
# A quest completing puts its line up, and the next quest's brief
# behind it - and each of those stops the tree (#128), which is
# what stops the goal below from ever being checked. Clicked
# through the way a player does, rather than waited out.
if narration.is_speaking():
# Seen here rather than asserted after the fact: by the time
# the chain has moved on, the lines it said are gone.
spoke = true
while narration.dismiss():
pass
elapsed = 0.0
return false
if elapsed < 0.3:
return false
if String(game_state.active_quest().get("id", "")) != "mothership":
return _fail("collecting the full 20 ore did not finish the stockpile (on %s, ore=%d, hold cap=%d)" \
% [game_state.active_quest().get("id", "<none>"),
game_state.available_resource(GameState.RESOURCE_ORE),
game_state.fleet_inventory.total_capacity])
if not spoke:
return _fail("finishing a quest said nothing")
print("20 ore collected: the chain moved on to \"%s\"" \
% game_state.active_quest().title)
# --- I. a line said with no banner alive --------------------
scene.free()
scene = null
game_state.complete_active_quest()
if game_state.quest_messages.is_empty():
return _fail("a quest finished with no banner and dropped its line")
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
current_scene = scene
narration = scene.get_node("UI/NarrationBanner")
elapsed = 0.0
stage = 4
return false
4:
if elapsed < 0.3:
return false
if not narration.is_speaking():
return _fail("the line queued with no banner alive was never said")
print("a quest finished off-scene is read out by the next banner that exists")
while narration.dismiss():
pass
# --- J. and the same on a planet surface ---------------------
scene.free()
game_state.planet_name = "Quest Test"
game_state.planet_id = "quest:test"
game_state.planet_definition_id = "rocky"
scene = load("res://surface/PlanetSurface.tscn").instantiate()
root.add_child(scene)
current_scene = scene
narration = scene.get_node_or_null("UI/NarrationBanner")
panel = scene.get_node_or_null("UI/QuestPanel")
quest_button = scene.get_node_or_null("UI/QuestButton")
if narration == null or panel == null or quest_button == null:
return _fail("a planet surface has no banner and no objective panel")
game_state.complete_active_quest()
elapsed = 0.0
stage = 5
return false
5:
if elapsed < 0.3:
return false
if not narration.is_speaking():
return _fail("a quest finished on a surface said nothing down there")
print("a quest finished on a surface is announced on the surface")
# The tracker is an ordinary HUD node, so it is frozen along with
# everything else behind a dialog (#128) and comes up as the line
# closes. Acknowledged first, then looked at.
while narration.dismiss():
pass
elapsed = 0.0
stage = 6
return false
6:
if elapsed < 0.3:
return false
if not quest_button.visible:
return _fail("the objective button is missing on a planet surface")
panel.open()
if not panel.visible or panel.quest_label.text.strip_edges() == "":
return _fail("the objective panel says nothing on a planet surface")
print("the surface carries the objective too: \"%s\"" % panel.quest_label.text)
panel.close()
# --- K. landing is recorded ----------------------------------
game_state.reset_quests()
game_state.note_planet_visited("quest:one", "rocky")
game_state.note_planet_visited("quest:one", "rocky")
game_state.note_planet_visited("quest:two", "gas_giant")
if game_state.visited_planets.size() != 2:
return _fail("landing twice on one planet counted as two worlds")
if game_state.visited_planet_kinds.size() != 2:
return _fail("two kinds of world were not recorded as two kinds")
var kinds: Dictionary = {"kind": QUESTS.GOAL_PLANET_KINDS, "count": 3}
if game_state.quest_goal_met({"goal": kinds}):
return _fail("a three-world survey passed on two worlds")
game_state.note_planet_visited("quest:three", "research_lab")
if not game_state.quest_goal_met({"goal": kinds}):
return _fail("a three-world survey did not pass on three kinds of world")
print("landings recorded: %d planets across %d kinds of world" \
% [game_state.visited_planets.size(), game_state.visited_planet_kinds.size()])
print("TEST_QUEST_CHAIN: PASS")
quit(0)
return true
return false