Files
stickman/docs/archive/plans/COMMAND_LINE_SCENE.md

297 lines
17 KiB
Markdown

# 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 <path>` | value (space-separated) | The `.scene` file to load. |
| `--scene=<path>` | 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 <project> res://scenes/sandbox_stage.tscn -- <user args>
```
`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=<path>` → 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 <path>."`).
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 <path>."`).
## 6. `--play` validity rules
| CLI input | Behavior |
| --- | --- |
| `--scene <valid>` | Load the scene, remain EDIT. Title bar shows the loaded scene. |
| `--play --scene <valid>` | Load the scene, then `set_mode(StageMode.PLAY)` (autosave suppressed). |
| `--play` (no `--scene`) | Parser error → `CliErrorDialog`, remain EDIT/blank. **Never** auto-enter PLAY. |
| `--scene <missing/corrupt>` | `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 — <file>.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://<file>.scene` opens the scene in EDIT with the correct title and Open Recent entry.
- `--play --scene res://<file>.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 <path>."`, 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 <path>` (with `--scene=<path>` 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.