OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_creature_tiers.gd

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