extends SceneTree
## Gate: a bot's key prefix follows its BINDING, not the fact that it is a bot.
##
## THE BUG THIS EXISTS FOR. KEY_REGISTRY and KEY_POS were constants —
## world:bots:registry: and world:bots:pos: — and STATE_ROUTES scopes that prefix
## to the map unconditionally. So a room- or session-bound bot got a MAP-SCOPED
## durable row: its node is parented to the registry autoload and survives a map
## change, while the row announcing it is dropped with the old map's durable
## state. Binding and namespace disagree for exactly the non-map bindings.
##
## Found by standing up the bot matrix in AI Testing Town, where the rows landed
## as map:map_flat:world:bots:registry:mx-pos-room for a bot bound to the ROOM.
##
## spec/bot-testbed.md (e2) already specifies the rule, under "The three
## derivations": the key prefix is "the same narrowest binding, so eviction by
## prefix is mechanically possible". The registry derived the node parent that
## way and the key prefix not at all.
##
## Two namespaces are enough, because eviction only needs to distinguish
## "dropped with the map" from "not":
## world:bots: map-bound — map-scoped, evicted with the map
## room:bots: room/session — room-global, survives a map change
##
## ⚠ The DOUBLE records raw keys. That is the point: it shows which prefix the
## registry chose, before GameState's scoping would rewrite it.
var ok := true
var _ran := false
func _p(c: bool, m: String) -> void:
print(("ok " if c else "FAIL: ") + m)
if not c:
ok = false
func _process(_delta: float) -> bool:
if _ran:
return true
_ran = true
_run()
print("GATE bot_key_scope: %s" % ("PASS" if ok else "FAIL"))
if not ok:
quit(1)
return true
func _run() -> void:
var gs: Node = root.get_node_or_null("GameState")
var br: Node = root.get_node_or_null("BotRegistry")
_p(gs != null and br != null, "GameState and BotRegistry autoloads present")
if gs == null or br == null:
return
# ---- the routes must declare both namespaces, differently scoped ----
var by_prefix: Dictionary = {}
for row: Dictionary in gs.STATE_ROUTES:
by_prefix[str(row.get("prefix", ""))] = row
_p(by_prefix.has("world:bots:registry:"), "world:bots:registry: is routed")
if by_prefix.has("world:bots:registry:"):
_p(str((by_prefix["world:bots:registry:"] as Dictionary).get("map_scope", "")) != "",
"world:bots:registry: is map-scoped (the map-bound case)")
_p(by_prefix.has("room:bots:registry:"), "room:bots:registry: is routed")
_p(by_prefix.has("room:bots:pos:"), "room:bots:pos: is routed")
if by_prefix.has("room:bots:registry:"):
var rr: Dictionary = by_prefix["room:bots:registry:"]
_p(str(rr.get("map_scope", "")) == "",
"room:bots:registry: is NOT map-scoped -- that is the whole fix")
_p(int(rr.get("channel", -1)) == gs.Channel.DURABLE, "room:bots:registry: is durable")
_p(str(rr.get("role", "")) == "mod", "room:bots:registry: keeps role mod")
if by_prefix.has("room:bots:pos:"):
var rp: Dictionary = by_prefix["room:bots:pos:"]
_p(str(rp.get("map_scope", "")) == "", "room:bots:pos: is NOT map-scoped")
_p(int(rp.get("channel", -1)) == gs.Channel.REALTIME, "room:bots:pos: is realtime")
# ---- and the registry must actually choose between them ----
var fake_map := Node3D.new()
fake_map.name = "GateMapKeyScope"
root.add_child(fake_map)
br.map_container = fake_map
var real_state: Object = br.state
br.state = load("res://scenes/test/permissive_state.gd").new()
var map_bot := "ks_map"
var room_bot := "ks_room"
var sess_bot := "ks_session"
_p(bool(br.register(map_bot, ["map"], "position").get("ok", false)), "map-bound body registers")
_p(bool(br.register(room_bot, ["room"], "position").get("ok", false)), "room-bound body registers")
_p(bool(br.register(sess_bot, ["session"], "position").get("ok", false)), "session-bound body registers")
_p(br.state.has_state("world:bots:registry:" + map_bot),
"map-bound row is under world:bots: (map-scoped)")
_p(not br.state.has_state("room:bots:registry:" + map_bot),
"and NOT under room:bots:")
_p(br.state.has_state("room:bots:registry:" + room_bot),
"room-bound row is under room:bots: (room-global)")
_p(not br.state.has_state("world:bots:registry:" + room_bot),
"and NOT under the map-scoped world:bots: -- the defect this gate exists for")
_p(br.state.has_state("room:bots:registry:" + sess_bot),
"session-bound row is room-global too")
_p(not br.state.has_state("world:bots:registry:" + sess_bot),
"and not map-scoped either")
# position rows follow the same split
_p(br.state.has_state("world:bots:pos:" + map_bot), "map-bound pos row under world:bots:")
_p(br.state.has_state("room:bots:pos:" + room_bot), "room-bound pos row under room:bots:")
# move() must write to the same namespace the bot was registered in, or the
# body announces itself in one place and moves in another.
_p(bool(br.move(room_bot, Vector3(1, 2, 3)).get("ok", false)), "move() on a room-bound bot ok")
var mp: Variant = br.state.get_state("room:bots:pos:" + room_bot)
_p(mp is Array and (mp as Array).size() >= 3 and float((mp as Array)[0]) == 1.0,
"and it wrote room:bots:pos:, not world:bots:pos:")
_p(not br.state.has_state("world:bots:pos:" + room_bot), "nothing leaked into world:bots:pos:")
# despawn must erase from the namespace it used
br.despawn(room_bot)
_p(not br.state.has_state("room:bots:registry:" + room_bot), "despawn erased the room-global row")
_p(not br.state.has_state("room:bots:pos:" + room_bot), "and the room-global pos row")
# SELF-CHECK: the assertions above only mean something if the two prefixes are
# genuinely different strings and the map case still uses the map one.
_p(br.state.has_state("world:bots:registry:" + map_bot),
"self-check: the map-bound row is still there and still map-namespaced")
br.despawn(map_bot)
br.despawn(sess_bot)
br.state = real_state