OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / terrain_edit_manager.gd

564 lines · gd ref:
class_name TerrainEditManager extends Node
## Routes terrain edits through GameState for persistence + sync.
## Any member may make edits; each edit is author-keyed (world:terrain:edit:
## <memberId>:<seq>) so a peer owns its own edits and mod+ can override others'.
## Edits stored as DURABLE state (Autobase).
## New peers receive all edits via GameState.load_room_state().
##
## Usage:
##   terrain_edit_manager.apply_edit(hit_pos, half_ext, strength)
##
## The manager finds DCTerrain nodes in the scene tree and applies edits.
## Listens for GameState changes to apply remote edits.

var _next_edit_id: int = 0
var _applied_edits: Dictionary = {}  # edit_id → true (prevent re-apply)
var _terrain_node: Node = null
var _retry_timer: Timer
var _retry_deadline: float = 0.0


func _ready() -> void:
	# Discoverable by the map's edit input so CSG edits route through here
	# (persist to GameState) instead of hitting the terrain node directly.
	add_to_group("terrain_edit_manager")
	GameState.state_changed.connect(_on_state_changed)
	GameState.write_rejected.connect(_on_write_rejected)
	# Enter-map lifecycle: on any map change, reset + re-apply this map's edits to
	# the fresh DCTerrain (re-apply is driven by the reload's state_changed + retry).
	GameState.map_changing.connect(func(_id: String) -> void: reenter_map())
	# Load existing edits from durable state (for late joiners)
	call_deferred("_load_existing_edits")
	# Durable terrain edits load BEFORE the map (dc_terrain) switches in and
	# before the terrain finishes generating, so the first apply finds no
	# DCTerrain. Retry re-applying until every persisted edit lands or we time out.
	_retry_deadline = Time.get_unix_time_from_system() + 30.0
	_retry_timer = Timer.new()
	_retry_timer.wait_time = 1.0
	_retry_timer.timeout.connect(_retry_apply)
	add_child(_retry_timer)
	_retry_timer.start()


## Called by game.gd on a map change (teleport). The new map has a fresh DCTerrain
## with no edits, and our _applied_edits still marks the previous instance's edits
## as done — so reset and re-apply this map's edits to the new terrain.
func reenter_map() -> void:
	# Drop the previous world's baked terrain edits from the DCTerrain NODE itself
	# (its in-memory edit_list_ / visual mesh) — otherwise a room/map switch keeps
	# the old craters. This clears LOCAL render state only; each room's PERSISTED
	# edits stay in that room's StateBase and replay when re-entered.
	var old_terrain: Node = _find_terrain()
	if old_terrain and old_terrain.has_method("clear_edits"):
		old_terrain.clear_edits()
	_applied_edits.clear()
	_terrain_node = null
	_retry_deadline = Time.get_unix_time_from_system() + 30.0
	if _retry_timer and not _retry_timer.is_stopped():
		pass
	elif _retry_timer:
		_retry_timer.start()
	call_deferred("_load_existing_edits")


func _retry_apply() -> void:
	_terrain_node = null  # force re-search (the map may have changed)
	_load_existing_edits()
	var keys: Array[String] = GameState.keys_with_prefix("world:terrain:edit:")
	var all_applied: bool = not keys.is_empty()
	for key: String in keys:
		if not _applied_edits.has(key.replace("world:terrain:edit:", "")):
			all_applied = false
			break
	if all_applied or Time.get_unix_time_from_system() > _retry_deadline:
		_retry_timer.stop()
		if all_applied:
			print("[TerrainEditManager] all %d durable edits applied" % keys.size())


func _load_existing_edits() -> void:
	_terrain_node = _find_terrain()
	if not _terrain_node:
		return

	var keys: Array[String] = GameState.keys_with_prefix("world:terrain:edit:")
	if keys.is_empty():
		return

	DebugLog.log("edits", "Loading %d existing edits" % keys.size())
	print("[TerrainEditManager] Replaying %d durable terrain edits from room state" % keys.size())
	replay_in_order(keys)


