OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / tools / release_home_map.gd

170 lines · gd ref:
extends SceneTree
## RELEASE A STARTER MAP from your Home (docs/home-world-design.md §6).
##
## A deliberate dev step, run when you mean to ship a new starter -- never CI:
##
##   tools/release-home-map.sh [map_id]          # default map_test
##
## Reads your home room's map from the running keet-daemon (read-only: state.list),
## drops positions and undone edits (scenes/maps/home_starter.gd build()), copies
## every content file the map references from user://content into
## res://home/content/<file>.bin, and writes res://home/<map_id>.home.json. Then
## review the diff, commit, git push pear master -- the push is the release.
##
## Fails, writing nothing, if: no daemon, no home room yet, the map scene does not
## exist, or any referenced content file is missing on this machine (a starter that
## cannot be built offline is not shippable).
##
## Args after `--`:  <map_id>  [--out <dir>]  [--content-dir <dir>]
## --out / --content-dir exist for the gate, which runs against a scratch daemon
## and must not touch res://home or the real user://content.

const HomeStarter := preload("res://scenes/maps/home_starter.gd")

var _map_id := "map_test"
var _out := HomeStarter.DIR
var _content_dir := "user://content"
var _ran := false


func _initialize() -> void:
	var a := OS.get_cmdline_user_args()
	var i := 0
	while i < a.size():
		match a[i]:
			"--out":
				i += 1
				_out = a[i]
			"--content-dir":
				i += 1
				_content_dir = a[i]
			_:
				_map_id = a[i]
		i += 1


func _process(_delta: float) -> bool:
	if _ran:
		return false
	_ran = true
	_release()
	return false


func _fail(msg: String) -> void:
	printerr("release-home-map: FAILED — " + msg)
	quit(1)


func _call(client: Object, method: String, params: Dictionary) -> Dictionary:
	var box := {"done": false}
	client.call_rpc(method, params, func(result: Variant, err: Variant) -> void:
		box["result"] = result
		box["err"] = err
		box["done"] = true)
	var t0 := Time.get_ticks_msec()
	while not box["done"] and Time.get_ticks_msec() - t0 < 30000:
		await process_frame
	if not box["done"]:
		return {"err": "%s timed out" % method}
	return box


func _release() -> void:
	var keet: Node = root.get_node_or_null("KeetService")
	if keet == null:
		_fail("no KeetService autoload")
		return
	var t0 := Time.get_ticks_msec()
	while not bool(keet.client.is_connected) and Time.get_ticks_msec() - t0 < 15000:
		await process_frame
	if not bool(keet.client.is_connected):
		_fail("no keet-daemon answered (start one: scripts/dev-run.sh --daemon-only)")
		return
	var scene := "res://scenes/maps/%s.tscn" % _map_id
	if not ResourceLoader.exists(scene):
		_fail("no map scene %s" % scene)
		return

	var h := await _call(keet.client, "home.get", {})
	if h.get("err") != null or not (h.get("result") is Dictionary):
		_fail("home.get: %s" % str(h.get("err")))
		return
	var home: Dictionary = h["result"]
	if bool(home.get("created", false)):
		_fail("this identity had no home room yet (one was just created, empty) — build your starter at Home first")
		return
	var room := String(home.get("roomId", ""))

	var m := await _call(keet.client, "state.list", {"roomId": room, "prefix": "map:%s:" % _map_id})
	var l := await _call(keet.client, "state.list", {"roomId": room, "prefix": "world:assets:"})
	if m.get("err") != null or l.get("err") != null:
		_fail("state.list: %s %s" % [str(m.get("err")), str(l.get("err"))])
		return
	var entries: Dictionary = (m["result"] as Dictionary).get("entries", {})
	var assets: Dictionary = (l["result"] as Dictionary).get("entries", {})

	var version := OS.get_environment("HYPER_TPS_BUILD")
	if version == "" and FileAccess.file_exists("res://version.txt"):
		version = FileAccess.get_file_as_string("res://version.txt").strip_edges()
	var st: Dictionary = HomeStarter.build(_map_id, scene, entries, assets, version)

	# Every referenced content file must be here, or nothing is written.
	var files: Array = []
	var missing: Array = []
	for oid: String in HomeStarter.referenced_hashes(st):
		var f := _find_content(oid)
		if f == "":
			missing.append(oid)
		else:
			files.append(f)
	if not missing.is_empty():
		_fail("%d referenced content file(s) not in %s: %s" % [missing.size(), _content_dir, str(missing)])
		return
	files.sort()
	st["content"] = files

	var cdir := _out.path_join("content")
	DirAccess.make_dir_recursive_absolute(cdir)
	var bytes_total := 0
	for f: String in files:
		var b := FileAccess.get_file_as_bytes(_content_dir.path_join(f))
		bytes_total += b.size()
		var w := FileAccess.open(cdir.path_join(f + HomeStarter.SHIPPED_SUFFIX), FileAccess.WRITE)
		w.store_buffer(b)
		w.close()
	var out_path := _out.path_join("%s.home.json" % _map_id)
	var w2 := FileAccess.open(out_path, FileAccess.WRITE)
	if w2 == null:
		_fail("cannot write %s" % out_path)
		return
	# Sorted keys, indented: a re-release is a readable diff.
	w2.store_string(JSON.stringify(st, "  ", true) + "\n")
	w2.close()

	var kinds := {}
	for k: String in (st["entries"] as Dictionary):
		var v: Variant = str_to_var(String(st["entries"][k]))
		var kind := String((v as Dictionary).get("kind", "")) if v is Dictionary else ""
		if kind == "":
			kind = "(plain key)"
		kinds[kind] = int(kinds.get(kind, 0)) + 1
	print("release-home-map: %s from home %s" % [_map_id, room.left(16)])
	print("  read %d keys, shipped %d (dropped %d: positions / undone)"
			% [entries.size(), (st["entries"] as Dictionary).size(), entries.size() - (st["entries"] as Dictionary).size()])
	print("  by kind: %s" % str(kinds))
	print("  library entries: %d   content files: %d (%d bytes)" % [(st["assets"] as Dictionary).size(), files.size(), bytes_total])
	print("  wrote %s" % ProjectSettings.globalize_path(out_path))
	quit(0)


## <oid>.<ext> in the content dir, or "".
func _find_content(oid: String) -> String:
	var d := DirAccess.open(_content_dir)
	if d == null:
		return ""
	for f in d.get_files():
		if f == oid or f.begins_with(oid + "."):
			return f
	return ""