diff --git a/core/GameState.gd b/core/GameState.gd index 50a8afb..e10c812 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -3039,6 +3039,8 @@ func quest_progress(quest: Dictionary) -> Vector2i: need), need) QUESTS.GOAL_VESSEL_UNLOCKED: return Vector2i(1 if vessel_is_unlocked(String(goal.get("vessel", ""))) else 0, 1) + QUESTS.GOAL_RESEARCH: + return Vector2i(1 if is_unlocked(String(goal.get("node", ""))) else 0, 1) QUESTS.GOAL_EXPLORE: var sectors: int = int(goal.get("sectors", 1)) return Vector2i(mini(explored_chunks.size(), sectors), sectors) diff --git a/core/Quests.gd b/core/Quests.gd index 1c8362c..4a0a8e7 100644 --- a/core/Quests.gd +++ b/core/Quests.gd @@ -36,6 +36,11 @@ const GOAL_EXPLORE: String = "explore" const GOAL_PLANET_VISITS: String = "planet_visits" const GOAL_PLANET_KINDS: String = "planet_kinds" const GOAL_CONQUERED: String = "conquered" +# #141: researching a doctrine, as a step of its own. The chain used to fold +# it into the brief of the thing it unlocks ("spend ore on Shipyards, then +# put a crafter down"), which asks the player to do two unrelated things - +# open a menu they have never opened, and place a structure - off one line. +const GOAL_RESEARCH: String = "research" const CHAIN: Array = [ { @@ -46,6 +51,18 @@ const CHAIN: Array = [ "brief": "Hull intact. Reactor holding. Everything else we had is on the other side of that hole. There is ore in the rocks out here - break some, and we can start again.", "done": "Five ore in the hold. It is not much. It is a beginning.", }, + { + # #142: the gap this fills was a real one. "Collect 5 ore" was + # followed straight by "build the mothership", which costs twenty - + # so the first thing the chain ever asked for was four times what it + # had just taught, with nothing saying so. + "id": "stockpile", + "title": "THE PILE", + "objective": "Break asteroids for 20 ore", + "goal": {"kind": GOAL_RESOURCE, "resource": "ore", "amount": 20}, + "brief": "Now do that four times over. Nothing permanent gets built out of five ore, and there is nothing else out here to spend it on yet.", + "done": "Twenty in the hold. That is a foundation.", + }, { "id": "mothership", "title": "A PLACE TO STAND", @@ -64,12 +81,24 @@ const CHAIN: Array = [ "brief": "So we search. Fly, and let the map fill in behind us - whatever is left of their course is somewhere in it.", "done": "Thirty sectors charted. No sign of the World Ship, but the sky behind us is no longer blank.", }, + { + # #141: its own step, and the first time the chain sends anyone into + # the research tree at all - so the brief says where the tree IS. + # Every later quest that needs a doctrine only mentions it in + # passing, which is enough once the player has been there once. + "id": "shipyards", + "title": "SHIPYARDS", + "objective": "Research the Shipyards doctrine", + "goal": {"kind": GOAL_RESEARCH, "node": "shipyards"}, + "brief": "One hull will not chart a sky, and we cannot build what we have not remembered how to build. The mothership holds the archive - go aboard it and research Shipyards.", + "done": "Shipyard plans recovered. Everything else worth having is in that archive too.", + }, { "id": "crafter", "title": "A YARD OF OUR OWN", "objective": "Build a ship crafter", "goal": {"kind": GOAL_BUILDING, "building": "ship_crafter"}, - "brief": "One hull will not chart a sky. Spend ore on the Shipyards doctrine, then put a ship crafter down: ore goes in, vessels come out.", + "brief": "Now put a ship crafter down. Ore goes in, vessels come out - it is the only way this fleet grows past the one hull that came through the breach.", "done": "The yard is live. We can build our own now.", }, { diff --git a/tests/test_quest_chain.gd b/tests/test_quest_chain.gd index 11f0b25..b631bed 100644 --- a/tests/test_quest_chain.gd +++ b/tests/test_quest_chain.gd @@ -26,6 +26,9 @@ extends SceneTree # 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 # 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 @@ -38,6 +41,7 @@ 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 @@ -101,6 +105,10 @@ func _catalogue_cases() -> bool: 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 ---------------------------------------------------- @@ -118,6 +126,70 @@ func _catalogue_cases() -> bool: return _fail("the quest after the mothership is %s, not exploration" % after.id) print("the mothership answers with distance, and exploration follows it") + # --- 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()): @@ -261,15 +333,43 @@ func _process(delta: float) -> bool: 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)" \ + # #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", "")) - 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) + 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()