diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a00eacc..ab6dbcd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,11 +82,11 @@ jobs: run: | & $env:GODOT --headless --path . --editor --import --quit if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - name: Contract, Studio, pieces and the art download + - name: Contract, Studio, pieces, colors, the shared list, the agent's tools and the window env: STUDIO_PIECES: ${{ github.workspace }}/build/pieces run: | - & $env:GODOT --headless --path . --script res://tests/run.gd -- contract studio pieces download connect + & $env:GODOT --headless --path . --script res://tests/run.gd -- contract studio pieces download connect recolour shared_pieces mcp window if ($LASTEXITCODE -ne 0) { throw 'A suite failed.' } - name: Every piece the suites made passes Agent Guild's own check run: | @@ -231,11 +231,11 @@ jobs: run: | & $env:GODOT --headless --path . --editor --import --quit if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - name: Contract, Studio, pieces and the art download + - name: Contract, Studio, pieces, colors, the shared list, the agent's tools and the window env: STUDIO_PIECES: ${{ github.workspace }}/build/pieces run: | - & $env:GODOT --headless --path . --script res://tests/run.gd -- contract studio pieces download connect + & $env:GODOT --headless --path . --script res://tests/run.gd -- contract studio pieces download connect recolour shared_pieces mcp window if ($LASTEXITCODE -ne 0) { throw 'A suite failed.' } - name: Every piece the suites made passes Agent Guild's own check run: | diff --git a/src/app/main.gd b/src/app/main.gd index c2aea7f..eb59677 100644 --- a/src/app/main.gd +++ b/src/app/main.gd @@ -31,6 +31,9 @@ var mcp: McpServer var _art_reason := "" var _section: Texture2D var _orbiting := false +var _closing := false +## What closing the window ends with; tests name another. +var on_quit := func(): get_tree().quit() func _ready() -> void: for arg in OS.get_cmdline_user_args(): @@ -59,6 +62,7 @@ func _ready() -> void: return if options.has("mcp"): Studio.quiet = true + _library() mcp = McpServer.new() mcp.host = self add_child(mcp) @@ -101,15 +105,29 @@ func _fetch(what: String) -> int: print("studio: the art is in %s" % StudioArt.folder()) return 0 +func _library() -> void: + if pieces != null: + return + pieces = PieceLibrary.new() + pieces.default_clips = default_clips + add_child(pieces) + ## The window: the stage, with the first screen beside it. func _open() -> void: world = options.get("world", world) if YardModel.WORLDS.has(options.get("world", world)) else world theme = "dark" if options.get("theme", "light") == "dark" else "light" get_window().min_size = Vector2i(960, 600) - pieces = PieceLibrary.new() - pieces.default_clips = default_clips - add_child(pieces) + get_tree().auto_accept_quit = false + _library() pieces.changed.connect(_show_home) + # An agent's connection is another program with the same list of pieces. + var look := Timer.new() + look.wait_time = 2.0 + look.timeout.connect(func(): + if flow == null: + pieces.refresh()) + add_child(look) + look.start() stage = StudioStage.new() add_child(stage) camera = CameraRig.new() @@ -256,8 +274,20 @@ func _unhandled_input(event: InputEvent) -> void: camera.orbit(-event.relative.x * 0.006, 0.0) func _notification(what: int) -> void: - if what == NOTIFICATION_WM_CLOSE_REQUEST and flow != null and panel.visible: - panel.leave_requested.emit() + if what == NOTIFICATION_WM_CLOSE_REQUEST and home != null: + close() + +## The window's close button: a save under way finishes first, so nothing is +## left half written; a download under way is dropped whole. +func close() -> void: + if _closing: + return + _closing = true + if flow == null: + on_quit.call() + return + flow.ended.connect(func(): on_quit.call(), CONNECT_ONE_SHOT) + panel.leave_requested.emit() func _shot(path: String) -> void: for i in 30: @@ -273,8 +303,11 @@ func agent_disconnected() -> void: ## Studio's window, as a program of its own beside an agent's connection. func open_window() -> bool: - var before := [] if OS.has_feature("template") else ["--path", ProjectSettings.globalize_path("res://").trim_suffix("/")] - return OS.create_process(OS.get_executable_path(), before) > 0 + return OS.create_process(OS.get_executable_path(), window_args()) > 0 + +## What Studio is started with to open its window: nothing, in the app itself. +static func window_args() -> Array: + return [] if OS.has_feature("template") else ["--path", ProjectSettings.globalize_path("res://").trim_suffix("/")] ## For PowerShell on Windows, and for Terminal's shell (zsh) elsewhere. static func connect_commands(exe: String, before: Array, posix := OS.get_name() != "Windows") -> Array: diff --git a/src/mcp/server.gd b/src/mcp/server.gd index 72a5931..b2352c9 100644 --- a/src/mcp/server.gd +++ b/src/mcp/server.gd @@ -14,19 +14,22 @@ const METHOD_NOT_FOUND := -32601 const INVALID_PARAMS := -32602 const UNSUPPORTED_VERSION := -32022 const PERSON_WAIT_MS := 30000 -const WINDOW_INIT := "studio-window-initialize" +const WINDOW_START := "studio-window-start" +const WINDOW_RETRY_MS := 10000 +const AGENT_LEFT := "notifications/studio/agent_disconnected" const WITHOUT_WINDOW := [] const INSTRUCTIONS := "Agent Guild: Studio makes characters and companions for Agent Guild from a model file, or by recoloring one of a world's own looks. It works without a window. Start with list_slots: a piece is made for one slot, a world's crew such as guild/anthropic, or a world's companions such as guild/companion. -make_character takes one model (.glb, .gltf or .vrm); make_companions takes one model or four; recolor_look changes a world's own look. Each writes a piece folder at `to` and checks it. A world's art downloads the first time it is needed, which can take a while. +make_character takes one model (.glb, .gltf or .vrm); make_companions takes one model or four; recolor_look starts from a world's own look. Each can change the piece's colors, writes a piece folder at `to` and checks it. A world's art downloads the first time it is needed, which can take a while. When Studio can't be sure of a model's bones, make_character says which, and you call it again with `bones`. -Use check_piece on any piece folder. show_window opens Studio for the person." +Pieces made here are listed for the person in Studio's window too: list_pieces, remove_piece. Use check_piece on any piece folder. show_window opens Studio for the person." var host: Node var tools: McpTools var read_stdin := true var write_line := func(line: String): print(line) var relay := false +var from_relay := false var window_args: Array = [] var launch_window := _launch_window var write_window := _write_window @@ -36,9 +39,13 @@ var _queue: Array = [] var _active: Dictionary = {} var _waiting_since := -1 var _closed := false +var _left := false var _window := {} var _window_open := false +var _window_starting := false +var _window_failed_at := -1 var _window_threads: Array = [] +var _exited_windows: Array = [] var _forwarded := {} func _ready() -> void: @@ -71,9 +78,11 @@ func _stdin_closed() -> void: _thread.wait_to_finish() _thread = null if _window_open: - if not _window.is_empty(): - _window.stdio.close() + write_window.call(JSON.stringify({"jsonrpc": "2.0", "method": AGENT_LEFT})) return + if _left: + return + _left = true if host and host.has_method("agent_disconnected"): host.agent_disconnected() @@ -82,6 +91,8 @@ func _exit_tree() -> void: _thread.wait_to_finish() func _process(_delta: float) -> void: + if not _exited_windows.is_empty(): + _exited_windows = _exited_windows.filter(OS.is_process_running) if not _queue.is_empty(): _pump() @@ -126,6 +137,9 @@ static func _id_of(msg: Variant) -> Variant: return null func _notification_received(msg: Dictionary) -> void: + if msg.method == AGENT_LEFT and from_relay: + _stdin_closed() + return if msg.method != "notifications/cancelled": return var params = msg.get("params", {}) @@ -279,12 +293,20 @@ func _send(msg: Dictionary) -> void: func _forward(ctx: Dictionary, msg: Dictionary) -> void: if not _window_open: + var wait := _window_failed_at + WINDOW_RETRY_MS - Time.get_ticks_msec() if _window_failed_at >= 0 else 0 + if wait > 0: + respond(ctx, tools.failure("Studio's window failed to start. Try again in %d seconds." % ceili(wait / 1000.0))) + return if not launch_window.call(): + _window_failed_at = Time.get_ticks_msec() respond(ctx, tools.failure("Studio couldn't open its window.")) return _window_open = true + _window_starting = true if _legacy_version != "": - write_window.call(JSON.stringify({"jsonrpc": "2.0", "id": WINDOW_INIT, "method": "initialize", "params": {"protocolVersion": _legacy_version}})) + write_window.call(JSON.stringify({"jsonrpc": "2.0", "id": WINDOW_START, "method": "initialize", "params": {"protocolVersion": _legacy_version}})) + else: + write_window.call(JSON.stringify({"jsonrpc": "2.0", "id": WINDOW_START, "method": "ping"})) _forwarded[ctx.id] = ctx msg["id"] = ctx.id write_window.call(JSON.stringify(msg)) @@ -311,7 +333,9 @@ func _from_window(line: String) -> void: if not msg is Dictionary: return if msg.has("id") and not msg.has("method"): - if msg.id is String and msg.id == WINDOW_INIT: + if _window_starting: + _window_starting = false + _window_failed_at = -1 return var id = _id_of(msg) if not _forwarded.has(id): @@ -322,13 +346,19 @@ func _from_window(line: String) -> void: func _window_closed() -> void: _window_open = false + var failed := _window_starting + _window_starting = false + if failed: + _window_failed_at = Time.get_ticks_msec() for thread in _window_threads: thread.wait_to_finish() _window_threads.clear() + if _window.has("pid") and OS.is_process_running(_window.pid): + _exited_windows.append(_window.pid) _window = {} var waiting := _forwarded.values() _forwarded.clear() for ctx in waiting: - respond(ctx, tools.failure("The Studio window was closed. Call the tool again to reopen it.")) + respond(ctx, tools.failure("Studio's window failed to start, so nothing was done." if failed else "The Studio window was closed. Call the tool again to reopen it.")) if _closed: _stdin_closed() diff --git a/src/mcp/tools.gd b/src/mcp/tools.gd index a1110da..6b78385 100644 --- a/src/mcp/tools.gd +++ b/src/mcp/tools.gd @@ -2,7 +2,8 @@ class_name McpTools extends RefCounted ## The tools an agent gets. Each checks its arguments against its own schema, ## then runs Studio as its command line does (Studio.run), with no window: -## the piece is made, written as a folder and checked. +## the piece is made, written as a folder, checked, and listed with the +## pieces the person sees in Studio's window. const MAKING := ["make_character", "make_companions", "recolor_look", "check_piece"] const LOOKS := ["look0", "look1", "look2"] @@ -134,6 +135,10 @@ func _definitions() -> Array: var to := _path("The folder to write the piece in, as a full path. It must not exist yet, or be empty, unless `replace` is true.") var title := {"type": "string", "minLength": 1, "maxLength": 128, "description": "The piece's name, as people see it."} var replace := {"type": "boolean", "description": "Write into a folder that already holds files."} + var hue := {"type": "number", "minimum": -360, "maximum": 360, "description": "Degrees to turn its colors' hue."} + var tint := _colour("A color laid over it; with `change_color`, the color that one becomes") + var change_color := _colour("The one color to change; colors near it change with it") + var spread := {"type": "number", "minimum": 0.01, "maximum": 1, "description": "How near a color must be to `change_color` to change, from 0 to 1. Left out, 0.15."} return [ {"name": "list_slots", "title": "List slots", "description": "Every slot a piece can be made for, by world: each crew's character and the world's companions, the looks each can be recolored from, and whether the world's art is on this computer yet.", "inputSchema": _object({}, []), "annotations": read}, @@ -141,6 +146,7 @@ func _definitions() -> Array: "inputSchema": _object({"model": _path("The model file, as a full path."), "slot": {"type": "string", "enum": _slots("character")}, "to": to, "title": title, "height": {"type": "number", "minimum": 0.5, "maximum": 5, "description": "How tall it stands, in metres. Left out, it stands as tall as the character it replaces."}, "bones": {"type": "string", "minLength": 1, "maxLength": 4096, "description": "Comma-separated choices: pairs like LeftHand=hand_l naming the model's bone for a skeleton bone, `swap` for a rig that names its sides the other way round, `accept` to take Studio's guesses."}, + "hue": hue, "tint": tint, "change_color": change_color, "spread": spread, "replace": replace}, ["model", "slot", "to"]), "annotations": writes}, {"name": "make_companions", "title": "Make companions", "description": "Turns one model, shown four ways, or four models into a world's four companions, then checks them. A humanoid is folded onto the trunk; anything else rides it.", "inputSchema": _object({"models": {"type": "array", "items": _path("A model file, as a full path."), "minItems": 1, "maxItems": 4, "description": "One model or four."}, @@ -148,15 +154,19 @@ func _definitions() -> Array: "move": {"type": "string", "enum": ["rigid", "soft"], "description": "How a model that isn't a humanoid moves with the trunk."}, "variants": {"type": "string", "enum": ["tint", "turn"], "description": "How one model is shown four ways."}, "turn": {"type": "number", "minimum": -180, "maximum": 180, "description": "Degrees to turn a model that doesn't face forward."}, + "hue": hue, "tint": tint, "change_color": change_color, "spread": spread, "replace": replace}, ["models", "slot", "to"]), "annotations": writes}, {"name": "recolor_look", "title": "Recolor a look", "description": "Recolors one of a slot's own looks into a piece: the mesh and clips stay the world's, only the base colors change. A character slot has look0, look1 and look2; a companion slot has agent and shell. Give `hue`, `tint`, or both; with `change_color` only colors near it change.", "inputSchema": _object({"slot": {"type": "string", "enum": _slots("character") + _slots("companion")}, "look": {"type": "string", "enum": LOOKS + HELPERS}, "to": to, "title": title, - "hue": {"type": "number", "minimum": -360, "maximum": 360, "description": "Degrees to turn the hue."}, - "tint": _colour("A color laid over it"), "change_color": _colour("The one color to change"), - "spread": {"type": "number", "minimum": 0.01, "maximum": 1, "description": "How near a color must be to `change_color` to change, from 0 to 1. Left out, 0.15."}, + "hue": hue, "tint": tint, "change_color": change_color, "spread": spread, "replace": replace}, ["slot", "look", "to"]), "annotations": writes}, {"name": "check_piece", "title": "Check a piece", "description": "Checks a piece folder (piece.json and its models) as Studio checks the ones it makes, for the world and slot its piece.json names.", "inputSchema": _object({"folder": _path("The piece's folder, as a full path.")}, ["folder"]), "annotations": read}, + {"name": "list_pieces", "title": "List pieces", "description": "The pieces made with Studio on this computer, by the person in its window or by an agent: each one's `piece` value, title, slot and the folder it was saved to.", + "inputSchema": _object({}, []), "annotations": read}, + {"name": "remove_piece", "title": "Remove a piece", "description": "Takes a piece off Studio's list. The folder it was saved to is left as it is.", + "inputSchema": _object({"piece": {"type": "string", "minLength": 1, "maxLength": 256, "description": "The piece's `piece` value from list_pieces."}}, ["piece"]), + "annotations": {"readOnlyHint": false, "destructiveHint": true, "openWorldHint": false}}, {"name": "show_window", "title": "Show the window", "description": "Opens the Studio window, so the person can make or edit a piece themselves.", "inputSchema": _object({}, []), "annotations": read}, ] @@ -179,7 +189,7 @@ func _tool_make_character(ctx: Dictionary, args: Dictionary) -> Dictionary: options.height = args.height if args.has("bones"): options.map = args.bones - return await _make(ctx, args, options) + return await _make(ctx, args, options, {"files": [args.model], "options": _kept(options, ["height", "map"])}) func _tool_make_companions(ctx: Dictionary, args: Dictionary) -> Dictionary: if args.models.size() != 1 and args.models.size() != 4: @@ -188,7 +198,7 @@ func _tool_make_companions(ctx: Dictionary, args: Dictionary) -> Dictionary: for key in ["move", "variants", "turn"]: if args.has(key): options[key] = args[key] - return await _make(ctx, args, options) + return await _make(ctx, args, options, {"files": args.models, "options": _kept(options, ["move", "variants", "turn"])}) func _tool_recolor_look(ctx: Dictionary, args: Dictionary) -> Dictionary: var companions: bool = String(args.slot).ends_with("/companion") @@ -196,18 +206,31 @@ func _tool_recolor_look(ctx: Dictionary, args: Dictionary) -> Dictionary: return failure("A character slot is recolored from look0, look1 or look2; a companion slot from agent or shell.") if not args.has("hue") and not args.has("tint"): return failure("Give `hue`, `tint`, or both.") + return await _make(ctx, args, {"recolour": args.look, "slot": args.slot, "out": args.to}, {"recolour": {"which": args.look}}) + +static func _kept(options: Dictionary, keys: Array) -> Dictionary: + var out := {} + for key in keys: + if options.has(key): + out[key] = options[key] + return out + +## One Studio run for a slot: its art fetched first, the folder it writes +## refused if it holds anything, and what Studio said given back as data. +func _make(ctx: Dictionary, args: Dictionary, options: Dictionary, recipe: Dictionary) -> Dictionary: for key in ["tint", "change_color"]: if args.has(key) and not String(args[key]).is_valid_html_color(): return failure("`%s` must be a color as #RRGGBB." % key) - var options := {"recolour": args.look, "slot": args.slot, "out": args.to} + # The colors as Studio's command line takes them, and as a recipe keeps them. + var change := {} for pair in [["hue", "hue"], ["tint", "tint"], ["change_color", "pick"], ["spread", "spread"]]: if args.has(pair[0]): options[pair[1]] = args[pair[0]] - return await _make(ctx, args, options) - -## One Studio run for a slot: its art fetched first, the folder it writes -## refused if it holds anything, and what Studio said given back as data. -func _make(ctx: Dictionary, args: Dictionary, options: Dictionary) -> Dictionary: + change[pair[1]] = args[pair[0]] + if recipe.has("recolour"): + recipe.recolour.change = change + elif change.has("hue") or change.has("tint"): + recipe.options.colour = change var to: String = args.to if not to.is_absolute_path(): return failure("`to` must be a full path.") @@ -227,8 +250,17 @@ func _make(ctx: Dictionary, args: Dictionary, options: Dictionary) -> Dictionary return failure("Studio isn't sure of these bones: %s. Call again with `bones`: pairs like LeftHand=, or `swap`, or `accept` to take its guesses." % ", ".join(Studio.said.unsure.keys()), {"unsure": Studio.said.unsure, "model_bones": Studio.said.bones}) return failure("Studio couldn't make it: %s." % Studio.said.get("reason", "it failed")) - var files := Array(DirAccess.get_files_at(Studio.said.folder)) - return _done("Made and checked. The piece is in %s." % Studio.said.folder, {"folder": Studio.said.folder, "files": files, "slot": options.slot, "notes": Studio.said.notes}) + var folder: String = Studio.said.folder + var notes: Array = Studio.said.notes + var files := Array(DirAccess.get_files_at(folder)) + var library: PieceLibrary = server.host.pieces + library.refresh() + var id := await library.add_local(folder, "", recipe) + var listed: bool = library.pieces.get(id, {}).get("error", "x") == "" + if listed: + library.pieces[id].saved = folder + library.keep() + return _done("Made and checked. The piece is in %s." % folder, {"folder": folder, "files": files, "slot": options.slot, "notes": notes, "piece": id if listed else ""}) ## The world's art, downloaded if it isn't here, then loaded; "" or why not. func _art(ctx: Dictionary, world: String) -> String: @@ -269,6 +301,26 @@ func _tool_check_piece(ctx: Dictionary, args: Dictionary) -> Dictionary: return _done("It doesn't pass: %s." % why, {"passes": false, "reason": why, "slot": slot}) return _done("It passes, for %s." % slot, {"passes": true, "slot": slot, "title": manifest.get("title", "")}) +func _tool_list_pieces(_ctx: Dictionary, _args: Dictionary) -> Dictionary: + var library: PieceLibrary = server.host.pieces + library.refresh() + var out := [] + for id in library.pieces: + var p: Dictionary = library.pieces[id] + if p.get("source", "") != "local": + continue + out.append({"piece": id, "title": p.get("title", ""), "slot": PieceLibrary.slot_of(p) if PieceLibrary.has_slot(p) else "", "folder": p.get("saved", ""), "problem": p.get("error", "")}) + return _done("%d piece%s." % [out.size(), "" if out.size() == 1 else "s"], {"pieces": out}) + +func _tool_remove_piece(_ctx: Dictionary, args: Dictionary) -> Dictionary: + var library: PieceLibrary = server.host.pieces + library.refresh() + var piece: Dictionary = library.pieces.get(args.piece, {}) + if piece.get("source", "") != "local": + return failure("There is no piece %s. Use list_pieces for the ones there are." % args.piece) + library.remove(args.piece) + return _done("%s is off the list. Its saved folder was left as it is." % piece.get("title", args.piece)) + func _tool_show_window(_ctx: Dictionary, _args: Dictionary) -> Dictionary: if not server.host.open_window(): return failure("Studio couldn't open its window.") diff --git a/src/pieces/piece_library.gd b/src/pieces/piece_library.gd index d36ea8f..b99fe88 100644 --- a/src/pieces/piece_library.gd +++ b/src/pieces/piece_library.gd @@ -22,6 +22,8 @@ var root := ROOT var _scenes := {} var _loading := {} var _adding := {} +var _written := {} +const RECORDS := "records" signal _settled(id: String) func _ready() -> void: @@ -261,21 +263,69 @@ func _settle(piece: Dictionary) -> void: func keep() -> void: _save() +## Each piece's record is a file of its own under records/, written whole +## and then moved into place, so Studio's window and an agent's connection, +## two programs with one list, never write over each other's pieces. func _save() -> void: - DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(root)) - var file := FileAccess.open(root.path_join("index.json"), FileAccess.WRITE) - if file: - file.store_string(StrictJson.stringify(pieces, " ")) + var dir := ProjectSettings.globalize_path(root.path_join(RECORDS)) + DirAccess.make_dir_recursive_absolute(dir) + for id in pieces: + var text := StrictJson.stringify(pieces[id], " ") + if _written.get(id, "") == text: + continue + var path := dir.path_join(_record(id)) + var file := FileAccess.open(path + ".part", FileAccess.WRITE) + if file == null: + continue + file.store_string(text) + file.close() + DirAccess.remove_absolute(path) + if DirAccess.rename_absolute(path + ".part", path) == OK: + _written[id] = text + for id in _written.keys(): + if not pieces.has(id): + DirAccess.remove_absolute(dir.path_join(_record(id))) + _written.erase(id) + +static func _record(id: String) -> String: + return id.validate_filename() + ".json" + +## Every record on disk: { id: piece } and { id: its text }. +func _read() -> Array: + var dir := ProjectSettings.globalize_path(root.path_join(RECORDS)) + var found := {} + var texts := {} + for name in DirAccess.get_files_at(dir) if DirAccess.dir_exists_absolute(dir) else PackedStringArray(): + if name.get_extension() != "json": + continue + var text := FileAccess.get_file_as_string(dir.path_join(name)) + # A record another program is part way through writing isn't one yet. + var json := JSON.new() + var piece = json.data if text != "" and json.parse(text) == OK else null + if piece is Dictionary and piece.get("id") is String and piece.has("kind") and (piece.get("dir", "") == "" or DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(piece.dir))): + found[piece.id] = piece + texts[piece.id] = text + return [found, texts] func _restore() -> void: - var text := FileAccess.get_file_as_string(root.path_join("index.json")) - var saved = JSON.parse_string(text) if text != "" else null - if not saved is Dictionary: + var read := _read() + pieces = read[0] + _written = read[1] + +## Takes in what another program changed: pieces it added, changed or +## removed. Nothing is read while a piece is being added here. +func refresh() -> void: + if not _adding.is_empty(): return - for id in saved: - var piece = saved[id] - if piece is Dictionary and piece.has("kind") and (piece.get("dir", "") == "" or DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(piece.dir))): - pieces[id] = piece + var read := _read() + if read[1] == _written: + return + for id in pieces.keys(): + if not read[0].has(id): + _scenes.erase(id) + pieces = read[0] + _written = read[1] + changed.emit() func _remove_dir(dir: String) -> void: var abs := ProjectSettings.globalize_path(dir) diff --git a/src/studio/studio.gd b/src/studio/studio.gd index 6de798d..5b1a194 100644 --- a/src/studio/studio.gd +++ b/src/studio/studio.gd @@ -7,7 +7,7 @@ class_name Studio ## and checking it (a child process the validator waits for frame by frame) ## stay on the main thread. ## -## AgentGuildStudio -- --build=model.glb --slot=guild/openai --out=DIR [--height=2.1] [--title=T] [--map=LeftHand=hand_l,...|swap|accept] +## AgentGuildStudio -- --build=model.glb --slot=guild/openai --out=DIR [--height=2.1] [--title=T] [--map=LeftHand=hand_l,...|swap|accept] [--hue=DEGREES] [--tint=#RRGGBB] [--pick=#RRGGBB --spread=0.15] ## AgentGuildStudio -- --build=ghost.glb[,b.glb,c.glb,d.glb] --slot=grove/companion --out=DIR [--move=rigid|soft] [--variants=tint|turn] [--turn=DEGREES] ## AgentGuildStudio -- --recolour=look0|look1|look2|agent|shell --slot=... --out=DIR [--pick=#RRGGBB|U,V] [--spread=0.15] [--hue=DEGREES] [--tint=#RRGGBB] @@ -222,6 +222,14 @@ static func work(where: Dictionary, files: Array, recolour: Dictionary, options: return made out.pieces = made.pieces out.notes = made.notes + # The piece's colors changed (`options.colour`, a StudioRecolour change): + # `base` keeps it as made, to change them again without making it again. + var colour := StudioRecolour.read(options.get("colour", {})) + if not StudioRecolour.plain(colour): + var most := int(options.get("preview", 0)) + out.base = made.pieces + out.pieces = StudioRecolour.painted(made.pieces, colour, most) + out.reduced = most > 0 if where.kind == "character": out.default = default return out @@ -303,6 +311,10 @@ static func run(host: Node, options: Dictionary) -> int: clips[name] = defaults[name].length default.free() StudioFiles.remove_tree(staged.dir) + if made.has("pieces") and (options.has("hue") or options.has("tint") or options.has("pick")): + if String(options.get("pick", "#")).contains(","): + return _fail("--pick takes a color (#RRGGBB) for a model you bring") + made.pieces = StudioRecolour.painted(made.pieces, change_from(options, [])) if made.has("notes"): made.notes = made.notes + staged.notes if made.has("unsure"): diff --git a/src/studio/studio_flow.gd b/src/studio/studio_flow.gd index 5547cc8..f692c0f 100644 --- a/src/studio/studio_flow.gd +++ b/src/studio/studio_flow.gd @@ -139,9 +139,10 @@ func _make(spec: Dictionary) -> void: return {} return await StudioSource.stage(pieces.validator, files)) -## The player changed how the piece fits. A character's height shows at -## once, its exact fit made only when it's saved; companions, quick to make, -## turn at once and are made again as set, the newest change only. +## The player changed how the piece fits or its colors. A character's height +## shows at once, its exact fit made only when it's saved, and its colors are +## changed on the piece as made; companions, quick to make, turn at once and +## are made again as set, the newest change only. func _adjusted(spec: Dictionary) -> void: if _made.is_empty() or not _made.has("pieces"): return @@ -153,6 +154,9 @@ func _adjusted(spec: Dictionary) -> void: if spec.where.kind == "character": _made.want = spec.options stage.resize(float(spec.options.get("height_share", 1.0)) / float(built.get("height_share", 1.0))) + if spec.options.get("colour", {}) != _made.get("colour", built.get("colour", {})): + spec.repaint = true + _recolour_changed(spec) return stage.turn(float(spec.options.get("turn", 0.0)) - float(built.get("turn", 0.0))) _recolour_changed(spec) @@ -179,6 +183,9 @@ func _run_recolour() -> void: _asked = _wanted var spec := _recolour var asked := _asked + if spec.get("repaint", false): + _repaint(spec, asked) + return var staged: Dictionary = _made.get("staged", {}) if not spec.get("files", []).is_empty() else {} job.run(get_tree(), func() -> Dictionary: var out := Studio.work(spec.where, staged.get("paths", []), spec.get("recolour", {}), spec.get("options", { "preview": PREVIEW_SIDE })) @@ -188,6 +195,25 @@ func _run_recolour() -> void: out.staged = staged return out) +## The made character in the colors now set, from the piece as made; shown +## smaller than it's saved. +func _repaint(spec: Dictionary, asked: int) -> void: + var made := _made + var base: Array = made.get("base", made.pieces) + var colour := StudioRecolour.read(spec.options.get("colour", {})) + var built: Dictionary = made.spec.options.duplicate() + if spec.options.has("colour"): + built.colour = spec.options.colour + else: + built.erase("colour") + job.run(get_tree(), func() -> Dictionary: + var out := { "where": spec.where, "notes": made.get("notes", []), "base": base, "pieces": StudioRecolour.painted(base, colour, PREVIEW_SIDE), + "default": Studio.default_model(spec.where), "asked": asked, "spec": { "where": spec.where, "files": spec.files, "options": built }, + "want": spec.options, "colour": spec.options.get("colour", {}), "reduced": not StudioRecolour.plain(colour), "colors": made.get("colors", []) } + if made.has("staged"): + out.staged = made.staged + return out) + ## Back on the main thread: show what was made, or say why not. A recolour ## overtaken by a newer change is let go, and the newest one asked for. func _made_one(result: Dictionary) -> void: @@ -227,6 +253,8 @@ func _made_one(result: Dictionary) -> void: else: shown = [result.host] + result.pieces.map(func(p): return StudioExport.scene(p, "companion")) result.erase("host") + if result.has("pieces") and not result.has("colors"): + result.colors = _colors_of(result, shown) stage.show_models(shown, result.where.kind) if result.has("asked"): stage.play(stage.motion if stage.motion != "" else "work" if result.where.kind == "character" else "idle") @@ -236,7 +264,19 @@ func _made_one(result: Dictionary) -> void: panel.adjusted_shown() return stage.play("work" if result.where.kind == "character" else "idle") - panel.show_preview(result.where.kind, result.get("notes", []), stage.motion) + panel.show_preview(result.where.kind, result.get("notes", []), stage.motion, result.get("colors", [])) + +## The made piece's own main colors, as made: read from what's about to be +## shown, or, with its colors already changed, from the piece before that. +func _colors_of(result: Dictionary, shown: Array) -> Array: + var kind: String = result.where.kind + if not result.has("base"): + return StudioRecolour.palette(shown.slice(0, 1) if kind == "character" else shown.slice(1), PREVIEW_SIDE) + var models: Array = result.base.map(func(p): return StudioExport.scene(p, kind)) + var colors := StudioRecolour.palette(models, PREVIEW_SIDE) + for m in models: + m.free() + return colors ## The made piece as it's saved: a recolor previewed smaller, made again at ## full size; a character resized on the stage, fitted at that height. "" @@ -246,6 +286,15 @@ func _exact() -> String: return await _refit() if not _made.get("reduced", false): return "" + if _made.has("base"): + _working("Coloring it at full size…") + var base: Array = _made.base + var colour := StudioRecolour.read(_made.spec.options.get("colour", {})) + job.run(get_tree(), func() -> Dictionary: return { "exact": true, "pieces": StudioRecolour.painted(base, colour) }) + var whole: Dictionary = await job.finished + _made.pieces = whole.pieces + _made.reduced = false + return "" _working("Recoloring it at full size…") var spec: Dictionary = _made.spec job.run(get_tree(), func() -> Dictionary: @@ -280,6 +329,11 @@ func _refit() -> String: _made.notes = fitted.notes _made.spec = spec _made.erase("want") + _made.erase("base") + if fitted.has("base"): + _made.base = fitted.base + _made.reduced = false + _made.colour = spec.options.get("colour", {}) if active: stage.show_models([StudioExport.scene(fitted.pieces[0], "character"), fitted.default], "character") stage.play(stage.motion) @@ -311,7 +365,10 @@ func _recipe() -> Dictionary: change[key] = "#" + change[key].to_html(false) return { "recolour": { "which": spec.recolour.which, "change": change } } if not spec.get("files", []).is_empty(): - return { "files": spec.files, "options": spec.options } + var options: Dictionary = spec.options.duplicate() + if options.has("colour"): + options.colour = StudioRecolour.written(options.colour) + return { "files": spec.files, "options": options } return {} ## Saves the made piece's folder in `parent`, named for its title, never over diff --git a/src/studio/studio_recolour.gd b/src/studio/studio_recolour.gd index 5489f74..d4b8c08 100644 --- a/src/studio/studio_recolour.gd +++ b/src/studio/studio_recolour.gd @@ -1,14 +1,16 @@ class_name StudioRecolour -## Recolours one of the game's own looks into a piece: a character's look -## for its slot, or a world's set of four helpers for its companions. Only -## the base colour textures change; the mesh and clips are the shipped ones, -## untouched, so the piece walks and works exactly as the look does. +## Changes a piece's colours: a piece made from a model (painted), or one of +## a world's own looks, a character's for its slot or a set of four helpers +## for its companions (dress). Every colour a material shows by changes, its +## base colour and its glow, as a value and as a texture; the mesh and clips +## are untouched. ## ## A change is { pick, spread, hue, tint }: the colour to change (`pick`, a ## Color; none for all of it) and how near a colour must be to count ## (`spread`, 0 to 1), then its hue turned (`hue`, degrees) and/or a colour ## laid over it (`tint`). Pixels near the pick change fully, fading out -## towards the spread's edge. +## towards the spread's edge. With a pick and a tint, the picked colour +## becomes the tint, brightness and all, and its shading follows. ## ## Threads: recolour() works on Images and runs anywhere; dress() swaps ## textures on a model outside the tree. @@ -59,11 +61,11 @@ static func models(where: Dictionary, which: String) -> Dictionary: static func _look_name(base: String, which: String) -> String: return base if which == "look0" else "%s_%s" % [base, which] -## The model's base color textures recolored, on its own copies of its -## materials (the shipped ones are the world's too), and every texture it -## has made plain (decompressed) so the glTF writer can take it. Outside the -## tree. With `most`, for a preview: only the base colors, at most that many -## pixels a side. +## The model's colors changed, on its own copies of its materials (the +## shipped ones are the world's too), and every texture it has made plain +## (decompressed) so the glTF writer can take it. Outside the tree. With +## `most`, for a preview: only the textures that show color, at most that +## many pixels a side. static func dress(model: Node, change: Dictionary, most := 0) -> void: var done := {} for mi: MeshInstance3D in model.find_children("*", "MeshInstance3D", true, false): @@ -76,18 +78,81 @@ static func dress(model: Node, change: Dictionary, most := 0) -> void: _dress(done[material], change, most) mi.set_surface_override_material(s, done[material]) +const COLORED := ["albedo_texture", "emission_texture"] + static func _dress(material: BaseMaterial3D, change: Dictionary, most: int) -> void: + material.albedo_color = _keep_alpha(changed(material.albedo_color, change), material.albedo_color) + if material.emission_enabled: + material.emission = changed(material.emission, change) for slot in ["albedo_texture", "normal_texture", "roughness_texture", "metallic_texture", "ao_texture", "emission_texture", "orm_texture"]: - if not slot in material or (most > 0 and slot != "albedo_texture"): + if not slot in material or (most > 0 and not slot in COLORED): continue var texture: Texture2D = material.get(slot) var image := _plain(texture, most) if texture else null if image == null: continue - if slot == "albedo_texture": + if slot in COLORED: image = recolour(image, change, PREVIEW_BITS if most > 0 else BITS) material.set(slot, ImageTexture.create_from_image(image)) +static func _keep_alpha(c: Color, from: Color) -> Color: + return Color(c.r, c.g, c.b, from.a) + +## A change as read back from a recipe or a tool's words: its colors, kept +## there as #RRGGBB, as colors. +static func read(change: Dictionary) -> Dictionary: + var out := change.duplicate() + for key in ["pick", "tint"]: + if out.get(key) is String: + out[key] = Color(out[key]) + return out + +## A change as a recipe keeps it: its colors as #RRGGBB. +static func written(change: Dictionary) -> Dictionary: + var out := change.duplicate() + for key in ["pick", "tint"]: + if out.get(key) is Color: + out[key] = "#" + out[key].to_html(false) + return out + +## Whether the change changes nothing. +static func plain(change: Dictionary) -> bool: + return float(change.get("hue", 0.0)) == 0.0 and not change.get("tint") is Color + +## Pieces made from a model (plain data, Studio.make's) with the change made +## to every surface's color and image; the pieces given are left as they +## were. With `most`, for a preview: images at most that many pixels a side. +## Any thread. +static func painted(pieces: Array, change: Dictionary, most := 0) -> Array: + if plain(change): + return pieces + var images := {} + var out := [] + for piece: Dictionary in pieces: + var copy := piece.duplicate() + copy.surfaces = [] + for s: Dictionary in piece.surfaces: + var look: Dictionary = s.look.duplicate() + look.color = _keep_alpha(changed(look.color, change), look.color) + var image: Image = look.get("image") + if image: + if not images.has(image): + var small: Image = image + if most > 0 and maxi(image.get_width(), image.get_height()) > most: + small = image.duplicate() + if small.is_compressed(): + small.decompress() + small.clear_mipmaps() + var side := maxi(small.get_width(), small.get_height()) + small.resize(maxi(1, small.get_width() * most / side), maxi(1, small.get_height() * most / side), Image.INTERPOLATE_BILINEAR) + images[image] = recolour(small, change, PREVIEW_BITS if most > 0 else BITS) + look.image = images[image] + var surface := s.duplicate() + surface.look = look + copy.surfaces.append(surface) + out.append(copy) + return out + ## Base colors decompressed and made smaller, kept while Studio is open: a ## preview's every change starts from them. static var _plains := {} @@ -203,5 +268,12 @@ static func changed(c: Color, change: Dictionary) -> Color: to = Color.from_hsv(fposmod(c.h + hue / 360.0, 1.0), c.s, c.v) if change.get("tint") is Color: var tint: Color = change.tint - to = Color.from_hsv(tint.h, lerpf(to.s, tint.s, 0.85), to.v * lerpf(1.0, tint.v, 0.5)) - return c.lerp(to, share) + if change.get("pick") is Color: + # The picked color becomes the tint: lighter by their difference, + # or darker by their ratio, so its shading keeps its shape. + var from: float = change.pick.v + var value := to.v + tint.v - from if tint.v >= from else to.v * tint.v / maxf(from, 1e-3) + to = Color.from_hsv(tint.h, tint.s, clampf(value, 0.0, 1.0)) + else: + to = Color.from_hsv(tint.h, lerpf(to.s, tint.s, 0.85), to.v * lerpf(1.0, tint.v, 0.5)) + return Color(c.lerp(to, share), c.a) diff --git a/src/ui/studio_panel.gd b/src/ui/studio_panel.gd index b42ae11..2474874 100644 --- a/src/ui/studio_panel.gd +++ b/src/ui/studio_panel.gd @@ -108,18 +108,16 @@ func open(at_world: String, tools: Dictionary, theme := "light", edit := {}) -> ## no longer where it was, or a piece Studio didn't make, is chosen again. func reopen(recipe: Dictionary) -> void: if recipe.has("recolour"): - var change: Dictionary = recipe.recolour.change - var one: bool = change.has("pick") - tints = { "which": recipe.recolour.which, "mode": "one" if one else "all", "hue": 0.0 if one else float(change.get("hue", 0.0)), - "over": not one and change.has("tint"), "tint": Color(change.get("tint", "#3060ff")) if not one else Color("#3060ff"), - "pick": Color(change.pick) if one else null, "to": Color(change.tint) if one and change.has("tint") else null, "chosen": one and change.has("tint"), - "spread": float(change.get("spread", SPREAD)) } + tints = _tints_from(recipe.recolour.change, recipe.recolour.which) _show_recolour() return var paths: Array = recipe.get("files", []) if not paths.is_empty() and paths.all(func(f): return FileAccess.file_exists(f)): files = paths.duplicate() options = recipe.get("options", {}).duplicate() + if options.has("colour"): + options.colour = StudioRecolour.read(options.colour) + tints = _tints_from(options.colour, "") _show_bring() _acting = null make_requested.emit({ "where": where, "files": files, "options": options.duplicate() }) @@ -218,8 +216,13 @@ var _last_kind := "character" var _last_notes := [] ## The piece in the world: what it does, day or dusk, and what Studio did. -func show_preview(kind: String, notes: Array, playing := "") -> void: +## `colors` are a made piece's own main colors, to choose one to change from. +func show_preview(kind: String, notes: Array, playing := "", colors := []) -> void: motion = playing + if recolour.is_empty(): + _colors = colors + if tints.is_empty() or tints.get("which", "") != "": + tints = _fresh_tints("") _show_preview(kind, notes) func show_done(text: String) -> void: @@ -296,7 +299,7 @@ func _show_recolour() -> void: _to = null _editing_line() if tints.is_empty() or not tints.which in looks: - tints = { "which": looks[0], "mode": "all", "hue": 0.0, "over": false, "tint": Color("#3060ff"), "pick": null, "to": null, "chosen": false, "spread": SPREAD } + tints = _fresh_tints(looks[0]) _colors = [] var look_pick := OptionButton.new() for l in looks: @@ -310,9 +313,30 @@ func _show_recolour() -> void: _fill_swatches() _tint("which", looks[i])) rows.add_child(look_pick) + _colour_controls(_show_recolour) + _status_line() + var buttons := HBoxContainer.new() + rows.add_child(buttons) + var next := _button("Next", func(): + recolour = { "which": tints.which, "change": _change() } + show_preview(_last_kind, [], motion), buttons) + next.theme_type_variation = "Primary" + next.disabled = true + next.set_meta("waits", true) + _button("Back", back, buttons) + _footer() + if tints.mode == "one": + _choose_first() + _fill_swatches() + _tint("", null) + +## The color choices, for a look or for a made piece: all its colors (a hue +## shift, a tint) or one of them changed to another. `rebuild` shows the +## screen again when the kind of change is switched. +func _colour_controls(rebuild: Callable) -> void: _chips("", [["Change all colors", "all"], ["Change one color", "one"]], tints.mode, func(v): tints.mode = v - _show_recolour()) + rebuild.call()) if tints.mode == "all": _slider("Hue shift", -180.0, 180.0, tints.hue, 5.0, func(v): _tint("hue", v), func(v): return "%+d°" % v if v != 0.0 else "0°", "Turns its colors around the color wheel; at 0° they stay as made.") @@ -336,21 +360,32 @@ func _show_recolour() -> void: _to.disabled = not tints.to is Color row.add_child(_to) rows.add_child(row) - _status_line() - var buttons := HBoxContainer.new() - rows.add_child(buttons) - var next := _button("Next", func(): - recolour = { "which": tints.which, "change": _change() } - show_preview(_last_kind, [], motion), buttons) - next.theme_type_variation = "Primary" - next.disabled = true - next.set_meta("waits", true) - _button("Back", back, buttons) - _footer() - if tints.mode == "one": - _choose_first() - _fill_swatches() - _tint("", null) + _slider("How much counts as that color", 0.05, 0.5, float(tints.get("spread", SPREAD)), 0.01, func(v): _tint("spread", v), + func(v): return "%d%%" % roundi(v * 100.0), "Lower changes only colors very like it; higher takes in its shades too.") + +static func _fresh_tints(which: String) -> Dictionary: + return { "which": which, "mode": "all", "hue": 0.0, "over": false, "tint": Color("#3060ff"), "pick": null, "to": null, "chosen": false, "spread": SPREAD } + +## The screen's choices as a saved change (StudioRecolour's) had them. +static func _tints_from(change: Dictionary, which: String) -> Dictionary: + var one: bool = change.has("pick") + return { "which": which, "mode": "one" if one else "all", "hue": 0.0 if one else float(change.get("hue", 0.0)), + "over": not one and change.has("tint"), "tint": Color(change.get("tint", "#3060ff")) if not one else Color("#3060ff"), + "pick": Color(change.pick) if one else null, "to": Color(change.tint) if one and change.has("tint") else null, "chosen": one and change.has("tint"), + "spread": float(change.get("spread", SPREAD)) } + +## A made piece's colors as the screen now has them, sent on if they changed. +func _sync_colour() -> void: + var change := _change() + var now: Dictionary = {} if StudioRecolour.plain(change) else change + if now == options.get("colour", {}): + return + if now.is_empty(): + options.erase("colour") + else: + options.colour = now + _ready_to_go(false, "Coloring…") + adjusted.emit({ "where": where, "files": files, "options": options.duplicate() }) ## A color clearly unlike `c` to change it to: its opposite hue, or for a ## gray, the same red every time. @@ -429,6 +464,9 @@ func _ready_to_go(ready: bool, text: String) -> void: func _tint(key: String, value) -> void: if key != "": tints[key] = value + if step == "preview": + _sync_colour() + return _ready_to_go(false, "Recoloring…") recolour_changed.emit({ "where": where, "recolour": { "which": tints.which, "change": _change() } }) @@ -487,6 +525,8 @@ func _show_preview(kind: String, notes: Array) -> void: _last_kind = kind _last_notes = notes _clear("See it on the stage") + _swatches = null + _to = null _chips("What it does", CHARACTER_MOTIONS if kind == "character" else COMPANION_MOTIONS, motion, func(v): motion = v motion_chosen.emit(v)) @@ -499,6 +539,8 @@ func _show_preview(kind: String, notes: Array) -> void: _note("It fits without changes.") if recolour.is_empty(): _adjust(kind) + _heading("Colors") + _colour_controls(func(): _show_preview(_last_kind, _last_notes)) _status_line() var buttons := HBoxContainer.new() rows.add_child(buttons) @@ -507,6 +549,11 @@ func _show_preview(kind: String, notes: Array) -> void: next.set_meta("waits", true) _button("Back", back, buttons) _footer() + if recolour.is_empty(): + if tints.mode == "one": + _choose_first() + _fill_swatches() + _sync_colour() ## How the piece fits, changed on the stage as it's set. func _adjust(kind: String) -> void: diff --git a/tests/download_test.gd b/tests/download_test.gd index 2a17016..81d93d9 100644 --- a/tests/download_test.gd +++ b/tests/download_test.gd @@ -6,9 +6,6 @@ extends RefCounted const OUT := "user://art-test/" var failures := [] -var _server := TCPServer.new() -var _serving := {} -var _peers := [] func check(ok: bool, what: String) -> void: if not ok: @@ -17,37 +14,27 @@ func check(ok: bool, what: String) -> void: func run(tree: SceneTree) -> Array: var folder := ProjectSettings.globalize_path(OUT) StudioFiles.remove_tree(folder) - OS.set_environment("STUDIO_ART", folder) - var port := 0 - for candidate in range(47810, 47830): - if _server.listen(candidate, "127.0.0.1") == OK: - port = candidate - break - if port == 0: + var server := LocalServer.new() + if not server.start(): return ["no port on this computer was free for the test's server"] - OS.set_environment("STUDIO_ART_URL", "http://127.0.0.1:%d/%%s/%%s" % port) - var good := PackedByteArray() - good.resize(300000) - for i in good.size(): - good[i] = i % 251 - var sum := HashingContext.new() - sum.start(HashingContext.HASH_SHA256) - sum.update(good) + OS.set_environment("STUDIO_ART", folder) + OS.set_environment("STUDIO_ART_URL", "http://127.0.0.1:%d/%%s/%%s" % server.port) + var good := LocalServer.pack("good.zip", 300000) var kept: Dictionary = StudioArt._lock StudioArt._lock = { "release": "art", "packs": { - "good": { "asset": "good.zip", "size": good.size(), "sha256": sum.finish().hex_encode() }, - "changed": { "asset": "changed.zip", "size": good.size(), "sha256": "00".repeat(32) }, + "good": good[1], + "changed": { "asset": "changed.zip", "size": 300000, "sha256": "00".repeat(32) }, "gone": { "asset": "gone.zip", "size": 10, "sha256": "00".repeat(32) } } } - _serving = { "/art/good.zip": good, "/art/changed.zip": good } + server.serving = { "/art/good.zip": good[0], "/art/changed.zip": good[0] } var download := ArtDownload.new() tree.root.add_child(download) - tree.process_frame.connect(_serve) + tree.process_frame.connect(server.poll) check(StudioArt.missing(["good"]) == ["good"], "a pack not downloaded yet is not counted as missing") download.start(["good"]) check(await download.finished == "", "a pack that matches its checksum was not downloaded") - check(StudioArt.have("good") and FileAccess.get_file_as_bytes(StudioArt.file("good")) == good, "the downloaded pack is not in place, whole") - check(download.received == good.size() and download.total == good.size(), "the download did not count its bytes: %d of %d" % [download.received, download.total]) + check(StudioArt.have("good") and FileAccess.get_file_as_bytes(StudioArt.file("good")) == good[0], "the downloaded pack is not in place, whole") + check(download.received == 300000 and download.total == 300000, "the download did not count its bytes: %d of %d" % [download.received, download.total]) download.start(["changed"]) var why: String = await download.finished @@ -59,33 +46,11 @@ func run(tree: SceneTree) -> Array: check(why.contains("could not be downloaded"), "a pack the server doesn't have was not refused: %s" % why) check(DirAccess.get_files_at(folder) == PackedStringArray(["good.zip"]), "a failed download left something behind: %s" % [DirAccess.get_files_at(folder)]) - tree.process_frame.disconnect(_serve) + tree.process_frame.disconnect(server.poll) download.queue_free() - _server.stop() + server.stop() StudioArt._lock = kept OS.set_environment("STUDIO_ART", "") OS.set_environment("STUDIO_ART_URL", "") StudioFiles.remove_tree(folder) return failures - -## Answers each request once its headers have arrived, then closes. -func _serve() -> void: - while _server.is_connection_available(): - _peers.append([_server.take_connection(), ""]) - for entry in _peers.duplicate(): - var peer: StreamPeerTCP = entry[0] - peer.poll() - if peer.get_status() != StreamPeerTCP.STATUS_CONNECTED: - _peers.erase(entry) - continue - var waiting := peer.get_available_bytes() - if waiting > 0: - entry[1] += peer.get_data(waiting)[1].get_string_from_utf8() - if not String(entry[1]).contains("\r\n\r\n"): - continue - var path := String(entry[1]).get_slice(" ", 1) - var body: PackedByteArray = _serving.get(path, PackedByteArray()) - var head := "HTTP/1.1 %s\r\nContent-Length: %d\r\nConnection: close\r\n\r\n" % ["200 OK" if _serving.has(path) else "404 Not Found", body.size()] - peer.put_data(head.to_utf8_buffer() + body) - peer.disconnect_from_host() - _peers.erase(entry) diff --git a/tests/local_server.gd b/tests/local_server.gd new file mode 100644 index 0000000..237aaa2 --- /dev/null +++ b/tests/local_server.gd @@ -0,0 +1,57 @@ +class_name LocalServer +extends RefCounted +## A web server on this computer for tests: answers each path in `serving` +## with its bytes, anything else with 404, and never answers a path in +## `holding`. poll() once a frame. + +var port := 0 +var serving := {} +var holding := [] +var _server := TCPServer.new() +var _peers := [] + +## Starts listening; false when no port was free. +func start() -> bool: + for candidate in range(47810, 47840): + if _server.listen(candidate, "127.0.0.1") == OK: + port = candidate + return true + return false + +func stop() -> void: + _peers.clear() + _server.stop() + +func poll() -> void: + while _server.is_connection_available(): + _peers.append([_server.take_connection(), ""]) + for entry in _peers.duplicate(): + var peer: StreamPeerTCP = entry[0] + peer.poll() + if peer.get_status() != StreamPeerTCP.STATUS_CONNECTED: + _peers.erase(entry) + continue + var waiting := peer.get_available_bytes() + if waiting > 0: + entry[1] += peer.get_data(waiting)[1].get_string_from_utf8() + if not String(entry[1]).contains("\r\n\r\n"): + continue + var path := String(entry[1]).get_slice(" ", 1) + if path in holding: + continue + var body: PackedByteArray = serving.get(path, PackedByteArray()) + var head := "HTTP/1.1 %s\r\nContent-Length: %d\r\nConnection: close\r\n\r\n" % ["200 OK" if serving.has(path) else "404 Not Found", body.size()] + peer.put_data(head.to_utf8_buffer() + body) + peer.disconnect_from_host() + _peers.erase(entry) + +## `size` bytes and the art lock's entry for them, as `asset`. +static func pack(asset: String, size: int) -> Array: + var bytes := PackedByteArray() + bytes.resize(size) + for i in size: + bytes[i] = i % 251 + var sum := HashingContext.new() + sum.start(HashingContext.HASH_SHA256) + sum.update(bytes) + return [bytes, { "asset": asset, "size": size, "sha256": sum.finish().hex_encode() }] diff --git a/tests/local_server.gd.uid b/tests/local_server.gd.uid new file mode 100644 index 0000000..c56150f --- /dev/null +++ b/tests/local_server.gd.uid @@ -0,0 +1 @@ +uid://dhfcgqym87rfn diff --git a/tests/mcp_smoke.mjs b/tests/mcp_smoke.mjs index caf3630..c745003 100644 --- a/tests/mcp_smoke.mjs +++ b/tests/mcp_smoke.mjs @@ -51,7 +51,7 @@ try { const discover = await request('server/discover', { _meta: meta }); check(discover.result?.supportedVersions?.includes('2026-07-28'), 'server/discover'); const list = await request('tools/list', {}); - check(list.result?.tools?.map((t) => t.name).join() === 'list_slots,make_character,make_companions,recolor_look,check_piece,show_window', 'tools/list has the six tools'); + check(list.result?.tools?.map((t) => t.name).join() === 'list_slots,make_character,make_companions,recolor_look,check_piece,list_pieces,remove_piece,show_window', 'tools/list has the eight tools'); child.stdin.write('{not json\n'); const slots = await tool('list_slots', {}); check(slots?.structuredContent?.worlds?.length === 6 && slots.structuredContent.worlds.every((w) => w.slots.length === 7), 'list_slots lists six worlds of seven slots'); @@ -71,12 +71,20 @@ try { check(passes?.structuredContent?.passes === true && passes.structuredContent.slot === 'gnomeland/openai', 'check_piece passes the piece it made'); const recolored = await tool('recolor_look', { slot: 'gnomeland/companion', look: 'agent', to: path.join(appData, 'recolored'), hue: 90 }, 300000); check(recolored?.isError === false && fs.existsSync(path.join(appData, 'recolored', 'companion_3.glb')), 'recolor_look writes four companions'); + const colored = await tool('make_character', { model: process.env.STUDIO_SMOKE_MODEL, slot: 'gnomeland/openai', to: path.join(appData, 'colored'), title: 'Colored', hue: 120, tint: '#d04040' }, 300000); + check(colored?.isError === false && fs.statSync(path.join(appData, 'colored', 'character.glb')).size !== fs.statSync(path.join(to, 'character.glb')).size, 'make_character changes the colors of a model brought to it'); + const listed = (await tool('list_pieces', {}))?.structuredContent?.pieces ?? []; + check(listed.length === 3 && listed.some((p) => p.piece === made.structuredContent.piece && p.title === 'Smoke' && p.slot === 'gnomeland/openai' && p.folder === to), `list_pieces lists the three pieces made (${listed.map((p) => p.title)})`); + const removed = await tool('remove_piece', { piece: made.structuredContent.piece }); + const left = (await tool('list_pieces', {}))?.structuredContent?.pieces ?? []; + check(removed?.isError === false && left.length === 2 && fs.existsSync(path.join(to, 'character.glb')), 'remove_piece takes one piece off the list and leaves its folder'); } check(lines.every((l) => { try { return typeof JSON.parse(l) === 'object'; } catch { return false; } }), 'stdout carries only JSON'); check(lines.some((l) => l.includes('"code":-32700')), 'a malformed line gets a parse error'); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: nextId++, method: 'tools/call', params: { name: 'list_slots', arguments: {} } }) + '\n'); child.stdin.end(); const code = await Promise.race([exited, new Promise((r) => setTimeout(() => r('timeout'), 60000))]); - check(code === 0, `exits cleanly when stdin closes (exit ${code})`); + check(code === 0, `exits cleanly when stdin closes with a call in flight (exit ${code})`); } catch (e) { check(false, e.message); child.kill(); diff --git a/tests/mcp_test.gd b/tests/mcp_test.gd new file mode 100644 index 0000000..8e1ff00 --- /dev/null +++ b/tests/mcp_test.gd @@ -0,0 +1,112 @@ +extends RefCounted +## The agent's tools that need no art: the list of tools, the shared list of +## pieces, opening the window, and arguments refused before any work starts. + +const FIXTURES := "res://tests/fixtures/pieces/" +const OUT := "user://pieces/mcp-test/" +const CLIPS := { "walk": 1.3333, "work_0": 3.0, "work_1": 5.0, "pause": 5.0, "idle_0": 5.0, "idle_1": 6.0, "idle_2": 5.0, "done": 4.0 } + +class Host extends Node: + const TOOLS := { "anthropic": "Claude Code" } + var pieces: PieceLibrary + var download: ArtDownload + var opened := 0 + func open_window() -> bool: + opened += 1 + return true + func default_clips(_world: String, _kind: String, _crew: String) -> Dictionary: + return CLIPS + +var failures := [] +var _lines := [] +var _server: McpServer +var _next := 1 + +func check(ok: bool, what: String) -> void: + if not ok: + failures.append(what) + +func run(tree: SceneTree) -> Array: + StudioFiles.remove_tree(ProjectSettings.globalize_path(OUT)) + var host := Host.new() + tree.root.add_child(host) + host.pieces = PieceLibrary.new() + host.pieces.root = OUT + "library/" + host.pieces.default_clips = host.default_clips + host.add_child(host.pieces) + host.download = ArtDownload.new() + host.add_child(host.download) + _server = McpServer.new() + _server.host = host + _server.read_stdin = false + _server.write_line = func(line: String): _lines.append(JSON.parse_string(line)) + host.add_child(_server) + await _ask(tree, "initialize", { "protocolVersion": "2025-11-25" }) + + var listed: Dictionary = await _ask(tree, "tools/list", {}) + var names: Array = listed.get("tools", []).map(func(t): return t.name) + check(names == ["list_slots", "make_character", "make_companions", "recolor_look", "check_piece", "list_pieces", "remove_piece", "show_window"], "the tools are %s" % [names]) + check(listed.get("tools", []).all(func(t): return t.inputSchema.type == "object" and t.annotations.has("readOnlyHint") and t.has("description")), "a tool lacks a schema, hints or a description") + for name in ["make_character", "make_companions", "recolor_look"]: + var props: Dictionary = listed.tools.filter(func(t): return t.name == name)[0].inputSchema.properties + check(props.has("hue") and props.has("tint") and props.has("change_color") and props.has("spread"), "%s takes no color choices" % name) + + var shown := await _call(tree, "show_window", {}) + check(shown.get("isError") == false and host.opened == 1, "show_window did not open the window once") + + var none := await _call(tree, "list_pieces", {}) + check(none.get("structuredContent", {}).get("pieces") == [], "an empty list is not empty: %s" % none) + var other := PieceLibrary.new() + other.root = host.pieces.root + other.default_clips = host.default_clips + tree.root.add_child(other) + var id := await other.add_local(_folder("in-the-window"), "", {}) + other.pieces[id].saved = "C:/pieces/in-the-window" + other.keep() + var one := await _call(tree, "list_pieces", {}) + var pieces: Array = one.get("structuredContent", {}).get("pieces", []) + check(pieces.size() == 1 and pieces[0].piece == id and pieces[0].title == "in-the-window" and pieces[0].slot == "guild/anthropic" and pieces[0].folder == "C:/pieces/in-the-window", + "a piece saved in the window is not listed for the agent: %s" % [pieces]) + var unknown := await _call(tree, "remove_piece", { "piece": "nothing" }) + check(unknown.get("isError") == true and host.pieces.pieces.size() == 1, "removing a piece that isn't there did something") + var gone := await _call(tree, "remove_piece", { "piece": id }) + other.refresh() + check(gone.get("isError") == false and other.pieces.is_empty(), "the agent's remove did not take the piece off the window's list") + + var to := ProjectSettings.globalize_path(OUT + "never") + var color := await _call(tree, "make_character", { "model": "C:/x.glb", "slot": "guild/anthropic", "to": to, "tint": "#zzzzzz" }) + check(color.get("isError") == true and "color" in String(color.content[0].text) and not DirAccess.dir_exists_absolute(to), "a tint that is no color was not refused before any work") + var nothing := await _call(tree, "recolor_look", { "slot": "guild/anthropic", "look": "look1", "to": to }) + check(nothing.get("isError") == true and "hue" in String(nothing.content[0].text), "a recolor with no color choice was not refused") + var four := await _call(tree, "make_companions", { "models": ["a.glb", "b.glb"], "slot": "guild/companion", "to": to }) + check(four.get("isError") == true and "one model or four" in String(four.content[0].text), "two models for companions were not refused") + + other.free() + host.free() + StudioFiles.remove_tree(ProjectSettings.globalize_path(OUT)) + return failures + +func _ask(tree: SceneTree, method: String, params: Dictionary) -> Dictionary: + var id := _next + _next += 1 + _server.receive(JSON.stringify({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })) + var answer := [{}] + await TestRunner.until(tree, func(): + for line in _lines: + if line is Dictionary and line.get("id") == float(id): + answer[0] = line + return true + return false, 60.0) + return answer[0].get("result", answer[0]) + +func _call(tree: SceneTree, name: String, args: Dictionary) -> Dictionary: + return await _ask(tree, "tools/call", { "name": name, "arguments": args }) + +func _folder(name: String) -> String: + var dir := ProjectSettings.globalize_path(OUT + "folders/" + name) + DirAccess.make_dir_recursive_absolute(dir) + FileAccess.open(dir.path_join("piece.json"), FileAccess.WRITE).store_string(JSON.stringify({ "format": 1, "kind": "character", "world": "guild", "crew": "anthropic", "title": name })) + var model := FileAccess.open(dir.path_join("character.glb"), FileAccess.WRITE) + model.store_buffer(FileAccess.get_file_as_bytes(FIXTURES + "character.bin")) + model.close() + return dir diff --git a/tests/mcp_test.gd.uid b/tests/mcp_test.gd.uid new file mode 100644 index 0000000..0e25ecf --- /dev/null +++ b/tests/mcp_test.gd.uid @@ -0,0 +1 @@ +uid://crhepqm1mca87 diff --git a/tests/recolour_test.gd b/tests/recolour_test.gd new file mode 100644 index 0000000..931189c --- /dev/null +++ b/tests/recolour_test.gd @@ -0,0 +1,74 @@ +extends RefCounted +## The color rule on colors and materials made here, none of them a world's: +## a picked color becomes the chosen one, lighter or darker, with its shading +## kept in order; colors not picked stay; and every color a piece or a model +## shows by changes, as a value and as a texture, base color and glow. + +var failures := [] + +func check(ok: bool, what: String) -> void: + if not ok: + failures.append(what) + +static func near(a: Color, b: Color, levels := 2.0) -> bool: + return Vector3(a.r - b.r, a.g - b.g, a.b - b.b).length() <= levels / 255.0 + +func run(_tree: SceneTree) -> Array: + var dark := Color8(12, 10, 10) + var cream := Color8(232, 226, 208) + var white := Color8(240, 240, 235) + var navy := Color8(32, 40, 64) + var green := Color8(40, 180, 60) + var lighter := { "pick": dark, "spread": 0.15, "tint": cream } + var darker := { "pick": white, "spread": 0.15, "tint": navy } + check(near(StudioRecolour.changed(dark, lighter), cream), "a dark color changed to a light one became %s, not %s" % [StudioRecolour.changed(dark, lighter), cream]) + check(near(StudioRecolour.changed(white, darker), navy), "a light color changed to a dark one became %s, not %s" % [StudioRecolour.changed(white, darker), navy]) + var fold := StudioRecolour.changed(Color8(20, 18, 18), lighter) + var crease := StudioRecolour.changed(Color8(6, 5, 5), lighter) + check(fold.v > StudioRecolour.changed(dark, lighter).v and crease.v < StudioRecolour.changed(dark, lighter).v, "going lighter, the picked color's shades lost their order") + var shade := StudioRecolour.changed(Color8(225, 225, 221), darker) + check(shade.v < StudioRecolour.changed(white, darker).v and shade.v > 0.0, "going darker, the picked color's shades lost their order or went black") + check(near(StudioRecolour.changed(green, lighter), green) and near(StudioRecolour.changed(green, darker), green), "a color far from the pick changed") + var laid := StudioRecolour.changed(Color8(128, 128, 128), { "tint": cream }) + check(absf(laid.h - cream.h) < 0.02 and laid.v <= 0.51, "a color laid over everything changed more than the hue and a share of the brightness: %s" % laid) + check(StudioRecolour.plain({ "hue": 0.0, "spread": 0.15 }) and not StudioRecolour.plain({ "hue": 0.0, "tint": cream }), "a change that changes nothing is not told from one that does") + var back := StudioRecolour.read(JSON.parse_string(JSON.stringify(StudioRecolour.written(lighter)))) + check(near(back.pick, dark, 0.5) and near(back.tint, cream, 0.5), "a change did not come back from a recipe as it went in: %s" % back) + + var image := Image.create(2, 1, false, Image.FORMAT_RGBA8) + image.set_pixel(0, 0, dark) + image.set_pixel(1, 0, green) + var kept := image.duplicate() + var piece := { "title": "made here", "surfaces": [ + { "name": "cloth", "look": { "color": Color.WHITE, "image": image } }, + { "name": "trim", "look": { "color": dark, "image": image } }, + { "name": "plain", "look": { "color": Color(dark, 0.5), "image": null } } ] } + var painted: Array = StudioRecolour.painted([piece], lighter) + var cloth: Dictionary = painted[0].surfaces[0].look + check(near(cloth.image.get_pixel(0, 0), cream, 6.0) and near(cloth.image.get_pixel(1, 0), green, 6.0), "a piece's texture did not take the change: %s %s" % [cloth.image.get_pixel(0, 0), cloth.image.get_pixel(1, 0)]) + check(near(painted[0].surfaces[1].look.color, cream) and near(painted[0].surfaces[2].look.color, cream) and is_equal_approx(painted[0].surfaces[2].look.color.a, 0.5), + "a piece's plain colors did not take the change, or lost how see-through they are") + check(painted[0].surfaces[0].look.image == painted[0].surfaces[1].look.image, "one texture two surfaces share was changed twice") + check(image.get_data() == kept.get_data() and piece.surfaces[1].look.color == dark and painted[0].title == "made here", "changing a piece's colors changed the piece it was made from") + check(StudioRecolour.painted([piece], { "hue": 0.0 })[0] == piece, "a change that changes nothing made a new piece") + + var material := StandardMaterial3D.new() + material.albedo_color = dark + material.albedo_texture = ImageTexture.create_from_image(image) + material.emission_enabled = true + material.emission = dark + material.emission_texture = ImageTexture.create_from_image(image) + var box := BoxMesh.new() + box.material = material + var model := Node3D.new() + var part := MeshInstance3D.new() + part.mesh = box + model.add_child(part) + StudioRecolour.dress(model, lighter) + var dressed: BaseMaterial3D = part.get_surface_override_material(0) + check(dressed != null and near(dressed.albedo_color, cream) and near(dressed.albedo_texture.get_image().get_pixel(0, 0), cream, 6.0), "a model's base color did not take the change") + check(dressed != null and near(dressed.emission, cream) and near(dressed.emission_texture.get_image().get_pixel(0, 0), cream, 6.0), "a model's glow did not take the change") + check(material.albedo_color == dark and material.emission == dark, "changing a model's colors changed the material it shares") + model.free() + StudioRecolour.forget() + return failures diff --git a/tests/recolour_test.gd.uid b/tests/recolour_test.gd.uid new file mode 100644 index 0000000..6fc4131 --- /dev/null +++ b/tests/recolour_test.gd.uid @@ -0,0 +1 @@ +uid://cj4igpougbey1 diff --git a/tests/shared_pieces_test.gd b/tests/shared_pieces_test.gd new file mode 100644 index 0000000..916af1b --- /dev/null +++ b/tests/shared_pieces_test.gd @@ -0,0 +1,67 @@ +extends RefCounted +## One list of pieces for two programs, as Studio's window and an agent's +## connection are: what one adds or removes the other sees, and neither +## writes over a piece the other added. + +const FIXTURES := "res://tests/fixtures/pieces/" +const OUT := "user://pieces/shared-test/" +const ROOT := "user://pieces/shared-test/library/" +const CLIPS := { "walk": 1.3333, "work_0": 3.0, "work_1": 5.0, "pause": 5.0, "idle_0": 5.0, "idle_1": 6.0, "idle_2": 5.0, "done": 4.0 } + +var failures := [] + +func check(ok: bool, what: String) -> void: + if not ok: + failures.append(what) + +func run(tree: SceneTree) -> Array: + StudioFiles.remove_tree(ProjectSettings.globalize_path(OUT)) + var window := _library(tree) + var agent := _library(tree) + var seen := [0] + window.changed.connect(func(): seen[0] += 1) + + var made := await agent.add_local(_folder("agent-made"), "", { "files": ["model.glb"] }) + check(agent.pieces.get(made, {}).get("error", "x") == "", "a valid piece was not listed: %s" % agent.pieces.get(made)) + seen[0] = 0 + window.refresh() + check(window.pieces.has(made) and window.pieces[made].get("studio", {}).get("files") == ["model.glb"] and seen[0] == 1, "the window did not see the piece the agent made, with how it was made") + window.refresh() + check(seen[0] == 1, "looking again with nothing new said the list changed") + + # Each adds one before seeing the other's: both are kept. + var hers := await window.add_local(_folder("window-made"), "", {}) + var his := await agent.add_local(_folder("agent-second"), "", {}) + var fresh := _library(tree) + check(fresh.pieces.has(made) and fresh.pieces.has(hers) and fresh.pieces.has(his), "a piece was lost when both added one: %s" % [fresh.pieces.keys()]) + + agent.refresh() + agent.remove(hers) + window.refresh() + check(not window.pieces.has(hers) and window.pieces.has(made) and window.pieces.has(his), "the window did not see the agent remove a piece, and only that one") + + var records := ProjectSettings.globalize_path(ROOT.path_join(PieceLibrary.RECORDS)) + FileAccess.open(records.path_join("half.json"), FileAccess.WRITE).store_string("{ \"id\": \"half") + window.refresh() + check(window.pieces.size() == 2, "a record caught half written was taken for a piece") + + for library in [window, agent, fresh]: + library.free() + StudioFiles.remove_tree(ProjectSettings.globalize_path(OUT)) + return failures + +func _library(tree: SceneTree) -> PieceLibrary: + var library := PieceLibrary.new() + library.root = ROOT + library.default_clips = func(_world, _kind, _crew): return CLIPS + tree.root.add_child(library) + return library + +func _folder(name: String) -> String: + var dir := ProjectSettings.globalize_path(OUT + "folders/" + name) + DirAccess.make_dir_recursive_absolute(dir) + FileAccess.open(dir.path_join("piece.json"), FileAccess.WRITE).store_string(JSON.stringify({ "format": 1, "kind": "character", "world": "guild", "crew": "anthropic", "title": name })) + var model := FileAccess.open(dir.path_join("character.glb"), FileAccess.WRITE) + model.store_buffer(FileAccess.get_file_as_bytes(FIXTURES + "character.bin")) + model.close() + return dir diff --git a/tests/shared_pieces_test.gd.uid b/tests/shared_pieces_test.gd.uid new file mode 100644 index 0000000..02a0ff1 --- /dev/null +++ b/tests/shared_pieces_test.gd.uid @@ -0,0 +1 @@ +uid://tc8f01a5b1bs diff --git a/tests/studio_view_test.gd b/tests/studio_view_test.gd index cca2c5d..a872258 100644 --- a/tests/studio_view_test.gd +++ b/tests/studio_view_test.gd @@ -1,9 +1,11 @@ extends RefCounted -## Studio's screens for one piece, on the stage, with the art packs in place -## (STUDIO_ART names their folder): a model is made and shown beside the slot -## default at their stations, saved as a folder that is never written over, -## and listed under Your pieces with how it was made; companions and a -## recolor show too; leaving empties the stage. +## Studio's window making a piece, with the art packs in place (STUDIO_ART +## names their folder): a model is made and shown beside the slot default at +## their stations; its colors are changed as a look's are; it is saved as a +## folder that is never written over, in the colors chosen, and listed under +## Your pieces with how it was made; it is opened again from there as it was +## left; companions show too; and closing the window during a save finishes +## the save first. const FIXTURES := "res://tests/fixtures/pieces/" const OUT := "user://studio/view-test/" @@ -24,24 +26,19 @@ func run(tree: SceneTree) -> Array: StudioFiles.remove_tree(ProjectSettings.globalize_path(LIBRARY)) DirAccess.make_dir_recursive_absolute(out) var main: Node = load("res://src/app/main.gd").new() - var pieces := PieceLibrary.new() - pieces.root = LIBRARY - pieces.default_clips = main.default_clips - tree.root.add_child(pieces) - var stage := StudioStage.new() - tree.root.add_child(stage) - var camera := CameraRig.new() - tree.root.add_child(camera) - stage.camera = camera - var panel := StudioPanel.new() - panel.pad_screen = func(): return false - tree.root.add_child(panel) - var flow := StudioFlow.new() - tree.root.add_child(flow) - var ended := [false] - flow.ended.connect(func(): ended[0] = true) - flow.start(panel, stage, pieces, main.TOOLS, "guild", "light") - check(panel.visible and panel.step == "slot" and panel.slots.size() == 7, "Studio did not open on the slot screen with guild's seven slots") + tree.root.add_child(main) + var quits := [0] + main.on_quit = func(): quits[0] += 1 + main.pieces.root = LIBRARY + main.pieces.refresh() + var pieces: PieceLibrary = main.pieces + var panel: StudioPanel = main.panel + var stage: StudioStage = main.stage + var home: HomePanel = main.home + check(home.art == "ready", "with its art in place, guild shows as %s" % home.art) + home.make_requested.emit("guild") + var flow: StudioFlow = main.flow + check(flow != null and panel.visible and not home.visible and panel.step == "slot" and panel.slots.size() == 7, "Make a piece did not open the slot screen with guild's seven slots") var model := _copy("studio_mixamo") var where: Dictionary = panel.slots[1] @@ -58,14 +55,32 @@ func run(tree: SceneTree) -> Array: stage.set_theme("dark") check(stage.theme == "dark" and is_equal_approx(stage._sun.light_energy, StudioStage.SUN_ENERGY.dark), "dusk did not change the stage's light") + # Its colors, changed as a look's are: here, turned a third of the way round. + check(not panel._colors.is_empty(), "the preview has none of the made piece's colors to choose from") + var as_made: Image = flow._made.pieces[0].surfaces[0].look.image.duplicate() + panel._tint("hue", 120.0) + check(await TestRunner.until(tree, func(): return flow._made.has("base") and not flow.job.running and flow._asked == flow._wanted, 120.0), "a color change never reached the stage") + check(flow._made.get("reduced", false) and stage.models.size() == 2, "the recolored piece is not on the stage beside the default") + check(panel.options.get("colour", {}).get("hue") == 120.0, "the panel did not keep the color change with the piece's choices") + panel.save_requested.emit(out, "View test") check(await TestRunner.until(tree, func(): return flow._keeping == "" and not flow.job.running and DirAccess.dir_exists_absolute(out.path_join("View test")), 240.0), "the piece was not saved") check(FileAccess.file_exists(out.path_join("View test/piece.json")) and FileAccess.file_exists(out.path_join("View test/character.glb")), "the saved folder is not a piece") + var saved: Dictionary = StudioSource.open(out.path_join("View test/character.glb")) + if saved.has("model"): + var read := StudioSource.read(saved.model) + saved.model.free() + check(_turned(as_made, read.surfaces[0].look.image) > 0.5, "the saved piece is not in the colors chosen") + else: + check(false, "the saved piece could not be opened again") var listed: Array = pieces.pieces.values().filter(func(p): return p.get("source", "") == "local") check(listed.size() == 1 and listed[0].get("error", "x") == "" and listed[0].title == "View test" and listed[0].world == "guild" and listed[0].crew == where.crew, "the saved piece is not listed under Your pieces: %s" % [listed]) - if listed.size() == 1: - check(listed[0].get("studio", {}).get("files", []) == [model] and String(listed[0].get("saved", "")).ends_with("View test"), "Your pieces did not keep how the piece was made and where it was saved") + var id: String = listed[0].id if listed.size() == 1 else "" + if id != "": + var recipe: Dictionary = listed[0].get("studio", {}) + check(recipe.get("files", []) == [model] and recipe.get("options", {}).get("colour", {}).get("hue") == 120.0 and String(listed[0].get("saved", "")).ends_with("View test"), + "Your pieces did not keep how the piece was made, its colors and where it was saved: %s" % recipe) panel.save_requested.emit(out, "View test") check(await TestRunner.until(tree, func(): return flow._keeping == "" and DirAccess.dir_exists_absolute(out.path_join("View test (2)")), 240.0), "saving again wrote over the first folder instead of beside it") check(pieces.pieces.values().filter(func(p): return p.get("source", "") == "local").size() == 1, "saving the piece being made again listed it twice") @@ -78,17 +93,52 @@ func run(tree: SceneTree) -> Array: check(stage.models.size() == 1 + StudioStage.COMPANIONS, "the stage shows %d models, not a character and six companions" % stage.models.size()) panel.leave_requested.emit() - check(await TestRunner.until(tree, func(): return ended[0], 60.0), "leaving did not end Studio") - check(stage.models.is_empty() and not panel.visible, "leaving left the stage or the panel showing") - await TestRunner.until(tree, func(): return not is_instance_valid(flow), 60.0) - for node in [panel, camera, stage, pieces]: - node.queue_free() - main.free() + check(await TestRunner.until(tree, func(): return main.flow == null and home.visible, 60.0), "leaving did not bring the first screen back") + check(stage.models.is_empty() and not panel.visible and home.pieces.size() == 1, "leaving left the stage or the panel showing, or Your pieces empty") + + # Opened again from Your pieces: made again as it was left, colors and all. + home.edit_requested.emit(id) + flow = main.flow + check(flow != null and flow.editing == id, "Edit did not open the piece") + check(await TestRunner.until(tree, func(): return panel.step == "preview" and not flow.job.running, 180.0), "the piece opened to edit was not made again") + check(panel.editing.get("id", "") == id and panel.options.get("colour", {}).get("hue") == 120.0 and panel.tints.get("hue") == 120.0, "the piece opened to edit lost its colors: %s" % [panel.options]) + + # Closing the window while it saves: the save finishes, then Studio ends. + panel.save_requested.emit(out, "Closing") + main.close() + check(quits[0] == 0, "closing during a save ended Studio before the save was done") + check(await TestRunner.until(tree, func(): return quits[0] == 1, 240.0), "closing during a save never ended Studio") + check(FileAccess.file_exists(out.path_join("Closing/piece.json")) and FileAccess.file_exists(out.path_join("Closing/character.glb")), "the piece being saved at closing is not whole") + check(not DirAccess.dir_exists_absolute(out.path_join(".Closing.part")), "closing during a save left a part folder behind") + + tree.auto_accept_quit = true + main.queue_free() + await tree.process_frame await tree.process_frame StudioFiles.remove_tree(out) StudioFiles.remove_tree(ProjectSettings.globalize_path(LIBRARY)) return failures +## The share of `before`'s colored pixels whose hue is a third of a turn on in `after`. +func _turned(before: Image, after: Image) -> float: + var a := before.duplicate() + var b := after.duplicate() + for image: Image in [a, b]: + if image.is_compressed(): + image.decompress() + image.resize(64, 64, Image.INTERPOLATE_NEAREST) + var colored := 0 + var turned := 0 + for y in 64: + for x in 64: + var was: Color = a.get_pixel(x, y) + if was.s < 0.3 or was.v < 0.2: + continue + colored += 1 + if absf(angle_difference(TAU * b.get_pixel(x, y).h, TAU * (was.h + 1.0 / 3.0))) < deg_to_rad(25.0): + turned += 1 + return float(turned) / maxf(colored, 1.0) + func _copy(name: String) -> String: var path := ProjectSettings.globalize_path(OUT.path_join(name + ".glb")) var file := FileAccess.open(path, FileAccess.WRITE) diff --git a/tests/window_test.gd b/tests/window_test.gd new file mode 100644 index 0000000..843b75f --- /dev/null +++ b/tests/window_test.gd @@ -0,0 +1,98 @@ +extends RefCounted +## Studio's window with no art: its first screen says what a world's art +## needs and follows a download through, a failure and a cancel, leaving +## nothing behind; pieces another program adds appear; closing ends Studio +## once; and the window starts with no agent's options. + +const FIXTURES := "res://tests/fixtures/pieces/" +const OUT := "user://window-test/" +const CLIPS := { "walk": 1.3333, "work_0": 3.0, "work_1": 5.0, "pause": 5.0, "idle_0": 5.0, "idle_1": 6.0, "idle_2": 5.0, "done": 4.0 } + +var failures := [] + +func check(ok: bool, what: String) -> void: + if not ok: + failures.append(what) + +func run(tree: SceneTree) -> Array: + var out := ProjectSettings.globalize_path(OUT) + StudioFiles.remove_tree(out) + var art := out.path_join("art") + var server := LocalServer.new() + if not server.start(): + return ["no port on this computer was free for the test's server"] + OS.set_environment("STUDIO_ART", art) + OS.set_environment("STUDIO_ART_URL", "http://127.0.0.1:%d/%%s/%%s" % server.port) + var guild := LocalServer.pack("guild.zip", 200000) + var helpers := LocalServer.pack("helpers.zip", 100000) + var kept: Dictionary = StudioArt._lock + StudioArt._lock = { "release": "art", "packs": { "guild": guild[1], "helpers": helpers[1], + "orbital": { "asset": "orbital.zip", "size": 50, "sha256": "00".repeat(32) }, "grove": { "asset": "grove.zip", "size": 50, "sha256": "00".repeat(32) } } } + server.serving = { "/art/guild.zip": guild[0], "/art/helpers.zip": helpers[0] } + server.holding = ["/art/grove.zip"] + tree.process_frame.connect(server.poll) + + var main: Node = load("res://src/app/main.gd").new() + tree.root.add_child(main) + var quits := [0] + main.on_quit = func(): quits[0] += 1 + var home: HomePanel = main.home + check(home != null and home.visible and not main.panel.visible, "the window did not open on its first screen") + check(home.world == "guild" and home.art == "missing" and home.art_bytes == 300000, "the first screen does not say guild's art is a 300000 byte download: %s %d" % [home.art, home.art_bytes]) + + home.download_requested.emit("guild") + check(home.art == "downloading", "a download did not show as under way") + check(await TestRunner.until(tree, func(): return not main.download.running, 60.0), "the download never ended") + check(home.art == "ready" and home.received == 300000 and home.total == 300000, "after the download the screen says %s, %d of %d" % [home.art, home.received, home.total]) + + home.world_chosen.emit("orbital") + check(home.world == "orbital" and home.art == "missing" and home.art_bytes == 50, "another world's art is not asked for by itself: %s %d" % [home.art, home.art_bytes]) + home.download_requested.emit("orbital") + check(await TestRunner.until(tree, func(): return not main.download.running, 60.0), "a download the server refuses never ended") + check(home.art == "failed" and "could not be downloaded" in home.art_reason, "a failed download shows as %s: %s" % [home.art, home.art_reason]) + + home.world_chosen.emit("grove") + home.download_requested.emit("grove") + check(home.art == "downloading", "a second download did not start") + home.cancel_requested.emit() + check(home.art == "failed" and "cancelled" in home.art_reason, "a cancelled download shows as %s: %s" % [home.art, home.art_reason]) + check(Array(DirAccess.get_files_at(art)) == ["guild.zip", "helpers.zip"], "a failed or cancelled download left something behind: %s" % [DirAccess.get_files_at(art)]) + + main.pieces.root = OUT + "library/" + main.pieces.refresh() + var agent := PieceLibrary.new() + agent.root = main.pieces.root + agent.default_clips = func(_world, _kind, _crew): return CLIPS + tree.root.add_child(agent) + var id := await agent.add_local(_folder("from-an-agent"), "", {}) + check(await TestRunner.until(tree, func(): return home.pieces.size() == 1, 30.0), "a piece an agent made never appeared in Your pieces") + if home.pieces.size() == 1: + check(home.pieces[0].id == id and home.pieces[0].title == "from-an-agent" and "Claude Code crew" in home.pieces[0].slot, "Your pieces shows %s" % [home.pieces[0]]) + agent.remove(id) + check(await TestRunner.until(tree, func(): return home.pieces.is_empty(), 30.0), "a piece an agent removed stayed in Your pieces") + + var args: Array = main.window_args() + check(not "--mcp" in args and not "--headless" in args and not "--" in args, "the window would start with an agent's options: %s" % [args]) + main.close() + main.close() + check(quits[0] == 1, "closing the window ended Studio %d times" % quits[0]) + + tree.process_frame.disconnect(server.poll) + tree.auto_accept_quit = true + agent.free() + main.free() + server.stop() + StudioArt._lock = kept + OS.set_environment("STUDIO_ART", "") + OS.set_environment("STUDIO_ART_URL", "") + StudioFiles.remove_tree(out) + return failures + +func _folder(name: String) -> String: + var dir := ProjectSettings.globalize_path(OUT + "folders/" + name) + DirAccess.make_dir_recursive_absolute(dir) + FileAccess.open(dir.path_join("piece.json"), FileAccess.WRITE).store_string(JSON.stringify({ "format": 1, "kind": "character", "world": "guild", "crew": "anthropic", "title": name })) + var model := FileAccess.open(dir.path_join("character.glb"), FileAccess.WRITE) + model.store_buffer(FileAccess.get_file_as_bytes(FIXTURES + "character.bin")) + model.close() + return dir diff --git a/tests/window_test.gd.uid b/tests/window_test.gd.uid new file mode 100644 index 0000000..8196ed5 --- /dev/null +++ b/tests/window_test.gd.uid @@ -0,0 +1 @@ +uid://c25nmkgn86hwx