Show buildings on the world map, and extractors on their planets

Forgejo #11: buildings the player has placed now appear on the world
map. They are placed freely in space rather than tied to a chunk, so
they get their own pass in WorldMapContent._draw() rather than riding
along with the chunk markers. Each type has its own shape and colour,
mirroring Building.gd's constants so a marker reads as the same
building you built.

Forgejo #9: a planet with an extractor built on it now shows it, in
both places you look at a planet - an amber derrick on its outline in
space, and a matching badge on its world-map marker. The extractor
lives on PlanetSurface.tscn, a scene you are not looking at from
space, so an already-mined planet was otherwise indistinguishable
from an untouched one until you landed on it.

The positioning steps are factored out (building_markers(),
chunk_has_extractor_badge(), extractor_derrick_tip()) so headless
tests can assert on them - --headless has no GPU and draws no pixels,
so there is nothing to check in _draw() itself. Both features were
also rendered for real via tests/screenshot.sh and looked at.

extractor_derrick_tip() exists because the first version of the
"derrick is not buried inside the planet" check measured the furthest
of all four derrick points, which passes a derrick pointing straight
into the ground: the base corners sit a half-width off the rim vertex
and clear the outline on their own. Mutation-testing caught it.

screenshot.sh now clears a stale /tmp/.X99-lock. A run killed
mid-screenshot leaves it behind and Xvfb then refuses :99, which
resurfaces later as Godot's misleading "X11 Display is not available".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-11 15:29:56 +02:00
commit 8dd5d7b78f
13 changed files with 472 additions and 0 deletions

45
tests/_marker_preview.gd Normal file
View file

@ -0,0 +1,45 @@
extends Node2D
# Preview, not a test (underscore prefix keeps it out of run_tests.sh).
# Renders the world map with buildings placed and an extractor built, so the
# issue #11 building markers and the issue #9 extractor badge can actually be
# LOOKED at. --headless draws no pixels, so no headless test can tell whether
# a marker is visible - see tests/screenshot.sh.
#
# bash tests/screenshot.sh res://tests/_marker_preview.tscn /tmp/map.png 20 720x1280
func _ready() -> void:
GameState.buildings.clear()
GameState.extractors.clear()
GameState.explored_chunks.clear()
# Near the origin, where the fleet starts, so everything lands on screen.
GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(900.0, -700.0))
GameState.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(-1100.0, 300.0))
GameState.add_building(GameState.BUILDING_COMMAND_CENTER, Vector2(400.0, 1200.0))
# Reveal a patch of map, and put an extractor on whichever planet is
# nearest the origin so its badge is in frame too.
var best: Vector2i = Vector2i.ZERO
var best_distance: int = 1 << 30
for x in range(-6, 7):
for y in range(-6, 7):
var chunk: Vector2i = Vector2i(x, y)
GameState.explored_chunks[chunk] = true
if WorldGen.has_planet_at_chunk(chunk):
var d: int = absi(x) + absi(y)
if d < best_distance:
best_distance = d
best = chunk
if best_distance < (1 << 30):
var data: Dictionary = WorldGen.planet_data_at_chunk(best)
GameState.add_extractor(data.name, WorldGen.chunk_origin(best) + data.local_pos)
print("extractor on '%s' at chunk %s" % [data.name, best])
var main: Node = load("res://Main.tscn").instantiate()
add_child(main)
# open_map(), not `visible = true` - opening also recentres the view on
# the fleet, and without that the map is drawn from its own origin in the
# corner of the screen with everything near the fleet cropped off.
main.get_node("UI/WorldMap").open_map()

View file

@ -0,0 +1 @@
uid://blbow3jq36fdw

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_marker_preview.gd" id="1"]
[node name="MarkerPreview" type="Node2D"]
script = ExtResource("1")

View file

@ -0,0 +1,25 @@
extends Node2D
# Preview, not a test. Two planets side by side in SPACE - the left one has
# an extractor built on it, the right one does not - so the issue #9 derrick
# can be compared against a plain planet and judged as actually visible.
#
# bash tests/screenshot.sh res://tests/_planet_derrick_preview.tscn /tmp/derrick.png 15 1280x720
func _ready() -> void:
GameState.extractors.clear()
GameState.buildings.clear()
GameState.add_extractor("Mined World", Vector2.ZERO)
_spawn("Mined World", Vector2(380.0, 380.0), 12345)
_spawn("Untouched World", Vector2(900.0, 380.0), 4242)
func _spawn(planet_name: String, at: Vector2, shape_seed: int) -> void:
var planet: Node2D = load("res://Planet.tscn").instantiate()
planet.planet_name = planet_name
planet.shape_seed = shape_seed
planet.position = at
add_child(planet)
print("%s: derrick=%s" % [planet_name, planet.shows_extractor])

