extends RefCounted
## A release's STARTER MAP (docs/home-world-design.md §6): a dev's home-room map,
## shipped as res://home/<map_id>.home.json, copied into a player's home ONCE.
##
## Pure functions over a state object with has_state / get_state / set_state, so the
## rules are gated without a daemon, room or filesystem (scenes/test/gate_home_starter.gd).
## The two thin callers: tools/release_home_map.gd (the dev's deliberate release step,
## reading the daemon) and GameState (seeding on entering a map at Home).
##
## Values stay RAW (var_to_str text, what state.set carries) in the file, so a
## starter is byte-for-byte what the dev's room held; seed() turns them back into
## Variants for GameState.set_state.
const FORMAT := 1
## Map-scoped (STATE_ROUTES row world:starter:), so each map has its own marker.
const MARKER := "world:starter:applied"
const DIR := "res://home"
const CONTENT_DIR := "res://home/content"
## Shipped content is named <oid>.<ext>.bin so Godot packs the raw bytes instead of
## IMPORTING a .glb / .png into a resource the runtime cannot hand back as bytes.
const SHIPPED_SUFFIX := ".bin"
static var _hex64 := RegEx.create_from_string("[0-9a-f]{64}")
static func path_for(map_id: String) -> String:
return "%s/%s.home.json" % [DIR, map_id]
## The dev's map -> the starter.
## entries: STORED keys under map:<map_id>: -> raw value (var_to_str text)
## assets: the room-global library world:assets:* -> raw value
## Drops positions (per player) and undone journal records (history, not content);
## keeps only the library entries whose oid the map references.
static func build(map_id: String, map_scene: String, entries: Dictionary,
assets: Dictionary, game_version: String) -> Dictionary:
var pre := "map:%s:" % map_id
var kept := {}
for k: String in entries:
if not k.begins_with(pre):
continue
var plain := k.substr(pre.length())
if plain.begins_with("world:players:") or plain.begins_with(MARKER):
continue
var raw := String(entries[k])
var v: Variant = str_to_var(raw)
if v is Dictionary and bool((v as Dictionary).get("undone", false)):
continue
kept[k] = raw
var used := _hashes_in(kept.values())
var lib := {}
for k: String in assets:
if not k.begins_with("world:assets:"):
continue
var av: Variant = str_to_var(String(assets[k]))
if av is Dictionary and used.has(String((av as Dictionary).get("oid", ""))):
lib[k] = String(assets[k])
return {"format": FORMAT, "map_id": map_id, "map_scene": map_scene,
"game_version": game_version, "made_at": Time.get_datetime_string_from_system(true),
"entries": kept, "assets": lib, "content": []}
## Every content hash the starter references (64 lower-case hex in any value).
static func referenced_hashes(starter: Dictionary) -> Array:
var vals: Array = (starter.get("entries", {}) as Dictionary).values()
vals.append_array((starter.get("assets", {}) as Dictionary).values())
return _hashes_in(vals).keys()
static func _hashes_in(values: Array) -> Dictionary:
var out := {}
for raw: Variant in values:
for m in _hex64.search_all(String(raw)):
out[m.get_string()] = true
return out
## The starter -> a player's home, ONCE per map. Writes only keys the home does not
## have (a key the player already set is theirs), then the marker. Returns
## {written, kept} | {skipped: true} | {error}.
static func seed(starter: Dictionary, state: Object) -> Dictionary:
if int(starter.get("format", 0)) != FORMAT:
return {"error": "starter format %s, this build reads %d" % [str(starter.get("format")), FORMAT]}
if state.has_state(MARKER):
return {"skipped": true}
var pre := "map:%s:" % String(starter.get("map_id", ""))
var written := 0
var kept := 0
var todo: Dictionary = {}
for k: String in (starter.get("entries", {}) as Dictionary):
if k.begins_with(pre):
todo[k.substr(pre.length())] = starter["entries"][k]
for k: String in (starter.get("assets", {}) as Dictionary):
todo[k] = starter["assets"][k]
var refused := 0
for plain: String in todo:
if state.has_state(plain):
kept += 1
continue
state.set_state(plain, str_to_var(String(todo[plain])))
# GameState.set_state DROPS a write its can_write gate refuses, without
# writing the mirror -- so "did it land" is a has_state question.
if state.has_state(plain):
written += 1
else:
refused += 1
# THE MARKER ONLY WHEN EVERYTHING LANDED. A marker after a partial seed would
# freeze that map half-copied forever; without it the next load tries again
# (keys that did land are then simply kept).
if refused == 0:
state.set_state(MARKER, {"game_version": String(starter.get("game_version", "")),
"made_at": String(starter.get("made_at", ""))})
return {"written": written, "kept": kept, "refused": refused}
## Load res://home/<map_id>.home.json, or {} if this release ships none for it.
static func load_starter(map_id: String) -> Dictionary:
var p := path_for(map_id)
if not FileAccess.file_exists(p):
return {}
var v: Variant = JSON.parse_string(FileAccess.get_file_as_string(p))
return v if v is Dictionary else {}
## Copy the starter's shipped content into the content cache (user://content) where
## ModelCache / ContentOverlay look for it, and serve it so guests can fetch it.
## Skips files already there. Returns the number copied.
static func install_content(starter: Dictionary, dst_dir: String = "user://content",
overlay: Object = null) -> int:
DirAccess.make_dir_recursive_absolute(dst_dir)
var n := 0
for name: Variant in (starter.get("content", []) as Array):
var dst := dst_dir.path_join(String(name))
if FileAccess.file_exists(dst):
continue
var src := CONTENT_DIR.path_join(String(name) + SHIPPED_SUFFIX)
var bytes := FileAccess.get_file_as_bytes(src)
if bytes.is_empty():
push_error("[HomeStarter] shipped content missing: %s" % src)
continue
var f := FileAccess.open(dst, FileAccess.WRITE)
if f == null:
continue
f.store_buffer(bytes)
f.close()
n += 1
if overlay != null and overlay.has_method("publish"):
overlay.publish(ProjectSettings.globalize_path(dst))
return n