OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / shell_tracks_edits_gate.gd

214 lines · gd ref:
extends SceneTree
## The shell must follow the brush set — hyper-games #25 §2, #32 NEXT-STEPS-2.
##
##     "$HYPER_TPS_GODOT" --headless --script scenes/test/shell_tracks_edits_gate.gd
##
## `_subtract_shell()` had ONE call site, at the end of `_import_from`, and
## `LevelPersistence.folded` was wired only to the wireframe. So the terrain kept
## the shape of the level AS IMPORTED: delete a wall and the world stayed carved
## to a level that no longer had one, until someone pressed `;`.
##
## What is asserted here is the half that was missing — that the map GETS TOLD.
## Whether a shell, once derived, is correct is shell_subtract_gate's job, and
## whether the stamp survives a room change is landscape_edit_gate's. This gate
## only claims: an edit re-derives, a burst of edits re-derives ONCE, and quiet
## re-derives nothing.
##
## It runs the REAL SCENE — map_dc_world.tscn, its own LevelPersistence, the real
## journal — because every bug this area has produced lived in the wiring rather
## than in the operation being wired. A gate that built its own map and called
## `_reshell()` by hand would have passed against the code this exists to reject.
##
## HEADLESS FREES THE TERRAIN (map_dc_world.gd:83), so nothing is stamped here and
## nothing can be measured about the stamp. The re-derive is still ATTEMPTED, and
## the attempt is what this counts.

const MAP_SCENE := "res://scenes/maps/map_dc_world.tscn"
const IMPORT_TIMEOUT_MS := 60000
## Generous against the map's own debounce: the point is that it fires, not when.
const RESHELL_TIMEOUT_MS := 8000
## Long enough that a per-fold re-derive would have fired again by now.
const QUIET_MS := 3000

var _map: Node = null
var _failures: Array[String] = []
var _t0 := 0


func _check(label: String, ok: bool, detail: String) -> void:
	if not ok:
		_failures.append("%s (%s)" % [label, detail])
	print("  %-52s %s  %s" % [label, "OK  " if ok else "FAIL", detail])


func _initialize() -> void:
	_run.call_deferred()


func _ms() -> int:
	return Time.get_ticks_msec()


## Pump frames until the map has attempted more re-derives than `was`, or the
## budget elapses. Returns the wait in ms, or -1 on timeout.
##
## A METHOD, NOT A LAMBDA ARGUMENT. A multi-line lambda written inline as a call
## argument makes this hang headless with no parse error and no output — which is
## how the first run of this gate spent 300 s doing nothing.
func _await_attempt(was: int, budget_ms: int) -> int:
	var start := _ms()
	while _ms() - start < budget_ms:
		await process_frame
		if _attempts() > was:
			return _ms() - start
	return -1


## Pump frames until the level reports brushes, or the budget elapses.
func _await_import(budget_ms: int) -> int:
	var start := _ms()
	while _ms() - start < budget_ms:
		await process_frame
		var lvl: Object = _map.get("level")
		if lvl != null and lvl.has_method("get_stats"):
			if int((lvl.get_stats() as Dictionary).get("brushes", 0)) > 0:
				return _ms() - start
	return -1


func _quiet(ms: int) -> void:
	var start := _ms()
	while _ms() - start < ms:
		await process_frame


## The first brush id at or after `from` that the store actually resolves.
##
## Brush ids are NOT indices — they are store ids, and id 0 resolves to nothing on
## START.MAP, so `get_brush_key(0)` is empty and a journal record naming it is
## skipped by the fold. The editor never has this problem because it deletes what
## a raycast hit.
func _live_brush(lvl: Object, from: int) -> int:
	var id := from
	while id < from + 64:
		if String(lvl.get_brush_key(id)) != "":
			return id
		id += 1
	return -1


func _attempts() -> int:
	var v: Variant = _map.get("_reshell_attempts")
	return int(v) if v != null else -1


