extends Node
## GATE — creature SIMULATION TIERS + SLEEP (CreatureScheduler, hyper-games #114; user
## 2026-10-13: "start on simulation tiers and sleep for creatures").
##
## Creatures are BOTS now (#114 phase 5): the real BotRegistry scheduler (authority: no
## Game node here) on a flat 2 km collider, two observers in the `player` group — A at
## the origin, B 400 m away — and creature bots registered at known distances, each
## sent walking (move). 240 physics frames later:
## T1 near A (10 m): ticked every frame (NEAR)
## T2 30 m from A: ~every 6th frame (MID, 10 Hz)
## T3 70 m from A: ~every 30th frame (FAR, 2 Hz)
## T4 250 m from everyone: ZERO ticks (SLEEP) and it did not move
## T5 next to B — 400 m from A, the authority's player — is NEAR, not asleep and not
## despawned (the old LOD and despawn looked at A alone)
## T6 every ticking tier was handed the time it missed: simulated ≈ elapsed
## T7 a far HOPPER keeps the ground it spawned on (it used to fall to y = 0) — on a
## collider raised to y = 50
## (T8 — "only creatures that ticked are synced" — retired with the spawner: a bot's
## rows carry its brain's targets, not a per-tick position, so there is no per-tick
## sync to thin out.)
## RUN AS A SCENE: "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_creature_tiers.tscn
var fails := 0
var checks := 0
const FRAMES := 240
func _ok(what: String, cond: bool, note: Variant = "") -> void:
checks += 1
if not cond:
fails += 1
print(" %-64s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)])
func _floor(y: float) -> void:
var body := StaticBody3D.new()
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(2000, 1, 2000)
cs.shape = box
body.add_child(cs)
add_child(body)
body.position = Vector3(0, y - 0.5, 0)
func _observer(at: Vector3) -> Node3D:
var o := Node3D.new()
add_child(o)
o.global_position = at
o.add_to_group("player")
return o
func _ready() -> void:
_floor(50.0) # raised ground: y = 50
_observer(Vector3(0, 50, 0))
_observer(Vector3(400, 50, 0))
var br: Node = get_node("/root/BotRegistry")
br.map_container = self
var real_state: Object = br.state
br.state = load("res://scenes/test/permissive_state.gd").new()
await get_tree().physics_frame
await get_tree().physics_frame
var ids := {}
# [name, body, start, walk-to]: each walk stays INSIDE its tier the whole way, and is
# under GroundCrawler.SNAP_DISTANCE (8 m) — further, set_target teleports the body.
for spec: Array in [["near", "crawler", Vector3(10, 50, 0), Vector3(4, 50, 0)],
["mid", "crawler", Vector3(30, 50, 0), Vector3(30, 50, 6)],
["far", "crawler", Vector3(70, 50, 0), Vector3(70, 50, 6)],
["sleep", "crawler", Vector3(-250, 50, 0), Vector3(-250, 50, 6)],
["by_b", "crawler", Vector3(405, 50, 0), Vector3(399, 50, 0)],
["hop_far", "hopper", Vector3(0, 50, 70), Vector3(0, 50, 76)]]:
var id := "tiers-" + String(spec[0])
var r: Dictionary = br.register(id, ["map"], spec[1], {"kind": "none"}, spec[2])
if bool(r.get("ok", false)):
ids[spec[0]] = id
br.move(id, spec[3]) # walking, as creatures wandered
_ok("setup: %s registered" % spec[0], ids.has(spec[0]), r)
var sched: CreatureScheduler = br.scheduler()
var node := func(k: String) -> Node3D:
return br.body_of(ids[k])
await get_tree().physics_frame # the first retier
await get_tree().physics_frame
var sleep_p0: Vector3 = node.call("sleep").global_position
sched.reset_stats()
SimStats.reset()
var dirty_seen := {}
for f in FRAMES:
await get_tree().physics_frame
var calls := func(k: String) -> int:
return _ticks_of(sched, node.call(k))
var elapsed := float(FRAMES) / float(Engine.physics_ticks_per_second)
_ok("T1 near crawler: 20 Hz (every 3rd frame — it interpolates)", absi(calls.call("near") - FRAMES / 3) <= 2,
calls.call("near"))
_ok("T2 mid: ~every 6th frame (10 Hz)", absi(calls.call("mid") - FRAMES / 6) <= 2, calls.call("mid"))
_ok("T3 far: ~every 30th frame (2 Hz)", absi(calls.call("far") - FRAMES / 30) <= 1, calls.call("far"))
_ok("T4 sleep: zero ticks", calls.call("sleep") == 0, calls.call("sleep"))
_ok("T4 and it did not move", (node.call("sleep") as Node3D).global_position.is_equal_approx(sleep_p0))
_ok("T5 next to the SECOND observer = NEAR (not asleep, not despawned)",
node.call("by_b") != null and sched.tier_of(node.call("by_b")) == CreatureScheduler.Tier.NEAR,
sched.tier_of(node.call("by_b")) if node.call("by_b") != null else "despawned")
for k: String in ["near", "mid", "far"]:
var sim: float = float(sched.simulated.get(node.call(k), 0.0))
_ok("T6 %s: simulated %.2f s of %.2f s elapsed" % [k, sim, elapsed], absf(sim - elapsed) < 0.6, sim)
var hop: Node3D = node.call("hop_far")
_ok("T7 a far hopper keeps its raised ground (y ≈ 50, not 0)", hop != null and hop.global_position.y > 45.0,
hop.global_position if hop != null else null)
# T9: the 20 Hz near crawler is DRAWN every render frame, between its last two poses.
var nc: Node3D = node.call("near")
# Walking NOW (it may have arrived): 6 m back out, still near; two ticks at 20 Hz.
br.move(ids["near"], Vector3(10, 50, 0))
for f in 7:
await get_tree().physics_frame
_ok("T9 the near crawler interpolates", bool(nc.get("interpolating")))
var draws: int = int((SimStats.snapshot().get("creature.crawler.draw", {}) as Dictionary).get("calls", 0))
_ok("T9 it was drawn on render frames between its ticks", draws >= FRAMES, draws)
# Draw NOW, against the current pose pair — a draw from the last render frame can
# predate the latest tick, which moved prev <- cur after it.
nc.call("_process", 0.0)
var prev: PackedVector3Array = nc.get("_pose_prev")
var cur: PackedVector3Array = nc.get("_pose_cur")
var drawn: Vector3 = nc.get("drawn_head") # a headless renderer keeps no MultiMesh data
var seg: Vector3 = cur[0] - prev[0]
var t: float = 0.0 if seg.length_squared() < 1e-12 else clampf((drawn - prev[0]).dot(seg) / seg.length_squared(), 0.0, 1.0)
_ok("T9 the drawn head lies between the last two simulated poses",
drawn.distance_to(prev[0].lerp(cur[0], t)) < 1e-3, [prev[0], drawn, cur[0]])
# And not just at an end: a half-way draw is exactly half-way between two DIFFERENT poses.
nc.call("_draw_pose", 0.5)
var half: Vector3 = nc.get("drawn_head")
_ok("T9 a half-way draw is the midpoint of two different poses",
prev[0].distance_to(cur[0]) > 1e-4 and half.distance_to(prev[0].lerp(cur[0], 0.5)) < 1e-4,
[prev[0], half, cur[0]])
for id: String in ids.values():
br.despawn(id)
br.state = real_state
br.map_container = null
print("\nGATE creature_tiers: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
get_tree().quit(0 if fails == 0 else 1)
## Ticks one creature got since reset_stats: its simulated seconds can't say how many
## steps, so count from the per-creature tick map the scheduler keeps.
func _ticks_of(sched: CreatureScheduler, c: Node3D) -> int:
return int(sched.tick_count.get(c, 0))