spaceship/tests/test_gas_bands.gd

307 lines
12 KiB
GDScript3
Raw Normal View History

Share one backdrop between game and editor, and fix three input/economy bugs The setup editor and the landed surface drew different backgrounds for the same planet. Each scene carried its own hand-written ShaderMaterial, and only the game side ever swapped in a profile's shader, so a gas giant authored in the editor showed recoloured craters while the game showed cloud bands. Both now instance one SurfaceBackground.tscn and configure it through one method, and every profile names its own shader - the old "null means keep whatever the scene already had" convention was the drift, since each scene "already had" something different. The shared material is resource_local_to_scene, or two live surfaces would repaint each other. The gas giant's bands broke into disjoint segments once per cell: the second turbulence octave doubled only the INTEGER half of a lattice coordinate, which is a different point in every cell rather than the same point at twice the frequency. Measured, the old form steps 0.153 at a cell boundary against 0.0002 now. A second discontinuity went with it - the camera cell index arrives pre-wrapped, so hashing it raw rearranged the whole sky every 32 cells; hashes are now wrapped per octave and the field is exactly periodic. Bands also gained low-frequency octaves and per-row differential drift. WorldMap swallowed every touch in _input(), which runs before the GUI system, so CloseButton never saw one: the map could be opened and not closed. Android and desktop hid it behind Godot's emulated mouse event, which this never swallowed; a phone browser gives no such second chance. Its test had been closing the map by calling the method the button was failing to reach. Construction now draws on the fleet hold as well as the bank. With an empty starting bank and ore bankable only at a mothership, charging construction to the bank alone made the first mothership cost ore that could not yet exist. Also: a conquered planet no longer re-spawns its garrison (guarded on having capture points, since a setup with none registers as vacuously conquered); planets keep the map they drew, with the candidate list sorted rather than left in filesystem order and assignments pruned rather than cleared on rescan; and the map editor lists setups at a tappable size and can delete one, as an explicit armed mode rather than a tap away from destroying work. Full suite 106/106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:16:38 +02:00
extends SceneTree
# Headless test: run with
# godot --headless -s res://tests/test_gas_bands.gd
# The gas giant's cloud bands were breaking into disjoint segments - the band
# edges stepped sideways once per cell, right across the screen. The cause was
# in gas_surface.gdshader's second turbulence octave, which took a
# double-frequency sample by doubling only the INTEGER half of a lattice
# coordinate and leaving the fraction alone. That is not the same point at
# twice the frequency; it is a different point on every cell, so the noise -
# and with it the band edge - jumped discontinuously at every cell boundary.
#
# A fragment shader cannot be asserted on from GDScript, so this ports the
# band field to GDScript against the SAME hashes and the SAME constants
# (parsed out of the shader, so the two cannot drift) and measures the thing
# that actually went wrong: continuity.
#
# Covers:
# A. the field is continuous in X across many cell boundaries - no step
# larger than a small multiple of what a smooth field produces between
# adjacent samples
# B. the same in Y, where the row loop's window slides by one
# C. the test has teeth: the OLD, broken octave is re-implemented here and
# must FAIL the same scan. Without this, a field that had been flattened
# to a constant would sail through A and B
# D. the row scan still reaches every row that can change a pixel, from the
# constants as they currently stand - the seam this prevents is a
# horizontal one, which A and B would not catch
# E. the field is EXACTLY periodic over the tile period, in both axes. The
# camera cell index arrives already wrapped (SurfaceBackground.gd wraps
# it in double precision), so a pixel's cell index shifts by exactly one
# period whenever the camera crosses a wrap boundary. Unless the hashes
# are wrapped to match, the whole sky rearranges itself at that instant
#84: move 215 root files into a folder hierarchy Everything lived in one flat directory, so a diff gave no clue what area it touched and finding the file you wanted meant knowing its exact name. Twelve folders now, grouped by what a thing IS rather than what type it is - a vessel's definition, its component scenes and its upgrade resources sit together, because that is the set you read when you change one of them. buildings core controllers enemies logistics planets resources shaders surface ui vessels vfx world 332 files moved and every res:// reference rewritten - 306 files touched, including the autoload and main-scene paths in project.godot. Nothing renamed: this is purely where files sit, so the diff is a pile of pure moves and no behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at the root, where Godot and the export preset expect them. Verified three ways, because a suite alone would not have caught all of it: the full 117 pass, the web export builds clean, and the game renders. The export is why that matters. The FIRST export after the move failed with "Attempt to open script 'res://GameState.gd'" - the old path - while project.godot on disk plainly said res://core/GameState.gd. That is this project's documented Android FUSE mtime trap: Godot served a cached copy of a file whose content had changed. A `touch` fixed it. Nothing in the test suite would have found that, because the suite runs from a copy on a native filesystem - the one place the bug cannot happen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:44:14 +02:00
const SHADER_PATH := "res://shaders/gas_surface.gdshader"
Share one backdrop between game and editor, and fix three input/economy bugs The setup editor and the landed surface drew different backgrounds for the same planet. Each scene carried its own hand-written ShaderMaterial, and only the game side ever swapped in a profile's shader, so a gas giant authored in the editor showed recoloured craters while the game showed cloud bands. Both now instance one SurfaceBackground.tscn and configure it through one method, and every profile names its own shader - the old "null means keep whatever the scene already had" convention was the drift, since each scene "already had" something different. The shared material is resource_local_to_scene, or two live surfaces would repaint each other. The gas giant's bands broke into disjoint segments once per cell: the second turbulence octave doubled only the INTEGER half of a lattice coordinate, which is a different point in every cell rather than the same point at twice the frequency. Measured, the old form steps 0.153 at a cell boundary against 0.0002 now. A second discontinuity went with it - the camera cell index arrives pre-wrapped, so hashing it raw rearranged the whole sky every 32 cells; hashes are now wrapped per octave and the field is exactly periodic. Bands also gained low-frequency octaves and per-row differential drift. WorldMap swallowed every touch in _input(), which runs before the GUI system, so CloseButton never saw one: the map could be opened and not closed. Android and desktop hid it behind Godot's emulated mouse event, which this never swallowed; a phone browser gives no such second chance. Its test had been closing the map by calling the method the button was failing to reach. Construction now draws on the fleet hold as well as the bank. With an empty starting bank and ore bankable only at a mothership, charging construction to the bank alone made the first mothership cost ore that could not yet exist. Also: a conquered planet no longer re-spawns its garrison (guarded on having capture points, since a setup with none registers as vacuously conquered); planets keep the map they drew, with the candidate list sorted rather than left in filesystem order and assignments pruned rather than cleared on rescan; and the map editor lists setups at a tappable size and can delete one, as an explicit armed mode rather than a tap away from destroying work. Full suite 106/106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:16:38 +02:00
# Sample spacing along the scan, in cells. Small enough that a genuinely
# smooth field moves very little between samples.
const STEP := 0.001
const SPAN_CELLS := 12.0
# What counts as a break. The field's slope is bounded by its octave weights
# (0.55/0.30/0.15 over 4/2/1 cells, smoothstep slope at most 1.5) times
# TURBULENCE_STRENGTH, divided by the thinnest band - about 0.9 per cell, so
# a smooth field moves ~0.0009 between samples. Twenty times that is still
# far below the ~0.3 jumps the broken octave produced, so this neither trips
# on noise nor lets the real defect through.
const MAX_STEP := 0.02
var source: String = ""
var min_thickness: float = 0.0
var max_thickness: float = 0.0
var turbulence_strength: float = 0.0
var max_significant: float = 0.0
var row_scan: int = 0
var tile_cells: float = 32.0
# Set while scanning, to run the same scan against the broken formulation.
var use_broken_octave: bool = false
func _initialize() -> void:
var file: FileAccess = FileAccess.open(SHADER_PATH, FileAccess.READ)
if file == null:
_fail("could not read %s" % SHADER_PATH)
return
source = file.get_as_text()
file.close()
min_thickness = _read_float_const("MIN_THICKNESS")
max_thickness = _read_float_const("MAX_THICKNESS")
turbulence_strength = _read_float_const("TURBULENCE_STRENGTH")
max_significant = _read_float_const("MAX_SIGNIFICANT_DISTANCE")
row_scan = int(_read_int_const("ROW_SCAN"))
tile_cells = _read_uniform_default("tile_cells")
if is_nan(tile_cells):
_fail("the shader no longer declares a tile_cells uniform, so nothing bounds what it hashes")
return
if is_nan(min_thickness) or is_nan(max_thickness) or is_nan(turbulence_strength) \
or is_nan(max_significant) or row_scan == 0:
_fail("could not parse the shader's band constants")
return
if not _check_premises():
return
if not _check_continuity():
return
if not _check_row_scan_headroom():
return
if not _check_periodicity():
return
print("TEST_GAS_BANDS: PASS")
quit(0)
# --- A/B/C: continuity ------------------------------------------------------
func _check_continuity() -> bool:
var x_break: float = _worst_step_along_x()
if x_break > MAX_STEP:
return _fail("the band field breaks along X: a step of %.4f between samples %0.4f cells apart" \
% [x_break, STEP])
var y_break: float = _worst_step_along_y()
if y_break > MAX_STEP:
return _fail("the band field breaks along Y: a step of %.4f between samples %0.4f cells apart" \
% [y_break, STEP])
print("continuous across %d cell boundaries: worst step %.5f in X, %.5f in Y (allowed %.3f)" \
% [int(SPAN_CELLS), x_break, y_break, MAX_STEP])
# C. The scan has to be able to see a break, or A and B prove nothing.
use_broken_octave = true
var broken: float = _worst_step_along_x()
use_broken_octave = false
if broken <= MAX_STEP:
return _fail("the continuity scan did not detect the ORIGINAL defect (worst step %.5f), so it cannot be trusted to detect a new one" % broken)
print("scan verified against the original defect: the broken octave steps %.3f, %.0fx the allowance" \
% [broken, broken / MAX_STEP])
return true
func _worst_step_along_x() -> float:
# Deliberately started off-lattice, so the sample points straddle cell
# boundaries rather than landing exactly on them - a break exactly at an
# integer could otherwise be stepped over.
var y: float = 0.317
var worst: float = 0.0
var x: float = -SPAN_CELLS * 0.5 + 0.0004
var previous: float = _nearest(x, y)
while x < SPAN_CELLS * 0.5:
x += STEP
var value: float = _nearest(x, y)
worst = maxf(worst, absf(value - previous))
previous = value
return worst
func _worst_step_along_y() -> float:
var x: float = 0.211
var worst: float = 0.0
var y: float = -SPAN_CELLS * 0.5 + 0.0004
var previous: float = _nearest(x, y)
while y < SPAN_CELLS * 0.5:
y += STEP
var value: float = _nearest(x, y)
worst = maxf(worst, absf(value - previous))
previous = value
return worst
# --- D: the row scan still covers every row that matters --------------------
func _check_row_scan_headroom() -> bool:
# A row can still change a pixel while the pixel is within
# MAX_SIGNIFICANT_DISTANCE * thickness of its band, and the band itself
# wanders by up to half the turbulence strength.
var reach: float = max_significant * max_thickness + turbulence_strength * 0.5
# to_band = (local.y - dy) - band_centre, with local.y in [0,1) and
# band_centre in [0,1), so (local.y - band_centre) spans (-1, 1) and a
# row is relevant for dy anywhere in (-1 - reach, 1 + reach).
var lowest_needed: int = ceili(-1.0 - reach)
var highest_needed: int = floori(1.0 + reach)
if lowest_needed < -1 or highest_needed > row_scan:
return _fail("a band reaches %.2f cells, so rows dy %d..%d can still change a pixel - but the loop only scans -1..%d" \
% [reach, lowest_needed, highest_needed, row_scan])
print("band reach %.2f cells needs dy within %d..%d; the loop scans -1..%d" \
% [reach, lowest_needed, highest_needed, row_scan])
return true
# --- the field, ported ------------------------------------------------------
func _nearest(x: float, y: float) -> float:
# Same split the shader does: an exact integer cell index plus a bounded
# fraction. TIME is pinned to zero here - drift slides the field, it does
# not change its shape, and a fixed value keeps this reproducible.
var cell := Vector2(floor(x), floor(y))
var local := Vector2(x - cell.x, y - cell.y)
var base_lattice := Vector2(cell.x, local.x)
var nearest: float = 1000.0
for dy in range(-1, row_scan + 1):
var row: float = cell.y + float(dy)
var row_hash: Vector3 = _hash23(Vector2(fposmod(row, tile_cells), 5.0))
var band_centre: float = row_hash.x
var thickness: float = lerpf(min_thickness, max_thickness, row_hash.y)
var turb: float = _row_turbulence(base_lattice, tile_cells,
fposmod(row, tile_cells) * 3.7)
var to_band: float = (local.y - float(dy)) - band_centre
to_band += (turb - 0.5) * turbulence_strength
nearest = minf(nearest, absf(to_band) / thickness)
return nearest
func _row_turbulence(lattice: Vector2, period: float, axis: float) -> float:
if use_broken_octave:
# The original, as it stood: one cell-frequency octave plus a second
# that doubled the integer part of the lattice coordinate and left
# the fraction untouched.
return _value_noise(lattice, period, axis + 11.0) * 0.65 \
+ _value_noise(Vector2(lattice.x * 2.0, lattice.y), period, axis + 47.0) * 0.35
var half_freq: Vector2 = _halve_lattice(lattice)
var quarter: Vector2 = _halve_lattice(half_freq)
return _value_noise(quarter, period * 0.25, axis + 11.0) * 0.55 \
+ _value_noise(half_freq, period * 0.5, axis + 47.0) * 0.30 \
+ _value_noise(lattice, period, axis + 83.0) * 0.15
func _halve_lattice(coord: Vector2) -> Vector2:
var half_i: float = floor(coord.x * 0.5)
var rem: float = coord.x - half_i * 2.0
return Vector2(half_i, (rem + coord.y) * 0.5)
func _value_noise(coord: Vector2, period: float, axis: float) -> float:
var a: float = _hash21(Vector2(fposmod(coord.x, period), axis))
var b: float = _hash21(Vector2(fposmod(coord.x + 1.0, period), axis))
return lerpf(a, b, smoothstep(0.0, 1.0, coord.y))
func _fract(v: float) -> float:
return v - floor(v)
func _fract3(v: Vector3) -> Vector3:
return Vector3(_fract(v.x), _fract(v.y), _fract(v.z))
func _hash21(p: Vector2) -> float:
var p3: Vector3 = _fract3(Vector3(p.x, p.y, p.x) * 0.1031)
var shifted := Vector3(p3.y, p3.z, p3.x) + Vector3(33.33, 33.33, 33.33)
p3 += Vector3.ONE * p3.dot(shifted)
return _fract((p3.x + p3.y) * p3.z)
func _hash23(p: Vector2) -> Vector3:
var p3: Vector3 = _fract3(Vector3(p.x, p.y, p.x) * Vector3(0.1031, 0.1030, 0.0973))
var shifted := Vector3(p3.y, p3.x, p3.z) + Vector3(33.33, 33.33, 33.33)
p3 += Vector3.ONE * p3.dot(shifted)
return _fract3(Vector3(
(p3.x + p3.y) * p3.z,
(p3.x + p3.z) * p3.y,
(p3.y + p3.z) * p3.x))
# --- premises ---------------------------------------------------------------
func _check_premises() -> bool:
# The exact defect, named so it cannot quietly return.
if "vec2(turb_x.x * 2.0" in source:
return _fail("the shader is doubling a lattice coordinate's integer part while leaving its fraction alone - that is the band-break defect itself")
if not ("halve_lattice" in source):
return _fail("the shader no longer rescales its octaves through halve_lattice, so continuity is no longer guaranteed by construction")
if not ("combine_lattice" in source):
return _fail("the shader no longer folds drift in through combine_lattice, so TIME is being added to a cell index directly")
return true
func _read_float_const(name: String) -> float:
var regex := RegEx.new()
regex.compile("const\\s+float\\s+%s\\s*=\\s*(-?[0-9.]+)" % name)
var found: RegExMatch = regex.search(source)
if found == null:
return NAN
return float(found.get_string(1))
func _read_int_const(name: String) -> float:
var regex := RegEx.new()
regex.compile("const\\s+int\\s+%s\\s*=\\s*(-?[0-9]+)" % name)
var found: RegExMatch = regex.search(source)
if found == null:
return 0.0
return float(found.get_string(1))
func _read_uniform_default(name: String) -> float:
var regex := RegEx.new()
regex.compile("uniform\\s+float\\s+%s\\s*=\\s*(-?[0-9.]+)" % name)
var found: RegExMatch = regex.search(source)
if found == null:
return NAN
return float(found.get_string(1))
# --- E: exact periodicity ---------------------------------------------------
func _check_periodicity() -> bool:
if fmod(tile_cells, 4.0) != 0.0:
return _fail("a tile period of %.0f cells is not a multiple of 4, so the quarter-frequency octave cannot wrap on a whole cell and the seam comes back" % tile_cells)
var worst: float = 0.0
for i in range(400):
var x: float = -7.3 + float(i) * 0.11
var y: float = 2.9 + float(i) * 0.07
worst = maxf(worst, absf(_nearest(x, y) - _nearest(x + tile_cells, y)))
worst = maxf(worst, absf(_nearest(x, y) - _nearest(x, y + tile_cells)))
# Exact, not approximate: the same hashes are evaluated on indices that
# differ by whole periods, so identical inputs reach them. Anything but a
# rounding-scale difference means the wrap is visible.
if worst > 1e-9:
return _fail("the field is not periodic over %.0f cells (differs by %.6f), so crossing a camera wrap rearranges the sky" \
% [tile_cells, worst])
print("field is exactly periodic over %.0f cells in both axes (worst difference %s)" % [tile_cells, str(worst)])
return true
func _fail(message: String) -> bool:
print("TEST_GAS_BANDS: FAIL (%s)" % message)
quit(1)
return false