## SORTED, because edit composition is NOT COMMUTATIVE — hyper-games #42 P1.
##
## apply() composes with min (subtract) and max (add), and those do not commute:
## base 0, add 5, subtract -3 gives -3 one way and +5 the other. Two overlapping
## hard spheres come out -1.5 against +1.5 — opposite SIGNS, so the two orders
## disagree about solid vs air at the same point (dc-terrain
## libdc/test/test_edit_order.cpp).
##
## This iterated keys_with_prefix() with no sort, so replay order was whatever the
## store handed back. Two peers with the SAME journal could materialise different
## geometry and nothing would notice. The level journal has folded by (seq, author)
## since it existed; this is terrain catching up to it.
##
## WHY IT WAS NEVER SEEN: same-op runs commute. subtract-then-subtract and
## add-then-add both come out identical, and terrain editing is overwhelmingly
## carving — so an all-carve session converges by luck. It breaks the first time
## someone fills something back in, and then only for the peer whose store
## enumerates differently.
##
## Extracted rather than inlined so the gate can drive the real path from a
## shuffled key list instead of asserting a helper it hopes is called.
func replay_in_order(keys: Array[String], interactive: bool = false) -> void:
	for key: String in sort_edit_keys(keys):
		# The high-water mark has to come from the REPLAY, not only from live
		# arrivals. Left at -1 after a fold, the first record to arrive would be
		# accepted whatever its seq — including one that belongs before edits this
		# very function just applied.
		_max_applied_seq = maxi(_max_applied_seq, int(_dot_of(key)[1]))
		var data: Variant = GameState.get_state(key)
		if data is Dictionary:
			# Replay of durable edits at spawn = landscape, NOT in-the-moment input:
			# non-interactive so it re-meshes at normal priority (no priority-lane flood
			# that starves LOD pre-mesh). The edits are still baked into the density.
			_apply_edit_local(key.replace("world:terrain:edit:", ""), data, interactive)


## The total order: (seq, author), the same comparator the level journal folds by.
##
## SEQ FIRST, AUTHOR ONLY TO BREAK TIES. Concurrent edits have no true order, so
## the requirement is a stable arbitrary one that every peer computes identically
## — not a correct one, which does not exist. A malformed key sorts first on seq 0
## rather than being dropped: a record we cannot place is still a record, and
## silently discarding it would be a worse failure than ordering it oddly.
## Delegates to TerrainEditOrder — pure logic, its own file, so a --script gate can
## preload it without dragging in the autoloads this class needs.
static func sort_edit_keys(keys: Array[String]) -> Array[String]:
	return TerrainEditOrder.sort_keys(keys)


static func _dot_of(key: String) -> Array:
	return TerrainEditOrder.dot_of(key)


## Public API: apply a terrain edit (routes through GameState for sync).
func apply_edit(hit_pos: Vector3, half_ext: Vector3, strength: float) -> void:
	var edit_id: String = _generate_edit_id()
	var data: Dictionary = {
		"pos": [hit_pos.x, hit_pos.y, hit_pos.z],
		"ext": [half_ext.x, half_ext.y, half_ext.z],
		"str": strength,
	}

	# Apply locally FIRST, interactive — see _apply_then_record.
	_apply_then_record(edit_id, data)


## Public API: apply a SPHERE edit (routes through GameState for persistence + sync).
## op: 0 = smooth metaball blend, 1 = hard SDF CSG (union/difference by strength sign).
func apply_sphere(hit_pos: Vector3, radius: float, strength: float, op: int) -> void:
	var edit_id: String = _generate_edit_id()
	var data: Dictionary = {
		"type": "sphere",
		"pos": [hit_pos.x, hit_pos.y, hit_pos.z],
		"radius": radius,
		"str": strength,
		"op": op,
	}
	_apply_then_record(edit_id, data)


## Public API: apply a MESH STAMP (routes through GameState for persistence + sync).
## Stores the mesh ASSET PATH + transform (compact + replayable) rather than the raw
## vertex arrays — every peer re-loads the bundled mesh and re-stamps on replay, so a
## stamp persists across restart and syncs to peers exactly like a box edit.
func apply_mesh_stamp(mesh_path: String, hit_pos: Vector3, radius: float, strength: float) -> void:
	var edit_id: String = _generate_edit_id()
	var data: Dictionary = {
		"type": "mesh",
		"mesh": mesh_path,
		"pos": [hit_pos.x, hit_pos.y, hit_pos.z],
		"radius": radius,
		"str": strength,
	}

	_apply_then_record(edit_id, data)


