diff --git a/.github/workflows/publish-unsigned-preview.yml b/.github/workflows/publish-unsigned-preview.yml index d50d95e..ac0bccd 100644 --- a/.github/workflows/publish-unsigned-preview.yml +++ b/.github/workflows/publish-unsigned-preview.yml @@ -12,12 +12,12 @@ on: description: New prerelease tag; an existing release is never overwritten required: true type: string - default: v0.5.0-beta.4 + default: v0.5.0-beta.5 preview_title: description: Public prerelease title required: true type: string - default: Parlyn Engine v0.5.0 Beta 4 (unsigned) + default: Parlyn Engine v0.5.0 Beta 5 (unsigned) permissions: contents: write @@ -29,8 +29,8 @@ jobs: env: CSC_IDENTITY_AUTO_DISCOVERY: false GH_TOKEN: ${{ github.token }} - PREVIEW_TAG: ${{ inputs.preview_tag || 'v0.5.0-beta.4' }} - PREVIEW_TITLE: ${{ inputs.preview_title || 'Parlyn Engine v0.5.0 Beta 4 (unsigned)' }} + PREVIEW_TAG: ${{ inputs.preview_tag || 'v0.5.0-beta.5' }} + PREVIEW_TITLE: ${{ inputs.preview_title || 'Parlyn Engine v0.5.0 Beta 5 (unsigned)' }} steps: - name: Checkout @@ -68,14 +68,14 @@ jobs: shell: pwsh run: | @" - # Parlyn Engine v0.5.0 Beta 4 + # Parlyn Engine v0.5.0 Beta 5 - This unsigned Windows beta contains the completed automated Phase 1 - foundation candidate. It restores the packaged Electron host bridge - required for project creation and saving. It adds safe project closing, - recoverable Recycle Bin deletion and explicit prerelease filenames alongside atomic persistence, - hardened Node and Undo/Redo invariants, visible editor errors, tighter - desktop trust boundaries and corrected packaged toolbar branding. + This unsigned Windows beta begins Phase 2 of the Parlyn editor. It adds + deep node duplication, safe hierarchy reparenting, recursive hierarchy + display and durable validated Scene History. Undo/Redo now survives + project close and a complete application restart when the saved history + matches the loaded Scene. Beta 4 project persistence, safe Recycle Bin + deletion and hardened desktop boundaries remain intact. **Important:** This beta is unsigned and may be blocked by Windows Smart App Control. It is published to document and validate the packaged @@ -88,7 +88,7 @@ jobs: remain pending. This release is not SignPath-signed. Maintainer acceptance instructions: - https://github.com/WebCrew/Parlyn-Engine/blob/main/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.4.md + https://github.com/WebCrew/Parlyn-Engine/blob/main/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.5.md "@ | Set-Content -LiteralPath release/PREVIEW-NOTES.md -Encoding utf8 - name: Publish GitHub pre-release diff --git a/CHANGES.md b/CHANGES.md index 9688a51..da08366 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,10 @@ ## Unreleased — Foundation Stabilization +- Started Phase 2 with editor commands for deep node duplication and safe reparenting. +- Added recursive hierarchy rendering and fixed subtree deletion in the viewport. +- Added a bounded, validated local Scene History that preserves Undo/Redo through project close and application restart. +- Added the versioned `parlyn-scene-history` document format with stale-history rejection. - Added explicit Close Project behavior that clears the privileged project session and protects unsaved scene changes. - Added exact-name-confirmed project deletion through the operating system Recycle Bin; Parlyn never permanently deletes the active project. - Adopted full Semantic Version prerelease names in application metadata and Windows installer filenames. diff --git a/README.md b/README.md index 5ab1f8f..3b7102d 100644 --- a/README.md +++ b/README.md @@ -222,8 +222,8 @@ identity supplied through the controlled signing boundary. See [`docs/WINDOWS-DISTRIBUTION.md`](docs/WINDOWS-DISTRIBUTION.md) for signing, verification and the maintainer acceptance test. -The current Phase 1 acceptance candidate uses the repeatable -[`v0.5.0-beta.4 maintainer checklist`](docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.4.md). +Phase 1 acceptance is complete. The current Phase 2 editor candidate uses the +repeatable [`v0.5.0-beta.5 maintainer checklist`](docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.5.md). ### Code signing policy diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index de992ce..97808fa 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -33,6 +33,12 @@ Node identity fields are non-empty engine-owned strings. Reparenting rejects sel Persisted scenes are bounded to 10,000 nodes and 256 hierarchy levels. These are protective document limits, not editor viewport or world-size limits. Undo/Redo likewise owns cloned snapshots, requires meaningful labels and enforces a positive configured history limit. +Saved project scenes also maintain a versioned `parlyn-scene-history` document +inside the project's `.parlyn` directory. The history is bounded to 100 states +and a 16 MiB exported transaction payload. Parlyn restores it only when its +validated current-scene snapshot exactly matches the loaded scene; stale, +oversized or invalid history is ignored without blocking the project itself. + ## Renderer rule Parlyn code talks to a RendererBackend contract. THREE.js can be replaced or complemented later without changing the project format or editor concepts. diff --git a/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.5.md b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.5.md new file mode 100644 index 0000000..78c85eb --- /dev/null +++ b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.5.md @@ -0,0 +1,49 @@ +# Parlyn Engine v0.5.0 Beta 5 — Maintainer Acceptance + +This pass verifies the first Phase 2 editor slice and all Beta 4 regressions on +the published `v0.5.0-beta.5` Windows artifact. + +## Test record + +Record the Windows version, installer filename, SHA-256 and pass/fail result in +Issue #29. Smart App Control and normal Windows security settings remain enabled. + +## 1. Download, install and launch + +1. Download the Beta 5 installer and `SHA256SUMS.txt`. +2. Confirm the hash from the following command matches the checksum file: + + ```powershell + Get-FileHash "$env:USERPROFILE\Downloads\Parlyn-Engine-Setup-0.5.0-beta.5-x64.exe" -Algorithm SHA256 + ``` + +3. Install normally and launch Parlyn. + +## 2. Durable Scene History + +1. Create a project named `Parlyn Beta 5 Test`. +2. Add a 2.5D Sprite, rename it and save. +3. Close and reopen the project, then use **Undo** and **Redo**. +4. Close Parlyn completely, launch it again, reopen the project and repeat **Undo** and **Redo**. +5. Confirm the scene states change in the expected order after both kinds of reopen. +6. Confirm `.parlyn/startup-scene.parlyn-history.json` exists inside the project. + +## 3. Duplication and hierarchy + +1. Select a node and duplicate it with the Hierarchy button. +2. Repeat with **Ctrl+D** and confirm each copy has a distinct selectable identity. +3. Move a node beneath another node through the hierarchy dialog. +4. Confirm the nested indentation appears and invalid descendant targets are unavailable. +5. Move the node back to **Scene Root**. +6. Undo and redo duplication and reparenting in order. +7. Save, restart Parlyn and confirm the nested structure survives. + +## 4. Beta 4 regression + +1. Confirm project creation, Scene Save, World Save and Open Project still work. +2. Make an unsaved change and confirm **Close** still offers Cancel, Discard and Save & Continue. +3. Create a disposable project, enter its exact name and confirm **Delete** moves it to the Recycle Bin. +4. Add a parent with a nested child, delete the parent, and confirm no orphan remains visible in the viewport. + +Beta 5 passes only when every required check succeeds or a defect is corrected +and the affected check is repeated against a new artifact. diff --git a/docs/PROJECT-FORMAT.md b/docs/PROJECT-FORMAT.md index 2588919..7faafed 100644 --- a/docs/PROJECT-FORMAT.md +++ b/docs/PROJECT-FORMAT.md @@ -41,7 +41,10 @@ Contains source assets imported by the developer. External asset formats are not ## `.parlyn/` -Reserved for generated caches, import metadata and other project-local internal data. Developers should not rely on its contents as a public API. +Reserved for generated caches, import metadata and other project-local internal +data. Beta 5 stores the validated startup Scene History here as +`startup-scene.parlyn-history.json`. Developers should not rely on internal +paths as a public API; the history document itself remains explicitly versioned. ## Compatibility contract v1 @@ -52,6 +55,7 @@ Every persisted Parlyn document declares a `format` and numeric `version`. | `parlyn-project` | 1 | version 1 | | `parlyn-scene` | 2 | versions 1 and 2; version 1 is upgraded in memory and saved as version 2 | | `parlyn-world` | 1 | version 1 | +| `parlyn-scene-history` | 1 | version 1 | Unknown future versions are rejected with an explicit error. Parlyn must never silently interpret an unsupported document as the current format. @@ -69,6 +73,7 @@ Project references such as `startupScene` and `world` are project-relative paths - Documents are fully normalized and validated before they replace an existing file. - Saves use a temporary sibling file followed by an atomic rename. A rejected document never truncates the last valid file. - Persisted values must be plain JSON data. Functions, non-finite numbers, custom object prototypes, symbol keys and circular references are rejected because JSON cannot preserve them reliably. +- Scene History contains at most 100 Undo/Redo states, is size-bounded on export and is restored only when its current-scene snapshot matches the loaded scene exactly. The persistence boundary is shared by loose scenes and project-owned project, scene and world files. Validation therefore does not depend on which editor command initiated the operation. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 645c18c..0696338 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -20,7 +20,7 @@ Official systems remain replaceable. Developers may use Parlyn defaults, custom ## Phase 1 — Engine and basic editor foundation -**Status: In Progress** +**Status: Complete** Already present: @@ -39,15 +39,16 @@ Already present: - visible, actionable errors for project, scene, world and asset operations; - automated structural, Smart Systems and CodeQL checks. -Completion gate: +Completion evidence: -- complete the repeatable `v0.5.0-beta.4` maintainer test pass and record the result in Issue #23. +- the repeatable `v0.5.0-beta.4` maintainer test passed and is recorded in completed Issue #23. ## Phase 2 — Editor foundation -**Status: Planned** +**Status: In Progress** -- duplication and reparenting; +- duplication and reparenting — initial editor commands complete; +- durable, validated local Scene History — initial foundation complete; - multi-selection where appropriate; - multi-scene project browser; - rename and move project assets; diff --git a/docs/TESTING-v0.5.0.md b/docs/TESTING-v0.5.0.md index 11d4afa..a81ba59 100644 --- a/docs/TESTING-v0.5.0.md +++ b/docs/TESTING-v0.5.0.md @@ -1,8 +1,8 @@ # Parlyn Engine v0.5.0 Test Guide -For the Phase 1 release-candidate acceptance pass, use the shorter artifact-specific -[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.4.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.4.md) -and record the result in Issue #23. The guide below remains the detailed feature reference. +For the current Phase 2 editor acceptance pass, use the shorter artifact-specific +[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.5.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.5.md) +and record the result in Issue #29. The guide below remains the detailed feature reference. This test focuses on the two new foundations introduced in v0.5.0: viewport transform gizmos and the module lifecycle. diff --git a/docs/WINDOWS-DISTRIBUTION.md b/docs/WINDOWS-DISTRIBUTION.md index 6d1b509..a6d1cbf 100644 --- a/docs/WINDOWS-DISTRIBUTION.md +++ b/docs/WINDOWS-DISTRIBUTION.md @@ -131,7 +131,7 @@ The packaged installer has passed the initial install-and-launch smoke test on the maintainer's Windows machine. Public trusted-signing acceptance remains pending separately from the resolved development-binary blocker. -The full Phase 1 functional acceptance candidate is the unsigned -`v0.5.0-beta.4` prerelease. Its repeatable checklist is documented in -[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.4.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.4.md), -and the result is recorded in Issue #23 before Phase 1 can be marked complete. +Phase 1 functional acceptance completed with the unsigned `v0.5.0-beta.4` +prerelease and Issue #23. The current Phase 2 editor candidate is +`v0.5.0-beta.5`; its repeatable checklist is documented in +[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.5.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.5.md). diff --git a/package-lock.json b/package-lock.json index 82d46de..2752775 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parlyn-engine", - "version": "0.5.0-beta.4", + "version": "0.5.0-beta.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "parlyn-engine", - "version": "0.5.0-beta.4", + "version": "0.5.0-beta.5", "license": "MIT", "dependencies": { "three": "0.185.1" diff --git a/package.json b/package.json index 006c62e..e7aaf37 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parlyn-engine", - "version": "0.5.0-beta.4", + "version": "0.5.0-beta.5", "private": true, "description": "Parlyn Engine - open-source 2.5D-first game engine editor", "author": "Parlyn Engine contributors", diff --git a/scripts/check-core-invariants.mjs b/scripts/check-core-invariants.mjs index 3487948..a4d5355 100644 --- a/scripts/check-core-invariants.mjs +++ b/scripts/check-core-invariants.mjs @@ -26,6 +26,22 @@ root.walk((node) => visited.push(node.id)); assert.deepEqual(visited, ['root', 'branch', 'leaf']); assert.throws(() => root.walk(null), /visitor/); +const hierarchyScene = new SceneDocument('Hierarchy Commands'); +const parentA = hierarchyScene.root.addChild(new Node({ id:'parent-a', name:'Parent A', type:'Node' })); +const parentB = hierarchyScene.root.addChild(new Node({ id:'parent-b', name:'Parent B', type:'Node' })); +const nested = parentA.addChild(new Node({ id:'nested', name:'Nested', type:'Node' })); +const duplicate = hierarchyScene.duplicateById(parentA.id); +assert.equal(duplicate.name, 'Parent A Copy'); +assert.equal(duplicate.parent, hierarchyScene.root); +assert.equal(duplicate.children.length, 1); +assert.notEqual(duplicate.id, parentA.id); +assert.notEqual(duplicate.children[0].id, nested.id); +assert.equal(hierarchyScene.reparentById(nested.id, parentB.id), true); +assert.equal(nested.parent, parentB); +assert.equal(hierarchyScene.reparentById(nested.id, parentB.id), false); +assert.throws(() => hierarchyScene.reparentById(parentB.id, nested.id), /descendants/); +assert.throws(() => hierarchyScene.reparentById(hierarchyScene.root.id, parentA.id), /scene nodes/); + const deepRoot = new Node({ id:'deep-0', name:'Deep 0', type:'Node' }); let deepCursor = deepRoot; for (let index = 1; index <= 2000; index += 1) { @@ -51,6 +67,16 @@ secondUndo.snapshot.value = 100; assert.deepEqual(history.redo({ value:2 }), { snapshot:{ value:3 }, label:'Second' }); history.push({ value:5 }, 'New branch'); assert.equal(history.canRedo, false, 'A new change must invalidate the redo branch.'); +const exportedHistory = history.exportState(); +assert.throws(() => history.exportState({ maxBytes:0 }), /positive/); +const boundedExport = history.exportState({ maxBytes:120 }); +assert.ok(boundedExport.undoStack.length < exportedHistory.undoStack.length, 'Bounded history export must discard oldest entries when required.'); +const restoredHistory = new History({ limit:2 }); +restoredHistory.restoreState(exportedHistory); +exportedHistory.undoStack[0].snapshot.value = 999; +assert.deepEqual(restoredHistory.undo({ value:6 }), { snapshot:{ value:5 }, label:'New branch' }); +assert.throws(() => restoredHistory.restoreState({ version:2, undoStack:[], redoStack:[] }), /supported/); +assert.throws(() => restoredHistory.restoreState({ version:1, undoStack:[{ snapshot:{}, label:'A' }, { snapshot:{}, label:'B' }], redoStack:[{ snapshot:{}, label:'C' }] }), /limit/); assert.throws(() => history.push(null, 'Invalid'), /snapshot/); assert.throws(() => history.push({ value:1 }, ' '), /label/); history.clear(); @@ -73,6 +99,9 @@ function sceneWithDepth(depth) { assert.doesNotThrow(() => SceneDocument.fromJSON(sceneWithDepth(256))); assert.throws(() => SceneDocument.fromJSON(sceneWithDepth(257)), /maximum depth/); +const depthBoundScene = SceneDocument.fromJSON(sceneWithDepth(256)); +const extraRootNode = depthBoundScene.root.addChild(new Node({ id:'depth-extra', name:'Depth Extra', type:'Node' })); +assert.throws(() => depthBoundScene.reparentById(extraRootNode.id, 'depth-node-256'), /maximum scene depth/); const oversizedScene = sceneWithDepth(0); for (let index = 0; index < 10000; index += 1) { diff --git a/scripts/check-core-persistence.mjs b/scripts/check-core-persistence.mjs index 0c76219..60b38c9 100644 --- a/scripts/check-core-persistence.mjs +++ b/scripts/check-core-persistence.mjs @@ -8,6 +8,8 @@ import { ProjectDocument } from '../src/engine/project/ProjectDocument.mjs'; import { SceneDocument } from '../src/engine/scene/SceneDocument.mjs'; import { Node2_5D } from '../src/engine/core/Node2_5D.mjs'; import { WorldDocument } from '../src/engine/world/WorldDocument.mjs'; +import { History } from '../src/engine/history/History.mjs'; +import { SceneHistoryDocument } from '../src/engine/history/SceneHistoryDocument.mjs'; import { normalizeDocument, parseDocumentText, stringifyDocument } from '../src/engine/persistence/DocumentPersistence.mjs'; import { readDocumentFile, writeDocumentFileAtomic } from '../src/main/documentFiles.mjs'; @@ -37,7 +39,16 @@ assert.deepEqual(migratedScene.root.children[0].metadata, {}); const world = new WorldDocument({ name:'Roundtrip World', seed:'roundtrip' }); assert.deepEqual(WorldDocument.fromJSON(world.toJSON()).toJSON(), world.toJSON()); -for (const document of [project.toJSON(), scene.toJSON(), world.toJSON()]) { +const sceneHistory = new History({ limit:100 }); +sceneHistory.push(scene.toJSON(), 'Add Sprite'); +const historyDocument = new SceneHistoryDocument({ + scenePath:'scenes/Main.parlyn-scene.json', + currentScene:scene.toJSON(), + history:sceneHistory.exportState() +}); +assert.deepEqual(SceneHistoryDocument.fromJSON(historyDocument.toJSON()).toJSON(), historyDocument.toJSON()); + +for (const document of [project.toJSON(), scene.toJSON(), world.toJSON(), historyDocument.toJSON()]) { const source = stringifyDocument(document, document.format); assert.deepEqual(parseDocumentText(source, document.format), normalizeDocument(document, document.format)); } @@ -64,6 +75,10 @@ assert.throws(() => SceneDocument.fromJSON({ format:'parlyn-scene', version:99, assert.throws(() => SceneDocument.fromJSON({ format:'parlyn-scene', version:2, name:'Missing Root' }), /missing root/); assert.throws(() => WorldDocument.fromJSON({ format:'parlyn-world', version:99 }), /Unsupported/); assert.throws(() => WorldDocument.fromJSON({ format:'parlyn-world', version:1 }), /missing name/); +assert.throws(() => SceneHistoryDocument.fromJSON({ ...historyDocument.toJSON(), version:99 }), /Unsupported/); +const invalidHistorySnapshot = historyDocument.toJSON(); +invalidHistorySnapshot.history.undoStack[0].snapshot.format = 'not-a-scene'; +assert.throws(() => SceneHistoryDocument.fromJSON(invalidHistorySnapshot), /scene file/); const duplicate = scene.toJSON(); duplicate.root.children.push(structuredClone(duplicate.root.children[0])); @@ -95,6 +110,10 @@ try { assert.equal(await fs.readFile(projectFile, 'utf8'), firstSource, 'A rejected save must preserve the previous file.'); assert.deepEqual((await fs.readdir(temporaryRoot)).filter((name) => name.includes('.tmp-')), []); + const historyFile = path.join(temporaryRoot, 'startup-scene.parlyn-history.json'); + await writeDocumentFileAtomic(historyFile, historyDocument.toJSON(), 'parlyn-scene-history', 'test scene history'); + assert.deepEqual(await readDocumentFile(historyFile, 'parlyn-scene-history', 'test scene history'), historyDocument.toJSON()); + const malformedFile = path.join(temporaryRoot, 'broken.parlyn-scene.json'); await fs.writeFile(malformedFile, '{ broken json', 'utf8'); await assert.rejects(readDocumentFile(malformedFile, 'parlyn-scene', 'broken scene'), /invalid JSON/); diff --git a/scripts/check-desktop-boundaries.cjs b/scripts/check-desktop-boundaries.cjs index ec6c32a..3f1b7e1 100644 --- a/scripts/check-desktop-boundaries.cjs +++ b/scripts/check-desktop-boundaries.cjs @@ -59,6 +59,9 @@ const { resolveExistingProjectPath, resolveWritableProjectPath } = require('../s assert.match(main, /secureHandle\('parlyn:project:open'/); assert.match(main, /secureHandle\('parlyn:project:close'/); assert.match(main, /secureHandle\('parlyn:project:delete'/); + assert.match(main, /parlyn-scene-history/); + assert.match(renderer, /history\.exportState\(/); + assert.match(renderer, /history\.restoreState\(result\.history\)/); let exposedHost = null; vm.runInNewContext(preload, { diff --git a/scripts/check.cjs b/scripts/check.cjs index 9641705..64424fc 100644 --- a/scripts/check.cjs +++ b/scripts/check.cjs @@ -18,6 +18,7 @@ const required = [ 'src/renderer/index.html', 'src/renderer/app.mjs', 'src/engine/project/ProjectDocument.mjs', + 'src/engine/history/SceneHistoryDocument.mjs', 'src/engine/persistence/DocumentPersistence.mjs', 'src/engine/world/WorldDocument.mjs', 'src/engine/world/DeterministicEncounter.mjs', @@ -30,6 +31,7 @@ const required = [ 'docs/AUTHENTICATION.md', 'docs/WINDOWS-DISTRIBUTION.md', 'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.4.md', + 'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.5.md', 'build/icon.ico', '.github/workflows/windows-installer.yml', 'scripts/check-smart-systems.mjs', @@ -48,7 +50,7 @@ for (const rel of required) { const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); if (pkg.name !== 'parlyn-engine') throw new Error('Unexpected package name.'); -if (pkg.version !== '0.5.0-beta.4') throw new Error(`Unexpected package version: ${pkg.version}`); +if (pkg.version !== '0.5.0-beta.5') throw new Error(`Unexpected package version: ${pkg.version}`); function walk(dir) { return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { diff --git a/src/engine/history/History.mjs b/src/engine/history/History.mjs index 6f0ffe6..b88f2dc 100644 --- a/src/engine/history/History.mjs +++ b/src/engine/history/History.mjs @@ -33,6 +33,33 @@ export class History { this.redoStack = []; } + exportState({ maxBytes = Number.POSITIVE_INFINITY } = {}) { + if (!(maxBytes > 0)) throw new RangeError('History export maxBytes must be positive.'); + const state = { + version:1, + undoStack:this.undoStack.map(cloneEntry), + redoStack:this.redoStack.map(cloneEntry) + }; + while (encodedSize(state) > maxBytes && (state.undoStack.length || state.redoStack.length)) { + if (state.undoStack.length) state.undoStack.shift(); + else state.redoStack.shift(); + } + return state; + } + + restoreState(state) { + if (!state || state.version !== 1 || !Array.isArray(state.undoStack) || !Array.isArray(state.redoStack)) { + throw new TypeError('History state must be a supported Parlyn history object.'); + } + if (state.undoStack.length + state.redoStack.length > this.limit) { + throw new RangeError(`History state exceeds the configured limit of ${this.limit}.`); + } + const undoStack = state.undoStack.map(requireEntry); + const redoStack = state.redoStack.map(requireEntry); + this.undoStack = undoStack; + this.redoStack = redoStack; + } + get canUndo() { return this.undoStack.length > 0; } get canRedo() { return this.redoStack.length > 0; } } @@ -50,3 +77,16 @@ function cloneSnapshot(snapshot) { throw new TypeError('History snapshot must be structured-cloneable.', { cause:error }); } } + +function cloneEntry(entry) { + return { snapshot:cloneSnapshot(entry.snapshot), label:requireLabel(entry.label) }; +} + +function requireEntry(entry) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) throw new TypeError('History entry must be an object.'); + return cloneEntry(entry); +} + +function encodedSize(value) { + return new TextEncoder().encode(JSON.stringify(value)).byteLength; +} diff --git a/src/engine/history/SceneHistoryDocument.mjs b/src/engine/history/SceneHistoryDocument.mjs new file mode 100644 index 0000000..438e7cd --- /dev/null +++ b/src/engine/history/SceneHistoryDocument.mjs @@ -0,0 +1,46 @@ +import { History } from './History.mjs'; +import { SceneDocument } from '../scene/SceneDocument.mjs'; + +const FORMAT = 'parlyn-scene-history'; +const VERSION = 1; +const MAX_ENTRIES = 100; + +export class SceneHistoryDocument { + static FORMAT = FORMAT; + static VERSION = VERSION; + + constructor({ scenePath, currentScene, history, updatedAt = new Date().toISOString() }) { + if (typeof scenePath !== 'string' || !scenePath.trim()) throw new TypeError('Scene history requires a scene path.'); + if (typeof updatedAt !== 'string' || !Number.isFinite(Date.parse(updatedAt))) throw new TypeError('Scene history requires a valid updatedAt timestamp.'); + this.format = FORMAT; + this.version = VERSION; + this.scenePath = scenePath.trim(); + this.currentScene = SceneDocument.fromJSON(currentScene).toJSON(); + const normalizedHistory = structuredClone(history); + for (const stackName of ['undoStack', 'redoStack']) { + if (!Array.isArray(normalizedHistory?.[stackName])) throw new TypeError(`Scene history ${stackName} must be an array.`); + for (const entry of normalizedHistory[stackName]) entry.snapshot = SceneDocument.fromJSON(entry.snapshot).toJSON(); + } + const validatedHistory = new History({ limit:MAX_ENTRIES }); + validatedHistory.restoreState(normalizedHistory); + this.history = validatedHistory.exportState(); + this.updatedAt = updatedAt; + } + + toJSON() { + return { + format:this.format, + version:this.version, + scenePath:this.scenePath, + currentScene:structuredClone(this.currentScene), + history:structuredClone(this.history), + updatedAt:this.updatedAt + }; + } + + static fromJSON(data) { + if (!data || data.format !== FORMAT) throw new Error('Not a Parlyn scene history file.'); + if (data.version !== VERSION) throw new Error(`Unsupported Parlyn scene history version: ${data.version}`); + return new SceneHistoryDocument(data); + } +} diff --git a/src/engine/persistence/DocumentPersistence.mjs b/src/engine/persistence/DocumentPersistence.mjs index 2064683..08a5392 100644 --- a/src/engine/persistence/DocumentPersistence.mjs +++ b/src/engine/persistence/DocumentPersistence.mjs @@ -1,11 +1,13 @@ import { ProjectDocument } from '../project/ProjectDocument.mjs'; import { SceneDocument } from '../scene/SceneDocument.mjs'; import { WorldDocument } from '../world/WorldDocument.mjs'; +import { SceneHistoryDocument } from '../history/SceneHistoryDocument.mjs'; const DOCUMENT_READERS = new Map([ [ProjectDocument.FORMAT, (data) => ProjectDocument.fromJSON(data)], [SceneDocument.FORMAT, (data) => SceneDocument.fromJSON(data)], - [WorldDocument.FORMAT, (data) => WorldDocument.fromJSON(data)] + [WorldDocument.FORMAT, (data) => WorldDocument.fromJSON(data)], + [SceneHistoryDocument.FORMAT, (data) => SceneHistoryDocument.fromJSON(data)] ]); function requirePlainJson(value, path = 'document') { diff --git a/src/engine/scene/SceneDocument.mjs b/src/engine/scene/SceneDocument.mjs index ba46edb..ddb9235 100644 --- a/src/engine/scene/SceneDocument.mjs +++ b/src/engine/scene/SceneDocument.mjs @@ -116,6 +116,35 @@ function nodeFromJSON(data, state, depth = 0) { return node; } +function duplicateNodeData(node) { + const data = node.toJSON(); + const pending = [data]; + while (pending.length) { + const current = pending.pop(); + current.id = crypto.randomUUID(); + for (const child of current.children ?? []) pending.push(child); + } + data.name = `${node.name} Copy`; + return data; +} + +function nodeDepth(node) { + let depth = 0; + for (let current = node; current.parent; current = current.parent) depth += 1; + return depth; +} + +function subtreeDepth(node) { + let maximum = 0; + const pending = [[node, 0]]; + while (pending.length) { + const [current, depth] = pending.pop(); + maximum = Math.max(maximum, depth); + for (const child of current.children) pending.push([child, depth + 1]); + } + return maximum; +} + export class SceneDocument { static FORMAT = FORMAT; static VERSION = VERSION; @@ -140,6 +169,27 @@ export class SceneDocument { return node.parent.removeChild(node); } + duplicateById(id) { + const node = this.findById(id); + if (!node || node === this.root || !node.parent) return null; + const duplicate = nodeFromJSON(duplicateNodeData(node), { ids:new Set(), count:0 }); + node.parent.addChild(duplicate); + return duplicate; + } + + reparentById(id, parentId) { + const node = this.findById(id); + const parent = this.findById(parentId); + if (!node || node === this.root) throw new Error('Only scene nodes can be reparented.'); + if (!parent) throw new Error('The target parent does not exist in this scene.'); + if (node.parent === parent) return false; + if (nodeDepth(parent) + 1 + subtreeDepth(node) > MAX_SCENE_DEPTH) { + throw new RangeError(`Reparenting would exceed the maximum scene depth of ${MAX_SCENE_DEPTH}.`); + } + parent.addChild(node); + return true; + } + toJSON() { return { format:this.format, version:VERSION, name:this.name, root:this.root.toJSON() }; } diff --git a/src/main/main.js b/src/main/main.js index d0792be..7d0c0eb 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -10,6 +10,8 @@ const persistence = import('../engine/persistence/DocumentPersistence.mjs'); const documentFiles = import('./documentFiles.mjs'); const EDITOR_FILE = path.join(__dirname, '..', 'renderer', 'index.html'); const EDITOR_URL = pathToFileURL(EDITOR_FILE).href; +const STARTUP_HISTORY_PATH = '.parlyn/startup-scene.parlyn-history.json'; +const MAX_HISTORY_FILE_BYTES = 32 * 1024 * 1024; app.setAppUserModelId('org.parlyn.engine'); @@ -32,6 +34,27 @@ async function writeDocumentAtomic(filePath, document, expectedFormat, label = ' return writeDocumentFileAtomic(filePath, document, expectedFormat, label); } +async function loadSceneHistory(projectRoot, sceneRelativePath, currentScene) { + let historyFile; + try { + historyFile = await resolveExistingProjectPath(projectRoot, STARTUP_HISTORY_PATH, 'Scene history file'); + } catch (error) { + if (error.code === 'ENOENT') return { history:null, warning:null }; + return { history:null, warning:error.message }; + } + try { + const info = await fs.stat(historyFile); + if (info.size > MAX_HISTORY_FILE_BYTES) return { history:null, warning:'Saved scene history exceeded the 32 MiB safety limit and was ignored.' }; + const document = await readDocument(historyFile, 'parlyn-scene-history', 'Parlyn scene history'); + if (document.scenePath !== sceneRelativePath || JSON.stringify(document.currentScene) !== JSON.stringify(currentScene)) { + return { history:null, warning:'Saved scene history did not match the current scene and was safely ignored.' }; + } + return { history:document.history, warning:null }; + } catch (error) { + return { history:null, warning:error.message }; + } +} + const projectSession = new ProjectSession({ async loadProject(projectRoot) { const projectFile = await resolveExistingProjectPath(projectRoot, 'parlyn.project.json', 'Parlyn project file'); @@ -168,8 +191,9 @@ secureHandle('parlyn:project:open', async () => { const worldPath=await resolveExistingProjectPath(projectRoot,project.world,'World document'); const scene=await readDocument(scenePath, 'parlyn-scene', 'Startup scene'); const world=await readDocument(worldPath, 'parlyn-world', 'World document'); + const sceneHistory=await loadSceneHistory(projectRoot, project.startupScene, scene); projectSession.activate(projectRoot); - return { canceled:false, projectRoot, project, scene, world, assets:await listAssets(projectRoot) }; + return { canceled:false, projectRoot, project, scene, world, assets:await listAssets(projectRoot), history:sceneHistory.history, historyWarning:sceneHistory.warning }; }); secureHandle('parlyn:project:close', async () => projectSession.close()); @@ -182,11 +206,22 @@ secureHandle('parlyn:project:save-scene', async (payload) => { const relativePath=payload?.relativePath || 'scenes/Main.parlyn-scene.json'; const target=await resolveWritableProjectPath(activeProjectRoot,relativePath,'Project scene path'); await writeDocumentAtomic(target, payload?.scene, 'parlyn-scene', 'Parlyn scene'); + let historyWarning = null; + if (payload?.history) { + try { + await fs.mkdir(path.join(activeProjectRoot,'.parlyn'), { recursive:true }); + const historyTarget=await resolveWritableProjectPath(activeProjectRoot,STARTUP_HISTORY_PATH,'Scene history path'); + const historyDocument={ format:'parlyn-scene-history', version:1, scenePath:relativePath, currentScene:payload.scene, history:payload.history, updatedAt:new Date().toISOString() }; + await writeDocumentAtomic(historyTarget, historyDocument, 'parlyn-scene-history', 'Parlyn scene history'); + } catch (error) { + historyWarning=error.message; + } + } const projectFile=await resolveWritableProjectPath(activeProjectRoot,'parlyn.project.json','Parlyn project file'); const project=await readDocument(projectFile, 'parlyn-project', 'Parlyn project file'); project.updatedAt=new Date().toISOString(); await writeDocumentAtomic(projectFile, project, 'parlyn-project', 'Parlyn project file'); - return { ok:true, filePath:target, relativePath }; + return { ok:true, filePath:target, relativePath, historyWarning }; }, { payload:true }); secureHandle('parlyn:project:save-world', async (payload) => { diff --git a/src/renderer/app.mjs b/src/renderer/app.mjs index c416922..322fc14 100644 --- a/src/renderer/app.mjs +++ b/src/renderer/app.mjs @@ -88,14 +88,17 @@ async function bootstrap() { rootButton.className = "tree-item scene-root"; rootButton.innerHTML = `${escapeHtml(scene.name)}`; root.appendChild(rootButton); - scene.root.children.forEach((node) => { + function appendNode(node, depth) { const b = document.createElement("button"); b.className = "tree-item child" + (selected?.id === node.id ? " active" : ""); + b.style.setProperty("--tree-depth", depth); b.dataset.id = node.id; b.innerHTML = `${escapeHtml(node.name)}`; b.addEventListener("click", () => selectById(node.id)); root.appendChild(b); - }); + node.children.forEach((child) => appendNode(child, depth + 1)); + } + scene.root.children.forEach((node) => appendNode(node, 1)); } function escapeHtml(value) { const d = document.createElement("div"); @@ -156,6 +159,8 @@ async function bootstrap() { $("inspector").hidden = true; $("selected-type").textContent = "None"; $("delete-node").disabled = true; + $("duplicate-node").disabled = true; + $("reparent-node").disabled = true; renderHierarchy(); } function selectById(id) { @@ -165,6 +170,8 @@ async function bootstrap() { renderHierarchy(); populateInspector(); $("delete-node").disabled = false; + $("duplicate-node").disabled = false; + $("reparent-node").disabled = false; status.textContent = `Selected: ${selected.name}`; } function populateInspector() { @@ -371,11 +378,67 @@ async function bootstrap() { const before = sceneSnapshot(); const name = selected.name, id = selected.id; if (!scene.removeById(id)) return; - renderer.removeNode(id); + renderer.rebuild(scene); pushHistory(before, `Delete ${name}`); clearSelection(); status.textContent = `Deleted: ${name}`; } + function duplicateSelected() { + if (!selected) return; + const before = sceneSnapshot(); + const originalName = selected.name; + const duplicate = scene.duplicateById(selected.id); + if (!duplicate) return; + renderer.rebuild(scene); + pushHistory(before, `Duplicate ${originalName}`); + renderHierarchy(); + selectById(duplicate.id); + status.textContent = `Duplicated: ${duplicate.name}`; + } + function nodePath(node) { + const names = []; + for (let current = node; current && current !== scene.root; current = current.parent) names.unshift(current.name); + return names.join(" › "); + } + function showReparentDialog() { + if (!selected) return; + const excluded = new Set(); + selected.walk((node) => excluded.add(node.id)); + const select = $("reparent-target"); + select.replaceChildren(); + const rootOption = document.createElement("option"); + rootOption.value = scene.root.id; + rootOption.textContent = "Scene Root"; + select.appendChild(rootOption); + scene.root.walk((node) => { + if (node === scene.root || excluded.has(node.id)) return; + const option = document.createElement("option"); + option.value = node.id; + option.textContent = nodePath(node); + select.appendChild(option); + }); + select.value = selected.parent?.id ?? scene.root.id; + $("reparent-node-name").textContent = selected.name; + $("reparent-dialog").showModal(); + } + function reparentSelected() { + if (!selected) return; + const before = sceneSnapshot(); + const target = scene.findById($("reparent-target").value); + try { + if (!scene.reparentById(selected.id, target?.id)) { + $("reparent-dialog").close(); + status.textContent = `${selected.name} already uses that parent.`; + return; + } + pushHistory(before, `Reparent ${selected.name}`); + renderHierarchy(); + $("reparent-dialog").close(); + status.textContent = `Moved ${selected.name} under ${target === scene.root ? "Scene Root" : target.name}`; + } catch (error) { + showError("Node reparent failed", error); + } + } function newScene() { pushHistory(sceneSnapshot(), "New Scene"); scene = new SceneDocument("Untitled Scene"); @@ -389,9 +452,14 @@ async function bootstrap() { async function saveScene() { try { if (currentProject && host?.saveProjectScene) { - const result2 = await host.saveProjectScene({ relativePath: currentSceneRelativePath || currentProject.startupScene, scene: scene.toJSON() }); + const result2 = await host.saveProjectScene({ relativePath: currentSceneRelativePath || currentProject.startupScene, scene: scene.toJSON(), history:history.exportState({ maxBytes:16 * 1024 * 1024 }) }); if (!result2.ok) throw new Error("Project scene could not be saved."); currentFilePath = result2.filePath; + if (result2.historyWarning) { + setDirty(true); + showError("Local history save failed", result2.historyWarning); + return false; + } setDirty(false); status.textContent = `Saved project scene: ${currentSceneRelativePath || currentProject.startupScene}`; return true; @@ -439,6 +507,8 @@ async function bootstrap() { currentSceneRelativePath = currentProject.startupScene; currentFilePath = null; assets = result.assets ?? []; + history.clear(); + updateHistoryButtons(); $("project-dialog").close(); updateProjectUI(); renderAssets(); @@ -462,13 +532,18 @@ async function bootstrap() { scene = SceneDocument.fromJSON(result.scene); renderer.rebuild(scene); } - history.clear(); + if (result.history) { + try { history.restoreState(result.history); } + catch (error) { console.warn("Saved scene history was ignored:", error); history.clear(); } + } else history.clear(); clearSelection(); updateHistoryButtons(); updateProjectUI(); renderAssets(); setDirty(false); - status.textContent = `Project opened: ${currentProject.name}`; + status.textContent = result.historyWarning + ? `Project opened without local history: ${result.historyWarning}` + : `Project opened: ${currentProject.name}`; } catch (error) { showError("Project open failed", error); } @@ -634,6 +709,10 @@ async function bootstrap() { }); $("cancel-add").addEventListener("click", () => $("add-dialog").close()); $("delete-node").addEventListener("click", deleteSelected); + $("duplicate-node").addEventListener("click", duplicateSelected); + $("reparent-node").addEventListener("click", showReparentDialog); + $("cancel-reparent").addEventListener("click", () => $("reparent-dialog").close()); + $("confirm-reparent").addEventListener("click", reparentSelected); $("undo").addEventListener("click", undo); $("redo").addEventListener("click", redo); $("import-asset").addEventListener("click", importAssets); @@ -664,6 +743,10 @@ async function bootstrap() { } } if (!(event.ctrlKey || event.metaKey)) return; + if (key === "d" && !editing) { + event.preventDefault(); + duplicateSelected(); + } if (key === "s") { event.preventDefault(); saveScene(); diff --git a/src/renderer/index.html b/src/renderer/index.html index 6cbf634..63d7eee 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -40,6 +40,8 @@