diff --git a/Assets/Scripts/Editor/GON/WorldEditor/JourneyCommitter.cs b/Assets/Scripts/Editor/GON/WorldEditor/JourneyCommitter.cs new file mode 100644 index 0000000..cde55de --- /dev/null +++ b/Assets/Scripts/Editor/GON/WorldEditor/JourneyCommitter.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using Intrepid.Gameplay.Journey; +using UnityEditor; + +namespace Editor.GON.WorldEditor +{ + public static class JourneyCommitter + { + public static List Commit(WorldLayoutSO layout, JGraphSO graph) + { + var messages = WorldLayoutValidator.Validate(layout); + if (WorldLayoutValidator.HasErrors(messages) || graph is null) + { + if (graph is null) + { + messages.Add(new WorldValidationMessage(WorldValidationSeverity.Error, "No target JGraphSO selected.")); + } + + return messages; + } + + layout.EnsureDefaults(); + graph.sourceWorldLayoutId = layout.id; + graph.publishedAt = DateTime.UtcNow.ToString("O"); + graph.schemaVersion = 1; + graph.stages = BuildStages(layout); + graph.endpoints = BuildEndpoints(layout); + graph.connections = BuildConnections(layout); + graph.contentHash = ComputeContentHash(graph); + graph.EnsureDefaults(); + + EditorUtility.SetDirty(graph); + AssetDatabase.SaveAssets(); + return messages; + } + + private static List BuildStages(WorldLayoutSO layout) + { + var stages = new List(); + foreach (var source in layout.stages) + { + if (source is null) + { + continue; + } + + source.EnsureDefaults(); + var stage = new JStage + { + id = source.id, + displayName = source.displayName, + worldLayer = source.worldLayer, + occupiedTiles = new List(source.occupiedTiles) + }; + stages.Add(stage); + } + + return stages; + } + + private static List BuildEndpoints(WorldLayoutSO layout) + { + var endpoints = new List(); + foreach (var source in layout.endpoints) + { + if (source is null) + { + continue; + } + + endpoints.Add(new JEndpoint + { + id = source.id, + stageId = source.stageId, + worldTile = source.worldTile, + kind = source.kind, + required = source.required, + socketFace = source.socketFace + }); + } + + return endpoints; + } + + private static List BuildConnections(WorldLayoutSO layout) + { + var connections = new List(); + foreach (var source in layout.connections) + { + if (source is null) + { + continue; + } + + connections.Add(new JConnection + { + id = source.id, + endpointAId = source.endpointAId, + endpointBId = source.endpointBId, + directionality = source.directionality + }); + } + + return connections; + } + + private static string ComputeContentHash(JGraphSO graph) + { + var builder = new StringBuilder(); + builder.Append(graph.sourceWorldLayoutId).Append('|').Append(graph.schemaVersion).AppendLine(); + + foreach (var stage in graph.stages) + { + builder.Append("S|").Append(stage.id).Append('|').Append(stage.displayName).Append('|') + .Append(stage.worldLayer).AppendLine(); + foreach (var tile in stage.occupiedTiles) + { + builder.Append("T|").Append(tile.x).Append('|').Append(tile.y).AppendLine(); + } + } + + foreach (var endpoint in graph.endpoints) + { + builder.Append("E|").Append(endpoint.id).Append('|').Append(endpoint.stageId).Append('|') + .Append(endpoint.worldTile.x).Append('|').Append(endpoint.worldTile.y).Append('|') + .Append(endpoint.kind).Append('|').Append(endpoint.required).Append('|') + .Append(endpoint.socketFace).AppendLine(); + } + + foreach (var connection in graph.connections) + { + builder.Append("C|").Append(connection.id).Append('|').Append(connection.endpointAId).Append('|') + .Append(connection.endpointBId).Append('|').Append(connection.directionality).AppendLine(); + } + + using var sha = SHA256.Create(); + var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(builder.ToString())); + var result = new StringBuilder(bytes.Length * 2); + foreach (var value in bytes) + { + result.Append(value.ToString("x2")); + } + + return result.ToString(); + } + } +} diff --git a/Assets/Scripts/Editor/GON/WorldEditor/JourneyCommitter.cs.meta b/Assets/Scripts/Editor/GON/WorldEditor/JourneyCommitter.cs.meta new file mode 100644 index 0000000..a63473e --- /dev/null +++ b/Assets/Scripts/Editor/GON/WorldEditor/JourneyCommitter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5b08cddf1c414e7b8d1f6ee4d7e67fb7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorWindow.cs b/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorWindow.cs index 721d0f6..18051ac 100644 --- a/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorWindow.cs +++ b/Assets/Scripts/Editor/GON/WorldEditor/WorldEditorWindow.cs @@ -11,10 +11,12 @@ namespace Editor.GON.WorldEditor public sealed class WorldEditorWindow : EditorWindow { private WorldLayoutSO layout; + private JGraphSO graph; private WorldEditorState state; private VisualElement topBar; private WorldMapCanvas canvas; private Label infoLabel; + private ScrollView validationView; [MenuItem("Tools/Gardens Of Nil/World Editor")] public static void Open() @@ -63,6 +65,17 @@ namespace Editor.GON.WorldEditor }; rootVisualElement.Add(infoLabel); + validationView = new ScrollView + { + style = + { + maxHeight = 130, + borderTopWidth = 1, + borderTopColor = new Color(1f, 1f, 1f, 0.12f) + } + }; + rootVisualElement.Add(validationView); + RebuildTopBar(); RebuildInfo(); } @@ -159,18 +172,41 @@ namespace Editor.GON.WorldEditor style = { marginLeft = 8 } }); - topBar.Add(new Button(ValidatePlaceholder) + var graphField = new ObjectField("J Graph") + { + objectType = typeof(JGraphSO), + value = graph, + style = + { + marginLeft = 8, + minWidth = 200 + } + }; + graphField.RegisterValueChangedCallback(evt => + { + graph = evt.newValue as JGraphSO; + RebuildTopBar(); + }); + topBar.Add(graphField); + + topBar.Add(new Button(CreateNewGraphAsset) + { + text = "+ JGraphSO", + style = { marginLeft = 4 } + }); + + topBar.Add(new Button(ValidateLayout) { text = "Validate", style = { marginLeft = 8 } }); - var commit = new Button(CommitPlaceholder) + var commit = new Button(CommitJourney) { text = "Commit Journey", style = { marginLeft = 4 } }; - commit.SetEnabled(false); + commit.SetEnabled(graph is not null); topBar.Add(commit); topBar.Add(new Button(SaveLayout) @@ -233,6 +269,26 @@ namespace Editor.GON.WorldEditor SetLayout(asset); } + private void CreateNewGraphAsset() + { + var path = EditorUtility.SaveFilePanelInProject( + "Create JGraphSO", + "JGraph", + "asset", + "Choose where to create the published Journey graph asset."); + + if (string.IsNullOrEmpty(path)) + { + return; + } + + var asset = CreateInstance(); + AssetDatabase.CreateAsset(asset, path); + AssetDatabase.SaveAssets(); + graph = asset; + RebuildTopBar(); + } + private void CreateStage() { MutateLayout("Create world stage", delegate @@ -262,14 +318,57 @@ namespace Editor.GON.WorldEditor Debug.Log("Saved WorldLayoutSO: " + AssetDatabase.GetAssetPath(layout)); } - private void ValidatePlaceholder() + private void ValidateLayout() { - EditorUtility.DisplayDialog("World Editor", "Validation is implemented by PLN-0004.", "OK"); + ShowValidationMessages(WorldLayoutValidator.Validate(layout)); } - private void CommitPlaceholder() + private void CommitJourney() { - EditorUtility.DisplayDialog("World Editor", "Journey commit is implemented by PLN-0004.", "OK"); + var messages = JourneyCommitter.Commit(layout, graph); + ShowValidationMessages(messages); + if (!WorldLayoutValidator.HasErrors(messages)) + { + Debug.Log("Committed Journey graph: " + AssetDatabase.GetAssetPath(graph)); + } + } + + private void ShowValidationMessages(System.Collections.Generic.List messages) + { + validationView?.Clear(); + if (validationView is null) + { + return; + } + + if (messages is null || messages.Count == 0) + { + validationView.Add(new Label("Validation passed.")); + return; + } + + foreach (var message in messages) + { + if (message is null) + { + continue; + } + + var label = new Label(message.severity + ": " + message.message + + (string.IsNullOrEmpty(message.objectId) ? string.Empty : " [" + message.objectId + "]")) + { + style = + { + color = message.severity == WorldValidationSeverity.Error + ? new Color(1f, 0.45f, 0.4f) + : new Color(1f, 0.82f, 0.35f), + paddingLeft = 8, + paddingTop = 2, + paddingBottom = 2 + } + }; + validationView.Add(label); + } } } } diff --git a/Assets/Scripts/Editor/GON/WorldEditor/WorldLayoutSO.cs b/Assets/Scripts/Editor/GON/WorldEditor/WorldLayoutSO.cs index 4cd0efa..0857a2b 100644 --- a/Assets/Scripts/Editor/GON/WorldEditor/WorldLayoutSO.cs +++ b/Assets/Scripts/Editor/GON/WorldEditor/WorldLayoutSO.cs @@ -76,6 +76,20 @@ namespace Editor.GON.WorldEditor return null; } + public WorldEndpointAuthoring FindEndpoint(string endpointId) + { + EnsureDefaults(); + foreach (var endpoint in endpoints) + { + if (endpoint != null && endpoint.id == endpointId) + { + return endpoint; + } + } + + return null; + } + public bool IsTileOccupiedByOther(string stageId, int worldLayer, JWorldTile tile) { var occupyingStage = FindStageAt(worldLayer, tile); diff --git a/Assets/Scripts/Editor/GON/WorldEditor/WorldLayoutValidator.cs b/Assets/Scripts/Editor/GON/WorldEditor/WorldLayoutValidator.cs new file mode 100644 index 0000000..674dcb5 --- /dev/null +++ b/Assets/Scripts/Editor/GON/WorldEditor/WorldLayoutValidator.cs @@ -0,0 +1,291 @@ +using System.Collections.Generic; +using Intrepid.Gameplay.Journey; + +namespace Editor.GON.WorldEditor +{ + public static class WorldLayoutValidator + { + public static List Validate(WorldLayoutSO layout) + { + var messages = new List(); + if (layout is null) + { + messages.Add(new WorldValidationMessage(WorldValidationSeverity.Error, "No WorldLayoutSO selected.")); + return messages; + } + + layout.EnsureDefaults(); + ValidateStages(layout, messages); + ValidateEndpoints(layout, messages); + ValidateConnections(layout, messages); + return messages; + } + + public static bool HasErrors(List messages) + { + if (messages is null) + { + return false; + } + + foreach (var message in messages) + { + if (message != null && message.severity == WorldValidationSeverity.Error) + { + return true; + } + } + + return false; + } + + private static void ValidateStages(WorldLayoutSO layout, List messages) + { + var occupied = new Dictionary(); + foreach (var stage in layout.stages) + { + if (stage is null) + { + continue; + } + + stage.EnsureDefaults(); + var endpointCount = 0; + foreach (var endpoint in layout.endpoints) + { + if (endpoint != null && endpoint.stageId == stage.id) + { + endpointCount++; + } + } + + if (endpointCount == 0) + { + messages.Add(new WorldValidationMessage(WorldValidationSeverity.Warning, + "Stage has no Journey endpoints.", stage.id)); + } + + foreach (var tile in stage.occupiedTiles) + { + var key = TileKey(stage.worldLayer, tile); + if (occupied.TryGetValue(key, out var otherStageId) && otherStageId != stage.id) + { + messages.Add(new WorldValidationMessage(WorldValidationSeverity.Error, + "WorldTile is occupied by more than one Stage in the same WorldLayer.", stage.id)); + } + else + { + occupied[key] = stage.id; + } + } + } + } + + private static void ValidateEndpoints(WorldLayoutSO layout, List messages) + { + var nonSocketByTile = new Dictionary(); + var socketByFace = new HashSet(); + + foreach (var endpoint in layout.endpoints) + { + if (endpoint is null) + { + continue; + } + + var stage = layout.FindStage(endpoint.stageId); + if (stage is null) + { + messages.Add(new WorldValidationMessage(WorldValidationSeverity.Error, + "JEndpoint references a missing Stage.", endpoint.id)); + continue; + } + + if (!stage.ContainsTile(endpoint.worldTile)) + { + messages.Add(new WorldValidationMessage(WorldValidationSeverity.Error, + "JEndpoint is placed on a WorldTile not occupied by its Stage.", endpoint.id)); + } + + if (endpoint.kind == JEndpointKind.Socket) + { + var socketKey = endpoint.stageId + ":" + endpoint.worldTile.x + ":" + endpoint.worldTile.y + ":" + + endpoint.socketFace; + if (!socketByFace.Add(socketKey)) + { + messages.Add(new WorldValidationMessage(WorldValidationSeverity.Error, + "More than one JEndpointSocket exists on the same tile face.", endpoint.id)); + } + + var adjacent = Adjacent(endpoint.worldTile, endpoint.socketFace); + if (stage.ContainsTile(adjacent)) + { + messages.Add(new WorldValidationMessage(WorldValidationSeverity.Error, + "JEndpointSocket is placed on an internal Stage face.", endpoint.id)); + } + } + else + { + var key = TileKey(stage.worldLayer, endpoint.worldTile); + if (nonSocketByTile.TryGetValue(key, out var existingKind) && existingKind != endpoint.kind) + { + messages.Add(new WorldValidationMessage(WorldValidationSeverity.Error, + "JEndpointAnchor and JEndpointLift cannot coexist on the same WorldTile.", endpoint.id)); + } + else + { + nonSocketByTile[key] = endpoint.kind; + } + } + } + } + + private static void ValidateConnections(WorldLayoutSO layout, List messages) + { + var endpointConnectionCounts = new Dictionary(); + foreach (var connection in layout.connections) + { + if (connection is null) + { + continue; + } + + var endpointA = layout.FindEndpoint(connection.endpointAId); + var endpointB = layout.FindEndpoint(connection.endpointBId); + + if (endpointA is null || endpointB is null) + { + messages.Add(new WorldValidationMessage(WorldValidationSeverity.Error, + "JConnection references a missing endpoint.", connection.id)); + continue; + } + + if (endpointA.id == endpointB.id) + { + messages.Add(new WorldValidationMessage(WorldValidationSeverity.Error, + "JConnection links an endpoint to itself.", connection.id)); + } + + if (endpointA.kind != endpointB.kind) + { + messages.Add(new WorldValidationMessage(WorldValidationSeverity.Error, + "JConnection links incompatible endpoint types.", connection.id)); + } + + Increment(endpointConnectionCounts, endpointA.id); + Increment(endpointConnectionCounts, endpointB.id); + + ValidateConnectionGeometry(layout, connection, endpointA, endpointB, messages); + } + + foreach (var endpoint in layout.endpoints) + { + if (endpoint is null) + { + continue; + } + + endpointConnectionCounts.TryGetValue(endpoint.id, out var count); + switch (endpoint.kind) + { + case JEndpointKind.Socket when count > 1: + messages.Add(new WorldValidationMessage(WorldValidationSeverity.Error, + "JEndpointSocket has more than one JConnection.", endpoint.id)); + break; + case JEndpointKind.Lift when count > 1: + messages.Add(new WorldValidationMessage(WorldValidationSeverity.Error, + "JEndpointLift has more than one JConnection.", endpoint.id)); + break; + } + + if (count == 0) + { + messages.Add(new WorldValidationMessage(endpoint.required + ? WorldValidationSeverity.Error + : WorldValidationSeverity.Warning, + endpoint.required + ? "Required JEndpoint has no JConnection." + : "Optional JEndpoint has no JConnection.", + endpoint.id)); + } + } + } + + private static void ValidateConnectionGeometry( + WorldLayoutSO layout, + WorldConnectionAuthoring connection, + WorldEndpointAuthoring endpointA, + WorldEndpointAuthoring endpointB, + List messages) + { + if (endpointA.kind == JEndpointKind.Socket && endpointB.kind == JEndpointKind.Socket) + { + if (!AreOpposite(endpointA.socketFace, endpointB.socketFace) || + !IsAdjacentForFaces(endpointA.worldTile, endpointA.socketFace, endpointB.worldTile)) + { + messages.Add(new WorldValidationMessage(WorldValidationSeverity.Error, + "JEndpointSocket connections must use opposite faces on adjacent WorldTiles.", connection.id)); + } + } + + if (endpointA.kind == JEndpointKind.Lift && endpointB.kind == JEndpointKind.Lift) + { + var stageA = layout.FindStage(endpointA.stageId); + var stageB = layout.FindStage(endpointB.stageId); + if (endpointA.worldTile.x != endpointB.worldTile.x || endpointA.worldTile.y != endpointB.worldTile.y) + { + messages.Add(new WorldValidationMessage(WorldValidationSeverity.Error, + "JEndpointLift connections must be aligned on the same 2D WorldTile coordinate.", + connection.id)); + } + + if (stageA != null && stageB != null && stageA.worldLayer == stageB.worldLayer) + { + messages.Add(new WorldValidationMessage(WorldValidationSeverity.Error, + "JEndpointLift connections must connect different WorldLayers.", connection.id)); + } + } + } + + private static void Increment(Dictionary counts, string key) + { + if (string.IsNullOrEmpty(key)) + { + return; + } + + counts.TryGetValue(key, out var count); + counts[key] = count + 1; + } + + private static string TileKey(int layer, JWorldTile tile) + { + return layer + ":" + tile.x + ":" + tile.y; + } + + private static JWorldTile Adjacent(JWorldTile tile, JSocketFace face) + { + return face switch + { + JSocketFace.North => new JWorldTile(tile.x, tile.y - 1), + JSocketFace.South => new JWorldTile(tile.x, tile.y + 1), + JSocketFace.East => new JWorldTile(tile.x + 1, tile.y), + _ => new JWorldTile(tile.x - 1, tile.y) + }; + } + + private static bool AreOpposite(JSocketFace a, JSocketFace b) + { + return (a == JSocketFace.North && b == JSocketFace.South) || + (a == JSocketFace.South && b == JSocketFace.North) || + (a == JSocketFace.East && b == JSocketFace.West) || + (a == JSocketFace.West && b == JSocketFace.East); + } + + private static bool IsAdjacentForFaces(JWorldTile a, JSocketFace faceA, JWorldTile b) + { + var expected = Adjacent(a, faceA); + return expected.x == b.x && expected.y == b.y; + } + } +} diff --git a/Assets/Scripts/Editor/GON/WorldEditor/WorldLayoutValidator.cs.meta b/Assets/Scripts/Editor/GON/WorldEditor/WorldLayoutValidator.cs.meta new file mode 100644 index 0000000..dcbf687 --- /dev/null +++ b/Assets/Scripts/Editor/GON/WorldEditor/WorldLayoutValidator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 86f52d9a97c44cb1b8593c99d411f5b1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Editor/GON/WorldEditor/WorldValidation.cs b/Assets/Scripts/Editor/GON/WorldEditor/WorldValidation.cs new file mode 100644 index 0000000..2092e3c --- /dev/null +++ b/Assets/Scripts/Editor/GON/WorldEditor/WorldValidation.cs @@ -0,0 +1,25 @@ +using System; + +namespace Editor.GON.WorldEditor +{ + public enum WorldValidationSeverity + { + Warning = 0, + Error = 1 + } + + [Serializable] + public sealed class WorldValidationMessage + { + public WorldValidationSeverity severity; + public string message; + public string objectId; + + public WorldValidationMessage(WorldValidationSeverity severity, string message, string objectId = null) + { + this.severity = severity; + this.message = message; + this.objectId = objectId; + } + } +} diff --git a/Assets/Scripts/Editor/GON/WorldEditor/WorldValidation.cs.meta b/Assets/Scripts/Editor/GON/WorldEditor/WorldValidation.cs.meta new file mode 100644 index 0000000..3ee388f --- /dev/null +++ b/Assets/Scripts/Editor/GON/WorldEditor/WorldValidation.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e037255368a041d491478b61b7e0e75e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/discussion/index.ndjson b/discussion/index.ndjson index 4e0b917..1199c65 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -1,2 +1,2 @@ {"type":"meta","next_id":{"DSC":2,"AGD":2,"DEC":2,"PLN":5,"LSN":1,"CLSN":1}} -{"type":"discussion","id":"DSC-0001","status":"accepted","ticket":"world-editor-journey-spec","title":"World Editor / Journey Spec","created_at":"2026-06-19","updated_at":"2026-06-19","tags":["world-editor","journey","stage-contract","architecture"],"agendas":[{"id":"AGD-0001","file":"AGD-0001-world-editor-journey-spec.md","status":"accepted","created_at":"2026-06-19","updated_at":"2026-06-19"}],"decisions":[{"id":"DEC-0001","file":"DEC-0001-world-editor-journey-v1.md","status":"accepted","created_at":"2026-06-19","updated_at":"2026-06-19","ref_agenda":"AGD-0001"}],"plans":[{"id":"PLN-0001","file":"PLN-0001-world-journey-spec.md","status":"done","created_at":"2026-06-19","updated_at":"2026-06-19","ref_decisions":["DEC-0001"]},{"id":"PLN-0002","file":"PLN-0002-journey-runtime-model.md","status":"done","created_at":"2026-06-19","updated_at":"2026-06-19","ref_decisions":["DEC-0001"]},{"id":"PLN-0003","file":"PLN-0003-world-editor-authoring.md","status":"done","created_at":"2026-06-19","updated_at":"2026-06-19","ref_decisions":["DEC-0001"]},{"id":"PLN-0004","file":"PLN-0004-journey-commit-validation.md","status":"review","created_at":"2026-06-19","updated_at":"2026-06-19","ref_decisions":["DEC-0001"]}],"lessons":[]} +{"type":"discussion","id":"DSC-0001","status":"accepted","ticket":"world-editor-journey-spec","title":"World Editor / Journey Spec","created_at":"2026-06-19","updated_at":"2026-06-19","tags":["world-editor","journey","stage-contract","architecture"],"agendas":[{"id":"AGD-0001","file":"AGD-0001-world-editor-journey-spec.md","status":"accepted","created_at":"2026-06-19","updated_at":"2026-06-19"}],"decisions":[{"id":"DEC-0001","file":"DEC-0001-world-editor-journey-v1.md","status":"accepted","created_at":"2026-06-19","updated_at":"2026-06-19","ref_agenda":"AGD-0001"}],"plans":[{"id":"PLN-0001","file":"PLN-0001-world-journey-spec.md","status":"done","created_at":"2026-06-19","updated_at":"2026-06-19","ref_decisions":["DEC-0001"]},{"id":"PLN-0002","file":"PLN-0002-journey-runtime-model.md","status":"done","created_at":"2026-06-19","updated_at":"2026-06-19","ref_decisions":["DEC-0001"]},{"id":"PLN-0003","file":"PLN-0003-world-editor-authoring.md","status":"done","created_at":"2026-06-19","updated_at":"2026-06-19","ref_decisions":["DEC-0001"]},{"id":"PLN-0004","file":"PLN-0004-journey-commit-validation.md","status":"done","created_at":"2026-06-19","updated_at":"2026-06-19","ref_decisions":["DEC-0001"]}],"lessons":[]} diff --git a/discussion/workflow/plans/PLN-0004-journey-commit-validation.md b/discussion/workflow/plans/PLN-0004-journey-commit-validation.md index d07c22e..30670ba 100644 --- a/discussion/workflow/plans/PLN-0004-journey-commit-validation.md +++ b/discussion/workflow/plans/PLN-0004-journey-commit-validation.md @@ -2,7 +2,7 @@ id: PLN-0004 ticket: world-editor-journey-spec title: Journey Commit and Validation -status: review +status: done created: 2026-06-19 decisions: - DEC-0001