diff --git a/controllers/BuildController.gd b/controllers/BuildController.gd index f2b0dea..f4ef654 100644 --- a/controllers/BuildController.gd +++ b/controllers/BuildController.gd @@ -36,6 +36,10 @@ const HUD_ROW_GAP := 16.0 @onready var station_menu: Control = get_node("../UI/StationMenu") # #220: what the fleet costs to keep, and the arithmetic behind it. @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") var pending_build_type: String = "" @@ -116,7 +120,7 @@ func _seat_hud_columns() -> void: func _menu_is_open() -> bool: for menu in [build_menu, craft_menu, lab_menu, transfer_menu, hangar_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: return true return false @@ -491,6 +495,14 @@ func _on_unlock_requested(id: String) -> void: return var payment_position: Variant = research_building.get("position", null) 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) return if int(GameState.skill_node(id).get("fleet_bonus", 0)) > 0: @@ -616,6 +628,12 @@ func _on_lab_buy() -> void: return var selected: Dictionary = options[lab_choice] 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): lab_menu.set_status("Research is already queued or complete") return diff --git a/core/GameState.gd b/core/GameState.gd index 790b933..8ce6917 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -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. "research_point_reward": point_reward, "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) QUESTS.GOAL_CONQUERED: 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 for key in planet_capture: - if bool(planet_capture[key].get("conquered", false)): - held += 1 + var record: Dictionary = planet_capture[key] + 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) # An unknown goal kind must not be quietly "already done" - that would # run the whole chain off in one frame. diff --git a/core/Quests.gd b/core/Quests.gd index b3d8e13..ad8c449 100644 --- a/core/Quests.gd +++ b/core/Quests.gd @@ -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.", "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", "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.", "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", "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.", "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", "title": "THE SURVEY", diff --git a/tests/test_planet_scanner.gd b/tests/test_planet_scanner.gd index 2ec08ba..2ee00b2 100644 --- a/tests/test_planet_scanner.gd +++ b/tests/test_planet_scanner.gd @@ -109,16 +109,28 @@ func _run() -> void: map_content.free() 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 lab_world_at: int = -1 var worlds_at: int = -1 for i in range(QUESTS.count()): - if String(QUESTS.at(i).id) == "planet_scanner": - scanner_quest_at = i - if String(QUESTS.at(i).id) == "worlds": - worlds_at = i - if scanner_quest_at < 0 or worlds_at != scanner_quest_at + 1: - return _fail("scanner quest is not directly before the world survey") + match String(QUESTS.at(i).id): + "planet_scanner": scanner_quest_at = i + "lab_world": lab_world_at = i + "worlds": worlds_at = i + if scanner_quest_at < 0: + 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: return _fail("world survey does not require four planet types") print("the quest chain teaches the scanner immediately before the four-world survey") diff --git a/tests/test_research_points_explainer.gd b/tests/test_research_points_explainer.gd new file mode 100644 index 0000000..aaef71a --- /dev/null +++ b/tests/test_research_points_explainer.gd @@ -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 diff --git a/ui/menus/ResearchPointsMenu.gd b/ui/menus/ResearchPointsMenu.gd new file mode 100644 index 0000000..de3d1c9 --- /dev/null +++ b/ui/menus/ResearchPointsMenu.gd @@ -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.") diff --git a/ui/menus/ResearchPointsMenu.tscn b/ui/menus/ResearchPointsMenu.tscn new file mode 100644 index 0000000..6bc985d --- /dev/null +++ b/ui/menus/ResearchPointsMenu.tscn @@ -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 diff --git a/world/Main.tscn b/world/Main.tscn index 66e6780..31147f7 100644 --- a/world/Main.tscn +++ b/world/Main.tscn @@ -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/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/ResearchPointsMenu.tscn" id="menu_points"] [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="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"]] visible = false mouse_filter = 2