Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions .github/workflows/publish-unsigned-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
49 changes: 49 additions & 0 deletions docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.5.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 6 additions & 1 deletion docs/PROJECT-FORMAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand All @@ -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.

Expand Down
11 changes: 6 additions & 5 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions docs/TESTING-v0.5.0.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
8 changes: 4 additions & 4 deletions docs/WINDOWS-DISTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
29 changes: 29 additions & 0 deletions scripts/check-core-invariants.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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();
Expand All @@ -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) {
Expand Down
21 changes: 20 additions & 1 deletion scripts/check-core-persistence.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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));
}
Expand All @@ -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]));
Expand Down Expand Up @@ -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/);
Expand Down
3 changes: 3 additions & 0 deletions scripts/check-desktop-boundaries.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
Loading
Loading