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)