extends RefCounted
## RESET THIS MAP TO THE RELEASE STARTER — Home only (docs/home-world-design.md §6,
## "Reset to the release starter"; user 2026-10-05: "yes build reset that way").
##
## The flatten pattern, because the designed tombstone reset could not work: the seed
## writes only ABSENT keys (a tombstoned starter record is still present, so the map
## would come back empty), and a flattened map's `base` records refuse tombstones.
##
## 1. refuse unless this room is HOME and you are its admin, and this release ships a
## starter for the map
## 2. BACKUP every key that will be erased (a failed backup aborts; nothing changed)
## 3. erase_state every key of this map — journal records of every author, plain keys
## (objects, sky, terrain edits, ...), the starter marker — EXCEPT player positions
## (per person, not content) and the level-source key (names the .MAP)
## 4. seed the starter at once (GameState.seed_home_starter): every key it ships is
## now absent, so all of it is written, and the marker with it
##
## No Ctrl+Z past it: the backup file is the way back (as for flatten).
const HomeStarter := preload("res://scenes/maps/home_starter.gd")
## The keys a reset erases, from all of the map's keys.
static func keys_to_reset(map_keys: Array) -> Array[String]:
var out: Array[String] = []
for k: Variant in map_keys:
var s := String(k)
if s.begins_with("world:players:"):
continue # where each person stood
if s.begins_with("world:level:") and s.ends_with(":source"):
continue # which .MAP: not content
out.append(s)
out.sort()
return out
## Why a reset is not possible here, or "" if it is.
static func refusal(gs: Object, ks: Object, starter: Dictionary) -> String:
var room := String(ks.get_current_room()) if ks != null and ks.has_method("get_current_room") else ""
if ks == null or not ks.is_home(room):
return "only your Home can be reset to the release starter"
if not gs._has_role("admin"):
return "only the Home's admin can reset it"
if starter.is_empty():
return "this release ships no starter for %s" % String(gs.current_map_id)
if String(starter.get("map_id", "")) != String(gs.current_map_id):
return "the starter is for %s, not %s" % [starter.get("map_id", "?"), gs.current_map_id]
return ""
## {erased, refused, seeded, backup} or {error}.
static func reset(gs: Object, ks: Object, starter: Dictionary, backup_dir: String = "") -> Dictionary:
var why := refusal(gs, ks, starter)
if why != "":
return {"error": why}
var keys := keys_to_reset(gs.map_scoped_keys())
var backup := _write_backup(gs, ks, keys, backup_dir)
if backup == "":
return {"error": "backup failed — nothing changed"}
var erased := 0
var refused := 0
for k: String in keys:
gs.erase_state(k)
if gs.has_state(k):
refused += 1
else:
erased += 1
var seeded: Dictionary = gs.seed_home_starter(String(ks.get_current_room()), starter)
print("[HomeReset] %s: erased %d key(s) (%d refused), seeded %s, backup %s" % [
gs.current_map_id, erased, refused, str(seeded), backup])
return {"erased": erased, "refused": refused, "seeded": seeded, "backup": backup}
static func _write_backup(gs: Object, ks: Object, keys: Array[String], dir: String) -> String:
if dir == "":
dir = "user://reset_backups_ci" if OS.get_environment("CI") == "true" else "user://reset_backups"
DirAccess.make_dir_recursive_absolute(dir)
var map_id := String(gs.current_map_id) if String(gs.current_map_id) != "" else "map"
var path := "%s/%s-%d.json" % [dir, map_id, int(Time.get_unix_time_from_system())]
var entries := {}
for k: String in keys:
entries[k] = var_to_str(gs.get_state(k))
var f := FileAccess.open(path, FileAccess.WRITE)
if f == null:
return ""
f.store_string(JSON.stringify({"format": 1, "kind": "home_reset", "map_id": map_id,
"room": String(ks.get_current_room()), "made_at": Time.get_datetime_string_from_system(true),
"entries": entries}, " ", true))
f.close()
return path