Teach where research data comes from, and say so when it runs out

The scanner quest moves ahead of the gas burner and gains the hunt it exists
for: a research lab world, which pays several times what an ordinary planet
does and is the only reliable source of research data. Finding one is what
the scanner is FOR, so the conquest record now remembers which kind of world
was taken and a quest can ask for that kind.

Bombers get a pair of quests after the transporters. That pulled the ship lab
up with them - a vessel is unlocked AT the lab, so asking for a bomber before
one exists would have been unfinishable, the same trap second_hull fell into.

And both research screens now explain a shortfall instead of hiding it. The
skill tree refused a doctrine in silence; the ship lab said "already queued
or complete", which is not why it refused and sends the player to look at the
wrong thing. Both open an explainer naming what was wanted, what is held, and
where data actually comes from.

#192's rule - a tool is taught immediately before the quest that needs it -
now points at the scanner's FIRST use rather than its only one, with the
world survey still required to follow it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
This commit is contained in:
Loïc Wisniewski 2026-09-06 16:24:09 +02:00
commit 8462099068
8 changed files with 327 additions and 26 deletions

View file

@ -36,6 +36,10 @@ const HUD_ROW_GAP := 16.0
@onready var station_menu: Control = get_node("../UI/StationMenu") @onready var station_menu: Control = get_node("../UI/StationMenu")
# #220: what the fleet costs to keep, and the arithmetic behind it. # #220: what the fleet costs to keep, and the arithmetic behind it.
@onready var upkeep_menu: Control = get_node("../UI/FleetUpkeepMenu") @onready var upkeep_menu: Control = get_node("../UI/FleetUpkeepMenu")
# Where research data comes from, shown when a player finds out they have
# none - the tree used to refuse in silence and the lab used to blame the
# queue.
@onready var research_points_menu: Control = get_node("../UI/ResearchPointsMenu")
@onready var scanner_menu: Control = get_node("../UI/ScannerMenu") @onready var scanner_menu: Control = get_node("../UI/ScannerMenu")
var pending_build_type: String = "" var pending_build_type: String = ""
@ -116,7 +120,7 @@ func _seat_hud_columns() -> void:
func _menu_is_open() -> bool: func _menu_is_open() -> bool:
for menu in [build_menu, craft_menu, lab_menu, transfer_menu, hangar_menu, for menu in [build_menu, craft_menu, lab_menu, transfer_menu, hangar_menu,
formation_menu, skill_menu, planning_menu, garrison_menu, scanner_menu, formation_menu, skill_menu, planning_menu, garrison_menu, scanner_menu,
station_menu, upkeep_menu]: station_menu, upkeep_menu, research_points_menu]:
if menu.visible: if menu.visible:
return true return true
return false return false
@ -491,6 +495,14 @@ func _on_unlock_requested(id: String) -> void:
return return
var payment_position: Variant = research_building.get("position", null) var payment_position: Variant = research_building.get("position", null)
if not GameState.unlock(id, payment_position): if not GameState.unlock(id, payment_position):
# A doctrine can be refused for several reasons, but only one of them
# is invisible on the node itself: the price in ORE is written there,
# the prerequisite is drawn as an unlit link, and research data is
# just a number the player has never been told how to get.
var points_short: int = GameState.skill_research_point_cost(id)
if GameState.research_points < points_short:
research_points_menu.open_for_shortfall(points_short,
String(GameState.skill_node(id).get("name", "This doctrine")))
skill_menu.refresh(id) skill_menu.refresh(id)
return return
if int(GameState.skill_node(id).get("fleet_bonus", 0)) > 0: if int(GameState.skill_node(id).get("fleet_bonus", 0)) > 0:
@ -616,6 +628,12 @@ func _on_lab_buy() -> void:
return return
var selected: Dictionary = options[lab_choice] var selected: Dictionary = options[lab_choice]
var id: String = String(selected.id) var id: String = String(selected.id)
var lab_points: int = GameState.research_point_cost(id)
if GameState.research_points < lab_points:
# Checked BEFORE the queue call, which would otherwise refuse this
# and report it as "already queued or complete" - which it is not.
research_points_menu.open_for_shortfall(lab_points, String(selected.name))
return
if not GameState.queue_lab_research(lab_building_id, id): if not GameState.queue_lab_research(lab_building_id, id):
lab_menu.set_status("Research is already queued or complete") lab_menu.set_status("Research is already queued or complete")
return return

View file

