OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / editor_history.gd

287 lines · gd ref:
extends Node
## HISTORY + ADJUST LAST (editor redesign E5, docs/editor-redesign.md §8b).
##
## HISTORY (right column): this player's gestures, newest first — "Stairs · 7", "Rotate · 2".
## Undone ones are greyed. Clicking one SEEKS (Godot's History dock): it becomes the newest
## applied gesture, by undoing the live ones above it or redoing the undone ones up to it.
## It only READS the journal (LevelPersistence.gestures()); undo is still the journal's
## tombstones, never an in-memory stack, so the list survives a restart like undo does.
## Only my own edits: another player's cannot be undone by me (GameState.can_write).
##
## ADJUST LAST (left column, top): after a confirm whose operator supports it, the same
## settings renderer as the tool row (ToolSettingsBar in adjust mode) shows what can still
## change. A change is OperatorStack.adjust: undo + re-run as a new gesture. The panel goes
## away as soon as that gesture is no longer my newest live one.
##
## EVENT-DRIVEN: refreshed on LevelPersistence.folded (every fold — my edits, an undo, a
## peer's record arriving), never polled.

const MAX_ROWS := 60
const FlattenRoom := preload("res://scenes/game/flatten_room.gd")
const HomeReset := preload("res://scenes/maps/home_reset.gd")

var map: Node = null
var _persist: Node = null
var _stack: Node = null
var _list: ItemList = null
var _rows: Array = []                 # gesture dicts, in list order
var _adjust_bar: Node = null
var _adjust_op: LevelOperator = null
var _flatten_map_btn: Button = null
var _flatten_room_btn: Button = null
var _confirm: ConfirmationDialog = null
var _confirm_action := ""             # "map" | "room" | "reset"
var _reset_btn: Button = null


func setup(map_node: Node) -> void:
	map = map_node
	_persist = map.get("_persist")
	_stack = map.call("_ensure_ops")
	var dock := get_node_or_null("/root/BuildDock")
	_list = ItemList.new()
	_list.custom_minimum_size = Vector2(0, 160)
	_list.auto_height = true
	_list.select_mode = ItemList.SELECT_SINGLE
	_list.item_clicked.connect(_on_clicked)
	var box := VBoxContainer.new()
	box.add_child(_build_flatten_row())
	box.add_child(_list)
	if dock != null:
		dock.register("history", "History", box, true, dock.SIDE_RIGHT)
	# After the dock's focus pass: a focused list would eat the arrows (the UV keys bug),
	# a focused button would eat Space.
	_list.focus_mode = Control.FOCUS_NONE
	_flatten_map_btn.focus_mode = Control.FOCUS_NONE
	_flatten_room_btn.focus_mode = Control.FOCUS_NONE
	if _persist != null and _persist.has_signal("folded"):
		_persist.folded.connect(_on_folded)
	if _stack != null and _stack.has_signal("adjust_changed"):
		_stack.adjust_changed.connect(_on_adjust_changed)
	# The role is its own RPC racing the map load; at Home nothing folds after it lands,
	# so the admin buttons must follow the role itself (gate_history_role, #109).
	var ks := get_node_or_null(^"/root/KeetService")
	if ks != null and ks.has_signal("role_changed"):
		ks.role_changed.connect(_on_role_changed)
	call_deferred("refresh")            # after the map has parented LevelPersistence


func list() -> ItemList:
	return _list


## The gestures as shown, for gates: [{g, label, count, undone}].
func rows() -> Array:
	return _rows


func row_text(i: int) -> String:
	return _list.get_item_text(i) if _list != null and i < _list.item_count else ""


func refresh() -> void:
	if _list == null or _persist == null:
		return
	_rows = (_persist.gestures() as Array).slice(0, MAX_ROWS)
	_list.clear()
	for e: Dictionary in _rows:
		var n := int(e["count"])
		var t := String(e["label"]) + ((" · %d" % n) if n > 1 else "")
		var i := _list.add_item(t)
		if bool(e["undone"]):
			_list.set_item_custom_fg_color(i, Color(0.5, 0.5, 0.5))
			_list.set_item_tooltip(i, "undone — click to redo up to here")
		else:
			_list.set_item_tooltip(i, "click to undo everything after this")
	_update_flatten_buttons()


