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. 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", "")) 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