OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / bots / builder.gd

182 lines · gd ref:
extends RefCounted
## A builder bot you can WATCH — it stacks bevelled blocks, one at a time.
##
##   BotRegistry.register("builder", ["map"], "position",
##       {"kind": "script_file", "path": "res://bots/builder.gd"})
##
## ⚠ body = position, NOT body = none, and that is the point of this one.
## spec/bot-testbed.md calls a builder bot the body=none case, and for a
## production builder that is right: it writes edits and needs no presence. But
## an invisible bot cannot be CONFIRMED. body=none has no node, so the only
## evidence it ran is the state it wrote, and "is my builder working" then means
## reading a key rather than looking at the world. Giving it a position body
## costs nothing, needs no new kind of bot -- body and brain are independent axes
## -- and turns the question into one you answer by looking.
##
## WHAT TO EXPECT IF IT IS WORKING. A coloured marker parks itself beside a spot
## and a 10 x 10 x 20 block appears under it every BUILD_INTERVAL seconds, each
## one LAYER_HEIGHT above the last, alternating a quarter turn so the stack reads
## as courses rather than as one tall box. It stops at `count` and says so, or
## keeps going for ever if count <= 0.
##
## ⛔ IT PLACES THROUGH THE JOURNAL, never by adding a node. create_object
## records object.create and folds it, here and on every other peer by the same
## path, so what you see is what they see. A bot that added geometry directly
## would look identical on this machine and exist nowhere else -- the same reason
## the brush operators do not touch the level.

## Injected by BotRegistry before _bot_init.
var bot_id: String = ""

## The block it stacks. An id in PrefabCatalogue, so this cannot name a scene
## that is not on the allow-list.
const BLOCK_ID := "block.bevelled"

## Bevelled block is 10 x 10 x 20, so a course is 10 up.
const LAYER_HEIGHT := 10.0
const BUILD_INTERVAL := 2.0

## HOW MANY BLOCKS, from the registration rather than from this file:
##
##   BotRegistry.register("builder", ["map"], "position",
##       {"kind": "script_file", "path": "res://bots/builder.gd",
##        "params": {"count": 40}})
##
## A property, not a const, because that is what ScriptHost's context applies
## with set(). 0 or less means KEEP BUILDING with no bound.
##
## ⚠ EACH COURSE IS LAYER_HEIGHT (10 m). An unbounded builder is a tower that
## grows 10 m every BUILD_INTERVAL and a durable journal that grows one record
## with it, replayed on every join. That is a deliberate choice, not an oversight
## — bound it with `count` when you want it bounded.
var count: int = 6

## The knobs a placed bot shows (BrainCatalogue.params_of).
const PARAMS := [
	{"id": "count", "label": "Blocks", "kind": "int", "default": 6, "min": 0, "max": 200, "step": 1},
]

## Where the stack goes, relative to wherever the bot's own body spawned. Far
## enough that the bot is not standing inside its own first block.
const STACK_OFFSET := Vector3(14.0, 0.0, 0.0)

var _t: float = 0.0
var _placed: int = 0
var _base := Vector3.ZERO
var _done := false
var _warned := false


func _bot_init() -> void:
	_base = _own_position() + STACK_OFFSET
	print("[builder] '%s' will stack %s blocks at %s"
			% [bot_id, ("unbounded" if count <= 0 else str(count)), str(_base)])
	_publish("placed", 0)
	_publish("target", count)


func _bot_tick(delta: float) -> void:
	if _done:
		return
	_t += delta
	if _t < BUILD_INTERVAL:
		return
	_t = 0.0

	# count <= 0 is "keep building": no bound, so this never trips.
	if count > 0 and _placed >= count:
		_done = true
		print("[builder] '%s' finished: %d blocks" % [bot_id, _placed])
		_publish("state", "done")
		return

	var map := _map()
	if map == null:
		# Not an error worth spamming: a headless peer that has not started the
		# game yet has no current_map, and the next tick is a second away.
		if not _warned:
			_warned = true
			print("[builder] '%s' waiting for a map that can hold objects" % bot_id)
		return
	_warned = false

	var y := _base.y + LAYER_HEIGHT * (float(_placed) + 0.5)
	var basis := Basis()
	# A quarter turn on alternate courses, so the stack reads as courses. It also
	# makes a failure to place obvious: a tower with no twist is one block.
	if _placed % 2 == 1:
		basis = basis.rotated(Vector3.UP, PI * 0.5)
	var xform := Transform3D(basis, Vector3(_base.x, y, _base.z))

	var dot: String = map.create_object(BLOCK_ID, xform)
	if dot == "":
		if not _warned:
			_warned = true
			push_warning("[builder] '%s' was refused a block — role, or no prefab" % bot_id)
		return

	_placed += 1
	_publish("placed", _placed)
	print("[builder] '%s' placed block %d/%s at %s"
			% [bot_id, _placed, ("~" if count <= 0 else str(count)), str(xform.origin)])

	# Move the BODY to the top of the stack, so the thing you can see is visibly
	# doing the thing it reports doing.
	_move_self(Vector3(_base.x - 8.0, y + LAYER_HEIGHT * 0.5, _base.z))


func _bot_exit() -> void:
	_publish("state", "gone")
	print("[builder] '%s' stopped after %d blocks" % [bot_id, _placed])


## Where our own body is, or the origin if we have none.
func _own_position() -> Vector3:
	var reg := _registry()
	if reg == null or bot_id == "":
		return Vector3.ZERO
	if reg.has_method("start_position"):
		return reg.start_position(bot_id)      # a placed bot's placement, else its cell
	if reg.has_method("_spawn_offset_for"):
		return reg._spawn_offset_for(bot_id)
	return Vector3.ZERO


func _move_self(pos: Vector3) -> void:
	var reg := _registry()
	if reg != null and reg.has_method("move"):
		reg.move(bot_id, pos)


## Progress, on the durable plane, so "is it working" is answerable from
## admin-cli as well as by looking. state: is the one generic key space a bot may
## write without reasoning about authorship — the same choice beacon.gd makes.
func _publish(what: String, value: Variant) -> void:
	var tree := Engine.get_main_loop() as SceneTree
	if tree == null or bot_id == "":
		return
	var gs := tree.root.get_node_or_null(^"GameState")
	if gs != null and gs.has_method("set_state"):
		gs.set_state("state:bot:%s:%s" % [bot_id, what], value)


func _registry() -> Node:
	var tree := Engine.get_main_loop() as SceneTree
	return tree.root.get_node_or_null(^"BotRegistry") if tree != null else null


## The current map, if it can hold authored objects. map_flat and map_dc_level
## both can; a terrain map does not, and that is a reason to wait rather than an
## error.
func _map() -> Node:
	var tree := Engine.get_main_loop() as SceneTree
	if tree == null:
		return null
	var game := tree.root.get_node_or_null(^"Game")
	if game == null:
		return null
	var m: Variant = game.get("current_map")
	if m == null or not is_instance_valid(m) or not (m is Node):
		return null
	return (m as Node) if (m as Node).has_method("create_object") else null