implements PLN-0004 journey commit validation

This commit is contained in:
Nilton Constantino 2026-06-19 13:42:55 +01:00
parent da4f1caaa7
commit 27b751fd40
No known key found for this signature in database
10 changed files with 621 additions and 9 deletions

View File

@ -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<WorldValidationMessage> 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<JStage> BuildStages(WorldLayoutSO layout)
{
var stages = new List<JStage>();
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<JWorldTile>(source.occupiedTiles)
};
stages.Add(stage);
}
return stages;
}
private static List<JEndpoint> BuildEndpoints(WorldLayoutSO layout)
{
var endpoints = new List<JEndpoint>();
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<JConnection> BuildConnections(WorldLayoutSO layout)
{
var connections = new List<JConnection>();
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();
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5b08cddf1c414e7b8d1f6ee4d7e67fb7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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<JGraphSO>();
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<WorldValidationMessage> 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);
}
}
}
}

View File

@ -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);

View File

@ -0,0 +1,291 @@
using System.Collections.Generic;
using Intrepid.Gameplay.Journey;
namespace Editor.GON.WorldEditor
{
public static class WorldLayoutValidator
{
public static List<WorldValidationMessage> Validate(WorldLayoutSO layout)
{
var messages = new List<WorldValidationMessage>();
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<WorldValidationMessage> 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<WorldValidationMessage> messages)
{
var occupied = new Dictionary<string, string>();
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<WorldValidationMessage> messages)
{
var nonSocketByTile = new Dictionary<string, JEndpointKind>();
var socketByFace = new HashSet<string>();
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<WorldValidationMessage> messages)
{
var endpointConnectionCounts = new Dictionary<string, int>();
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<WorldValidationMessage> 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<string, int> 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;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 86f52d9a97c44cb1b8593c99d411f5b1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e037255368a041d491478b61b7e0e75e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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":[]}

View File

@ -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