View file

@ -0,0 +1 @@
uid://dl4abkye58bd6

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_planet_derrick_preview.gd" id="1"]
[node name="PlanetDerrickPreview" type="Node2D"]
script = ExtResource("1")

View file

@ -28,6 +28,13 @@ WAIT_FRAMES="${3:-60}"
RESOLUTION="${4:-1280x720}"
proot-distro login ubuntu -- bash -c "
# A killed run (or a session that ended mid-screenshot) leaves /tmp/.X99-lock
# behind, and Xvfb then refuses to claim :99 - which surfaces much later and
# far less obviously as Godot's 'X11 Display is not available'. Nothing else
# uses :99 here, so a lock with no live Xvfb behind it is always stale.
if ! pgrep -x Xvfb >/dev/null; then
rm -f /tmp/.X99-lock /tmp/.X11-unix/X99
fi
Xvfb :99 -screen 0 ${RESOLUTION}x24 &
XVFB_PID=\$!
sleep 2

View file

@ -0,0 +1,99 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_map_building_markers.gd
# Forgejo issue #11: a building the player has placed must appear on the
# world map. Without it the map showed explored chunks, planets and the
# fleet, but nothing the player had actually built - so there was no way to
# find your own base again once you had flown away from it.
#
# Buildings are not tied to a chunk (they are placed freely in space), so
# they cannot ride along with the chunk markers and need their own pass.
#
# --headless has no GPU and draws no pixels, so this cannot assert on what
# was rendered. It asserts on building_markers(), the step that turns
# GameState.buildings into map-local marker data, which is the part that
# _draw() then simply stamps out.
#
# Verifies:
# 1. no buildings -> no markers (so a passing count is not just "always 3")
# 2. every placed building produces exactly one marker
# 3. markers land at the building's world position scaled into map space
# 4. the three building types are told apart by colour, rather than all
# being drawn as the same anonymous dot
var scene: Node
var game_state: Node
var map_content: Node2D
var stage: int = 0
const MOTHERSHIP_AT: Vector2 = Vector2(2000.0, -1200.0)
const CRAFTER_AT: Vector2 = Vector2(-800.0, 400.0)
const COMMAND_AT: Vector2 = Vector2(150.0, 3000.0)
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
map_content = scene.get_node("UI/WorldMap/MapContent")
func _process(_delta: float) -> bool:
match stage:
0:
# Nothing built yet: the map must show no building markers at all.
var empty: Array = map_content.building_markers()
if not empty.is_empty():
print("TEST_MAP_BUILDING_MARKERS: FAIL (%d markers with nothing built)" % empty.size())
quit(1)
return true
game_state.add_building(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
game_state.add_building(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT)
game_state.add_building(GameState.BUILDING_COMMAND_CENTER, COMMAND_AT)
stage = 1
return false
1:
var markers: Array = map_content.building_markers()
print("markers=%d for %d buildings" % [markers.size(), game_state.buildings.size()])
if markers.size() != 3:
print("TEST_MAP_BUILDING_MARKERS: FAIL (expected one marker per building)")
quit(1)
return true
var scale: float = map_content.WORLD_TO_LOCAL
var expected: Dictionary = {
GameState.BUILDING_MOTHERSHIP: MOTHERSHIP_AT * scale,
GameState.BUILDING_SHIP_CRAFTER: CRAFTER_AT * scale,
GameState.BUILDING_COMMAND_CENTER: COMMAND_AT * scale,
}
var colours: Array = []
for m in markers:
if not expected.has(m.type):
print("TEST_MAP_BUILDING_MARKERS: FAIL (unexpected marker type %s)" % m.type)
quit(1)
return true
var want: Vector2 = expected[m.type]
if m.position.distance_to(want) > 0.001:
print("%s marker at %s, expected %s" % [m.type, m.position, want])
print("TEST_MAP_BUILDING_MARKERS: FAIL (marker is not where the building is)")
quit(1)
return true
colours.append(m.color)
# Three buildings drawn in one indistinguishable colour would
# satisfy every check above and still be useless to read.
if colours[0] == colours[1] or colours[1] == colours[2] or colours[0] == colours[2]:
print("colours=%s" % [colours])
print("TEST_MAP_BUILDING_MARKERS: FAIL (building types are not visually distinct)")
quit(1)
return true
print("all 3 markers positioned correctly, colours distinct: %s" % [colours])
print("TEST_MAP_BUILDING_MARKERS: PASS")
quit(0)
return true
return false

View file

@ -0,0 +1 @@
uid://bx3bce8ix4rbg

View file

@ -0,0 +1,138 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_planet_extractor_marker.gd
# Forgejo issue #9: a planet with an extractor built on it must show that
# something is built there. The extractor lives on PlanetSurface.tscn, a
# scene the player is not looking at from space, so an already-mined planet
# was indistinguishable from an untouched one - you had to land to find out.
#
# Covered in both places the player looks at a planet:
# - in space, as a derrick on the planet's own outline (Planet.gd)
# - on the world map, as a badge on the planet marker (WorldMapContent.gd)
#
# --headless has no GPU, so this asserts on the state and geometry that
# _draw() consumes, not on pixels. tests/screenshot.sh covers whether it is
# actually visible.
#
# Verifies:
# 1. a planet with no extractor shows neither derrick nor badge (so a pass
# cannot just mean "always on")
# 2. building one turns both on, keyed by planet NAME - the same key
# GameState.has_extractor_at uses
# 3. the derrick sits outside the planet's outline rather than buried in
# it, which is the difference between visible and not
var game_state: Node
var scene: Node
var map_content: Node2D
var planet_chunk: Vector2i
var planet_name: String = ""
var stage: int = 0
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
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):
planet_chunk = chunk
planet_name = WorldGen.planet_data_at_chunk(chunk).name
found = true
break
if found:
break
if not found:
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (no planet chunk to test with)")
quit(1)
return
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
map_content = scene.get_node("UI/WorldMap/MapContent")
func _spawn_planet() -> Node2D:
var planet: Node2D = load("res://Planet.tscn").instantiate()
planet.planet_name = planet_name
planet.shape_seed = 12345
scene.add_child(planet)
return planet
func _process(_delta: float) -> bool:
match stage:
0:
# --- nothing built there yet --------------------------------
var clean: Node2D = _spawn_planet()
if clean.shows_extractor:
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (derrick on a planet with no extractor)")
quit(1)
return true
if map_content.chunk_has_extractor_badge(planet_chunk):
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (map badge with no extractor)")
quit(1)
return true
clean.queue_free()
print("no extractor -> no derrick, no badge (planet '%s')" % planet_name)
game_state.add_extractor(planet_name, WorldGen.chunk_origin(planet_chunk))
stage = 1
return false
1:
# --- extractor built ----------------------------------------
var mined: Node2D = _spawn_planet()
if not mined.shows_extractor:
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (no derrick after building an extractor)")
quit(1)
return true
if not map_content.chunk_has_extractor_badge(planet_chunk):
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (no map badge after building an extractor)")
quit(1)
return true
# The derrick has to clear the outline to be seen at all. Compare
# its tip against the planet's own radius in that direction, not
# against a constant - the outline is a lumpy seeded shape.
var derrick: PackedVector2Array = mined.extractor_derrick_points()
if derrick.size() < 3:
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (derrick has no shape)")
quit(1)
return true
# Measure the TIP, not the whole polygon. The base corners sit a
# half-width to either side of the rim vertex, so they are
# sqrt(|base|^2 + w^2) from the centre and clear the rim on their
# own - a max over every point called a derrick pointing straight
# into the ground "visible", which is exactly the bug this check
# exists to catch.
var furthest: float = mined.extractor_derrick_tip().length()
var rim: float = 0.0
for p in mined.shape_points:
rim = maxf(rim, p.length())
print("derrick tip reaches %.1f, planet outline reaches %.1f" % [furthest, rim])
if furthest <= rim:
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (derrick buried inside the planet)")
quit(1)
return true
# A different planet must NOT inherit the marker: the flag is keyed
# by name, and getting that wrong would light up every planet.
var other: Node2D = load("res://Planet.tscn").instantiate()
other.planet_name = planet_name + " II"
other.shape_seed = 999
scene.add_child(other)
if other.shows_extractor:
print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (marker leaked onto a different planet)")
quit(1)
return true
print("extractor -> derrick + badge, and only on the right planet")
print("TEST_PLANET_EXTRACTOR_MARKER: PASS")
quit(0)
return true
return false

View file

@ -0,0 +1 @@
uid://dcr7qb1yoewcy