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
206 lines
9.1 KiB
GDScript
206 lines
9.1 KiB
GDScript
extends SceneTree
|
|
|
|
# Focused coverage for #192-#196: the scanner progression, its scene-owned
|
|
# menu, the one hostile region and the ambient enemies that inhabit it.
|
|
|
|
const QUESTS = preload("res://core/Quests.gd")
|
|
|
|
class FakeFleet:
|
|
extends Node2D
|
|
var ships: Array = [Node2D.new()]
|
|
|
|
func get_centroid() -> Vector2:
|
|
return position
|
|
|
|
|
|
func _initialize() -> void:
|
|
_run.call_deferred()
|
|
|
|
|
|
func _run() -> void:
|
|
var state: Node = root.get_node("GameState")
|
|
state.reset_new_game()
|
|
state.intro_active = false
|
|
state.reset_quests(false)
|
|
|
|
# #194: exact, finite hostile geography instead of an endless rare roll.
|
|
WorldGen.dev_planets_enabled = false
|
|
var boss_count: int = 0
|
|
for x in range(-40, 41):
|
|
for y in range(-40, 41):
|
|
var chunk := Vector2i(x, y)
|
|
if WorldGen.has_planet_at_chunk(chunk) \
|
|
and String(WorldGen.planet_data_at_chunk(chunk).definition_id) \
|
|
== PlanetDefinitions.BOSS_ID:
|
|
boss_count += 1
|
|
if boss_count != 1:
|
|
return _fail("normal generation contains %d boss planets, expected exactly one" % boss_count)
|
|
if WorldGen.biome_at_chunk(WorldGen.OCTOPUS_BIOME_CENTER) \
|
|
!= WorldGen.Biome.OCTOPUS:
|
|
return _fail("the unique boss is not inside the octopus biome")
|
|
if WorldGen.octopus_biome_chunks().size() < 5:
|
|
return _fail("the octopus biome is not a multi-chunk region")
|
|
for chunk in WorldGen.octopus_biome_chunks():
|
|
if WorldGen.biome_at_chunk(chunk) != WorldGen.Biome.OCTOPUS:
|
|
return _fail("declared octopus chunk %s has another biome" % chunk)
|
|
if chunk != WorldGen.OCTOPUS_BIOME_CENTER \
|
|
and WorldGen.has_planet_at_chunk(chunk):
|
|
return _fail("the hostile region contains an extra planet at %s" % chunk)
|
|
print("one finite nine-chunk octopus biome contains the one normal boss planet")
|
|
|
|
# #193/#195: research gate, real generated scan results and boss gating.
|
|
if state.is_building_unlocked(GameState.BUILDING_PLANET_SCANNER):
|
|
return _fail("planet scanner is available without research")
|
|
state.unlocked.append("coordination")
|
|
state.bank_inventory.deposit(GameState.RESOURCE_ORE, 50)
|
|
state.bank_inventory.deposit(GameState.RESOURCE_GAS, 15)
|
|
state.research_points = 1
|
|
if not state.unlock("deep_space_scanning") \
|
|
or not state.is_building_unlocked(GameState.BUILDING_PLANET_SCANNER):
|
|
return _fail("Deep-Space Scanning did not unlock its building")
|
|
state.add_building(GameState.BUILDING_PLANET_SCANNER, Vector2.ZERO)
|
|
var explored_before_scan: Dictionary = state.explored_chunks.duplicate()
|
|
var rocky: Dictionary = state.scan_for_planet(PlanetDefinitions.ROCKY_ID,
|
|
Vector2.ZERO)
|
|
if rocky.is_empty() or String(rocky.definition_id) != PlanetDefinitions.ROCKY_ID:
|
|
return _fail("the scanner did not find a real rocky planet")
|
|
if explored_before_scan.has(Vector2i(rocky.chunk)):
|
|
return _fail("the scanner test's first target was already explored")
|
|
if not state.explored_chunks.has(Vector2i(rocky.chunk)):
|
|
return _fail("a scan result was not revealed on the persistent map")
|
|
if String(state.active_scanner_target().planet_id) != String(rocky.planet_id):
|
|
return _fail("the revealed planet was not retained as navigation target")
|
|
if not _is_nearest_unconquered(state, rocky, PlanetDefinitions.ROCKY_ID):
|
|
return _fail("the scanner did not select the physically closest rocky planet")
|
|
# A conquered nearest match must be skipped on the next scan.
|
|
state.planet_capture[String(rocky.planet_id)] = {
|
|
"captured": [], "conquered": true,
|
|
}
|
|
var next_rocky: Dictionary = state.scan_for_planet(PlanetDefinitions.ROCKY_ID,
|
|
Vector2.ZERO)
|
|
if next_rocky.is_empty() or String(next_rocky.planet_id) == String(rocky.planet_id) \
|
|
or state.is_planet_conquered(String(next_rocky.planet_id)):
|
|
return _fail("scanner reused the nearest conquered rocky planet")
|
|
print("scanner found the nearest unvisited world and skipped it after conquest")
|
|
if PlanetDefinitions.BOSS_ID in state.scanner_available_planet_types():
|
|
return _fail("boss signature is available before the four-world survey")
|
|
|
|
var menu: Control = load("res://ui/menus/ScannerMenu.tscn").instantiate()
|
|
root.add_child(menu)
|
|
menu.open_menu()
|
|
if menu.get_node("BossButton").visible:
|
|
return _fail("scanner menu shows the boss before the survey")
|
|
for definition_id in GameState.SCANNER_SURVEY_TYPES:
|
|
state.note_planet_visited("scanner:test:%s" % definition_id, definition_id)
|
|
menu.refresh()
|
|
if not menu.get_node("BossButton").visible \
|
|
or not menu.get_node("BossLabel").visible:
|
|
return _fail("the fifth scanner option did not appear after four planet types")
|
|
var boss: Dictionary = state.scan_for_planet(PlanetDefinitions.BOSS_ID,
|
|
Vector2.ZERO)
|
|
if boss.is_empty() or Vector2i(boss.chunk) != WorldGen.OCTOPUS_BIOME_CENTER:
|
|
return _fail("boss scan did not resolve the unique hostile region")
|
|
var map_content: Node2D = load("res://world/WorldMapContent.gd").new()
|
|
var marker: Dictionary = map_content.scanner_target_marker()
|
|
if marker.is_empty() or String(marker.planet_id) != String(boss.planet_id) \
|
|
or Vector2(marker.position).distance_to(
|
|
Vector2(boss.world_position) * map_content.WORLD_TO_LOCAL) > 0.01:
|
|
return _fail("the active scan has no matching destination point on the map")
|
|
map_content.free()
|
|
print("researched scanner reveals generated destinations; the fifth option waits for 4/4")
|
|
|
|
# #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()):
|
|
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")
|
|
|
|
# #196: entering/leaving the biome owns a bounded roaming population.
|
|
var host := Node2D.new()
|
|
root.add_child(host)
|
|
var fake_fleet := FakeFleet.new()
|
|
fake_fleet.name = "Fleet"
|
|
fake_fleet.position = Vector2.ZERO
|
|
fake_fleet.add_to_group("player_fleet")
|
|
host.add_child(fake_fleet)
|
|
menu.close_menu()
|
|
var guidance: Control = load("res://ui/ScannerGuidance.tscn").instantiate()
|
|
root.add_child(guidance)
|
|
guidance._process(0.0)
|
|
if not guidance.visible or not guidance.get_node("TargetLabel").text.contains(
|
|
String(boss.name).to_upper()):
|
|
return _fail("the flight HUD did not point toward the scanned destination")
|
|
var indicator: Control = guidance.get_node("Indicator")
|
|
var indicator_center: Vector2 = indicator.position + indicator.size * 0.5
|
|
var viewport_size: Vector2 = guidance.get_viewport_rect().size
|
|
if indicator_center.x < 20.0 or indicator_center.y < 20.0 \
|
|
or indicator_center.x > viewport_size.x - 20.0 \
|
|
or indicator_center.y > viewport_size.y - 20.0:
|
|
return _fail("scanner direction indicator was placed outside the viewport")
|
|
guidance.queue_free()
|
|
|
|
fake_fleet.position = WorldGen.chunk_origin(WorldGen.OCTOPUS_BIOME_CENTER) \
|
|
+ Vector2(WorldGen.CHUNK_SIZE * 0.5, WorldGen.CHUNK_SIZE * 0.5)
|
|
var field: Node2D = load("res://world/OctopusField.gd").new()
|
|
field.name = "OctopusField"
|
|
host.add_child(field)
|
|
field._process(0.1)
|
|
if field.active.size() != 1:
|
|
return _fail("entering the octopus biome spawned %d roamers" % field.active.size())
|
|
var roamer: Node = field.active[0]
|
|
if roamer.max_hp < 7 or not roamer.is_in_group("roaming_octopuses"):
|
|
return _fail("ambient octopus lacks its durable roaming composition")
|
|
fake_fleet.position = Vector2.ZERO
|
|
field._process(0.1)
|
|
if not field.active.is_empty():
|
|
return _fail("roaming octopuses followed the fleet outside their biome")
|
|
print("octopus biome spawns bounded durable roamers and retires them at its edge")
|
|
|
|
print("TEST_PLANET_SCANNER: PASS")
|
|
quit(0)
|
|
|
|
|
|
func _fail(message: String) -> void:
|
|
print("FAIL: %s" % message)
|
|
quit(1)
|
|
|
|
|
|
func _is_nearest_unconquered(state: Node, result: Dictionary,
|
|
definition_id: String) -> bool:
|
|
var origin := Vector2.ZERO
|
|
var result_distance: float = origin.distance_to(Vector2(result.world_position))
|
|
var radius: int = int(result.distance_chunks) + 2
|
|
for x in range(-radius, radius + 1):
|
|
for y in range(-radius, radius + 1):
|
|
var chunk := Vector2i(x, y)
|
|
if not WorldGen.has_planet_at_chunk(chunk):
|
|
continue
|
|
var data: Dictionary = WorldGen.planet_data_at_chunk(chunk)
|
|
if String(data.definition_id) != definition_id \
|
|
or state.is_planet_conquered(String(data.planet_id)):
|
|
continue
|
|
var world_position: Vector2 = WorldGen.chunk_origin(chunk) \
|
|
+ Vector2(data.local_pos)
|
|
if origin.distance_to(world_position) < result_distance - 0.01:
|
|
return false
|
|
return true
|