From 90c34f6c749e95d2a9f66032eeb83cf5827a0869 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Fri, 28 Aug 2026 15:47:38 +0200 Subject: [PATCH] #141, #142: the chain teaches research, and asks for the pile before the build Two gaps between the first quests, both of the same kind: a step the chain assumed rather than asked for. #142 - "collect 5 ore" was followed straight by "build the mothership", which costs twenty. The first thing the chain ever asked for was four times what it had just taught, with nothing in between saying so. A stockpile quest sits between them now. #141 - the crafter quest's brief said "spend ore on the Shipyards doctrine, then put a ship crafter down", which is two unrelated things off one line: open a menu the player has never opened, and place a structure. Researching Shipyards is its own quest now, and it is the one that says where the research tree lives. Every later quest that needs a doctrine still only mentions it in passing - once is enough. That needed a goal kind for researching a doctrine, which the model did not have. Both are pinned as properties rather than as positions in a list. The chain must ask for at least as much ore as the first structure it wants costs; and the first structure that is gated behind a doctrine must have that doctrine as a quest of its own. Only the first - demanding one per building would double the chain for no teaching. One thing that fell out of writing those: the pile is exactly the starting fleet's hold. An interceptor carries 20 and the mothership costs 20, and before the mothership exists there is nowhere to bank anything, so the hold is the only store there is. It fits with no slack at all, so there is now an assertion saying so - a pile larger than the hold would be a quest that cannot be finished, and nothing else would have caught it. --- core/GameState.gd | 2 + core/Quests.gd | 31 ++++++++++- tests/test_quest_chain.gd | 112 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 138 insertions(+), 7 deletions(-) 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()