func seek_row(i: int) -> int:
	if i < 0 or i >= _rows.size():
		return 0
	var n: int = _persist.seek(String(_rows[i]["g"]))
	if n > 0:
		_persist.refold()
	return n


func _on_clicked(index: int, _at: Vector2, button: int) -> void:
	if button == MOUSE_BUTTON_LEFT:
		seek_row(index)


func _on_folded(_stats: Dictionary) -> void:
	refresh()
	if _stack != null and not _stack.adjusting() and _stack.adjustable() != null and not _stack.adjust_valid():
		_stack.clear_adjust()


# ---- flatten (2026-10-05: "purge history permanently ... per map but with an optional
# full room flatten") ----------------------------------------------------------------
# LevelPersistence.flatten_map erases EVERY author's records of the map, so it is the
# room admin's call. Always behind a confirmation: past it there is no undo (a backup
# file is the only way back).

func _build_flatten_row() -> Control:
	var row := HBoxContainer.new()
	_flatten_map_btn = Button.new()
	_flatten_map_btn.text = "Flatten map…"
	_flatten_map_btn.tooltip_text = FLATTEN_MAP_TIP
	_flatten_map_btn.pressed.connect(ask_flatten.bind("map"))
	row.add_child(_flatten_map_btn)
	_flatten_room_btn = Button.new()
	_flatten_room_btn.text = "Flatten room…"
	_flatten_room_btn.tooltip_text = FLATTEN_ROOM_TIP
	_flatten_room_btn.pressed.connect(ask_flatten.bind("room"))
	row.add_child(_flatten_room_btn)
	# HOME ONLY: back to what this release ships for the map (home_reset.gd).
	_reset_btn = Button.new()
	_reset_btn.text = "Reset to starter…"
	_reset_btn.tooltip_text = "Erase this map's content in your Home and put the release's starter map back. A backup is written first; no undo past it."
	_reset_btn.pressed.connect(ask_flatten.bind("reset"))
	_reset_btn.focus_mode = Control.FOCUS_NONE
	row.add_child(_reset_btn)
	_confirm = ConfirmationDialog.new()
	_confirm.title = "Flatten history"
	_confirm.dialog_autowrap = true
	_confirm.min_size = Vector2i(420, 0)
	_confirm.confirmed.connect(_on_flatten_confirmed)
	row.add_child(_confirm)
	return row


const FLATTEN_MAP_TIP := "Replace this map's history with the minimum records that rebuild it now. No undo past it."
const FLATTEN_ROOM_TIP := "Flatten every map in this room that has history (visits each one)."


func _on_role_changed(_room_id: String, _role: String) -> void:
	_update_flatten_buttons()


func _can_flatten() -> bool:
	var ks := get_node_or_null(^"/root/KeetService")
	return ks == null or String(ks.get("current_role")) == "admin"


func _update_flatten_buttons() -> void:
	if _flatten_map_btn == null:
		return
	var ok := _can_flatten()
	_flatten_map_btn.disabled = not ok
	_flatten_room_btn.disabled = not ok
	if not ok:
		_flatten_map_btn.tooltip_text = "Only the room's admin can flatten history."
		_flatten_room_btn.tooltip_text = _flatten_map_btn.tooltip_text
	else:
		_flatten_map_btn.tooltip_text = FLATTEN_MAP_TIP
		_flatten_room_btn.tooltip_text = FLATTEN_ROOM_TIP
	# Shown only where it can work: at Home, for a map this release ships a starter for.
	if _reset_btn != null:
		_reset_btn.visible = HomeReset.refusal(GameState, KeetService, _starter()) == ""


func _starter() -> Dictionary:
	if not starter_override.is_empty():
		return starter_override
	return HomeReset.HomeStarter.load_starter(String(GameState.current_map_id)) if GameState != null else {}


