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)