OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / home_starter.gd

150 lines · gd ref:
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