func _run() -> void:
	print("\n=== the shell follows the brush set ===")
	_t0 = _ms()

	var ps: PackedScene = load(MAP_SCENE)
	if ps == null:
		print("RESULT: FAIL — cannot load %s" % MAP_SCENE)
		quit(1)
		return
	_map = ps.instantiate()
	root.add_child(_map)

	var waited: int = await _await_import(IMPORT_TIMEOUT_MS)
	if waited < 0:
		print("RESULT: SKIP — the level never imported (no START.MAP on this machine?)")
		quit(0)
		return
	var lvl: Object = _map.get("level")
	var brushes0 := int((lvl.get_stats() as Dictionary).get("brushes", 0))
	print("  imported %d brushes in %d ms" % [brushes0, waited])

	# The cost the debounce exists to amortise, measured rather than quoted.
	var tb := _ms()
	var shell: Dictionary = lvl.build_shell()
	print("  build_shell(): %d ms, %d tris, sealed=%s" % [
			_ms() - tb, int(shell.get("triangles", 0)), str(shell.get("sealed", false))])

	# --- does the map re-derive at all? ---------------------------------------
	var base := _attempts()
	_check("the map counts its re-derives", base >= 0,
			"_reshell_attempts=%s — a map with no automatic re-derive cannot have this" % str(base))
	if base < 0:
		_finish()
		return

	var persist: Object = _map.get("_persist")
	_check("the map has its journal", persist != null and persist.has_method("record"),
			"_persist=%s" % str(persist))
	if persist == null:
		_finish()
		return

	# --- ONE edit re-derives ---------------------------------------------------
	# The editor's own delete, minus the raycast: remove, rebuild, journal it.
	var bid := _live_brush(lvl, 0)
	_check("found a brush with a durable key", bid >= 0,
			"brush %d = '%s'" % [bid, lvl.get_brush_key(bid) if bid >= 0 else ""])
	if bid < 0:
		_finish()
		return
	# KEY FIRST, THEN REMOVE — the editor's order (map_dc_level.gd:1083). Read it
	# afterwards and the brush is already gone, so the record names nothing and the
	# fold skips it.
	var bkey: String = String(lvl.get_brush_key(bid))
	lvl.remove_brush(bid)
	lvl.rebuild()
	persist.record("brush.remove", {"brush_key": bkey})

	var t_edit: int = await _await_attempt(base, RESHELL_TIMEOUT_MS)
	_check("an edit re-derives the shell", t_edit >= 0,
			("%d ms after the delete" % t_edit) if t_edit >= 0
			else "no re-derive within %d ms — the shell still has the level as imported" % RESHELL_TIMEOUT_MS)

	# --- and does NOT keep re-deriving ----------------------------------------
	# refold() emits `folded` for every journal change, including other peers' and
	# its own replays. Re-deriving per fold would rebuild a 563k-triangle mesh on a
	# timer for as long as the map is open.
	var after_one := _attempts()
	await _quiet(QUIET_MS)
	_check("quiet does not re-derive", _attempts() == after_one,
			"%d attempt(s) during %d ms of no edits" % [_attempts() - after_one, QUIET_MS])

	# --- a burst coalesces -----------------------------------------------------
	# Dragging a face writes a record per step. One re-derive per record would put
	# a multi-second build between every frame of the drag.
	var before_burst := _attempts()
	var next := bid + 1
	var burst := 0
	while burst < 5:
		next = _live_brush(lvl, next)
		if next < 0:
			break
		var k: String = String(lvl.get_brush_key(next))
		lvl.remove_brush(next)
		lvl.rebuild()
		persist.record("brush.remove", {"brush_key": k})
		burst += 1
		next += 1
		await process_frame
	_check("the burst wrote 5 records", burst == 5, "%d written" % burst)
	var t_burst: int = await _await_attempt(before_burst, RESHELL_TIMEOUT_MS)
	_check("a burst of 5 edits re-derives", t_burst >= 0, "%d ms" % t_burst)
	await _quiet(QUIET_MS)
	_check("the burst re-derived ONCE, not per edit", _attempts() - before_burst == 1,
			"%d re-derives for 5 edits" % (_attempts() - before_burst))

	_finish()


func _finish() -> void:
	print("")
	if _failures.is_empty():
		print("RESULT: PASS (%d ms)" % (_ms() - _t0))
		quit(0)
	else:
		for f in _failures:
			print("  FAILED: %s" % f)
		print("RESULT: FAIL")
		quit(1)