## Public API: apply a CONVEX BRUSH edit — a level brush voxelized into the SDF.
##
## Same shape as apply_sphere and apply_mesh_stamp: write the record, then apply
## locally and optimistically. The payload is the brush's own plane set, which is
## what makes it replayable — every peer re-stamps from the planes rather than
## from a mesh it would have to agree on.
##
## PLAIN ARRAYS, not PackedFloat32Array. The value goes through GameState into
## room state and back out on every peer and every reload; a plain Array of
## floats is the shape the rest of this file's payloads already use (pos, ext).
##
## `brush_key` is the level brush this came from. Nothing reads it yet. It is
## stored because the operation SPANS TWO JOURNALS — this record adds terrain,
## and a `brush.remove` record in the level journal takes the brush away — and
## without the link there is no way to notice later that one landed and the
## other did not.
func apply_convex(planes: PackedFloat32Array, center: Vector3, radius: float,
		strength: float, material_id: int = 0,
		proj: PackedInt32Array = PackedInt32Array(),
		brush_key: String = "") -> void:
	var edit_id: String = _generate_edit_id()
	var pl: Array = []
	for v in planes:
		pl.append(v)
	var pr: Array = []
	for v in proj:
		pr.append(v)
	var data: Dictionary = {
		"type": "convex",
		"planes": pl,
		"pos": [center.x, center.y, center.z],
		"radius": radius,
		"str": strength,
		"mat": material_id,
		"proj": pr,
		"brush_key": brush_key,
	}
	_apply_then_record(edit_id, data)


## A LOCAL edit: apply it to the terrain INTERACTIVE, then write the record.
##
## The order is the fix. set_state emits state_changed SYNCHRONOUSLY, and
## _on_state_changed applies what it sees as non-interactive (state sync). With the
## record written first, that handler applied the player's own carve at normal
## priority and the interactive apply after it was skipped as already applied --
## no worker priority lane, no priority collision rebuild. Measured: 16 bench
## carves, 0 priority collision builds (gate_local_carve_interactive).
##
## Applied before the write, the echo finds it in _applied_edits and returns. If
## GameState refuses the write (can_write), the edit stays visual-only, as it
## always has been.
func _apply_then_record(edit_id: String, data: Dictionary) -> void:
	_apply_edit_local(edit_id, data, true)
	GameState.set_state("world:terrain:edit:" + edit_id, data)


## Clear all terrain edits (admin only).
func clear_edits() -> void:
	var keys: Array[String] = GameState.keys_with_prefix("world:terrain:edit:")
	for key: String in keys:
		GameState.erase_state(key)

	_applied_edits.clear()
	var terrain: Node = _find_terrain()
	if terrain and terrain.has_method("clear_edits"):
		terrain.clear_edits()
	DebugLog.log("edits", "Cleared all edits")


## Undo the caller's most-recent durable edit (highest seq authored by us).
## Erasing syncs to peers; the remaining journal is re-meshed. Repeated calls
## walk back the edit history — a durable undo stack. (Edits can't be
## individually subtracted from the SDF, so undo = re-bake from the journal.)
func undo_last_edit() -> void:
	var author: String = KeetService.current_member_id
	if author == "":
		author = "p%d" % multiplayer.get_unique_id()
	var keys: Array[String] = GameState.keys_with_prefix("world:terrain:edit:")
	var best_key: String = ""
	var best_seq: int = -1
	for key: String in keys:
		var eid: String = key.replace("world:terrain:edit:", "")
		var parts: PackedStringArray = eid.rsplit(":", true, 1)
		if parts.size() == 2 and parts[0] == author:
			var seq: int = int(parts[1])
			if seq > best_seq:
				best_seq = seq
				best_key = key
	if best_key == "":
		print("[TerrainEditManager] undo: no edits to undo")
		return
	GameState.erase_state(best_key)  # durable + syncs to peers
	_request_rebuild()


## Re-mesh from the current durable journal after an erase. Debounced so a
## bulk clear (many erases) rebuilds once, not once per key.
## Highest seq applied since the last fold — the append/re-fold decision. Reset by
## _do_rebuild, because after a fold the whole journal is replayed in order and the
## previous high-water mark describes a composition that no longer exists.
var _max_applied_seq: int = -1
var _rebuild_pending: bool = false
func _request_rebuild() -> void:
	if _rebuild_pending:
		return
	_rebuild_pending = true
	call_deferred("_do_rebuild")


