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

507 lines
29 KiB
Markdown

# Phase 5 — Sandbox Scene File Features
Status: implemented and verified in Godot/headless Phase 5 coverage.
## 1. Goal and boundaries
Phase 5 gives the standalone Sandbox Stage Builder durable, versioned scene files. It adds New, Open, Open Recent, Save, Save As, Rename Scene, dirty-state prompts, an autosave-on-Play recovery path, and tolerant handling of missing `.stk` assets.
This plan targets `res://scenes/sandbox_stage.tscn`, which is run separately with F6. It does not change the stickman editor's existing `.stk` Save/Load flow or the project's main scene.
Current architecture facts that constrain the design:
- `SandboxStage` builds its entire UI in `scripts/sandbox_stage.gd`; the `.tscn` contains only `SandboxStage/Camera2D` and `SandboxStage/World`.
- There is no existing sandbox File menu or Clear command. Phase 5 creates the menu; it does not reuse a Clear flow.
- All authored objects are direct children of `World`. `NavigationRegion2D`, gizmos, overlays, ragdoll bodies, and collision proxies are not scene-file objects.
- `_authored` is the source of the edit pose restored after Play. Saving must use that authored transform rather than a prop's or rig's transient physics transform. Currently `_on_transform_committed()` refreshes `_authored` only for `RigidBody2D`; Phase 5 must move `_save_object_state(node)` outside that type branch so transformed terrain, areas, and stickmen are also authoritative.
- Runtime rules store `Object.get_instance_id()` integers in `trigger.source`, `trigger.target`, and rule-action `target`. Those IDs cannot be written as persistent references.
- Queue and rule dictionaries contain `Vector2` values. They require an explicit JSON conversion; raw runtime dictionaries are not the on-disk contract.
- `TerrainBlock.spawn_id` records its source template. `PropBlock` does not currently record its prop-library ID, and `StickmanRig` does not record its source `.stk` path. Phase 5 must add that provenance at spawn time.
## 2. Files and classes
| File | Planned responsibility |
| --- | --- |
| `scripts/stage_scene_io.gd` (new `class_name StageSceneIO`, `extends RefCounted`) | Own the v1.0 JSON contract, JSON-safe action/rule conversion, validation, migration dispatch, and failure-safe disk reads/writes. It does not mutate the live tree. |
| `scripts/sandbox_stage.gd` | Own file-menu/dialog flow, dirty state, pending destructive actions, live-tree collection/application, runtime-ID mapping, recents, autosave lifecycle, title/status updates, and one final navigation rebuild. |
| `scripts/stage_spawner.gd` | Record asset provenance and expose load-safe spawn methods whose positions are actual object origins, not placement-cursor coordinates. |
| `scripts/prop_block.gd` | Add `var spawn_id: String = ""`. |
| `scripts/stickman_rig.gd` | Add `var source_stk_path: String = ""`. This is provenance only and does not change runtime rig behavior. |
| `scripts/missing_stickman_placeholder.gd` (new `MissingStickmanPlaceholder`, `extends Node2D`) | Draw and expose selection bounds for a missing-stickman marker while retaining the original stickman file entry. |
| `tests/test_phase5_file_features.gd` (new) | Headless codec, round-trip, mapping, dirty-state, missing-asset, and autosave lifecycle coverage. |
No authored node changes are required in `scenes/sandbox_stage.tscn`; the new controls remain code-built to match the current stage.
## 3. Versioned `.scene` contract
The extension is `.scene`. The content is UTF-8, tab-indented JSON. v1.0 uses only JSON primitives, arrays, and dictionaries.
### 3.1 Root schema
```json
{
"version": "1.0",
"scene_name": "Playground",
"camera": {
"position": { "x": 0.0, "y": -400.0 },
"zoom": 0.5
},
"objects": [],
"event_rules": [],
"metadata": {
"created_unix": 1789420000,
"modified_unix": 1789420300,
"autosave": false,
"source_path": ""
}
}
```
- `version` is required and must equal a supported version after migration.
- `scene_name` is a non-empty string after trimming; invalid or empty input becomes `"Untitled"`.
- `camera` is optional. Missing/invalid values use position `(0, -400)` and uniform zoom `0.5`; loaded zoom is clamped to the stage's `min_zoom`/`max_zoom`.
- `objects` and `event_rules` are required arrays. Unknown root and entry keys are ignored and are not preserved on the next save.
- `created_unix` is retained across manual saves. `modified_unix` is refreshed for each written snapshot. Both are UTC Unix seconds.
- Manual files use `autosave: false` and an empty `source_path`. An autosave uses `autosave: true` and stores the manual scene path, if any, in `source_path`.
All positions are stage-world coordinates. All rotations are degrees in the file and radians at runtime. All vectors use `{ "x": number, "y": number }`.
### 3.2 Object IDs and ordering
Each entry has a required, non-empty `file_id` string. IDs must be unique across the whole `objects` array; a duplicate entry is rejected before anything is spawned or inserted into the reference map. On each save, objects are walked in current `World` child order and assigned readable IDs with per-type counters: `terrain_1`, `prop_1`, `stickman_1`, `area_1`. IDs are stable within that snapshot, not permanent node identities.
The encoder simultaneously builds `runtime instance ID -> file_id`. The loader spawns objects in saved array order and builds `file_id -> runtime instance ID`. Navigation does not require terrain-first spawning, so preserving file order avoids an unnecessary World-order change; navigation is rebuilt once after all objects and rules are applied.
### 3.3 Object entries
Terrain:
```json
{
"file_id": "terrain_1",
"type": "terrain",
"asset_id": "ground",
"position": { "x": 0.0, "y": 0.0 },
"rotation": 0.0,
"scale": { "x": 1.0, "y": 1.0 }
}
```
Prop:
```json
{
"file_id": "prop_1",
"type": "prop",
"asset_id": "crate",
"position": { "x": 100.0, "y": -40.0 },
"rotation": 12.0,
"scale": { "x": 1.0, "y": 1.0 },
"material_preset": "WOOD"
}
```
Stickman:
```json
{
"file_id": "stickman_1",
"type": "stickman",
"stk_path": "res://stickmen/test.stk",
"position": { "x": 0.0, "y": -385.0 },
"rotation": 0.0,
"facing_profile": "FORWARD",
"action_queue": [
{ "type": "walk_to", "target": { "x": 300.0, "y": 0.0 } }
]
}
```
Trigger area:
```json
{
"file_id": "area_1",
"type": "area",
"position": { "x": 500.0, "y": 0.0 },
"rotation": 0.0,
"scale": { "x": 1.0, "y": 1.0 },
"size": { "x": 160.0, "y": 80.0 }
}
```
Rules:
- Terrain `asset_id` comes from `TerrainBlock.spawn_id`.
- Prop `asset_id` comes from the new `PropBlock.spawn_id`. `material_preset` is saved as the stable enum name `"NONE"`, `"WOOD"`, `"RUBBER"`, `"CARDBOARD"`, or `"METAL"` rather than its reorderable integer, even though current library templates also provide it.
- `stk_path` is the exact normalized source used to spawn the rig: keep `res://` paths project-relative and keep external paths absolute. Scene files do not embed `.stk` data in Phase 5.
- Stickman `position`, `rotation`, and `facing_profile` come from `_authored` when available, not the live Play pose. Root scale is intentionally not stored for stickmen because `StickmanRig.set_facing_profile(LEFT)` derives the root X mirror. Reapplying a serialized mirror would double-flip the rig.
- Terrain, prop, and area scale are saved. The present gizmo does not edit scale, but transformed areas already exist in tests and must round-trip correctly.
- Only known stage object classes are serialized. Ghosts, overlays, ragdoll bodies, navigation nodes, collision proxies, and unknown World children are skipped with a warning.
### 3.4 Queue-action schema
Queue actions preserve their existing flat runtime shape, with vectors converted to JSON dictionaries:
```text
walk_to -> { "type": "walk_to", "target": {x, y} }
speak -> { "type": "speak", "text": String, "duration": float, optional "stop_current": bool }
wait -> { "type": "wait", "duration": float }
ragdoll -> { "type": "ragdoll" }
recover -> { "type": "recover" }
```
Order is preserved. `StageSceneIO` validates types against `ActionRegistry`; malformed or unknown actions are skipped with warnings instead of reaching the runner.
### 3.5 Event-rule schema and reference boundary
The disk rule deliberately differs from the live rule at reference fields:
```json
{
"id": 4,
"trigger": {
"type": "entered_area",
"source_id": "stickman_1",
"target_id": "area_1",
"params": {}
},
"actions": [
{
"type": "speak",
"target_id": "stickman_2",
"params": { "text": "Hello", "duration": 2.0 }
}
]
}
```
- `source_id`/`target_id` are file-ID strings. A runtime `-1` wildcard is encoded as JSON `null` and decoded back to `-1`.
- Trigger params are: `waypoint_pos: {x,y}` for `arrived_at_waypoint`, `action_type: String` for `action_finished`, and `{}` for the other current trigger types.
- Rule-action params keep the existing nested runtime shape; `walk_to.params.target` is converted to/from `{x,y}`.
- On load, a rule is installed only when every non-null object reference resolves to a compatible live object. One unresolved reference skips the complete rule and contributes to the final load warning.
- Runtime rules remain unchanged: after decoding they again use integer instance IDs under `trigger.source`, `trigger.target`, and action `target`. Existing event matching, panels, and editors therefore need no data-model rewrite.
- Loaded numeric rule IDs are retained when unique and non-negative. Duplicate/invalid IDs are reassigned in array order. `_next_rule_id` becomes `max(loaded IDs) + 1`.
This conversion boundary replaces the original assumption that the runtime rule dictionary can be serialized unchanged.
### 3.6 Validation and migration
`StageSceneIO` validates the complete root before the live stage is cleared. A missing file, I/O failure, invalid JSON, non-dictionary root, absent/unsupported version, or non-array `objects`/`event_rules` fails the load and leaves the current stage untouched.
Individual invalid object/action/rule entries are warn-and-skip. Every decoded numeric field must be finite (`is_finite()`); vectors require finite numeric `x` and `y`, rotations/durations/zoom reject NaN or infinity, and area size must be positive. Unknown keys are dropped. v1.0 is the first version, so `_migrate_to_current(data: Dictionary) -> Dictionary` has no transformations yet, but version dispatch must exist and reject newer unsupported versions cleanly.
## 4. `StageSceneIO` API contracts
```gdscript
class_name StageSceneIO
extends RefCounted
const FILE_VERSION: String = "1.0"
static func read_file(path: String) -> Dictionary
# { "ok": bool, "data": Dictionary, "error": String, "warnings": Array[String] }
static func write_file_safe(path: String, data: Dictionary) -> Dictionary
# { "ok": bool, "error": String }
static func encode_queue(actions: Array[Dictionary]) -> Dictionary
# { "actions": Array[Dictionary], "warnings": Array[String] }
static func decode_queue(entries: Array) -> Dictionary
# { "actions": Array[Dictionary], "warnings": Array[String] }
static func encode_rules(rules: Array[Dictionary], runtime_to_file: Dictionary) -> Dictionary
# { "rules": Array[Dictionary], "warnings": Array[String] }
static func decode_rules(entries: Array, file_to_runtime: Dictionary, unavailable_ids: Dictionary) -> Dictionary
# { "rules": Array[Dictionary], "warnings": Array[String], "skipped": int }
static func vector_to_data(value: Vector2) -> Dictionary
static func is_vector_data(value: Variant) -> bool
static func data_to_vector(value: Variant, fallback: Vector2) -> Vector2
```
`write_file_safe` uses sibling `<target>.tmp` and `<target>.bak` files because Godot's `DirAccess.rename_absolute(temp, target)` is not a guaranteed overwrite operation when `target` already exists on Windows:
1. Resolve and verify the absolute target/temp/backup paths share the same parent directory.
2. Write, flush, and close the complete temporary file.
3. If a target exists, remove only a stale verified sibling backup, then rename the target to the backup.
4. Rename the temporary file to the target.
5. If step 4 fails, rename the backup back to the target and report failure; never report success until the new target exists.
6. On success, remove the backup. On startup/read, if the target is absent but its backup exists, restore the backup before reading; if both target and backup exist, keep the target and remove the stale backup. Stale temporary files are safe to remove.
Each same-directory rename is atomic at the filesystem level; the backup and rollback protocol protects the prior destination across the multi-step replacement and makes an interrupted swap recoverable. No recursive filesystem operation is involved.
## 5. Spawn and provenance contracts
`StageSpawner.spawn(id, cursor_position)` remains the palette API, including the stickman's `STICKMAN_FOOT_OFFSET`. Add load-safe APIs that do not apply a second placement offset:
```gdscript
func spawn_terrain_asset(asset_id: String, object_position: Vector2) -> TerrainBlock
func spawn_prop_asset(asset_id: String, object_position: Vector2) -> PropBlock
func spawn_stickman_asset(stk_path: String, root_position: Vector2) -> StickmanRig
func spawn_area(object_position: Vector2) -> TriggerArea
```
The existing palette helpers delegate to these methods. On success:
- terrain sets `TerrainBlock.spawn_id = asset_id`;
- prop sets `PropBlock.spawn_id = asset_id`;
- stickman sets `StickmanRig.source_stk_path = normalized stk_path`.
Unknown terrain/prop IDs return `null` plus a warning. A missing or malformed `.stk` returns `null` and is handled by the stage as §11 describes. Load APIs must not mutate `selected_prop_id` or `selected_stickman_path`.
## 6. Sandbox stage state and internal APIs
Add:
```gdscript
enum PendingFileAction { NONE, NEW_SCENE, OPEN_DIALOG, OPEN_PATH, QUIT }
const AUTOSAVE_PATH: String = "user://sandbox_autosave.scene"
const RECENT_SCENE_LIMIT: int = 5
const DEFAULT_SCENE_NAME: String = "Untitled"
const DEFAULT_CAMERA_POSITION: Vector2 = Vector2(0.0, -400.0)
const DEFAULT_CAMERA_ZOOM: float = 0.5
var _current_scene_path: String = ""
var _scene_name: String = DEFAULT_SCENE_NAME
var _scene_created_unix: int = 0
var _dirty: bool = false
var _suppress_dirty: bool = false
var _recent_scenes: Array[String] = []
var _pending_file_action: PendingFileAction = PendingFileAction.NONE
var _pending_open_path: String = ""
```
Primary stage methods:
```gdscript
func _mark_scene_dirty() -> void
func _set_scene_dirty(value: bool) -> void
func _collect_scene_data(is_autosave: bool = false) -> Dictionary
func _save_scene_to_path(path: String, is_autosave: bool = false) -> bool
func _load_scene_from_path(path: String, is_recovery: bool = false) -> bool
func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool) -> bool
func _reset_scene_content() -> void
func _request_pending_file_action(action: PendingFileAction, path: String = "") -> void
func _continue_pending_file_action() -> void
func _update_scene_chrome() -> void
func _update_recent_menu() -> void
```
`_suppress_dirty` is set with a scoped save/restore around load/New operations. It prevents spawn wiring and queue population from marking the new document dirty. It must be restored on every return path.
## 7. File menu, dialogs, and layout
Add a `MenuButton` as the first control in the existing code-built top-bar `HBoxContainer`. Give new dynamic controls explicit names so tests and future code can resolve them:
```text
SandboxStage/UI (CanvasLayer)
├── TopBar (PanelContainer)
│ └── Content (HBoxContainer)
│ └── FileMenu (MenuButton)
├── SceneOpenDialog (FileDialog)
├── SceneSaveDialog (FileDialog)
├── RenameSceneDialog (ConfirmationDialog)
│ └── SceneNameEdit (LineEdit)
├── MissingAssetsDialog (AcceptDialog)
├── UnsavedChangesDialog (ConfirmationDialog)
└── AutosaveRecoveryDialog (ConfirmationDialog)
```
The existing asset `BrowseDialog` remains dedicated to `.stk`. The existing generic `_confirm_dialog` remains dedicated to delete/clear confirmations; file flow uses separate dialogs and callbacks.
File menu items:
| Item | Contract |
| --- | --- |
| New | Dirty-check, then reset content/name/path/camera. |
| Open… | Dirty-check, then show `SceneOpenDialog`. |
| Open Recent | Submenu, most recent first; missing paths are pruned when rebuilt. |
| Save | Save to `_current_scene_path`, or open Save As when empty. |
| Save As… | Open `SceneSaveDialog`; append `.scene` if the selected path has another/no extension. |
| Rename Scene… | Opens a wide `ConfirmationDialog` with Cancel, pre-fills `SceneNameEdit`, focuses it, and selects all text. The `LineEdit` uses the placeholder `Scene name`, which is visible only while the field is empty. A changed, non-empty confirmed name marks dirty. |
There is no separate Clear item: the live sandbox has no existing Clear command, and New already supplies the destructive blank-stage behavior.
The Open and Save dialogs use `ACCESS_FILESYSTEM` and filter `*.scene ; Stickman Scene Files`. Keyboard accelerators may be added for Ctrl+N/O/S and Ctrl+Shift+S, but are not required for Phase 5 acceptance.
`_update_scene_chrome()` sets:
- window title: `"%s%s — %s — Stickman Studio" % [_scene_name, " *" if _dirty else "", scene_file]` through `get_window().title`, where `scene_file` is the current `.scene` basename or `Unsaved`;
- status prefix: `"%s | Objects: ..."`, retaining the existing selection, pending-action, rule-hint, toast, and cursor behavior.
## 8. Dirty-state and unsaved-changes flow
Dirty becomes true on:
- successful object placement, terrain paint commit, transform commit, or deletion;
- any rig `queue_changed` signal;
- rule create/update/delete/clear/reorder;
- a committed scene rename.
Camera pan/zoom, selection/hover, grid/snap settings, mode changes, Play physics, and opening/canceling an editor do not dirty the file.
Replace the current rig queue connection to `StageDirectorVisuals.mark_dirty` with a stage handler that calls both `mark_dirty()` and `_mark_scene_dirty()`.
Unsaved protection applies before New, Open, Open Recent, and window close. `UnsavedChangesDialog` exposes Save, Discard, and Cancel:
- Save performs Save or Save As. The pending action continues only after a successful write. Canceling Save As or a write failure cancels the pending action.
- Discard hides the unsaved-changes dialog and continues the pending action without writing. New therefore clears the stage and closes the prompt in the same operation.
- Cancel/window-close clears the pending action and changes nothing.
Only one pending action exists at a time. For close interception, set `get_tree().auto_accept_quit = false` in `_ready()` and handle `NOTIFICATION_WM_CLOSE_REQUEST`. A clean close, successful save-then-close, or explicit discard-then-close retires the autosave before `get_tree().quit()`.
Dirty is cleared only by successful manual Save/Save As, successful ordinary load, and New. Autosave never changes `_current_scene_path`, recents, timestamps held for the manual document, or `_dirty`.
## 9. New and load transaction
`_reset_scene_content()` first returns to Edit semantics so queues/ragdolls/physics are stopped, then closes transient popups and capture flows, clears selection and authored physics targets, removes all direct `World` children, and clears:
- `_authored`, `_event_rules`, `_next_rule_id`;
- `_area_overlap`, `_collision_pairs`, terrain occupancy, director visuals;
- selection/hover/gizmo targets and placement ghosts.
New additionally sets `Untitled`, empty current path, a new creation timestamp, the default camera, clean state, then rebuilds the grid/navigation/status once.
Ordinary load is transactional at the document boundary:
1. Read, parse, root-validate, and migrate before touching the live stage.
2. If root validation fails, show one error and keep the current document unchanged.
3. Reset the live content under `_suppress_dirty`.
4. Spawn valid objects in saved array order. Apply transform/properties before calling the existing `_finalize_placed_node()` wiring.
5. Build `file_id -> runtime instance ID`; track missing-stickman IDs separately.
6. Decode/install only rules whose references resolve.
7. Restore camera, name, creation timestamp, authored transforms, `_next_rule_id`, grid occupancy, and director visuals. Clear any queued `_nav_dirty` request, then rebuild navigation exactly once.
8. Ordinary load adopts the normalized source path, clears dirty, adds it to recents, and updates title/status.
Object-level warnings are summarized in one toast after load; details also go to `push_warning`. A partially valid document therefore loads without turning one bad entry into total data loss.
## 10. Recent files and settings merge
Extend the existing `user://sandbox_settings.json` rather than creating another settings file:
```json
{
"version": "1.0",
"grid_size": 15.0,
"snap_to_grid": false,
"show_grid": true,
"recent_scenes": ["C:/Scenes/playground.scene"]
}
```
`_load_settings()` type-checks the list, normalizes/deduplicates paths, removes missing files, and caps it at five. `_save_settings()` writes both existing grid fields and recent scenes so one settings change cannot erase the other feature's state.
A successful manual open or save moves that path to the front. Autosave is never added. The submenu rebuild removes newly missing paths and displays one disabled `(No recent scenes)` row when empty. If a recent file disappears between menu rebuild and click, show `File not found: <path>`, remove it, save settings, and leave the current document untouched.
## 11. Missing or corrupt `.stk` files
Add `MissingStickmanPlaceholder` with:
```gdscript
var original_entry: Dictionary = {}
var missing_stk_path: String = ""
func configure(entry: Dictionary) -> void
func get_area_rect() -> Rect2
```
It draws a red rectangle labeled `Missing asset - <filename>` at local origin. `get_area_rect()` makes it selectable by the current duck-typed stage bounds path. It is a normal World child for movement, rotation, and deletion, but it has no rig/action/event behavior.
When a stickman asset is absent or fails JSON parsing:
1. Log the detailed failure.
2. Spawn a placeholder at the saved authored transform.
3. Store a deep copy of the original stickman entry, including its `stk_path`, facing, and action queue.
4. Mark its `file_id` unavailable when decoding rules, so every rule that refers to that missing rig is skipped as a whole.
5. Show one end-of-load toast with missing-stickman and skipped-rule counts.
6. Show one `MissingAssetsDialog` listing each distinct missing asset filename and saved path, with a note that red markers identify their scene positions.
Saving a placeholder serializes it back as a `type: "stickman"` entry using `original_entry`, updated with the placeholder's current file ID and authored transform. Thus the missing path and queue survive and can recover automatically if the `.stk` later returns. The placeholder is not serialized as a new fifth object type. Deleting the placeholder and saving removes that stickman entry.
## 12. Autosave-on-Play and recovery lifecycle
Before `set_mode(StageMode.PLAY)` changes mode or unfreezes physics, write the authored scene snapshot to `user://sandbox_autosave.scene`. Failure shows a toast and logs a warning but does not block Play.
Autosave write rules:
- use the same v1.0 schema and failure-safe writer;
- set `metadata.autosave = true`, `metadata.source_path = _current_scene_path`, and a fresh `modified_unix`;
- do not clear dirty, update recents, adopt the autosave path, or change the manual document's creation/modified bookkeeping.
On `_ready()`, after UI construction and settings load, inspect the autosave. Offer recovery only when it is valid and either has no source path, its source is missing, or its `modified_unix` is newer than the source file's filesystem modification time. A stale/invalid autosave is removed with a warning.
- Recover applies the snapshot without adding the autosave path to recents. It restores `metadata.source_path` as `_current_scene_path` only when non-empty, and sets `_dirty = true` so the recovered snapshot must be manually saved. Keep the autosave until that manual save/New/Open/discard/clean shutdown retires it, protecting a second crash before save.
- Decline deletes the autosave and keeps the new blank stage.
- A successful manual save retires the autosave.
- A normal confirmed shutdown retires it. A crash/forced process stop leaves it behind, which is what makes the next-launch prompt meaningful.
This lifecycle prevents a normal Play followed by an orderly exit from prompting on every next launch.
## 13. Signals and theme impact
No new public gameplay signal is required. Existing stage signals keep their current payloads and behavior. Loading should not emit `object_placed` once per reconstructed entry; it applies a document snapshot under `_suppress_dirty` and refreshes dependents once at the end.
The file controls use the existing stage font and popup-theme helpers. No new `sandbox_theme.json` key is required. Dialog buttons use Godot's standard dialog theme.
## 14. Verification strategy
Create `tests/test_phase5_file_features.gd` and run it headlessly with the project's established test form:
```powershell
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script "res://tests/test_phase5_file_features.gd"
```
Required automated coverage:
1. Vector/action/rule codecs emit only JSON-safe types and round-trip every current action/trigger type.
2. Runtime instance IDs encode to file IDs and decode to new live instance IDs, including wildcard `null <-> -1`.
3. Terrain/prop/stickman/area provenance, authored position/rotation, non-rig scale, area size, named material/facing enums, queues, rule order, and World order survive save/load.
4. A LEFT-facing rig loads with one mirror only; its derived root scale is not serialized.
5. Save while/after Play uses `_authored`, not displaced physics state.
6. Parse/root-validation failure does not clear the existing World.
7. Empty/duplicate file IDs, unknown object IDs, non-finite numbers/vectors, and malformed entries skip safely; navigation is rebuilt once.
8. Missing/corrupt `.stk` creates a selectable red labeled placeholder, shows a load-error dialog listing the missing asset(s), skips dependent rules, and re-save preserves the original stickman entry and queue.
9. Place/transform/delete, queue edits, rule edits/reorder, and rename dirty the scene; camera/grid/selection/mode do not. Load-time reconstruction remains clean.
10. Rename focuses and selects all text, exposes Cancel, and uses an empty-field-only placeholder hint. Save-via-Save-As continuation and Save/Discard/Cancel pending-action paths behave correctly, including canceled/failed saves and a closed prompt after Discard.
11. Recents deduplicate, reorder, cap at five, prune missing paths, and never include autosave.
12. Autosave does not clear dirty or adopt a path; stale autosaves are pruned, recovery is dirty, decline/manual save/orderly quit retire the file.
13. Safe-write replacement, rollback, and interrupted-backup recovery leave the prior destination recoverable and report failures.
After the focused suite passes, run every existing script under `tests/` using its documented headless command; report suite names/results rather than relying on the stale aggregate assertion counts in older plans.
Manual Godot Editor verification:
- exercise File menu and all dialogs, including title `scene name * — scene-file.scene — Stickman Studio`, rename focus/select-all/placeholder behavior, cancel paths, and keyboard/window close;
- create all four object types, actions, cross-object rules, transforms, save, close, reload, and Play;
- rename/move a referenced `.stk`, verify the load-error dialog lists its filename/path and the scene shows a red `Missing asset - <filename>` rectangle, save, restore the `.stk`, and reload;
- force-stop after Play to verify recovery, then exit normally after Play to verify no false recovery prompt.
## 15. Approval gate: decisions to accept before implementation
The architect recommends approving these as a set:
1. Keep `.scene` as the user-facing JSON extension and v1.0 as the first file version.
2. Use per-save readable file IDs and a strict runtime-ID/file-ID conversion boundary; never persist Godot instance IDs.
3. Add typed provenance fields to `PropBlock` and `StickmanRig`, plus load-safe `StageSpawner` APIs, instead of relying on mutable palette selections or ad-hoc node metadata.
4. Omit a redundant Clear command; New is the blank-stage destructive action.
5. Preserve missing stickmen as stickman entries through a selectable placeholder, but drop complete rules that reference an unavailable rig.
6. Keep one autosave snapshot, write it on Play, retain it only across unclean exits/recovery, and mark recovered work dirty.
7. Drop unknown keys on re-save and reject newer unsupported versions; do not attempt forward-compatible preservation in v1.0.
8. Preserve saved World order and rebuild navigation once after load rather than grouping terrain first.
## 16. Out of scope
- Undo/redo
- Scene thumbnails/library/browser/templates
- Periodic or rotating autosave history
- Bundling/embedding `.stk` contents into `.scene`
- Relinking a missing `.stk` from the placeholder UI
- Cloud sharing/collaboration
- Video/GIF export
Implementation and focused headless validation are complete; the user confirmed the feature is verified and working, so this plan records the final behavior for handoff.