@ -3052,6 +3052,12 @@ func register_planet_garrison(planet_key: String, enemy_count: int,
# re-registered on a second visit must not become payable again. # re-registered on a second visit must not become payable again.
"research_point_reward": point_reward, "research_point_reward": point_reward,
"research_awarded": bool(existing.get("research_awarded", false)), "research_awarded": bool(existing.get("research_awarded", false)),
# Which kind of world this was. A quest can then ask for a research
# lab specifically - "any planet" and "the one worth research data"
# are different lessons - and the record is the only place that
# survives leaving the surface.
"definition_id": definition_id if definition_id != "" \
else String(existing.get("definition_id", "")),
} }
@ -3985,10 +3991,18 @@ func quest_progress(quest: Dictionary) -> Vector2i:
return Vector2i(mini(visited_planet_kinds.size(), kinds), kinds) return Vector2i(mini(visited_planet_kinds.size(), kinds), kinds)
QUESTS.GOAL_CONQUERED: QUESTS.GOAL_CONQUERED:
var wanted: int = int(goal.get("count", 1)) var wanted: int = int(goal.get("count", 1))
# An optional kind: "take a planet" and "take a research lab" are
# different asks, and the second is how a player learns where
# research data comes from in quantity.
var of_kind: String = String(goal.get("definition", ""))
var held: int = 0 var held: int = 0
for key in planet_capture: for key in planet_capture:
if bool(planet_capture[key].get("conquered", false)): var record: Dictionary = planet_capture[key]
held += 1 if not bool(record.get("conquered", false)):
continue
if of_kind != "" and String(record.get("definition_id", "")) != of_kind:
continue
held += 1
return Vector2i(mini(held, wanted), wanted) return Vector2i(mini(held, wanted), wanted)
# An unknown goal kind must not be quietly "already done" - that would # An unknown goal kind must not be quietly "already done" - that would
# run the whole chain off in one frame. # run the whole chain off in one frame.

View file

