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