extends Node
## MOUNTAIN EDIT GATE — terrain CSG and from-scratch brushes on map_mountain.
##
## . scripts/engine.sh
## "$GODOT" --path . --resolution 640x360 res://scenes/test/mountain_edit_gate.tscn
##
## ⛔ NOT HEADLESS. The mountain's generator is GPU-only
## (DCTerrainGeneratorProcNodesBounded::evaluate returns air by contract) and a
## headless peer frees the terrain outright, so there is nothing to carve without a
## real Vulkan device. Headless reports SKIP and exits 2 rather than passing.
##
## WHAT IT PROVES, each by MEASUREMENT rather than by an edit count:
##
## terrain RMB's own path (map_core._carve_terrain) subtracts and then adds a
## hard sphere at a surface point. The record lands in GameState
## (world:terrain:edit:*), the GPU density at the point flips sign, and
## a vertical raycast against the COLLISION the player walks on moves
## by metres -- down for the subtract, back above the original surface
## for the add.
## brushes the mountain starts with an EMPTY DCLevel (level_import = false: no
## START.MAP, no WAD, no rtlights). The create-box operator makes a
## brush from scratch on the terrain; it renders (LevelMesh has
## triangles and the untextured level material), it collides (a
## raycast onto it reports the DCLevel as the collider), it is a
## brush.create record in the level journal, and undo/redo through the
## journal takes it away and brings it back.
## picking a raycast that lands on TERRAIN is never resolved to a brush. The
## shape index of a terrain chunk hit is meaningless to DCLevel; with
## brushes present it used to name one of them.
## reload the map is freed and entered again the way game.gd does it
## (enter_map clears the mirror, the room's entries arrive), and both
## journals replay: the crater-and-mound is back in the collision and
## the brush is back in the level.
## base the mountain's own settings survive the rebase (voxel 2.0 m, not
## map_dc_world's 0.224) and the terrain panel binds to the bounded
## generator without offering DensityConfig knobs that move nothing.
const MAP := "res://scenes/maps/map_mountain.tscn"
const MAP_ID := "map_mountain"
const PROBE := "res://scenes/test/terrain_probe.gd"
const CARVE_R := 8.0
const ADD_R := 12.0
const WAIT_S := 120.0
var _fail := 0
var _checks := 0
var _map: Node = null
var _cam: Camera3D = null
var _mgr: Node = null
## Hard ceiling for the whole run. Every phase below has its own shorter wait; this
## catches the one thing they cannot -- a script error that aborts the coroutine,
## after which nothing would ever call _done().
const DEADLINE_S := 420.0
func _ready() -> void:
var wd := get_tree().create_timer(DEADLINE_S)
wd.timeout.connect(func() -> void:
print(" FAIL gate deadline: no verdict after %.0f s (a phase stalled or the script errored)" % DEADLINE_S)
_fail += 1
_done())
_run.call_deferred()
func _ck(label: String, ok: bool, detail: String = "") -> void:
_checks += 1
if not ok:
_fail += 1
print(" %s %s%s" % ["ok " if ok else "FAIL", label,
"" if detail == "" else " [" + detail + "]"])
func _done() -> void:
print("\n%d/%d checks passed" % [_checks - _fail, _checks])
print("RESULT: %s" % ("PASS" if _fail == 0 else "FAIL"))
get_tree().quit(1 if _fail > 0 else 0)
func _frames(n: int) -> void:
for i in n:
await get_tree().process_frame
func _terrain() -> Node:
return _map.get_node_or_null(^"MountainTerrain") if _map != null else null
func _level() -> Node:
return _map.get_node_or_null(^"DCLevel") if _map != null else null
## Vertical ray against the physics world. {} on a miss.
func _down(x: float, z: float, top: float, bottom: float) -> Dictionary:
var q := PhysicsRayQueryParameters3D.create(Vector3(x, top, z), Vector3(x, bottom, z))
return get_viewport().get_world_3d().direct_space_state.intersect_ray(q)
## The GPU density at one point -- the field the mesher meshes, edits included
## (density MAIN applies them). Positive = solid. NAN if the GPU declined.
func _dens(p: Vector3) -> float:
var t := _terrain()
var vox := 0.5
var data: PackedFloat32Array = t.call("debug_gpu_density",
p - Vector3.ONE * vox * 2.0, vox, 4)
if data.size() < 125:
return NAN
return data[2 * 25 + 2 * 5 + 2]
func _load_map() -> void:
var ps: PackedScene = load(MAP)
_map = ps.instantiate()
add_child(_map)
await _frames(3)
func _aim(at: Vector3, from_offset: Vector3) -> void:
_cam.global_position = at + from_offset
_cam.look_at(at, Vector3.UP if absf(from_offset.normalized().y) < 0.99 else Vector3.FORWARD)
## Elapsed seconds since `t0_ms`. Polling is done with plain while-loops below,
## NOT lambdas: a GDScript lambda captures locals BY VALUE, so a result assigned
## inside one never reaches the caller, and multi-line inline lambdas have hung
## headless runs here before.
func _since(t0_ms: int) -> float:
return (Time.get_ticks_msec() - t0_ms) / 1000.0
func _run() -> void:
print("\n=== mountain edit gate ===")
if DisplayServer.get_name() == "headless":
print("RESULT: SKIP (needs a display: the mountain's field is GPU-only)")
get_tree().quit(2)
return
# game.gd's order: enter the map, THEN instantiate it.
GameState.enter_map(MAP_ID)
# game.gd owns one of these for the whole session; the map never creates it.
_mgr = TerrainEditManager.new()
_mgr.name = "TerrainEditManager"
add_child(_mgr)
_cam = Camera3D.new()
_cam.far = 20000.0
add_child(_cam)
_cam.current = true
_cam.global_position = Vector3(0, 4000, 6000)
_cam.look_at(Vector3.ZERO)
await _load_map()
var t := _terrain()
var level := _level()
_ck("map_mountain has its terrain and a DCLevel", t != null and level != null)
if t == null or level == null:
_done()
return
var gen: Variant = t.get("generator")
_ck("the bounded generator is assigned", gen != null
and (gen as Object).get_class() == "DCTerrainGeneratorProcNodesBounded",
str(gen))
# ---- base: the mountain's own settings survive the rebase ----------------
_ck("_carve_terrain exists (map_core is in the chain)", _map.has_method("_carve_terrain"))
_ck("brush editor exists (map_dc_level is in the chain)", _map.has_method("_ensure_ops"))
_ck("level_import is OFF on the mountain", _map.get("level_import") == false,
str(_map.get("level_import")))
if not (_map.has_method("_carve_terrain") and _map.has_method("_ensure_ops")
and _map.has_method("required_assets")):
# A script error mid-coroutine would otherwise leave this gate idling until
# the outer timeout. Nothing below can run on a map without the chain.
print(" FAIL map_mountain is not on the map_dc_world/map_dc_level/map_core chain — stopping")
_done()
return
_ck("voxel_size is the mountain's 2.0, not DCWorldSetup's 0.224",
is_equal_approx(float(t.get("voxel_size")), 2.0), str(t.get("voxel_size")))
_ck("collision_distance is the mountain's 4 chunk-spans (256)",
is_equal_approx(float(t.get("collision_distance")), 256.0),
str(t.get("collision_distance")))
_ck("required_assets() declares nothing (no START.MAP/WAD to serve)",
(_map.call("required_assets") as Array).is_empty(),
str(_map.call("required_assets")))
var quake_nodes := []
for n in ["SkyAreaLights", "RtlightManager", "QuakeLights", "QuakeTeleporters"]:
if _map.get_node_or_null(NodePath(n)) != null:
quake_nodes.append(n)
_ck("no Quake import side effects (lights/entities)", quake_nodes.is_empty(), str(quake_nodes))
var src_keys: Array = []
for k: String in GameState.keys_with_prefix("world:level:"):
if k.ends_with(":source"):
src_keys.append(k)
_ck("no level source announced", src_keys.is_empty(), str(src_keys))
# The panel, exactly as map_core builds it.
var panel := VBoxContainer.new()
panel.set_script(load("res://scenes/game/terrain_panel.gd"))
add_child(panel)
panel.call("setup", t)
_ck("terrain panel targets the generator, not the DCTerrain",
panel.get("_target") == gen, str(panel.get("_target")))
_ck("terrain panel offers no DensityConfig knobs to a bounded field",
(panel.get("_sliders") as Dictionary).is_empty(),
"%d sliders" % (panel.get("_sliders") as Dictionary).size())
panel.queue_free()
# ---- find a surface ------------------------------------------------------
var Probe = load(PROBE)
var surf: Variant = null
var col := Vector3.ZERO
var t0 := Time.get_ticks_msec()
while surf == null and _since(t0) < 60.0:
for c: Vector3 in [Vector3(0, 1500, 0), Vector3(1500, 1500, 0),
Vector3(-1500, 1500, 0), Vector3(0, 1500, 1500)]:
var s = _map.call("_find_surface_gpu", Probe, t, c, 64.0)
if s != null:
var fine = _map.call("_find_surface_gpu", Probe, t, Vector3(c.x, float(s), c.z), 1.0)
surf = fine if fine != null else s
col = c
break
if surf == null:
await get_tree().process_frame
_ck("GPU density finds the mountain's surface", surf != null,
"column %s y=%s after %.1f s" % [str(Vector3(col.x, 0, col.z)), str(surf), _since(t0)])
if surf == null:
_done()
return
var P := Vector3(col.x, float(surf), col.z)
# Stand off and wait for the collision under the aim point.
_aim(P, Vector3(0, 25, 25))
var h0 := {}
t0 = Time.get_ticks_msec()
while h0.is_empty() and _since(t0) < WAIT_S:
await get_tree().process_frame
h0 = _down(P.x, P.z, P.y + 60.0, P.y - 60.0)
_ck("collision builds under the surface point", not h0.is_empty(),
"%.1f s, hit y=%s (field y=%.2f)" % [_since(t0),
str((h0["position"] as Vector3).y) if not h0.is_empty() else "-", P.y])
if h0.is_empty():
_done()
return
var y0: float = (h0["position"] as Vector3).y
var d_below0 := _dens(P - Vector3(0, 3, 0))
var d_above0 := _dens(P + Vector3(0, 3, 0))
print(" before: surface y=%.2f density 3 m below=%.3f 3 m above=%.3f" % [y0, d_below0, d_above0])
# ---- SUBTRACT, through RMB's own function --------------------------------
_map.set("_csg_type", 2) # hard sphere
_map.set("_csg_radius", CARVE_R)
var edits_before := GameState.keys_with_prefix("world:terrain:edit:").size()
var count_before: int = t.call("get_edit_count")
_map.call("_carve_terrain", false)
var ekeys := GameState.keys_with_prefix("world:terrain:edit:")
_ck("(a) subtract recorded in GameState world:terrain:edit:*",
ekeys.size() == edits_before + 1, "%d -> %d" % [edits_before, ekeys.size()])
_ck("(a) subtract reached the DCTerrain edit list",
int(t.call("get_edit_count")) == count_before + 1,
"%d -> %d" % [count_before, int(t.call("get_edit_count"))])
var y1 := y0
t0 = Time.get_ticks_msec()
while y1 >= y0 - CARVE_R * 0.5 and _since(t0) < 30.0:
await get_tree().process_frame
var h := _down(P.x, P.z, y0 + 60.0, y0 - 60.0)
if not h.is_empty():
y1 = (h["position"] as Vector3).y
var d_below1 := _dens(P - Vector3(0, 3, 0))
_ck("(b) subtract: collision surface drops", y1 < y0 - CARVE_R * 0.5,
"y %.2f -> %.2f (%.2f m) after %.1f s" % [y0, y1, y1 - y0, _since(t0)])
_ck("(b) subtract: GPU density 3 m below the surface flips solid -> air",
d_below0 > 0.0 and d_below1 < 0.0, "%.3f -> %.3f" % [d_below0, d_below1])
# ---- ADD, same aim, Shift ------------------------------------------------
_map.set("_csg_radius", ADD_R)
_map.call("_carve_terrain", true)
ekeys = GameState.keys_with_prefix("world:terrain:edit:")
_ck("(a) add recorded in GameState", ekeys.size() == edits_before + 2, "%d" % ekeys.size())
var y2 := y1
t0 = Time.get_ticks_msec()
while y2 <= y0 + 1.0 and _since(t0) < 30.0:
await get_tree().process_frame
var h := _down(P.x, P.z, y0 + 80.0, y0 - 60.0)
if not h.is_empty():
y2 = (h["position"] as Vector3).y
var d_above2 := _dens(P + Vector3(0, 3, 0))
_ck("(b) add: collision surface rises above the ORIGINAL surface", y2 > y0 + 1.0,
"y %.2f -> %.2f (orig %.2f) after %.1f s" % [y1, y2, y0, _since(t0)])
_ck("(b) add: GPU density 3 m above the original surface flips air -> solid",
d_above0 < 0.0 and d_above2 > 0.0, "%.3f -> %.3f" % [d_above0, d_above2])
# ---- BRUSHES from scratch ------------------------------------------------
var S_col := Vector3(P.x + 30.0, P.y, P.z)
var s_surf = _map.call("_find_surface_gpu", Probe, t, S_col, 1.0)
var S := Vector3(S_col.x, float(s_surf) if s_surf != null else P.y, S_col.z)
_aim(S, Vector3(0, 18, 12))
var hs := {}
t0 = Time.get_ticks_msec()
while hs.is_empty() and _since(t0) < 30.0:
await get_tree().process_frame
hs = _down(S.x, S.z, S.y + 40.0, S.y - 40.0)
_ck("brush site has terrain collision", not hs.is_empty(),
"y=%s" % (str((hs["position"] as Vector3).y) if not hs.is_empty() else "-"))
_ck("level starts EMPTY", (level.call("get_live_brush_ids") as PackedInt32Array).size() == 0,
"%d live" % (level.call("get_live_brush_ids") as PackedInt32Array).size())
var ops: Node = _map.call("_ensure_ops")
_map.set("_grid_idx", 6) # 64 units = 1.792 m
var box: Node = _map.get("_box")
box.set("_size_steps", 3) # 5.376 m cube
var activated: bool = ops.call("activate", box)
_ck("create-box activates against the terrain surface", activated)
var made: bool = ops.call("confirm") if activated else false
_ck("create-box confirms", made)
await _frames(3)
var live: PackedInt32Array = level.call("get_live_brush_ids")
_ck("one brush exists", live.size() == 1, "%d" % live.size())
var lstats: Dictionary = level.call("get_stats")
var lm := level.get_node_or_null(^"LevelMesh") as MeshInstance3D
_ck("it renders: LevelMesh has triangles", lm != null and lm.mesh != null
and lm.mesh.get_surface_count() > 0 and int(lstats.get("triangles", 0)) >= 12,
"%d tris" % int(lstats.get("triangles", 0)))
var lmat: Variant = lm.material_override if lm != null else null
_ck("it has the untextured level material (no WAD)", lmat is ShaderMaterial
and (lmat as ShaderMaterial).get_shader_parameter("use_textures") == false,
str(lmat))
var brecs := []
for k: String in GameState.keys_with_prefix("world:level:"):
var v: Variant = GameState.get_state(k)
if v is Dictionary and String((v as Dictionary).get("kind", "")) == "brush.create":
brecs.append(k)
_ck("it is a brush.create record in the level journal", brecs.size() == 1, str(brecs))
if live.size() != 1:
_done()
return
var b: Dictionary = level.call("get_brush", live[0])
var bc: Vector3 = b.get("center", S)
var bmin: Vector3 = b.get("aabb_min", S)
var bmax: Vector3 = b.get("aabb_max", S)
var btop: float = bmax.y
var hb := {}
t0 = Time.get_ticks_msec()
while _since(t0) < 10.0:
await get_tree().process_frame
hb = _down(bc.x, bc.z, btop + 20.0, btop - 20.0)
if not hb.is_empty() and hb.get("collider") == level:
break
_ck("it collides: a ray onto it hits the DCLevel at the brush top",
not hb.is_empty() and hb.get("collider") == level
and absf((hb["position"] as Vector3).y - btop) < 0.05,
"hit %s on %s, top %.3f" % [str(hb.get("position", "-")), str(hb.get("collider")), btop])
# PICKING: terrain must never resolve to a brush.
var ht := _down(bmax.x + 6.0, bc.z, btop + 40.0, bmin.y - 40.0)
_ck("a terrain hit beside the brush is terrain",
not ht.is_empty() and ht.get("collider") != level, str(ht.get("collider", "miss")))
if not ht.is_empty():
var got: int = _map.call("_brush_from_hit", ht)
_ck("_brush_from_hit(terrain hit) is -1, not a brush", got == -1,
"got %d (shape %s)" % [got, str(ht.get("shape"))])
# UNDO / REDO through the journal.
_map.call("_undo_redo", false)
await _frames(3)
var after_undo := (level.call("get_live_brush_ids") as PackedInt32Array).size()
var hu := _down(bc.x, bc.z, btop + 20.0, btop - 40.0)
_ck("undo takes the brush away (journal tombstone)", after_undo == 0
and (hu.is_empty() or hu.get("collider") != level),
"%d live, ray hits %s" % [after_undo, str(hu.get("collider", "nothing"))])
_map.call("_undo_redo", true)
await _frames(3)
var after_redo := (level.call("get_live_brush_ids") as PackedInt32Array).size()
var hr := _down(bc.x, bc.z, btop + 20.0, btop - 20.0)
_ck("redo brings it back", after_redo == 1 and not hr.is_empty() and hr.get("collider") == level,
"%d live, ray hits %s" % [after_redo, str(hr.get("collider", "nothing"))])
# ---- RELOAD: free the map, enter it again, let the room's state arrive ----
var entries := {}
for k: String in GameState.keys_with_prefix("world:"):
var stored: String = ("map:%s:%s" % [MAP_ID, k]) if GameState._is_map_scoped(k) else k
entries[stored] = GameState.get_state(k)
print(" reload: %d durable entries carried" % entries.size())
_map.queue_free()
_map = null
await _frames(3)
GameState.enter_map(MAP_ID) # clears the mirror, map_changing
_ck("enter_map cleared the mirror", GameState.keys_with_prefix("world:").is_empty(),
"%d left" % GameState.keys_with_prefix("world:").size())
await _load_map()
t = _terrain()
level = _level()
# What KeetGameState hands over when the room's snapshot arrives.
GameState._on_durable_loaded("", entries)
_aim(P, Vector3(0, 25, 25))
var y3 := INF
t0 = Time.get_ticks_msec()
while absf(y3 - y2) >= 1.5 and _since(t0) < WAIT_S:
await get_tree().process_frame
var h := _down(P.x, P.z, y0 + 80.0, y0 - 60.0)
if not h.is_empty():
y3 = (h["position"] as Vector3).y
_ck("(c) reload replays the terrain edits into the collision",
absf(y3 - y2) < 1.5 and int(t.call("get_edit_count")) == 2,
"y %.2f (before reload %.2f, original %.2f), %d edit(s), %.1f s"
% [y3, y2, y0, int(t.call("get_edit_count")), _since(t0)])
var d_above3 := _dens(P + Vector3(0, 3, 0))
_ck("(c) reload: the GPU density carries the edit", d_above3 > 0.0, "%.3f" % d_above3)
t0 = Time.get_ticks_msec()
while (level.call("get_live_brush_ids") as PackedInt32Array).size() != 1 and _since(t0) < 10.0:
await get_tree().process_frame
var live2: PackedInt32Array = level.call("get_live_brush_ids")
_ck("(c) reload replays the brush from the journal", live2.size() == 1, "%d live" % live2.size())
_aim(S, Vector3(0, 18, 12))
var hb2 := {}
t0 = Time.get_ticks_msec()
while _since(t0) < 30.0:
await get_tree().process_frame
hb2 = _down(bc.x, bc.z, btop + 20.0, btop - 20.0)
if not hb2.is_empty() and hb2.get("collider") == level:
break
_ck("(c) the replayed brush collides at the same top",
not hb2.is_empty() and hb2.get("collider") == level
and absf((hb2["position"] as Vector3).y - btop) < 0.05,
"hit %s on %s" % [str(hb2.get("position", "-")), str(hb2.get("collider"))])
_done()