@ -264,14 +264,6 @@ const CHAIN: Array = [
"done": "The hub is working. That transporter will wear out after a run of trips, and the hub will not replace it - watch its condition and build another before it goes.", "done": "The hub is working. That transporter will wear out after a run of trips, and the hub will not replace it - watch its condition and build another before it goes.",
"important": true, "important": true,
}, },
{
"id": "burner",
"title": "POWER",
"objective": "Build a gas burner",
"goal": {"kind": GOAL_BUILDING, "building": "gas_burner"},
"brief": "Research Power Systems and build a gas burner. Gas in, energy out. The better weapons run on energy.",
"done": "The burner is lit. This fleet has power again for the first time since the breach.",
},
{ {
"id": "lab", "id": "lab",
"title": "SHIP SCIENCE", "title": "SHIP SCIENCE",
@ -280,6 +272,55 @@ const CHAIN: Array = [
"brief": "Research Vessel Science and build a ship lab. It makes the ships we fly better, and it opens designs we have never built.", "brief": "Research Vessel Science and build a ship lab. It makes the ships we fly better, and it opens designs we have never built.",
"done": "The lab is open.", "done": "The lab is open.",
}, },
{
# Bombers hang off the transporter pair because they are the first
# thing the player builds for a REASON rather than because the chain
# said so - and the ship lab above is where a vessel is unlocked, so
# it has to come first.
"id": "bomber_research",
"title": "ORDNANCE",
"objective": "Unlock the Gas Bomber",
"goal": {"kind": GOAL_VESSEL_UNLOCKED, "vessel": "gas_bomber"},
"brief": "Interceptor fire is one hull at a time. Unlock the Gas Bomber at the ship lab - it costs gas and research data - and we can clear a whole camp at once.",
"done": "The bomber is on the crafter's list. It throws a charge that blooms where it lands.",
},
{
"id": "bomber_built",
"title": "A HEAVY HAND",
"objective": "Fly a Gas Bomber",
"goal": {"kind": GOAL_FLEET_VESSEL, "vessel": "gas_bomber", "count": 1},
"brief": "Order one at a crafter and crew it. Aim it by dragging from its button; the further you drag, the further it throws.",
"done": "We are carrying ordnance now. Ground that took a long fight before takes one throw.",
},
{
"id": "planet_scanner",
"title": "LISTENING POST",
"objective": "Build a planet scanner",
"goal": {"kind": GOAL_BUILDING, "building": "planet_scanner"},
"brief": "Blind searching is wasting our reach. Research Deep-Space Scanning at the mothership, then build a planet scanner. Give it a world signature and it will put the nearest match on our map.",
"done": "The scanner is listening. Now we can choose where the search goes next.",
},
{
# The scanner earns its place here: it is the only way to FIND a
# research lab world, and a lab world is the only thing that pays
# research data in quantity. Before the burner, because everything
# past it wants data the player has no reliable way to get yet.
"id": "lab_world",
"title": "SOMETHING WORTH TAKING",
"objective": "Find and take a research lab world",
"goal": {"kind": GOAL_CONQUERED, "definition": "research_lab", "count": 1},
"brief": "Scan for a research lab. Whatever was working there left its data behind, and taking one pays far better than an ordinary world - every doctrine worth having is waiting on it.",
"done": "Their lab is ours, and so is what they were working on. That is where research data comes from: take the worlds that have it.",
"important": true,
},
{
"id": "burner",
"title": "POWER",
"objective": "Build a gas burner",
"goal": {"kind": GOAL_BUILDING, "building": "gas_burner"},
"brief": "Research Power Systems and build a gas burner. Gas in, energy out. The better weapons run on energy.",
"done": "The burner is lit. This fleet has power again for the first time since the breach.",
},
{ {
"id": "shield", "id": "shield",
"title": "A NEW HULL", "title": "A NEW HULL",
@ -304,14 +345,6 @@ const CHAIN: Array = [
"brief": "An empty outpost is only a building. Open its hangar and station a ship there. It will hold that ground whether we are there or not.", "brief": "An empty outpost is only a building. Open its hangar and station a ship there. It will hold that ground whether we are there or not.",
"done": "The outpost has a crew. It watches that approach now, so we do not have to.", "done": "The outpost has a crew. It watches that approach now, so we do not have to.",
}, },
{
"id": "planet_scanner",
"title": "LISTENING POST",
"objective": "Build a planet scanner",
"goal": {"kind": GOAL_BUILDING, "building": "planet_scanner"},
"brief": "Blind searching is wasting our reach. Research Deep-Space Scanning at the mothership, then build a planet scanner. Give it a world signature and it will put the nearest match on our map.",
"done": "The scanner is listening. Now we can choose where the search goes next.",
},
{ {
"id": "worlds", "id": "worlds",
"title": "THE SURVEY", "title": "THE SURVEY",

View file

@ -109,16 +109,28 @@ func _run() -> void:
map_content.free() map_content.free()
print("researched scanner reveals generated destinations; the fifth option waits for 4/4") print("researched scanner reveals generated destinations; the fifth option waits for 4/4")
# #192: the scanner is taught immediately before the widened survey. # #192: the scanner is taught immediately before the quest that needs it,
# and before every later one that does.
#
# That used to mean the world survey, which was the only thing it served.
# The scanner now also finds research lab worlds - the only reliable
# source of research data - and THAT is taught first, so the adjacency
# moved with it. The rule is unchanged in substance: a tool is taught
# where it is first wanted, not filed away several quests early.
var scanner_quest_at: int = -1 var scanner_quest_at: int = -1
var lab_world_at: int = -1
var worlds_at: int = -1 var worlds_at: int = -1
for i in range(QUESTS.count()): for i in range(QUESTS.count()):
if String(QUESTS.at(i).id) == "planet_scanner": match String(QUESTS.at(i).id):
scanner_quest_at = i "planet_scanner": scanner_quest_at = i
if String(QUESTS.at(i).id) == "worlds": "lab_world": lab_world_at = i
worlds_at = i "worlds": worlds_at = i
if scanner_quest_at < 0 or worlds_at != scanner_quest_at + 1: if scanner_quest_at < 0:
return _fail("scanner quest is not directly before the world survey") return _fail("nothing in the chain teaches the scanner")
if lab_world_at != scanner_quest_at + 1:
return _fail("the scanner is not taught directly before the lab-world hunt that needs it")
if worlds_at < scanner_quest_at:
return _fail("the world survey comes before the scanner that serves it")
if int(QUESTS.at(worlds_at).goal.count) != 4: if int(QUESTS.at(worlds_at).goal.count) != 4:
return _fail("world survey does not require four planet types") return _fail("world survey does not require four planet types")
print("the quest chain teaches the scanner immediately before the four-world survey") print("the quest chain teaches the scanner immediately before the four-world survey")

View file

@ -0,0 +1,132 @@
extends SceneTree
# A player who is short of research data is told where it comes from.
#
# It is the one currency with a non-obvious source: ore and gas are visibly
# mined, but data comes off conquered worlds and nothing said so at the point
# it mattered. The skill tree refused the node in SILENCE, and the ship lab
# refused with "Research is already queued or complete" - which is not why it
# refused, and sends the player looking at the wrong thing.
#
# Covers:
# A. a doctrine that costs data the player has not got opens the explainer,
# naming what was wanted and what they hold
# B. the same from the ship lab, instead of blaming the queue
# C. with the data in hand, the research goes through and no popup appears
var game_state: Node
var scene: Node
var controller: Node
var popup: Control
var elapsed: float = 0.0
var stage: int = 0
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.reset_progression()
game_state.reset_new_game()
game_state.intro_active = false
game_state.reset_quests()
game_state.rocks = 5000
game_state.bank_inventory.set_amount(GameState.RESOURCE_GAS, 5000)
game_state.bank_inventory.set_amount(GameState.RESOURCE_ENERGY, 5000)
game_state.research_points = 0
game_state.unlocked = ["shipyards"]
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
current_scene = scene
controller = scene.get_node("BuildController")
popup = scene.get_node("UI/ResearchPointsMenu")
func _costly_doctrine() -> String:
for node in GameState.SKILL_TREE:
if int(node.get("research_points", 0)) > 0:
return String(node.id)
return ""
func _process(delta: float) -> bool:
elapsed += delta
if elapsed < 0.4:
return false
match stage:
0:
var doctrine: String = _costly_doctrine()
if doctrine == "":
return _fail("no doctrine costs research data, so this proves nothing")
# Everything but the data: the prerequisite met and the ore paid,
# so data is unambiguously the thing that is missing.
game_state.unlocked = ["shipyards"]
var requires: String = String(game_state.skill_node(doctrine).get("requires", ""))
if requires != "" and not game_state.is_unlocked(requires):
game_state.unlocked.append(requires)
game_state.research_points = 0
var mothership_id: int = game_state.add_building(
GameState.BUILDING_MOTHERSHIP, Vector2.ZERO)
controller.skill_menu.building_id = mothership_id
if popup.visible:
return _fail("the explainer was open before anything was refused")
controller._on_unlock_requested(doctrine)
if not popup.visible:
return _fail("a doctrine costing data was refused with no explanation")
if game_state.is_unlocked(doctrine):
return _fail("the doctrine was researched with no data at all")
var said: String = popup.have_label.text
if not said.contains("YOU HAVE 0"):
return _fail("the explainer does not say what the player holds: '%s'" % said)
if not popup.detail_label.text.to_lower().contains("scanner"):
return _fail("the explainer never mentions how to find a lab world")
print("skill tree: refused and explained - '%s'" % said)
popup.close_menu()
# --- C. with the data, it goes through, quietly ---------------
game_state.research_points = 20
controller._on_unlock_requested(doctrine)
if not game_state.is_unlocked(doctrine):
return _fail("the doctrine was refused with data in hand")
if popup.visible:
return _fail("the explainer opened on a research that succeeded")
print("with data in hand it goes through, and says nothing")
elapsed = 0.0
stage = 1
return false
1:
# --- B. the lab explains too, rather than blaming the queue ---
game_state.research_points = 0
var lab_id: int = game_state.add_building(GameState.BUILDING_SHIP_LAB,
Vector2(600.0, 0.0))
controller.lab_building_id = lab_id
controller.lab_vessel = GameState.VESSEL_INTERCEPTOR
controller.lab_choice = 0
var options: Array = controller._lab_options()
if options.is_empty():
return _fail("the lab offers nothing, so this proves nothing")
# Pick an option that actually costs data.
for i in range(options.size()):
if game_state.research_point_cost(String(options[i].id)) > 0:
controller.lab_choice = i
break
if game_state.research_point_cost(String(options[controller.lab_choice].id)) <= 0:
return _fail("no lab option costs research data, so this proves nothing")
controller._on_lab_buy()
if not popup.visible:
return _fail("the lab refused for want of data with no explanation")
if controller.lab_menu.status.text.contains("already queued"):
return _fail("the lab still blames the queue: '%s'" % controller.lab_menu.status.text)
print("ship lab: refused and explained, not blamed on the queue")
print("TEST_RESEARCH_POINTS_EXPLAINER: PASS")
quit(0)
return true
return false
func _fail(message: String) -> bool:
print("TEST_RESEARCH_POINTS_EXPLAINER: FAIL (%s)" % message)
quit(1)
return true

View file

@ -0,0 +1,25 @@
extends "res://ui/menus/ModalMenu.gd"
# Where research data comes from, said at the moment the player finds out they
# have none.
#
# Both research screens used to answer a shortfall badly: the skill tree
# refused the node and said nothing at all, and the ship lab said "already
# queued or complete", which is not why it refused. Research data is the one
# currency in the game with a single non-obvious source - you take it off
# planets, and lab worlds carry most of it - so a player short of it needs
# telling where to go, not a silent button.
@onready var have_label: Label = $HaveLabel
@onready var detail_label: Label = $DetailLabel
func open_for_shortfall(needed: int, subject: String) -> void:
open_menu()
have_label.text = "%s NEEDS %d - YOU HAVE %d" % [
subject.to_upper(), needed, GameState.research_points]
detail_label.text = ("Research data is not mined and not built. It comes off worlds "
+ "you take from whatever is holding them, and a research lab world pays "
+ "several times what an ordinary one does.\n\n"
+ "Build a planet scanner to find the nearest lab world, fly there, and "
+ "destroy its garrison. What they were working on becomes yours.")

View file

@ -0,0 +1,64 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://ui/IconButton.gd" id="icon"]
[ext_resource type="Script" path="res://ui/menus/ResearchPointsMenu.gd" id="menu"]
[node name="ResearchPointsMenu" type="Control" groups=["modal_menus"]]
visible = false
mouse_filter = 2
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("menu")
[node name="Backdrop" type="ColorRect" parent="."]
mouse_filter = 2
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0.03, 0.03, 0.08, 0.97)
[node name="TitleLabel" type="Label" parent="."]
mouse_filter = 2
anchor_right = 1.0
offset_left = 24.0
offset_top = 24.0
offset_right = -180.0
offset_bottom = 100.0
theme_override_font_sizes/font_size = 40
text = "NO RESEARCH DATA"
horizontal_alignment = 1
[node name="CloseButton" type="Control" parent="."]
anchor_left = 1.0
anchor_right = 1.0
offset_left = -140.0
offset_top = 24.0
offset_right = -24.0
offset_bottom = 140.0
script = ExtResource("icon")
icon = 1
glow_color = Color(0.55, 0.6, 1, 1)
[node name="HaveLabel" type="Label" parent="."]
mouse_filter = 2
anchor_right = 1.0
offset_left = 32.0
offset_top = 160.0
offset_right = -32.0
offset_bottom = 230.0
theme_override_font_sizes/font_size = 32
theme_override_colors/font_color = Color(1, 0.85, 0.35, 1)
text = ""
horizontal_alignment = 1
[node name="DetailLabel" type="Label" parent="."]
mouse_filter = 2
anchor_right = 1.0
offset_left = 48.0
offset_top = 280.0
offset_right = -48.0
offset_bottom = 900.0
theme_override_font_sizes/font_size = 30
theme_override_colors/font_color = Color(0.72, 0.88, 0.96, 1)
text = ""
horizontal_alignment = 1
autowrap_mode = 2

View file

@ -12,6 +12,7 @@
[ext_resource type="PackedScene" path="res://ui/menus/ScannerMenu.tscn" id="menu_scanner"] [ext_resource type="PackedScene" path="res://ui/menus/ScannerMenu.tscn" id="menu_scanner"]
[ext_resource type="PackedScene" path="res://ui/menus/StationMenu.tscn" id="menu_station"] [ext_resource type="PackedScene" path="res://ui/menus/StationMenu.tscn" id="menu_station"]
[ext_resource type="PackedScene" path="res://ui/menus/FleetUpkeepMenu.tscn" id="menu_upkeep"] [ext_resource type="PackedScene" path="res://ui/menus/FleetUpkeepMenu.tscn" id="menu_upkeep"]
[ext_resource type="PackedScene" path="res://ui/menus/ResearchPointsMenu.tscn" id="menu_points"]
[ext_resource type="PackedScene" path="res://ui/ScannerGuidance.tscn" id="scanner_guidance"] [ext_resource type="PackedScene" path="res://ui/ScannerGuidance.tscn" id="scanner_guidance"]
@ -533,6 +534,8 @@ icon = 1
[node name="StationMenu" parent="UI" instance=ExtResource("menu_station")] [node name="StationMenu" parent="UI" instance=ExtResource("menu_station")]
[node name="FleetUpkeepMenu" parent="UI" instance=ExtResource("menu_upkeep")] [node name="FleetUpkeepMenu" parent="UI" instance=ExtResource("menu_upkeep")]
[node name="ResearchPointsMenu" parent="UI" instance=ExtResource("menu_points")]
[node name="QuestPanel" type="Control" parent="UI" groups=["modal_menus"]] [node name="QuestPanel" type="Control" parent="UI" groups=["modal_menus"]]
visible = false visible = false
mouse_filter = 2 mouse_filter = 2