334 lines
14 KiB
GDScript3
334 lines
14 KiB
GDScript3
|
|
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
|
||
|
|
# 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
|
||
|
|
# 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 scene: Node
|
||
|
|
var tracker: 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])
|
||
|
|
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")
|
||
|
|
|
||
|
|
# --- 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
|
||
|
|
tracker = scene.get_node("UI/QuestTracker")
|
||
|
|
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
|
||
|
|
if not tracker.visible:
|
||
|
|
return _fail("the objective tracker never came up")
|
||
|
|
var quest: Dictionary = game_state.active_quest()
|
||
|
|
if tracker.title_label.text != String(quest.title):
|
||
|
|
return _fail("the tracker shows \"%s\", the quest is \"%s\"" \
|
||
|
|
% [tracker.title_label.text, quest.title])
|
||
|
|
if tracker.objective_label.text != String(quest.objective):
|
||
|
|
return _fail("the tracker does not show the objective")
|
||
|
|
if tracker.progress != Vector2i(0, 5):
|
||
|
|
return _fail("the tracker reads %s, expected 0 of 5" % tracker.progress)
|
||
|
|
print("tracker: \"%s\" - %s (%d/%d)" % [tracker.title_label.text,
|
||
|
|
tracker.objective_label.text, tracker.progress.x, tracker.progress.y])
|
||
|
|
|
||
|
|
# 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
|
||
|
|
if String(game_state.active_quest().get("id", "")) != "mothership":
|
||
|
|
return _fail("collecting 5 ore did not finish the ore quest (on %s)" \
|
||
|
|
% game_state.active_quest().get("id", "<none>"))
|
||
|
|
if not narration.is_speaking():
|
||
|
|
return _fail("finishing a quest said nothing")
|
||
|
|
print("5 ore collected: the chain moved on to \"%s\"" \
|
||
|
|
% game_state.active_quest().title)
|
||
|
|
while narration.dismiss():
|
||
|
|
pass
|
||
|
|
|
||
|
|
# --- 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")
|
||
|
|
tracker = scene.get_node_or_null("UI/QuestTracker")
|
||
|
|
if narration == null or tracker == null:
|
||
|
|
return _fail("a planet surface has no banner and no objective tracker")
|
||
|
|
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 tracker.visible:
|
||
|
|
return _fail("the objective tracker is missing on a planet surface")
|
||
|
|
print("the surface carries the tracker too: \"%s\"" % tracker.title_label.text)
|
||
|
|
|
||
|
|
# --- 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
|