OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / mountain_edit_gate.gd

428 lines · gd ref:
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()