func _do_rebuild() -> void:
	_rebuild_pending = false
	var terrain: Node = _find_terrain()
	if terrain and terrain.has_method("clear_edits"):
		terrain.clear_edits()
	_applied_edits.clear()
	_max_applied_seq = -1
	_load_existing_edits()


## Edit id = "<authorMemberId>:<seq>" so each peer OWNS the edits it makes
## (GameState.can_write lets a peer write its own edits; mod+ can override others').
## Falls back to the session peer id only if our memberId hasn't loaded yet.
##
## CRITICAL: seq must NOT collide with already-persisted edits. Previously seq
## started at 1, so a fresh session's first edits reused ids "<me>:1..N" that were
## already applied by the replay of this room's persisted edits — so _apply_edit_local's
## dedup skipped them and live edits never re-meshed (they still persisted, hence
## "shows on restart but not live"). Seed from the wall clock so new ids always
## sort above any persisted low-numbered ids and never repeat across sessions.
func _generate_edit_id() -> String:
	if _next_edit_id == 0:
		_next_edit_id = int(Time.get_unix_time_from_system())
	_next_edit_id += 1
	var author: String = KeetService.current_member_id
	if author == "":
		author = "p%d" % multiplayer.get_unique_id()
	return "%s:%d" % [author, _next_edit_id]


func _on_state_changed(key: String, value: Variant, _channel: int) -> void:
	if not key.begins_with("world:terrain:edit:"):
		return

	var edit_id: String = key.replace("world:terrain:edit:", "")

	if value == null:
		# Edit erased (undo / durable clear). Re-mesh from the remaining
		# journal — edits aren't individually subtractable from the SDF.
		_request_rebuild()
		return

	if value is Dictionary:
		# ALREADY APPLIED? NOTHING TO DO — hyper-games #88 Tier 1 #1. Asked FIRST,
		# because the order check below cannot tell a re-delivery from an arrival:
		# carve A then B, and A's own write confirmation (set_value -> state_updated)
		# comes back with seq(A) < seq(B) and re-folded the whole journal — once per
		# carve, while carving fast. Edit records are immutable (undo ERASES the key,
		# handled above), so a key already in _applied_edits carries nothing new.
		if _applied_edits.has(edit_id):
			# Still counts toward the high-water mark: a LOCAL carve is applied
			# before its record is written, so this is where its seq is first seen.
			_max_applied_seq = maxi(_max_applied_seq, int(_dot_of(key)[1]))
			return
		# OUT OF ORDER? RE-FOLD, DO NOT APPEND — hyper-games #42 P1.
		#
		# Appending is only sound when the arrival sorts ABOVE everything already
		# applied. A record from a peer whose seq is lower belongs EARLIER in the
		# composition, and since min/max do not commute, appending it produces a
		# field that no ordering of the journal would ever produce — a state unique
		# to this peer's arrival timing.
		#
		# Re-fold is the level journal's answer too, and it costs a debounced pass
		# over a set that is smaller than the brush journal already folds.
		var dot: Array = _dot_of(key)
		if int(dot[1]) < _max_applied_seq:
			DebugLog.log("edits", "out-of-order edit %s (seq %d < %d) — re-folding"
				% [edit_id, int(dot[1]), _max_applied_seq])
			_request_rebuild()
			return
		_max_applied_seq = maxi(_max_applied_seq, int(dot[1]))
		# State-sync (3s poll / remote) = not local in-the-moment input -> non-interactive
		# (normal priority). NOTE (future): a REMOTE player's live carve also arrives here;
		# to give it priority too, key `interactive` on edit recency (authored-at time)
		# rather than local-vs-remote. See edit_terrain interactive-flag comment.
		_apply_edit_local(edit_id, value, false)


func _on_write_rejected(key: String, reason: String) -> void:
	if key.begins_with("world:terrain:edit:"):
		push_warning("[TerrainEditManager] Edit rejected: %s — %s" % [key, reason])