## For gates: a starter to use instead of res://home/<map>.home.json (no release ships
## one for every map, and a gate must not depend on what a release happens to ship).
var starter_override: Dictionary = {}


func _room_maps() -> Array:
	var ks := get_node_or_null(^"/root/KeetService")
	var all: Dictionary = ks.game_state.get_all() if ks != null and ks.get("game_state") != null else {}
	return FlattenRoom.maps_with_history(all)


## Opens the confirmation. Gates call it and then confirm_flatten().
func ask_flatten(what: String) -> void:
	if not _can_flatten() or _persist == null:
		return
	if what == "reset":
		var why := HomeReset.refusal(GameState, KeetService, _starter())
		if why != "":
			print("[History] reset refused: %s" % why)
			return
		_confirm_action = what
		var keys := HomeReset.keys_to_reset(GameState.map_scoped_keys())
		_confirm.dialog_text = ("Reset %s in your Home to this release's starter map?\n\n%d saved key(s) of this map "
			+ "(every edit, object and setting) are erased and the starter is put back. Undo will not reach "
			+ "past this. A backup is written to user://reset_backups first.") % [GameState.current_map_id, keys.size()]
		_confirm.popup_centered()
		return
	_confirm_action = what
	var n: int = (_persist.records() as Array).size()
	if what == "room":
		var maps := _room_maps()
		_confirm.dialog_text = ("Flatten the history of %d map(s) in this room (%s)?\n\nEach map is visited and its "
			+ "history replaced by the minimum records that rebuild it as it is now. Undo will not reach "
			+ "past this. A backup of each map's old records is written to user://flatten_backups.") % [
			maps.size(), ", ".join(PackedStringArray(maps))]
	else:
		_confirm.dialog_text = ("Flatten this map's history (%d records, every author)?\n\nIt is replaced by the "
			+ "minimum records that rebuild the map as it is now. Undo will not reach past this. A backup "
			+ "of the old records is written to user://flatten_backups.") % n
	_confirm.popup_centered()


func confirm_flatten() -> void:
	_on_flatten_confirmed()


func _on_flatten_confirmed() -> void:
	var what := _confirm_action
	_confirm_action = ""
	if what == "map":
		var r: Dictionary = _persist.flatten_map()
		_persist.refold()
		print("[History] flatten map: %s" % str(r))
	elif what == "room":
		var game := get_tree().root.get_node_or_null(^"Game")
		if game != null:
			FlattenRoom.start(game, _room_maps())
	elif what == "reset":
		var rr: Dictionary = HomeReset.reset(GameState, KeetService, _starter())
		_persist.refold()
		print("[History] reset to starter: %s" % str(rr))


# ---- adjust last ------------------------------------------------------------------

func adjust_bar() -> Node:
	return _adjust_bar


func _on_adjust_changed(op: LevelOperator) -> void:
	var dock := get_node_or_null("/root/BuildDock")
	if dock == null:
		return
	if op == null:
		if _adjust_bar != null:
			dock.unregister("adjust_last")
			_adjust_bar = null
			_adjust_op = null
		return
	if _adjust_bar != null and _adjust_op == op:
		return                                   # same tool adjusted again: the bar follows it
	_adjust_op = op
	var bar := HFlowContainer.new()
	bar.set_script(load("res://scenes/game/tool_settings_bar.gd"))
	bar.target = op
	bar.apply_override = Callable(self, "_on_adjust")
	bar.setup(map)
	dock.register("adjust_last", "Adjust Last: %s" % op.gesture_label(), bar, true, dock.SIDE_LEFT)
	# Just under the tools — it answers "what did I just do", so it sits by where you did it.
	var col: Node = dock._sides[dock.SIDE_LEFT]["column"]
	var frame: Node = (dock._panels["adjust_last"]["section"] as Node).get_parent()
	col.move_child(frame, mini(1, col.get_child_count() - 1))
	_adjust_bar = bar


func _on_adjust(sid: String, value: Variant) -> void:
	if _stack != null:
		_stack.adjust(sid, value)