From cbe07bbb55c0f6025253dc9cf4f48f1e32139921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sun, 16 Aug 2026 13:13:38 +0200 Subject: [PATCH] Add black hole planet and dark matter resource (#55) A rare, deterministically-seeded landable body yielding a fourth resource, dark matter, obtainable only through an extractor - never a surface pickup. Reuses every existing generic system (resource/planet definitions, surface profiles, extractor/transporter pipeline) with no type-switch branching. Also fixes a latent conquest-gating gap: a surface profile with no authored PlanetSetup (like the black hole's "void") never registered capture points at all, permanently blocking extractor placement. register_capture_points() now treats zero required points as vacuously conquered, and PlanetSurfaceController registers that zero-point state explicitly when no setup exists. --- BlackHolePlanetDefinition.tres | 14 ++ BlackHoleVisual.gd | 49 +++++++ BlackHoleVisual.gd.uid | 1 + BlackHoleVisual.tscn | 6 + DarkMatterResourceDefinition.tres | 11 ++ DarkMatterResourceGlyph.gd | 26 ++++ DarkMatterResourceGlyph.gd.uid | 1 + DarkMatterResourceGlyph.tscn | 13 ++ GameState.gd | 7 +- PlanetDefinitions.gd | 2 + PlanetSurfaceController.gd | 10 ++ PlanetSurfaceProfiles.gd | 1 + ResourceDefinitions.gd | 4 +- VoidSurfaceProfile.tres | 12 ++ WorldGen.gd | 16 +++ tests/_black_hole_space_preview.gd | 23 +++ tests/_black_hole_space_preview.tscn | 6 + tests/_void_surface_preview.gd | 15 ++ tests/_void_surface_preview.tscn | 6 + tests/test_black_hole.gd | 200 +++++++++++++++++++++++++++ 20 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 BlackHolePlanetDefinition.tres create mode 100644 BlackHoleVisual.gd create mode 100644 BlackHoleVisual.gd.uid create mode 100644 BlackHoleVisual.tscn create mode 100644 DarkMatterResourceDefinition.tres create mode 100644 DarkMatterResourceGlyph.gd create mode 100644 DarkMatterResourceGlyph.gd.uid create mode 100644 DarkMatterResourceGlyph.tscn create mode 100644 VoidSurfaceProfile.tres create mode 100644 tests/_black_hole_space_preview.gd create mode 100644 tests/_black_hole_space_preview.tscn create mode 100644 tests/_void_surface_preview.gd create mode 100644 tests/_void_surface_preview.tscn create mode 100644 tests/test_black_hole.gd diff --git a/BlackHolePlanetDefinition.tres b/BlackHolePlanetDefinition.tres new file mode 100644 index 0000000..9fa5589 --- /dev/null +++ b/BlackHolePlanetDefinition.tres @@ -0,0 +1,14 @@ +[gd_resource type="Resource" script_class="PlanetDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://PlanetDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://BlackHoleVisual.tscn" id="2"] + +[resource] +script = ExtResource("1") +definition_id = "black_hole" +display_name = "Black hole" +names = PackedStringArray("Event Zero", "Umbral Well", "The Sinkhole", "Lightless Reach", "Null Point") +visual_scene = ExtResource("2") +surface_profile_id = "void" +resource_id = "dark_matter" +map_color = Color(0.55, 0.15, 0.85, 1) diff --git a/BlackHoleVisual.gd b/BlackHoleVisual.gd new file mode 100644 index 0000000..486adee --- /dev/null +++ b/BlackHoleVisual.gd @@ -0,0 +1,49 @@ +extends Node2D + +# Same informal interface as GasGiantVisual/RockyPlanetVisual - configure(), +# extractor_anchor(), animates via its own _process(). No solid body: the +# core stays genuinely empty (nothing drawn inside CORE_RADIUS), and an +# accretion disc of rotating arc segments - not closed rings - stands in +# for a surface, brighter and thicker toward the centre. + +var disc_phase: float = 0.0 + +const CORE_RADIUS: float = 46.0 +const RING_COUNT: int = 5 +const RING_STEP: float = 16.0 +const DISC_COLOR: Color = Color(0.55, 0.15, 0.85) +const DISC_HOT_COLOR: Color = Color(0.9, 0.6, 1.0) + + +func configure(_shape_seed: int) -> void: + queue_redraw() + + +func extractor_anchor() -> Vector2: + return Vector2(0.0, -(CORE_RADIUS + float(RING_COUNT - 1) * RING_STEP + 12.0)) + + +func _process(delta: float) -> void: + disc_phase += delta + queue_redraw() + + +func _draw() -> void: + for i in range(RING_COUNT): + var radius: float = CORE_RADIUS + float(i) * RING_STEP + # 1.0 at the innermost ring, fading to 0.0 at the outermost - drives + # both colour (hotter inward) and stroke weight/brightness. + var t: float = 1.0 - float(i) / float(maxi(RING_COUNT - 1, 1)) + var ring_color: Color = DISC_COLOR.lerp(DISC_HOT_COLOR, t * 0.65) + # Alternating spin direction per ring and a speed tied to t reads as + # genuine turbulence rather than one disc rotating as a rigid whole. + var spin: float = disc_phase * (0.12 + t * 0.22) * (1.0 if i % 2 == 0 else -1.0) + for arc_i in range(2): + var start: float = spin + float(arc_i) * PI + 0.15 + var end: float = start + PI - 0.3 + var points := PackedVector2Array() + var steps: int = 24 + for j in range(steps + 1): + var angle: float = lerpf(start, end, float(j) / float(steps)) + points.append(Vector2(cos(angle), sin(angle)) * radius) + NeonDraw.draw_glowing_path(self, points, ring_color, 0.5 + t * 0.5, 0.45 + t * 0.35) diff --git a/BlackHoleVisual.gd.uid b/BlackHoleVisual.gd.uid new file mode 100644 index 0000000..8f4dc2f --- /dev/null +++ b/BlackHoleVisual.gd.uid @@ -0,0 +1 @@ +uid://c6448m0c5bses diff --git a/BlackHoleVisual.tscn b/BlackHoleVisual.tscn new file mode 100644 index 0000000..65c3d79 --- /dev/null +++ b/BlackHoleVisual.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://BlackHoleVisual.gd" id="1"] + +[node name="Visual" type="Node2D"] +script = ExtResource("1") diff --git a/DarkMatterResourceDefinition.tres b/DarkMatterResourceDefinition.tres new file mode 100644 index 0000000..07b9ee5 --- /dev/null +++ b/DarkMatterResourceDefinition.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="ResourceDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://ResourceDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://DarkMatterResourceGlyph.tscn" id="2"] + +[resource] +script = ExtResource("1") +resource_id = "dark_matter" +display_name = "Dark Matter" +color = Color(0.55, 0.15, 0.85, 1) +glyph_scene = ExtResource("2") diff --git a/DarkMatterResourceGlyph.gd b/DarkMatterResourceGlyph.gd new file mode 100644 index 0000000..76da858 --- /dev/null +++ b/DarkMatterResourceGlyph.gd @@ -0,0 +1,26 @@ +extends Control + +@export var colour: Color = Color(0.55, 0.15, 0.85) + + +func _draw() -> void: + var centre: Vector2 = size * 0.5 + var radius: float = minf(size.x, size.y) * 0.34 + var points := PackedVector2Array() + for i in range(8): + var angle: float = TAU * float(i) / 8.0 - PI * 0.5 + points.append(centre + Vector2(cos(angle), sin(angle)) * radius) + # Deep violet glow around a near-black core - not colour.darkened() like + # every other glyph's core, deliberately: dark matter has to read as an + # absence even against deep space, where a merely-darker version of the + # glow colour would still show up as a faint fill instead of a void + # with a rim. + NeonDraw.draw_glowing_polygon(self, points, colour, 1.0, Color(0.04, 0.01, 0.06), maxf(0.5, radius * 0.035)) + # A tighter inner ring hints at an event horizon rather than a solid + # body - the same outline-only idiom every glyph already uses, just + # nested once more. + var inner := PackedVector2Array() + for i in range(8): + var angle: float = TAU * float(i) / 8.0 - PI * 0.5 + inner.append(centre + Vector2(cos(angle), sin(angle)) * radius * 0.5) + NeonDraw.draw_glowing_polygon(self, inner, colour.lightened(0.25), 0.75, Color(0.04, 0.01, 0.06), maxf(0.35, radius * 0.02)) diff --git a/DarkMatterResourceGlyph.gd.uid b/DarkMatterResourceGlyph.gd.uid new file mode 100644 index 0000000..5398fcb --- /dev/null +++ b/DarkMatterResourceGlyph.gd.uid @@ -0,0 +1 @@ +uid://bldtveflnyu2x diff --git a/DarkMatterResourceGlyph.tscn b/DarkMatterResourceGlyph.tscn new file mode 100644 index 0000000..21d54f0 --- /dev/null +++ b/DarkMatterResourceGlyph.tscn @@ -0,0 +1,13 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://DarkMatterResourceGlyph.gd" id="1"] + +[node name="GlyphVisual" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +script = ExtResource("1") diff --git a/GameState.gd b/GameState.gd index a1e385c..910aa6b 100644 --- a/GameState.gd +++ b/GameState.gd @@ -33,6 +33,7 @@ var explored_chunks: Dictionary = {} const RESOURCE_ORE: String = RESOURCE_DEFINITIONS.ORE_ID const RESOURCE_GAS: String = RESOURCE_DEFINITIONS.GAS_ID const RESOURCE_ENERGY: String = RESOURCE_DEFINITIONS.ENERGY_ID +const RESOURCE_DARK_MATTER: String = RESOURCE_DEFINITIONS.DARK_MATTER_ID const RESOURCE_TYPES: PackedStringArray = RESOURCE_DEFINITIONS.ORDER # All resource-bearing locations use the same container. The old public @@ -1115,7 +1116,11 @@ func register_capture_points(planet_key: String, total: int) -> void: var captured: Array = [] captured.resize(total) captured.fill(false) - planet_capture[planet_key] = {"captured": captured, "conquered": false} + # A planet demanding zero points is vacuously already held (#55: a + # black hole's "void" profile has no authored PlanetSetup at all, so + # this is the only registration it ever gets) - every planet WITH real + # points still starts unconquered, exactly as before. + planet_capture[planet_key] = {"captured": captured, "conquered": total <= 0} func capture_state(planet_key: String) -> Dictionary: diff --git a/PlanetDefinitions.gd b/PlanetDefinitions.gd index fa9dd2d..bada655 100644 --- a/PlanetDefinitions.gd +++ b/PlanetDefinitions.gd @@ -3,10 +3,12 @@ extends RefCounted const ROCKY_ID: String = "rocky" const GAS_GIANT_ID: String = "gas_giant" +const BLACK_HOLE_ID: String = "black_hole" const DEFAULT_DEFINITION_ID: String = ROCKY_ID const DEFINITIONS: Dictionary = { ROCKY_ID: preload("res://RockyPlanetDefinition.tres"), GAS_GIANT_ID: preload("res://GasGiantDefinition.tres"), + BLACK_HOLE_ID: preload("res://BlackHolePlanetDefinition.tres"), } diff --git a/PlanetSurfaceController.gd b/PlanetSurfaceController.gd index 1a74b2c..2777a61 100644 --- a/PlanetSurfaceController.gd +++ b/PlanetSurfaceController.gd @@ -65,6 +65,16 @@ func _compose_surface(profile: Resource) -> void: var setup_host: Node = SETUP_HOST_SCENE.instantiate() feature_host.add_child(setup_host) setup_host.apply_setup(setup) + else: + # No PlanetSetup exists for this profile at all (#55: "void" has + # none authored) - register zero required points directly, rather + # than leaving this planet's conquest state unregistered forever. + # capture_state()'s fallback for an unregistered planet_id defaults + # to conquered=false (see test_planet_conquest_gate.gd's own "a + # fresh planet reported conquered" check), which would otherwise + # permanently block extractor placement here - register_capture_ + # points() treats a total of 0 as vacuously already conquered. + GameState.register_capture_points(GameState.planet_id, 0) func _on_exit_pressed() -> void: diff --git a/PlanetSurfaceProfiles.gd b/PlanetSurfaceProfiles.gd index 97da418..5624fae 100644 --- a/PlanetSurfaceProfiles.gd +++ b/PlanetSurfaceProfiles.gd @@ -5,6 +5,7 @@ const DEFAULT_PROFILE_ID: String = "rocky" const PROFILES: Dictionary = { "rocky": preload("res://RockySurfaceProfile.tres"), "gas": preload("res://GasSurfaceProfile.tres"), + "void": preload("res://VoidSurfaceProfile.tres"), } diff --git a/ResourceDefinitions.gd b/ResourceDefinitions.gd index 3726aae..12ca1bd 100644 --- a/ResourceDefinitions.gd +++ b/ResourceDefinitions.gd @@ -4,11 +4,13 @@ extends RefCounted const ORE_ID: String = "ore" const GAS_ID: String = "gas" const ENERGY_ID: String = "energy" -const ORDER: PackedStringArray = [ORE_ID, GAS_ID, ENERGY_ID] +const DARK_MATTER_ID: String = "dark_matter" +const ORDER: PackedStringArray = [ORE_ID, GAS_ID, ENERGY_ID, DARK_MATTER_ID] const DEFINITIONS: Dictionary = { ORE_ID: preload("res://OreResourceDefinition.tres"), GAS_ID: preload("res://GasResourceDefinition.tres"), ENERGY_ID: preload("res://EnergyResourceDefinition.tres"), + DARK_MATTER_ID: preload("res://DarkMatterResourceDefinition.tres"), } diff --git a/VoidSurfaceProfile.tres b/VoidSurfaceProfile.tres new file mode 100644 index 0000000..da0b6f3 --- /dev/null +++ b/VoidSurfaceProfile.tres @@ -0,0 +1,12 @@ +[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=2 format=3] + +[ext_resource type="Script" path="res://PlanetSurfaceProfile.gd" id="1"] + +[resource] +script = ExtResource("1") +profile_id = "void" +base_color = Color(0.04, 0.02, 0.07, 1) +crater_color = Color(0.01, 0.005, 0.02, 1) +highlight_color = Color(0.45, 0.15, 0.65, 1) +caption_color = Color(0.85, 0.7, 0.95, 1) +caption_outline_color = Color(0.03, 0.01, 0.05, 0.9) diff --git a/WorldGen.gd b/WorldGen.gd index 7cc6c6c..3238051 100644 --- a/WorldGen.gd +++ b/WorldGen.gd @@ -16,6 +16,12 @@ const NOISE_SEED: int = 1337 const ASTEROID_FIELD_THRESHOLD: float = 0.15 const PLANET_THRESHOLD: float = 0.33 const GAS_GIANT_THRESHOLD: float = 0.55 +# A flat per-planet-chunk probability rather than another noise field: gas +# giants already cover every SPACE-biome planet chunk (see GAS_GIANT_THRESHOLD +# above), so making black holes noticeably rarer just needs a small, uniform +# chance independent of biome - not a second smooth region to tune against +# the first. +const BLACK_HOLE_CHANCE: float = 0.05 # A predictable early gas source for play-testing. The origin chunk is # deterministically normal space. Its explicit in-chunk position puts it @@ -84,10 +90,20 @@ static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary: var local_pos: Vector2 = NEARBY_GAS_GIANT_LOCAL_POS if guaranteed_nearby_giant \ else Vector2(rng.randf() * CHUNK_SIZE, rng.randf() * CHUNK_SIZE) var shape_seed: int = rng.randi() + # Drawn unconditionally, in the same fixed position every call, + # regardless of biome or the gas-giant roll below - so adding this + # roll doesn't shift which random values any LATER draw (planet_name, + # below) consumes for chunks that would have existed before it. + var black_hole_roll: float = rng.randf() # A stable per-chunk roll makes gas giants persistent landmarks rather # than a different resource whenever the scene reloads. var gas_giant: bool = guaranteed_nearby_giant or biome_at_chunk(chunk) == Biome.SPACE var definition_id: String = PLANET_DEFINITIONS.GAS_GIANT_ID if gas_giant else PLANET_DEFINITIONS.ROCKY_ID + # A rare third outcome, seeded the same deterministic way as everything + # else here. Never overrides the guaranteed early gas giant landmark - + # that chunk's whole purpose is to always be the same reachable source. + if not guaranteed_nearby_giant and black_hole_roll < BLACK_HOLE_CHANCE: + definition_id = PLANET_DEFINITIONS.BLACK_HOLE_ID var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id) var names: PackedStringArray = definition.names var planet_name: String = names[rng.randi() % names.size()] diff --git a/tests/_black_hole_space_preview.gd b/tests/_black_hole_space_preview.gd new file mode 100644 index 0000000..7949634 --- /dev/null +++ b/tests/_black_hole_space_preview.gd @@ -0,0 +1,23 @@ +extends Node2D + +# Preview, not a test - a black hole planet as seen in space, next to a +# rocky planet for scale/contrast. +# +# bash tests/screenshot.sh res://tests/_black_hole_space_preview.tscn /tmp/black_hole.png 15 720x1280 + + +func _ready() -> void: + GameState.extractors.clear() + GameState.buildings.clear() + + _spawn("Event Zero", "black_hole", Vector2(360.0, 420.0), 12345) + _spawn("Dustworn", "rocky", Vector2(360.0, 950.0), 4242) + + +func _spawn(planet_name: String, definition_id: String, at: Vector2, shape_seed: int) -> void: + var planet: Node2D = load("res://Planet.tscn").instantiate() + planet.planet_name = planet_name + planet.definition_id = definition_id + planet.shape_seed = shape_seed + planet.position = at + add_child(planet) diff --git a/tests/_black_hole_space_preview.tscn b/tests/_black_hole_space_preview.tscn new file mode 100644 index 0000000..7d178d5 --- /dev/null +++ b/tests/_black_hole_space_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_black_hole_space_preview.gd" id="1"] + +[node name="BlackHoleSpacePreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/_void_surface_preview.gd b/tests/_void_surface_preview.gd new file mode 100644 index 0000000..6ff3607 --- /dev/null +++ b/tests/_void_surface_preview.gd @@ -0,0 +1,15 @@ +extends Node2D + +# Preview, not a test - a black hole's landed "void" surface: no craters, no +# resource pickups, nothing but the empty field itself. +# +# bash tests/screenshot.sh res://tests/_void_surface_preview.tscn /tmp/void.png 15 720x1280 + + +func _ready() -> void: + GameState.planet_name = "Event Zero" + GameState.planet_definition_id = "black_hole" + GameState.surviving_ship_count = 3 + var surface: Node = load("res://PlanetSurface.tscn").instantiate() + surface.get_node("SurfaceController").fade_duration = 0.0 + add_child(surface) diff --git a/tests/_void_surface_preview.tscn b/tests/_void_surface_preview.tscn new file mode 100644 index 0000000..e2bda33 --- /dev/null +++ b/tests/_void_surface_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_void_surface_preview.gd" id="1"] + +[node name="VoidSurfacePreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/test_black_hole.gd b/tests/test_black_hole.gd new file mode 100644 index 0000000..304536f --- /dev/null +++ b/tests/test_black_hole.gd @@ -0,0 +1,200 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_black_hole.gd +# Forgejo #55: a rare landable body yielding dark matter, obtainable only +# through an extractor - never a surface pickup. +# +# Covers: +# A. WorldGen.planet_data_at_chunk() is a pure, deterministic function of +# chunk coordinates, and black holes are noticeably rarer than gas +# giants across a wide scan +# B. dark matter is fully generic - in resource_types(), its own +# ResourceDefinition/glyph/colour, distinct from every other resource +# C. landing on a black hole spawns no resource provider and no capture +# points at all (void's features_scene is null, and it has no +# authored PlanetSetup) - yet the planet is immediately conquered +# (register_capture_points treats zero required points as vacuous), +# so an extractor is buildable there right away +# D. an extractor on a black hole mines dark matter locally exactly like +# any other extractor, and a hub transporter banks it through the +# normal collection path with no black-hole-specific code + +var game_state: Node +var stage: int = 0 +var elapsed: float = 0.0 +var scene: Node +var extractor_index: int = 0 +var load_limit: int = 0 +var baseline: Dictionary = {} + +const BLACK_HOLE_AT := Vector2(500.0, 0.0) +const MOTHERSHIP_AT := Vector2.ZERO +const HUB_AT := Vector2(150.0, 0.0) + + +func _initialize() -> void: + game_state = root.get_node("GameState") + + # --- A. deterministic and rare ----------------------------------------- + var black_hole_count: int = 0 + var gas_giant_count: int = 0 + var total_planets: int = 0 + for x in range(-40, 40): + for y in range(-40, 40): + var chunk: Vector2i = Vector2i(x, y) + if not WorldGen.has_planet_at_chunk(chunk): + continue + total_planets += 1 + var first: Dictionary = WorldGen.planet_data_at_chunk(chunk) + var second: Dictionary = WorldGen.planet_data_at_chunk(chunk) + if first.definition_id != second.definition_id or first.local_pos != second.local_pos \ + or first.shape_seed != second.shape_seed or first.name != second.name: + _fail("planet_data_at_chunk(%s) is not deterministic" % chunk) + return + if first.definition_id == "black_hole": + black_hole_count += 1 + elif first.definition_id == "gas_giant": + gas_giant_count += 1 + print("scanned %d planet chunks: %d black holes, %d gas giants" % [ + total_planets, black_hole_count, gas_giant_count + ]) + if black_hole_count == 0: + _fail("no black holes generated across a wide scan - the rare outcome never fires") + return + if black_hole_count >= gas_giant_count: + _fail("black holes (%d) are not rarer than gas giants (%d)" % [black_hole_count, gas_giant_count]) + return + + # --- B. dark matter is fully generic ----------------------------------- + if not game_state.resource_types().has(GameState.RESOURCE_DARK_MATTER): + _fail("dark_matter is not in resource_types() - every resource menu would miss it") + return + var definition: Resource = game_state.resource_definition(GameState.RESOURCE_DARK_MATTER) + if definition == null or definition.glyph_scene == null: + _fail("dark_matter has no ResourceDefinition or glyph_scene") + return + var dm_color: Color = game_state.resource_color(GameState.RESOURCE_DARK_MATTER) + for other in [GameState.RESOURCE_ORE, GameState.RESOURCE_GAS, GameState.RESOURCE_ENERGY]: + if dm_color == game_state.resource_color(other): + _fail("dark_matter shares a colour with %s" % other) + return + print("dark matter is generic: in resource_types(), own definition, own colour %s" % dm_color) + + # --- structural: the black hole definition itself ----------------------- + var black_hole_def: Resource = load("res://PlanetDefinitions.gd").get_definition("black_hole") + if black_hole_def.resource_id != "dark_matter": + _fail("black_hole planet's resource_id is '%s', expected 'dark_matter'" % black_hole_def.resource_id) + return + if black_hole_def.surface_profile_id != "void": + _fail("black_hole planet's surface_profile_id is '%s', expected 'void'" % black_hole_def.surface_profile_id) + return + var void_profile: Resource = load("res://PlanetSurfaceProfiles.gd").get_profile("void") + if void_profile.features_scene != null: + _fail("void surface profile has a features_scene - dark matter must never be a surface pickup") + return + + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.planet_capture.clear() + game_state.planet_id = "test_black_hole" + game_state.planet_name = "Event Zero" + game_state.planet_definition_id = "black_hole" + scene = load("res://PlanetSurface.tscn").instantiate() + root.add_child(scene) + current_scene = scene + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + # --- C. no resource provider, no capture points, yet conquered --- + if elapsed < 0.3: + return false + var feature_host: Node = scene.get_node("SurfaceFeatures") + if feature_host.get_child_count() != 0: + return _fail("black hole surface spawned %d children under SurfaceFeatures - expected none" % feature_host.get_child_count()) + if not game_state.is_planet_conquered(game_state.planet_id): + return _fail("a black hole with no capture points required is not marked conquered") + print("no resource provider, no capture points, already conquered") + + # Snapshot rather than assert > 0: bank_inventory starts with 500 + # ore by default (a fresh game's starting stock), unrelated to + # this planet - "nothing was picked up" has to mean "unchanged", + # not "still zero". + for resource_type in game_state.resource_types(): + baseline[resource_type] = game_state.banked_resource(resource_type) + game_state.fleet_resource(resource_type) + + var fleet: Node = scene.get_node("Fleet") + for ship in fleet.ships: + ship.position += Vector2(300.0, 200.0) + fleet.move_to(fleet.get_centroid()) + elapsed = 0.0 + stage = 1 + return false + 1: + # --- C continued: flying around picks up nothing, ever ----------- + if elapsed < 2.0: + return false + for resource_type in game_state.resource_types(): + var total: int = game_state.banked_resource(resource_type) + game_state.fleet_resource(resource_type) + if total != baseline.get(resource_type, 0): + return _fail("something (%s) was picked up over a black hole surface" % resource_type) + print("flew around for %.1fs, nothing picked up" % elapsed) + + # --- D: extractor mining + transporter banking -------------------- + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.reset_progression() + for node in GameState.SKILL_TREE: + game_state.unlocked.append(node.id) + game_state.add_building(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT) + game_state.add_building(GameState.BUILDING_TRANSPORT_HUB, HUB_AT) + game_state.add_extractor("Event Zero", BLACK_HOLE_AT, GameState.RESOURCE_DARK_MATTER, "test_black_hole_extractor") + extractor_index = game_state.extractor_index_at("test_black_hole_extractor") + if extractor_index < 0: + return _fail("add_extractor did not register the black hole extractor") + load_limit = game_state.transporter_load_limit() + elapsed = 0.0 + stage = 2 + return false + 2: + # Mining is local, exactly like ore - no black-hole-specific code. + if elapsed < 1.2: + return false + var mined: int = game_state.extractor_resource(extractor_index, GameState.RESOURCE_DARK_MATTER) + if mined <= 0: + return _fail("extractor mined no dark matter locally after %.1fs" % elapsed) + print("extractor mined %d dark matter locally, bank still %d" % [ + mined, game_state.banked_resource(GameState.RESOURCE_DARK_MATTER) + ]) + # Force a full load directly (bypasses waiting out the real + # mining rate for the rest) so the transporter pickup dispatch + # fires without a long extra wait. + game_state.extractors[extractor_index].inventory.set_amount(GameState.RESOURCE_DARK_MATTER, load_limit) + elapsed = 0.0 + stage = 3 + return false + 3: + # --- a transporter collects the full load and banks it ----------- + if game_state.banked_resource(GameState.RESOURCE_DARK_MATTER) >= load_limit: + print("transporter banked %d dark matter" % game_state.banked_resource(GameState.RESOURCE_DARK_MATTER)) + print("TEST_BLACK_HOLE: PASS") + quit(0) + return true + if elapsed > 10.0: + return _fail("dark matter was never collected to the bank (extractor=%d bank=%d)" % [ + game_state.extractor_resource(extractor_index, GameState.RESOURCE_DARK_MATTER), + game_state.banked_resource(GameState.RESOURCE_DARK_MATTER), + ]) + return false + return false + + +func _fail(message: String) -> bool: + print("TEST_BLACK_HOLE: FAIL (%s)" % message) + quit(1) + return true