func _apply_edit_local(edit_id: String, data: Dictionary, interactive: bool = true) -> void:
	if _applied_edits.has(edit_id):
		return

	var terrain: Node = _find_terrain()
	if not terrain:
		return  # terrain not loaded yet (map switches in after state loads) —
		        # leave UNMARKED so the retry re-applies once dc_terrain exists.

	# Mesh stamps (F2), box edits, sphere edits and voxelized brushes all share the
	# world:terrain:edit:* namespace so a single replay/retry loop restores them
	# all. Absent "type" == legacy box edit.
	if data.get("type", "box") == "mesh":
		if not _apply_mesh_stamp_local(terrain, data, interactive):
			return  # mesh not stampable yet — leave UNMARKED so the retry re-applies.
		_focus_mesh_stamp(terrain, data)
		_applied_edits[edit_id] = true
		return

	if data.get("type", "box") == "convex":
		if not terrain.has_method("edit_terrain_convex"):
			return  # older extension — leave UNMARKED so a rebuild picks it up
		var c_arr: Array = data.get("pos", [0, 0, 0])
		var c_planes: PackedFloat32Array = PackedFloat32Array()
		for v in (data.get("planes", []) as Array):
			c_planes.append(float(v))
		var c_proj: PackedInt32Array = PackedInt32Array()
		for v in (data.get("proj", []) as Array):
			c_proj.append(int(v))
		if c_planes.is_empty():
			# A convex edit with no planes bounds nothing and would be a silent
			# no-op; mark it applied so the retry loop does not spin on it.
			_applied_edits[edit_id] = true
			return
		terrain.edit_terrain_convex(c_planes, Vector3(c_arr[0], c_arr[1], c_arr[2]),
				float(data.get("radius", 0.0)), float(data.get("str", 1.9)),
				interactive, int(data.get("mat", 0)), c_proj)
		_focus(terrain, Vector3(c_arr[0], c_arr[1], c_arr[2]), float(data.get("radius", 0.0)))
		_applied_edits[edit_id] = true
		return

	if data.get("type", "box") == "sphere":
		var s_pos_arr: Array = data.get("pos", [0, 0, 0])
		var s_pos := Vector3(s_pos_arr[0], s_pos_arr[1], s_pos_arr[2])
		var s_radius: float = data.get("radius", 1.0)
		var s_strength: float = data.get("str", 1.0)
		var s_op: int = int(data.get("op", 0))
		if terrain.has_method("edit_terrain"):
			terrain.edit_terrain(s_pos, s_radius, s_strength, s_op, interactive)
		_focus(terrain, s_pos, s_radius)
		_applied_edits[edit_id] = true
		return

	var pos_arr: Array = data.get("pos", [0, 0, 0])
	var ext_arr: Array = data.get("ext", [1, 1, 1])
	var strength: float = data.get("str", 1.0)

	var pos := Vector3(pos_arr[0], pos_arr[1], pos_arr[2])
	var ext := Vector3(ext_arr[0], ext_arr[1], ext_arr[2])

	if terrain.has_method("edit_terrain_box"):
		terrain.edit_terrain_box(pos, ext, strength, interactive)
	elif terrain.has_method("edit_terrain"):
		terrain.edit_terrain(pos, ext.x, strength, 0, interactive)
	_focus(terrain, pos, ext.length())

	_applied_edits[edit_id] = true  # mark only AFTER actually applying


## KEEP PLAYER EDITS SHARP. A focus sphere lets the terrain refine to LOD -2
## (voxel/4) around the edit — only while the camera is near it, and only once
## the world has settled at LOD0 (dc_octree_clipmap.cpp). Called for LIVE and
## REPLAYED edits alike, so a carve stays sharp after a reload. Overlapping
## spheres merge (OctreeClipmap::add_focus_point), so carving one spot over and
## over stays one sphere. Registered here, for player edits only, and NOT inside
## DCTerrain.edit_terrain*: importers call those for every brush of a level.
## Measured offline (dc-terrain tests/test_edit_focus_leaves.cpp): one carve at
## the camera +280 leaves (~11%), one 500 m away +0.
func _focus(terrain: Node, pos: Vector3, radius: float) -> void:
	if terrain.has_method("add_edit_focus"):
		terrain.add_edit_focus(pos, radius)


