diff --git a/.opencode/agents/developer.md b/.opencode/agents/developer.md index e35edff..bbb3d0a 100644 --- a/.opencode/agents/developer.md +++ b/.opencode/agents/developer.md @@ -17,8 +17,9 @@ You are a Godot 4 / GDScript 2.0 implementation developer. Adhere to coding stan - **Targeted Edits Only:** Do NOT output full unchanged files. Output only modified functions, concise diffs, or specific node configurations. - **Static Typing:** Enforce explicit type hints for all signatures and variables. - **Decoupling:** Follow "call down, signal up". -- **Validation:** Run syntax verification command when finished: - `& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://path/to/script.gd" --path "C:\Godot4\stickman"` +- **Validation:** Run syntax verification when finished (resolve ``/`` per platform — see the "Command-line testing & debug playback" section of `AGENTS.md`): + ` --headless --check-only --script "res://path/to/script.gd" --path ""` +- **Visual/debug verification:** when a runtime/visual bug needs observation, launch the Sandbox Stage with `--play --scene ` (add `--verbose` for richer logs) so you can watch it run and read `print()` output — see `AGENTS.md`. ## Output Format diff --git a/.opencode/skills/spec-pipeline/SKILL.md b/.opencode/skills/spec-pipeline/SKILL.md index 9561436..4be3666 100644 --- a/.opencode/skills/spec-pipeline/SKILL.md +++ b/.opencode/skills/spec-pipeline/SKILL.md @@ -11,9 +11,10 @@ description: "Lean implementation loop: Architect -> Developer -> User Verificat 2. **Phase 2: Implementation & Engine Validation** - `@developer` updates code and scenes using targeted diffs/snippets. - - `@developer` runs headless syntax verification: - `& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://path/to/script.gd" --path "C:\Godot4\project"` + - `@developer` runs headless syntax verification (resolve ``/`` per platform — see the "Command-line testing & debug playback" section of `AGENTS.md`): + ` --headless --check-only --script "res://path/to/script.gd" --path ""` - If static check fails, `@developer` fixes the code immediately. + - **Visual/debug verification (optional):** to actually watch a scene run and read `print()` debug output, launch the Sandbox Stage with `--play --scene ` from the command line (append `--verbose` for engine warnings) — see the "Command-line testing & debug playback" section of `AGENTS.md`. This is faster than relying on headless assertions alone for runtime/visual bugs. 3. **Phase 3: User Hand-off (Stop)** - Present the changes to the user to test inside the Godot Editor. diff --git a/AGENTS.md b/AGENTS.md index 2186831..87a5568 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,9 +2,24 @@ ## Project type - **Godot 4.4** 2D/GUI project (Forward Plus renderer) -- No CLI build/test/lint commands; open in the Godot editor to run +- Headless tests + command-line scene playback exist (see "Command-line testing & debug playback" below); the GUI editor runs via `run/main_scene`. - **Main scene:** `res://scenes/stickman_editor.tscn` (set as `run/main_scene` in `project.godot`) +## Command-line testing & debug playback +- **Godot binary (4.7.1) — resolve per platform.** Commands below use `` as shorthand: + - macOS: `/Applications/Godot.app/Contents/MacOS/Godot` + - Windows (PowerShell): `& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe"` + - `` = the repo root (e.g. `/Users/ryan/Projects/stickman`, or `C:\Godot4\stickman` on Windows). +- **Run a headless test suite** (`tests/*.gd` extend `SceneTree`, print PASS/FAIL, `quit(0/1)`): + ` --headless --path --script res://tests/test_cli_scene.gd` +- **Launch the Sandbox Stage and auto-PLAY a `.scene`** (visual/runtime debugging — `print()` output streams to the launching terminal): + ` --path res://scenes/sandbox_stage.tscn -- --play --scene .scene` + - Args after `--` are parsed by `scripts/cli_scene_launcher.gd` (`CliSceneLauncher.parse`) via `OS.get_cmdline_user_args()`. + - `--scene ` (also `--scene=` or a single bare positional) loads the scene; `--play` enters PLAY after a successful load. + - `--play` requires a scene — alone it shows the error dialog and stays blank/EDIT. + - A load failure shows a "Could Not Load Scene" dialog and falls back to a blank scene. +- **Richer debug logs:** append `--verbose` to the scene-playback (or headless) command to surface engine warnings/errors alongside your own `print()` lines. + ## Project overview Stickman Studio is an editor tool for drawing and assembling stick figures. It is a `Control`-based GUI (not physics/animation) in its current phase. Body-part vector shapes diff --git a/plans/COMMAND_LINE_SCENE.md b/plans/COMMAND_LINE_SCENE.md new file mode 100644 index 0000000..dadb098 --- /dev/null +++ b/plans/COMMAND_LINE_SCENE.md @@ -0,0 +1,296 @@ +# Command-Line `.scene` Loader + PLAY-on-Launch + +Status: proposed — awaiting approval before implementation. + +## 1. Goal and boundaries + +After Phase 5, a tester must be able to launch the standalone Sandbox Stage from the command line with a `.scene` file, have it load that scene, and (optionally) enter PLAY immediately so the scene "just plays" for automated/manual testing. + +Four hard requirements: + +1. A command-line `.scene` loader. +2. A flag to start "Stickman Studio" in PLAY mode. +3. PLAY mode is invalid unless a `.scene` is specified. +4. On scene-load error, show the user a dialog and default to a new (blank) scene. + +This plan targets `res://scenes/sandbox_stage.tscn` (run standalone), its root script `scripts/sandbox_stage.gd`, a new pure parser, and a new headless test. It does **not** change the stickman editor, `main.tscn`, `project.godot`'s `run/main_scene`, or the `.scene` v1.0 JSON schema. + +Constraints that shape the design: + +- There is currently **no** command-line argument parsing anywhere in the repo. +- `SandboxStage._ready()` ends with `_update_scene_chrome()` then `_check_autosave_recovery()`. Startup integration must slot in there. +- `set_mode(StageMode.PLAY)` (line ~607) writes an autosave via `_save_scene_to_path(AUTOSAVE_PATH, true)` **before** `_enter_play_mode()`. A CLI PLAY transition must not create that autosave. +- `_load_scene_from_path(path, is_recovery=false)` surfaces read failures with a **toast**, not a dialog (line ~1284). The CLI path needs a dialog-bearing variant. +- `StageSceneIO.read_file(path)` returns `{ok, data, error, warnings}`; `error` is a `String`. +- `_show_missing_assets_dialog(paths)` (line ~1383) is the existing `AcceptDialog` pattern to reuse. +- The stage starts as a blank Untitled document (`_scene_name = "Untitled"`, `_current_scene_path = ""`, default camera), `current_mode` starts `StageMode.EDIT`. +- Tests live in `tests/*.gd`, extend `SceneTree`, instantiate `sandbox_stage.tscn`, and drive internals directly (see `tests/test_phase5_file_features.gd`). No test autoload exists. + +## 2. Command-line contract + +### 2.1 Flags + +| Flag | Type | Meaning | +| --- | --- | --- | +| `--play` | boolean | After a successful `--scene` load, enter `StageMode.PLAY`. | +| `--scene ` | value (space-separated) | The `.scene` file to load. | +| `--scene=` | value (equals form) | Same, `=` separated. | + +A **bare positional** token that is not `--`-prefixed is accepted as a convenience scene path, but **only when `--scene` is absent**. Supplying both `--scene` and a positional path is a parse error (avoid silent ambiguity). + +### 2.2 Godot separator mechanics + +Godot consumes its own arguments up to a `--` separator; everything **after** `--` is exposed to the script via `OS.get_cmdline_user_args()`. To also run the sandbox stage instead of the project main scene, the stage scene is passed **positionally before** `--`: + +``` +Godot --path res://scenes/sandbox_stage.tscn -- +``` + +`OS.get_cmdline_user_args()` then returns exactly `["--play", "--scene", "/abs/foo.scene"]` (the positional scene path is **not** in the user-args array — it is a Godot argument). + +### 2.3 Exact launch commands (macOS binary per project facts) + +Primary tester flow — open and PLAY: + +```bash +/Applications/Godot.app/Contents/MacOS/Godot \ + --path /Users/ryan/Projects/stickman \ + res://scenes/sandbox_stage.tscn \ + -- --play --scene /Users/ryan/Projects/stickman/stickmen/test.scene +``` + +Equivalent forms: + +```bash +# --scene= form +.../Godot --path /Users/ryan/Projects/stickman res://scenes/sandbox_stage.tscn -- --play --scene=res://my_playground.scene + +# bare positional scene path (no --scene) +.../Godot --path /Users/ryan/Projects/stickman res://scenes/sandbox_stage.tscn -- --play /Users/ryan/Projects/stickman/my_playground.scene + +# load into EDIT (no PLAY) +.../Godot --path /Users/ryan/Projects/stickman res://scenes/sandbox_stage.tscn -- --scene res://my_playground.scene +``` + +Windows equivalent (same tokens, project's established console binary): + +```powershell +& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --path "C:\Godot4\stickman" res://scenes/sandbox_stage.tscn -- --play --scene "C:\Godot4\stickman\stickmen\test.scene" +``` + +### 2.4 Path handling + +- `res://…`, `user://…`, and absolute paths are passed through unchanged; `StageSceneIO.read_file` / `FileAccess` already handle all three (the stage already stores both `res://` and absolute paths in recents). +- The parser performs **no** scheme normalization. A bare relative path (no scheme, not absolute) is resolved by Godot against the process CWD and is therefore fragile; the documented and recommended forms are `res://` (project-relative) or absolute. This keeps the parser pure and testable. + +## 3. Files and classes + +| File | Change | Responsibility | +| --- | --- | --- | +| `scripts/cli_scene_launcher.gd` | **new** `class_name CliSceneLauncher`, `extends RefCounted` | Pure argument parser; no scene-tree or OS access. Returns a structured result. | +| `scripts/sandbox_stage.gd` | modified | Startup integration, the CLI load/error-dialog path, the autosave-suppress guard, and the new error dialog in `_build_ui()`. | +| `tests/test_cli_scene.gd` | **new** | Headless parser + startup-integration coverage. | + +No changes to `StageSceneIO`, `stickman_editor.tscn`, `main.tscn`, `project.godot`, or the `.scene` v1.0 schema. No authored `.tscn` node changes in `sandbox_stage.tscn` (the error dialog is code-built like the other file dialogs). + +### 3.1 `CliSceneLauncher` API contract + +```gdscript +class_name CliSceneLauncher +extends RefCounted + +static func parse(args: Array) -> Dictionary +# -> { "scene_path": String, "play": bool, "error": String } +# scene_path = "" when no scene requested +# play = false unless --play was present +# error = "" when valid, else a human-readable message +``` + +Parsing rules (documented so tests assert them): + +1. Scan tokens left to right. +2. `--play` → `play = true`. +3. `--scene` → consume the next token as the scene path; missing/empty value → error. +4. `--scene=` → scene path; empty value → error. +5. A non-`--`-prefixed token → positional scene path; a second positional → error. +6. Both `--scene` and a positional path → error (`"Specify the scene with either --scene or a single positional path."`). +7. Any other `--`-prefixed token → error (`"Unknown option: …"`). +8. After the scan: `play == true` with `scene_path == ""` → error (`"--play requires --scene ."`). + +The parser enforces requirement 3 (PLAY requires a scene) so the stage can treat any `error` uniformly. + +### 3.2 `sandbox_stage.gd` additions + +```gdscript +var _cli_error_dialog: AcceptDialog = null # code-built in _build_ui() +var _cli_startup: bool = false # scoped suppress of autosave-on-Play + +func _handle_cli_startup(args: Array) -> void # entry point from _ready() +func _load_cli_scene(path: String) -> Dictionary # -> { "ok": bool, "error": String } +func _show_cli_error_dialog(message: String) -> void +``` + +`_load_cli_scene` reuses `StageSceneIO.read_file` + `_apply_scene_data(data, path, false)` (the ordinary clean-load path), but returns the read error instead of toasting, so the caller can surface it in a dialog: + +```gdscript +func _load_cli_scene(path: String) -> Dictionary: + var read: Dictionary = STAGE_SCENE_IO.read_file(path) + if not bool(read.get("ok", false)): + return {"ok": false, "error": String(read.get("error", "Could not load scene."))} + if not _apply_scene_data(read["data"], path, false): + return {"ok": false, "error": "Could not apply scene data."} + return {"ok": true, "error": ""} +``` + +The existing `_load_scene_from_path` toast path is left untouched for interactive Open. + +## 4. Startup flow integration into `_ready()` + +Replace the final `_check_autosave_recovery()` line in `_ready()` with a single `_handle_cli_startup(OS.get_cmdline_user_args())` call (which internally owns recovery for the no-`--scene` case): + +```gdscript +# _ready() tail (current -> new) +_update_scene_chrome() +_check_autosave_recovery() # removed from _ready() body +# becomes: +_update_scene_chrome() +_handle_cli_startup(OS.get_cmdline_user_args()) +``` + +`_handle_cli_startup(args)` flow: + +```text +result := CliSceneLauncher.parse(args) + +if result.error != "": + _show_cli_error_dialog(result.error) + _check_autosave_recovery() # invalid CLI -> normal blank-session recovery + return # stay EDIT / blank + +if result.scene_path.is_empty(): + _check_autosave_recovery() # no --scene -> unchanged behavior + return + +# Explicit --scene: retire autosave and skip recovery (explicit file intent wins). +if FileAccess.file_exists(AUTOSAVE_PATH): DirAccess.remove_absolute(AUTOSAVE_PATH) + +load := _load_cli_scene(result.scene_path) +if not load.ok: + _show_cli_error_dialog(load.error) + return # blank/Untitled stage remains + +if result.play: + _cli_startup = true # suppress the autosave-on-Play write + set_mode(StageMode.PLAY) + _cli_startup = false +``` + +### 4.1 Ordering with autosave recovery (decision) + +When `--scene` is explicitly given, `_check_autosave_recovery()` is **skipped** and the autosave file is **retired (deleted)** before the CLI load. Justification: + +- Explicit user intent ("open *this* file, and play it") must win over an offer to recover a previous interrupted session; popping the recovery dialog over a freshly CLI-loaded scene is both wrong and confusing. +- `_apply_scene_data(..., is_recovery=false)` already deletes the autosave on success, but the **failure** path never reaches that line; deleting upfront covers both outcomes so a stale autosave cannot prompt on a later launch. +- When no `--scene` is given (including the invalid `--play`-only case), recovery is unchanged: `_check_autosave_recovery()` still runs, preserving the existing crash-recovery lifecycle for ordinary launches. + +### 4.2 `set_mode` autosave guard + +```gdscript +# set_mode(), current: +if mode == StageMode.PLAY: + if not _save_scene_to_path(AUTOSAVE_PATH, true): ... +# becomes: +if mode == StageMode.PLAY and not _cli_startup: + if not _save_scene_to_path(AUTOSAVE_PATH, true): ... +``` + +`_cli_startup` is a scoped boolean set only around the CLI `set_mode(StageMode.PLAY)` call. This prevents the CLI PLAY transition from writing `user://sandbox_autosave.scene` (which would otherwise be newer than its own source file and trigger a false recovery prompt on the next launch). It does not change the interactive Play button path, and it still routes the transition through the public `set_mode()` so mode bookkeeping (`_sync_mode_buttons`, `_apply_mode_frame`, `_refresh_mode_badge`, `mode_changed`) runs exactly as for a manual Play press. + +## 5. Error-dialog and fallback-to-new-scene behavior + +A new `AcceptDialog` `_cli_error_dialog` is built in `_build_ui()` alongside `_missing_assets_dialog`: + +```text +SandboxStage/UI +└── CliErrorDialog (AcceptDialog) + title: "Could Not Load Scene" + name: "CliErrorDialog" +``` + +`_show_cli_error_dialog(message)` sets `dialog_text = message` and calls `popup_centered()`, mirroring `_show_missing_assets_dialog`. It is a **blocking** dialog (not the transient toast), satisfying requirement 4. + +After the dialog is dismissed, the stage is left exactly as a brand-new document: `Untitled`, empty `World`, default camera, `current_mode == EDIT`. No PLAY is entered. This is the "default to a new (blank) scene" guarantee. + +The error dialog is also used for the `--play`-without-`--scene` case (requirement 3), with the parser's error text (e.g. `"--play requires --scene ."`). + +## 6. `--play` validity rules + +| CLI input | Behavior | +| --- | --- | +| `--scene ` | Load the scene, remain EDIT. Title bar shows the loaded scene. | +| `--play --scene ` | Load the scene, then `set_mode(StageMode.PLAY)` (autosave suppressed). | +| `--play` (no `--scene`) | Parser error → `CliErrorDialog`, remain EDIT/blank. **Never** auto-enter PLAY. | +| `--scene ` | `CliErrorDialog` with the read error, remain EDIT/blank. | +| unknown flag / two positional paths / `--scene`+positional | Parser error → `CliErrorDialog`, remain EDIT/blank. | + +## 7. Dirty / recent state + +A CLI load uses the **normal clean-load** semantics (`_apply_scene_data(..., is_recovery=false)`): it adopts `_current_scene_path`, clears `_dirty`, adds the path to recents (`_add_recent_scene`), removes the autosave, and updates the title. This is what makes the title bar show `Scene Name — .scene — Stickman Studio` and keeps the Open Recent menu consistent with the launched file. No special "read-only" or "dirty-on-load" CLI state is introduced. + +## 8. Verification strategy + +### 8.1 Headless test — `tests/test_cli_scene.gd` + +Run with the project's established headless form (macOS): + +```bash +/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/ryan/Projects/stickman --script res://tests/test_cli_scene.gd +``` + +Required assertions: + +1. **Parser (pure, no scene tree):** `CliSceneLauncher.parse([])` → `{scene_path:"", play:false, error:""}`; `["--play","--scene","res://x.scene"]` → `{scene_path:"res://x.scene", play:true, error:""}`; `["--scene=res://x.scene"]` → scene path set; bare positional `["/abs/x.scene","--play"]` → scene path + play; `["--play"]` → error; `["--scene"]` (missing value) → error; `["--bogus"]` → error; two positionals → error; `--scene` + positional → error. +2. **Load:** instantiate `sandbox_stage.tscn`, write a small valid `.scene` fixture to `user://`, call `_handle_cli_startup(["--scene", fixture])`, assert `_current_scene_path == fixture`, `World` child count matches, `_dirty == false`, `_recent_scenes[0] == fixture`, title contains the scene basename, and the autosave file is absent. +3. **PLAY:** `_handle_cli_startup(["--play","--scene", fixture])` → `current_mode == StageMode.PLAY` and `user://sandbox_autosave.scene` does **not** exist afterward (autosave-on-Play suppressed). +4. **Error → blank:** `_handle_cli_startup(["--scene", "user://definitely_missing_cli.scene"])` → `_cli_error_dialog` visible with non-empty `dialog_text`, `World.get_child_count() == 0`, `current_mode == StageMode.EDIT`. +5. **`--play` without scene:** `_handle_cli_startup(["--play"])` → `_cli_error_dialog` visible, `current_mode == StageMode.EDIT`, never PLAY. +6. **Recovery skipped:** pre-write a valid `user://sandbox_autosave.scene` (autosave metadata), call `_handle_cli_startup(["--scene", fixture])`, assert the autosave file is removed and no `AutosaveRecoveryDialog` was created. + +Note the repo gotcha (`docs/tech_debt_and_optimizations.md` #22): a newly added `class_name CliSceneLauncher` is not resolvable by `--check-only --script` until an `--editor` pass regenerates `.godot/global_script_class_cache.cfg`. Run once first: + +```bash +/Applications/Godot.app/Contents/MacOS/Godot --headless --editor --path /Users/ryan/Projects/stickman --quit +``` + +Then a parse check: + +```bash +/Applications/Godot.app/Contents/MacOS/Godot --headless --check-only --script res://scripts/cli_scene_launcher.gd --path /Users/ryan/Projects/stickman --quit +``` + +After the focused suite passes, re-run every existing script under `tests/` using its documented headless command and report suite names/results. + +### 8.2 Manual Godot Editor verification + +Run the exact launch commands from §2.3 in a terminal (not F6) and confirm: + +- `--scene res://.scene` opens the scene in EDIT with the correct title and Open Recent entry. +- `--play --scene res://.scene` opens and immediately plays; the Play mode badge shows `▶️ SIMULATING`; no recovery prompt appears on the following ordinary launch. +- `--play` alone shows the "Could Not Load Scene" dialog with `"--play requires --scene ."`, leaves a blank Untitled stage, and does not enter PLAY. +- `--scene /nonexistent.scene` shows the dialog with the read error and leaves a blank Untitled stage. +- A pre-existing `user://sandbox_autosave.scene` is removed when launching with `--scene` (explicit file intent overrides recovery). + +## 9. Approval gate: decisions to accept before implementation + +The architect recommends approving these as a set: + +1. **Flag syntax** — `--play` (boolean) and `--scene ` (with `--scene=` and a single bare-positional convenience), parsed from `OS.get_cmdline_user_args()`; the parser enforces "PLAY requires a scene" as an error. Paths must be `res://`/`user://`/absolute (no scheme guessing). +2. **Autosave policy** — an explicit `--scene` skips `_check_autosave_recovery()` and retires the autosave file; the CLI PLAY transition suppresses the autosave-on-Play write via a scoped `_cli_startup` guard (leaving the interactive Play button untouched). +3. **Failure semantics** — on any CLI parse error or `--scene` load failure, show a blocking `AcceptDialog` (`CliErrorDialog`) and remain on a blank EDIT/Untitled stage; never auto-enter PLAY. A successful CLI load uses normal clean-load semantics (adopts path, clears dirty, updates recents/title). + +## 10. Out of scope + +- Changes to `main.tscn`, `stickman_editor.tscn`, `project.godot` `run/main_scene`, or the `.scene` v1.0 JSON schema. +- Reusing the CLI loader to launch the stickman **editor** (`.stk`) — this targets the Sandbox Stage only. +- `--headless`-only automation mode, output/logging flags, or a `--quit-after` flag. +- Argument path normalization (scheme guessing), environment-variable overrides, or multiple-scene batch loading. diff --git a/scripts/cli_scene_launcher.gd b/scripts/cli_scene_launcher.gd new file mode 100644 index 0000000..9a926d5 --- /dev/null +++ b/scripts/cli_scene_launcher.gd @@ -0,0 +1,47 @@ +class_name CliSceneLauncher +extends RefCounted +## Pure command-line argument parser for the Sandbox Stage scene loader. +## No scene-tree or OS access, so it is directly unit-testable. Parses +## OS.get_cmdline_user_args() output into a structured {scene_path, play, error} +## result; the caller (SandboxStage) is responsible for the error dialog and +## autosave policy. + +static func parse(args: Array) -> Dictionary: + var scene_path := "" + var play := false + var used_scene_flag := false + var used_positional := false + var error := "" + var i := 0 + while i < args.size(): + var token := String(args[i]) + if token == "--play": + play = true + elif token == "--scene": + used_scene_flag = true + if i + 1 >= args.size(): + return {"scene_path": "", "play": play, "error": "--scene requires a path."} + var value := String(args[i + 1]) + if value.is_empty(): + return {"scene_path": "", "play": play, "error": "--scene requires a path."} + scene_path = value + i += 1 + elif token.begins_with("--scene="): + used_scene_flag = true + var value := token.substr("--scene=".length()) + if value.is_empty(): + return {"scene_path": "", "play": play, "error": "--scene requires a path."} + scene_path = value + elif token.begins_with("--"): + return {"scene_path": "", "play": play, "error": "Unknown option: %s" % token} + else: + if used_positional: + return {"scene_path": "", "play": play, "error": "Specify the scene with either --scene or a single positional path."} + used_positional = true + scene_path = token + i += 1 + if used_scene_flag and used_positional: + error = "Specify the scene with either --scene or a single positional path." + elif play and scene_path.is_empty(): + error = "--play requires --scene ." + return {"scene_path": scene_path, "play": play, "error": error} diff --git a/scripts/cli_scene_launcher.gd.uid b/scripts/cli_scene_launcher.gd.uid new file mode 100644 index 0000000..2b5a5ae --- /dev/null +++ b/scripts/cli_scene_launcher.gd.uid @@ -0,0 +1 @@ +uid://dmoyg7nq46v07 diff --git a/scripts/sandbox_stage.gd b/scripts/sandbox_stage.gd index 041f0e9..fb9f3fd 100644 --- a/scripts/sandbox_stage.gd +++ b/scripts/sandbox_stage.gd @@ -198,6 +198,8 @@ var _unsaved_changes_dialog: ConfirmationDialog = null var _rename_scene_dialog: ConfirmationDialog = null var _scene_name_edit: LineEdit = null var _missing_assets_dialog: AcceptDialog = null +var _cli_error_dialog: AcceptDialog = null +var _cli_startup: bool = false var _panning: bool = false var _pan_last: Vector2 = Vector2.ZERO @@ -423,7 +425,7 @@ func _ready() -> void: _apply_cursor() _refresh_status() _update_scene_chrome() - _check_autosave_recovery() + _handle_cli_startup(OS.get_cmdline_user_args()) func _process(delta: float) -> void: @@ -608,7 +610,7 @@ func _viewport_to_world(viewport_pos: Vector2) -> Vector2: func set_mode(mode: StageMode) -> void: if mode == current_mode: return - if mode == StageMode.PLAY: + if mode == StageMode.PLAY and not _cli_startup: if not _save_scene_to_path(AUTOSAVE_PATH, true): push_warning("SandboxStage: autosave before Play failed.") current_mode = mode @@ -1459,6 +1461,44 @@ func _check_autosave_recovery() -> void: get_node(NodePath("UI")).add_child(dialog); dialog.popup_centered() +func _handle_cli_startup(args: Array) -> void: + var result: Dictionary = CliSceneLauncher.parse(args) + if not String(result.get("error", "")).is_empty(): + _show_cli_error_dialog(String(result["error"])) + _check_autosave_recovery() + return + var scene_path := String(result.get("scene_path", "")) + if scene_path.is_empty(): + _check_autosave_recovery() + return + if FileAccess.file_exists(AUTOSAVE_PATH): + DirAccess.remove_absolute(AUTOSAVE_PATH) + var load: Dictionary = _load_cli_scene(scene_path) + if not bool(load.get("ok", false)): + _show_cli_error_dialog(String(load.get("error", "Could not load scene."))) + return + if bool(result.get("play", false)): + _cli_startup = true + set_mode(StageMode.PLAY) + _cli_startup = false + + +func _load_cli_scene(path: String) -> Dictionary: + var read: Dictionary = STAGE_SCENE_IO.read_file(path) + if not bool(read.get("ok", false)): + return {"ok": false, "error": String(read.get("error", "Could not load scene."))} + if not _apply_scene_data(read["data"], path, false): + return {"ok": false, "error": "Could not apply scene data."} + return {"ok": true, "error": ""} + + +func _show_cli_error_dialog(message: String) -> void: + if _cli_error_dialog == null: + return + _cli_error_dialog.dialog_text = message + _cli_error_dialog.popup_centered() + + func _notification(what: int) -> void: if what == NOTIFICATION_WM_CLOSE_REQUEST: _request_pending_file_action(PendingFileAction.QUIT) @@ -1911,6 +1951,11 @@ func _build_ui() -> void: _missing_assets_dialog.title = "Missing Scene Assets" _missing_assets_dialog.min_size = Vector2i(560, 0) ui.add_child(_missing_assets_dialog) + _cli_error_dialog = AcceptDialog.new() + _cli_error_dialog.name = "CliErrorDialog" + _cli_error_dialog.title = "Could Not Load Scene" + _cli_error_dialog.min_size = Vector2i(560, 0) + ui.add_child(_cli_error_dialog) _unsaved_changes_dialog = ConfirmationDialog.new() _unsaved_changes_dialog.name = "UnsavedChangesDialog"; _unsaved_changes_dialog.title = "Unsaved Changes"; _unsaved_changes_dialog.dialog_text = "Save changes before continuing?"; _unsaved_changes_dialog.ok_button_text = "Save" _unsaved_changes_dialog.add_button("Discard", false, "discard") diff --git a/tests/test_cli_scene.gd b/tests/test_cli_scene.gd new file mode 100644 index 0000000..6a5b4bd --- /dev/null +++ b/tests/test_cli_scene.gd @@ -0,0 +1,166 @@ +extends SceneTree + +const IO := preload("res://scripts/stage_scene_io.gd") +const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn") + +const FIXTURE_PATH := "user://cli_fixture.scene" + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _cleanup() + _test_parser() + await _test_load_into_edit() + await _test_play_mode() + await _test_missing_scene_error() + await _test_play_without_scene() + await _test_recovery_skipped() + _cleanup() + print("----------------------------------------") + if _failures == 0: + print("RESULT: ALL PASSED (%d assertions)" % _checks) + quit(0) + else: + print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks]) + quit(1) + + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if condition: + print("PASS: " + message) + else: + _failures += 1 + push_error("CLI scene test failed: " + message) + + +func _cleanup() -> void: + if FileAccess.file_exists(FIXTURE_PATH): + DirAccess.remove_absolute(FIXTURE_PATH) + if FileAccess.file_exists("user://sandbox_autosave.scene"): + DirAccess.remove_absolute("user://sandbox_autosave.scene") + + +func _write_fixture() -> void: + var fixture := { + "version": "1.0", + "scene_name": "CLI Fixture", + "camera": {"position": {"x": 0.0, "y": -400.0}, "zoom": 0.5}, + "objects": [{ + "file_id": "terrain_1", + "type": "terrain", + "position": {"x": 20.0, "y": 30.0}, + "rotation": 0.0, + "asset_id": "ground", + "scale": {"x": 1.0, "y": 1.0}, + }], + "event_rules": [], + "metadata": {}, + } + IO.write_file_safe(FIXTURE_PATH, fixture) + + +func _make_stage() -> Node2D: + var stage: Node2D = STAGE_SCENE.instantiate() + root.add_child(stage) + return stage + + +func _test_parser() -> void: + var empty: Dictionary = CliSceneLauncher.parse([]) + _expect(String(empty["scene_path"]) == "" and not bool(empty["play"]) and String(empty["error"]) == "", "empty args parse clean") + var full: Dictionary = CliSceneLauncher.parse(["--play", "--scene", "res://x.scene"]) + _expect(String(full["scene_path"]) == "res://x.scene" and bool(full["play"]) and String(full["error"]) == "", "--play --scene sets both") + var eq: Dictionary = CliSceneLauncher.parse(["--scene=res://x.scene"]) + _expect(String(eq["scene_path"]) == "res://x.scene" and not bool(eq["play"]), "--scene= form sets path") + var positional: Dictionary = CliSceneLauncher.parse(["/abs/x.scene", "--play"]) + _expect(String(positional["scene_path"]) == "/abs/x.scene" and bool(positional["play"]), "bare positional plus --play") + var play_only: Dictionary = CliSceneLauncher.parse(["--play"]) + _expect(not String(play_only["error"]).is_empty(), "--play alone is an error") + var missing_value: Dictionary = CliSceneLauncher.parse(["--scene"]) + _expect(not String(missing_value["error"]).is_empty(), "--scene without value is an error") + var unknown: Dictionary = CliSceneLauncher.parse(["--bogus"]) + _expect(not String(unknown["error"]).is_empty(), "unknown option is an error") + var two_pos: Dictionary = CliSceneLauncher.parse(["a.scene", "b.scene"]) + _expect(not String(two_pos["error"]).is_empty(), "two positionals are an error") + var flag_and_pos: Dictionary = CliSceneLauncher.parse(["--scene", "a.scene", "b.scene"]) + _expect(not String(flag_and_pos["error"]).is_empty(), "--scene plus positional is an error") + + +func _test_load_into_edit() -> void: + _write_fixture() + var stage: Node2D = _make_stage() + await process_frame + stage._handle_cli_startup(["--scene", FIXTURE_PATH]) + _expect(stage._current_scene_path == FIXTURE_PATH, "load adopts the scene path") + _expect(stage._world.get_child_count() == 1, "load reconstructs one object") + _expect(not stage._dirty, "load clears dirty") + _expect(stage._recent_scenes.size() > 0 and stage._recent_scenes[0] == FIXTURE_PATH, "load updates Open Recent") + _expect("cli_fixture.scene" in stage.get_window().title, "title shows the loaded scene file") + _expect(not FileAccess.file_exists(stage.AUTOSAVE_PATH), "clean load leaves no autosave") + stage.queue_free() + await process_frame + + +func _test_play_mode() -> void: + _write_fixture() + var stage: Node2D = _make_stage() + await process_frame + stage._handle_cli_startup(["--play", "--scene", FIXTURE_PATH]) + _expect(stage.current_mode == stage.StageMode.PLAY, "--play enters PLAY mode") + _expect(not FileAccess.file_exists(stage.AUTOSAVE_PATH), "CLI PLAY suppresses the autosave write") + stage.queue_free() + await process_frame + + +func _test_missing_scene_error() -> void: + var stage: Node2D = _make_stage() + await process_frame + stage._handle_cli_startup(["--scene", "user://definitely_missing_cli.scene"]) + _expect(stage._cli_error_dialog != null and stage._cli_error_dialog.visible, "missing scene shows the error dialog") + _expect(not stage._cli_error_dialog.dialog_text.is_empty(), "missing scene error has dialog text") + _expect(stage._world.get_child_count() == 0, "missing scene leaves a blank stage") + _expect(stage.current_mode == stage.StageMode.EDIT, "missing scene stays in EDIT") + stage._cli_error_dialog.hide() + stage.queue_free() + await process_frame + + +func _test_play_without_scene() -> void: + var stage: Node2D = _make_stage() + await process_frame + stage._handle_cli_startup(["--play"]) + _expect(stage._cli_error_dialog != null and stage._cli_error_dialog.visible, "--play without scene shows the error dialog") + _expect(stage._cli_error_dialog.dialog_text == "--play requires --scene .", "--play error text matches the parser contract") + _expect(stage.current_mode == stage.StageMode.EDIT, "--play without scene never enters PLAY") + stage._cli_error_dialog.hide() + stage.queue_free() + await process_frame + + +func _test_recovery_skipped() -> void: + var stage: Node2D = _make_stage() + await process_frame + _write_fixture() + var autosave := { + "version": "1.0", + "scene_name": "Stale Autosave", + "objects": [], + "event_rules": [], + "metadata": {"autosave": true, "source_path": "", "modified_unix": Time.get_unix_time_from_system()}, + } + IO.write_file_safe(stage.AUTOSAVE_PATH, autosave) + _expect(FileAccess.file_exists(stage.AUTOSAVE_PATH), "autosave fixture pre-written") + stage._handle_cli_startup(["--scene", FIXTURE_PATH]) + _expect(not FileAccess.file_exists(stage.AUTOSAVE_PATH), "explicit --scene retires the autosave") + var ui: Node = stage.get_node(NodePath("UI")) + _expect(ui.get_node_or_null(NodePath("AutosaveRecoveryDialog")) == null, "no recovery dialog is created for explicit --scene") + _expect(stage._current_scene_path == FIXTURE_PATH, "explicit --scene loads the requested scene") + stage.queue_free() + await process_frame diff --git a/tests/test_cli_scene.gd.uid b/tests/test_cli_scene.gd.uid new file mode 100644 index 0000000..af43b10 --- /dev/null +++ b/tests/test_cli_scene.gd.uid @@ -0,0 +1 @@ +uid://bn0a3hoywsidc