diff --git a/BuildController.gd b/BuildController.gd index f316929..02fb933 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -543,10 +543,13 @@ func _refresh_build_status() -> void: # Prices are shown under each option now, so this only needs to say # whether the fleet is somewhere an extractor could go. - if _planet_under_fleet() != null: - build_status.text = "Over %s - an extractor can be built here" % _planet_under_fleet().planet_name - else: + var planet: Node2D = _planet_under_fleet() + if planet == null: build_status.text = "" + elif not GameState.is_planet_conquered(planet.planet_id): + build_status.text = "Over %s - capture it before building an extractor" % planet.planet_name + else: + build_status.text = "Over %s - an extractor can be built here" % planet.planet_name func _refresh_build_option_states() -> void: @@ -554,7 +557,8 @@ func _refresh_build_option_states() -> void: var definition: Resource = entry[4] var enabled: bool = GameState.is_building_unlocked(definition.definition_id) if definition.placement_mode == "planet": - enabled = enabled and _planet_under_fleet() != null + var planet: Node2D = _planet_under_fleet() + enabled = enabled and planet != null and GameState.is_planet_conquered(planet.planet_id) entry[1].enabled = enabled entry[1].queue_redraw() @@ -593,6 +597,9 @@ func _show_building_info(type: String) -> void: if planet == null: lines.append("No planet in reach - fly over one.") can_build = false + elif not GameState.is_planet_conquered(planet.planet_id): + lines.append("%s is not captured yet - take all its capture points." % planet.planet_name) + can_build = false elif GameState.has_extractor_at(planet.planet_id): lines.append("%s already has an extractor." % planet.planet_name) can_build = false @@ -639,6 +646,9 @@ func _confirm_build() -> void: if planet == null or GameState.has_extractor_at(planet.planet_id): build_status.text = "No planet in reach any more" return + if not GameState.is_planet_conquered(planet.planet_id): + build_status.text = "%s is not captured yet" % planet.planet_name + return if not GameState.spend(definition.cost): build_status.text = "Not enough rocks (need %d, have %d)" % [definition.cost, GameState.rocks] return diff --git a/GameState.gd b/GameState.gd index 3854f56..0f2a3db 100644 --- a/GameState.gd +++ b/GameState.gd @@ -1020,6 +1020,66 @@ func _extractor_inventory(extractor: Dictionary) -> RefCounted: return inventory +# Planet conquest: a planet must have every one of its capture points held +# before an extractor can be built there (see BuildController's placement +# gates). Keyed by the same stable planet_id as `extractors`, entirely +# separate from it - a planet can be conquered long before anyone builds an +# extractor on it, or never get one at all. +var planet_capture: Dictionary = {} + +signal planet_conquered(planet_id: String) + + +func register_capture_points(planet_key: String, total: int) -> void: + # Idempotent: landing again on a planet must not wipe points already + # captured on a previous visit. + if planet_capture.has(planet_key): + return + var captured: Array = [] + captured.resize(total) + captured.fill(false) + planet_capture[planet_key] = {"captured": captured, "conquered": false} + + +func capture_state(planet_key: String) -> Dictionary: + return planet_capture.get(planet_key, {"captured": [], "conquered": false}) + + +func is_point_captured(planet_key: String, index: int) -> bool: + var captured: Array = capture_state(planet_key).get("captured", []) + if index < 0 or index >= captured.size(): + return false + return bool(captured[index]) + + +func mark_point_captured(planet_key: String, index: int) -> void: + if not planet_capture.has(planet_key): + return + var state: Dictionary = planet_capture[planet_key] + var captured: Array = state.captured + if index < 0 or index >= captured.size(): + return + captured[index] = true + if state.conquered: + return + if captured.all(func(c): return bool(c)): + state.conquered = true + planet_conquered.emit(planet_key) + + +func is_planet_conquered(planet_key: String) -> bool: + return bool(capture_state(planet_key).get("conquered", false)) + + +func captured_point_count(planet_key: String) -> int: + var captured: Array = capture_state(planet_key).get("captured", []) + var count: int = 0 + for c in captured: + if c: + count += 1 + return count + + func _process(delta: float) -> void: # Runs in the autoload, so mining and transport continue while the player # is on a planet's surface or nowhere near either. diff --git a/tests/test_extractor_building.gd b/tests/test_extractor_building.gd index 0e52c21..656c2c5 100644 --- a/tests/test_extractor_building.gd +++ b/tests/test_extractor_building.gd @@ -42,6 +42,12 @@ func _initialize() -> void: game_state.rocks = 200 game_state.buildings.clear() game_state.extractors.clear() + game_state.planet_capture.clear() + # This test is about the planet-proximity/duplicate rules, not conquest + # (see test_planet_conquest_gate), so pre-conquer the target planet. + var target_planet_id: String = "chunk:%d:%d" % [target_chunk.x, target_chunk.y] + game_state.register_capture_points(target_planet_id, 1) + game_state.mark_point_captured(target_planet_id, 0) # Extractors are gated behind research (see test_skill_tree). This test # is about the planet-proximity rule, so grant the research up front. game_state.reset_progression() diff --git a/tests/test_planet_conquest_gate.gd b/tests/test_planet_conquest_gate.gd new file mode 100644 index 0000000..93b69ba --- /dev/null +++ b/tests/test_planet_conquest_gate.gd @@ -0,0 +1,157 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_planet_conquest_gate.gd +# Verifies: +# 1. GameState starts every planet unconquered, and register_capture_points +# is idempotent (a second call with the same total doesn't wipe progress) +# 2. mark_point_captured only conquers once every point is captured, and +# the planet_conquered signal fires exactly once +# 3. over an unconquered planet, the extractor build option is disabled, +# the info popup explains why, and confirming spends nothing and builds +# nothing +# 4. once conquered, the same build flow works and charges the cost + +var main_scene: Node +var fleet: Node +var game_state: Node +var controller: Node +var button: IconButton +var popup: Control +var target_chunk: Vector2i +var stage: int = 0 +var elapsed: float = 0.0 +var conquered_signal_count: int = 0 + + +func _initialize() -> void: + var found: bool = false + for x in range(-15, 15): + for y in range(-15, 15): + var chunk: Vector2i = Vector2i(x, y) + if WorldGen.has_planet_at_chunk(chunk): + target_chunk = chunk + found = true + break + if found: + break + if not found: + return _fail("no planet chunk to test with") + + game_state = root.get_node("GameState") + game_state.rocks = 200 + game_state.buildings.clear() + game_state.extractors.clear() + game_state.planet_capture.clear() + game_state.reset_progression() + for node in GameState.SKILL_TREE: + game_state.unlocked.append(node.id) + + var target_planet_id: String = "chunk:%d:%d" % [target_chunk.x, target_chunk.y] + + # Unit-level checks on GameState itself, before any scene involvement. + if game_state.is_planet_conquered(target_planet_id): + return _fail("a fresh planet reported conquered") + game_state.register_capture_points(target_planet_id, 3) + game_state.mark_point_captured(target_planet_id, 0) + game_state.register_capture_points(target_planet_id, 3) # idempotent re-registration + if game_state.captured_point_count(target_planet_id) != 1: + return _fail("re-registering wiped already-captured progress") + game_state.planet_conquered.connect(func(_id): conquered_signal_count += 1) + game_state.mark_point_captured(target_planet_id, 1) + if game_state.is_planet_conquered(target_planet_id): + return _fail("conquered with points still missing") + game_state.mark_point_captured(target_planet_id, 2) + if not game_state.is_planet_conquered(target_planet_id): + return _fail("all points captured but planet not marked conquered") + # Marking an already-captured point again must not double-fire the signal. + game_state.mark_point_captured(target_planet_id, 2) + if conquered_signal_count != 1: + return _fail("planet_conquered fired %d times, expected exactly 1" % conquered_signal_count) + + # Reset conquest for the scene-level gate check below - this run tests + # the unconquered path first, then conquers it. + game_state.planet_capture.clear() + + main_scene = load("res://Main.tscn").instantiate() + root.add_child(main_scene) + fleet = main_scene.get_node("Fleet") + controller = main_scene.get_node("BuildController") + button = main_scene.get_node("UI/BuildMenu/ExtractorButton") + popup = main_scene.get_node("UI/BuildMenu/InfoPopup") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + main_scene.get_node("AsteroidField").set_process(false) + main_scene.get_node("EnemySpawner").set_process(false) + var near: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5 + for ship in fleet.ships: + ship.position = near + fleet.move_to(near) + elapsed = 0.0 + stage = 1 + return false + 1: + var planet_field: Node = main_scene.get_node("PlanetField") + if elapsed < planet_field.check_interval + 0.2: + return false + if not planet_field.populated_chunks.has(target_chunk): + return _fail("planet never spawned") + var planet: Node2D = planet_field.populated_chunks[target_chunk][0] + for ship in fleet.ships: + ship.position = planet.position + fleet.move_to(planet.position) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.3: + return false + var planet_field: Node = main_scene.get_node("PlanetField") + var planet: Node2D = planet_field.populated_chunks[target_chunk][0] + + controller._on_build_pressed() + if button.enabled: + return _fail("extractor option enabled over an unconquered planet") + controller._show_building_info(GameState.BUILDING_EXTRACTOR) + var explains_capture: bool = popup.visible \ + and "captur" in popup.get_node("Panel/MessageLabel").text.to_lower() + var offered: bool = popup.get_node("Panel/BuildButton").visible + if not explains_capture or offered: + return _fail("unconquered popup did not explain itself, or offered a build") + var rocks_before: int = game_state.rocks + controller._confirm_build() # must do nothing even if somehow pressed + if game_state.rocks != rocks_before or not game_state.extractors.is_empty(): + return _fail("build proceeded over an unconquered planet") + popup.visible = false + + # Now conquer it and confirm the same flow succeeds. + game_state.register_capture_points(planet.planet_id, 2) + game_state.mark_point_captured(planet.planet_id, 0) + game_state.mark_point_captured(planet.planet_id, 1) + + controller._on_build_pressed() + if not button.enabled: + return _fail("extractor option stayed disabled after conquest") + controller._show_building_info(GameState.BUILDING_EXTRACTOR) + if not popup.get_node("Panel/BuildButton").visible: + return _fail("conquered planet did not offer a build") + controller._confirm_build() + var built: bool = game_state.has_extractor_at(planet.planet_id) + var charged: bool = game_state.rocks == rocks_before - GameState.EXTRACTOR_COST + if not built or not charged: + return _fail("conquered planet did not build/charge correctly") + + print("TEST_PLANET_CONQUEST_GATE: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_PLANET_CONQUEST_GATE: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_planet_conquest_gate.gd.uid b/tests/test_planet_conquest_gate.gd.uid new file mode 100644 index 0000000..236fedc --- /dev/null +++ b/tests/test_planet_conquest_gate.gd.uid @@ -0,0 +1 @@ +uid://k4l7u3r84jch