## A mesh stamp's reach, computed exactly as libdc's mesh_bounding_radius
## (dc_edit.h): the stamp centres the mesh on its vertex centroid, then takes the
## far AABB corner, max(|lo|,|hi|) per axis, times scale, plus a 2 m margin.
func _focus_mesh_stamp(terrain: Node, data: Dictionary) -> void:
	var arrays: Dictionary = _load_mesh_arrays(String(data.get("mesh", "")))
	if arrays.is_empty():
		return
	var verts: PackedVector3Array = arrays["verts"]
	var c := Vector3.ZERO
	for v in verts:
		c += v
	c /= maxf(float(verts.size()), 1.0)
	var ext := Vector3.ZERO
	for v in verts:
		ext = ext.max((v - c).abs())
	var pos_arr: Array = data.get("pos", [0, 0, 0])
	_focus(terrain, Vector3(pos_arr[0], pos_arr[1], pos_arr[2]),
			(ext * float(data.get("radius", 1.0))).length() + 2.0)


## Stamp a persisted mesh edit onto the terrain. Returns false (leaving the edit
## UNMARKED for the retry loop) if the terrain can't stamp or the mesh can't be
## loaded/parsed yet, so a stamp made before its map/mesh is ready still lands later.
func _apply_mesh_stamp_local(terrain: Node, data: Dictionary, interactive: bool = true) -> bool:
	if not terrain.has_method("stamp_mesh_arrays"):
		return false
	var mesh_path: String = data.get("mesh", "")
	if mesh_path == "":
		return false
	var arrays: Dictionary = _load_mesh_arrays(mesh_path)
	if arrays.is_empty():
		return false
	var pos_arr: Array = data.get("pos", [0, 0, 0])
	var pos := Vector3(pos_arr[0], pos_arr[1], pos_arr[2])
	var radius: float = data.get("radius", 1.0)
	var strength: float = data.get("str", 3.0)
	terrain.stamp_mesh_arrays(arrays["verts"], arrays["idxs"], pos, radius, strength, interactive)
	return true


## Extract (and cache) the vertex/index arrays of a bundled mesh scene's first
## MeshInstance3D. Cached because a room can replay many stamps of the same mesh.
var _mesh_arrays_cache: Dictionary = {}  # mesh_path → { verts, idxs }
func _load_mesh_arrays(mesh_path: String) -> Dictionary:
	if _mesh_arrays_cache.has(mesh_path):
		return _mesh_arrays_cache[mesh_path]
	var scene: PackedScene = load(mesh_path)
	if not scene:
		return {}
	var instance: Node3D = scene.instantiate()
	var mesh_inst: MeshInstance3D = null
	for child in instance.get_children():
		if child is MeshInstance3D:
			mesh_inst = child
			break
	if not mesh_inst or not mesh_inst.mesh:
		instance.queue_free()
		return {}
	var mesh: Mesh = mesh_inst.mesh
	var m_arrays: Array = mesh.surface_get_arrays(0)
	var result: Dictionary = {
		"verts": m_arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array,
		"idxs": m_arrays[Mesh.ARRAY_INDEX] as PackedInt32Array,
	}
	instance.queue_free()
	_mesh_arrays_cache[mesh_path] = result
	return result


func _find_terrain() -> Node:
	if _terrain_node and is_instance_valid(_terrain_node):
		return _terrain_node

	# Target the ACTIVE map's DCTerrain — NOT the first one in the whole tree.
	# Room-switches/teleports can leave stale, detached map_dc_terrain nodes alive;
	# _find_node_by_class would return that stale DCTerrain first, so edits land on
	# an invisible terrain (the displayed one — under game.current_map — never gets
	# them). Prefer the active map's terrain; fall back to a tree-wide search.
	var game := get_tree().root.get_node_or_null("Game")
	if game:
		var active_map: Variant = game.get("current_map")
		if active_map is Node and is_instance_valid(active_map):
			var t := _find_node_by_class(active_map, "DCTerrain")
			if t:
				_terrain_node = t
				return t
	_terrain_node = _find_node_by_class(get_tree().root, "DCTerrain")
	return _terrain_node


func _find_node_by_class(node: Node, class_name_str: String) -> Node:
	if node.get_class() == class_name_str:
		return node
	for child in node.get_children():
		var found: Node = _find_node_by_class(child, class_name_str)
		if found:
			return found
	return null