diff --git a/Editor/DialogLineRefDrawer.cs b/Editor/DialogLineRefDrawer.cs
new file mode 100644
index 0000000..692aa09
--- /dev/null
+++ b/Editor/DialogLineRefDrawer.cs
@@ -0,0 +1,122 @@
+using Jovian.EncounterSystem;
+using UnityEditor;
+using UnityEngine;
+
+namespace Jovian.EncounterSystem.Editor {
+ ///
+ /// Drawer for . Shows the library + id picker on one row, the inline
+ /// fallback text area underneath, and a resolved preview below that. Preserves WYSIWYG — the
+ /// final text is always visible.
+ ///
+ [CustomPropertyDrawer(typeof(DialogLineRef))]
+ public class DialogLineRefDrawer : PropertyDrawer {
+ private const string NonePlaceholder = "";
+ private const string EmptyLibraryPlaceholder = "";
+ private const float PreviewHeight = 32f;
+
+ public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) {
+ var libraryProp = property.FindPropertyRelative("library");
+ var idProp = property.FindPropertyRelative("id");
+ var inlineProp = property.FindPropertyRelative("inlineText");
+
+ EditorGUI.BeginProperty(position, label, property);
+
+ var lineHeight = EditorGUIUtility.singleLineHeight;
+ var spacing = EditorGUIUtility.standardVerticalSpacing;
+
+ var libraryRect = new Rect(position.x, position.y, position.width, lineHeight);
+ EditorGUI.BeginChangeCheck();
+ EditorGUI.PropertyField(libraryRect, libraryProp, label);
+ var libraryChanged = EditorGUI.EndChangeCheck();
+
+ using(new EditorGUI.IndentLevelScope()) {
+ var idRect = new Rect(position.x, position.y + lineHeight + spacing, position.width, lineHeight);
+ DrawIdPicker(idRect, libraryProp, idProp, libraryChanged);
+
+ var inlineRect = new Rect(
+ position.x,
+ position.y + (lineHeight + spacing) * 2,
+ position.width,
+ lineHeight * 2);
+ EditorGUI.PropertyField(inlineRect, inlineProp, new GUIContent("Inline"));
+
+ var previewRect = new Rect(
+ position.x,
+ position.y + (lineHeight + spacing) * 2 + lineHeight * 2 + spacing,
+ position.width,
+ PreviewHeight);
+ DrawPreview(previewRect, libraryProp, idProp, inlineProp);
+ }
+
+ EditorGUI.EndProperty();
+ }
+
+ public override float GetPropertyHeight(SerializedProperty property, GUIContent label) {
+ var lineHeight = EditorGUIUtility.singleLineHeight;
+ var spacing = EditorGUIUtility.standardVerticalSpacing;
+ // library + id + (inline 2 lines) + preview
+ return (lineHeight + spacing) * 2 + lineHeight * 2 + spacing + PreviewHeight;
+ }
+
+ private static void DrawIdPicker(Rect rect, SerializedProperty libraryProp, SerializedProperty idProp, bool libraryChanged) {
+ var library = libraryProp.objectReferenceValue as DialogLineLibrary;
+ if(library == null || library.lines == null || library.lines.Count == 0) {
+ using(new EditorGUI.DisabledScope(true)) {
+ EditorGUI.Popup(rect, "Id", 0, new[] { EmptyLibraryPlaceholder });
+ }
+ if(libraryChanged) {
+ idProp.stringValue = string.Empty;
+ }
+ return;
+ }
+
+ var count = library.lines.Count;
+ var ids = new string[count + 1];
+ var names = new string[count + 1];
+ ids[0] = string.Empty;
+ names[0] = NonePlaceholder;
+
+ var currentIndex = 0;
+ for(int i = 0; i < count; i++) {
+ var line = library.lines[i];
+ var id = line?.id ?? string.Empty;
+ ids[i + 1] = id;
+ names[i + 1] = string.IsNullOrEmpty(id) ? $"" : id;
+ if(!libraryChanged && id == idProp.stringValue && !string.IsNullOrEmpty(id)) {
+ currentIndex = i + 1;
+ }
+ }
+
+ if(libraryChanged) {
+ idProp.stringValue = string.Empty;
+ currentIndex = 0;
+ }
+
+ var newIndex = EditorGUI.Popup(rect, "Id", currentIndex, names);
+ if(newIndex != currentIndex) {
+ idProp.stringValue = ids[newIndex];
+ }
+ }
+
+ private static void DrawPreview(Rect rect, SerializedProperty libraryProp, SerializedProperty idProp, SerializedProperty inlineProp) {
+ var library = libraryProp.objectReferenceValue as DialogLineLibrary;
+ var id = idProp.stringValue;
+ var inline = inlineProp.stringValue;
+
+ string resolved = null;
+ if(library != null && !string.IsNullOrEmpty(id)) {
+ resolved = library.Resolve(id);
+ }
+ if(string.IsNullOrEmpty(resolved)) {
+ resolved = inline;
+ }
+
+ var style = new GUIStyle(EditorStyles.helpBox) {
+ fontStyle = FontStyle.Italic,
+ wordWrap = true
+ };
+ var label = string.IsNullOrEmpty(resolved) ? "" : resolved;
+ EditorGUI.LabelField(rect, new GUIContent("Preview: " + label), style);
+ }
+ }
+}
diff --git a/Editor/EncounterValidator.cs b/Editor/EncounterValidator.cs
index 0992c34..0528b7e 100644
--- a/Editor/EncounterValidator.cs
+++ b/Editor/EncounterValidator.cs
@@ -160,7 +160,13 @@ namespace Jovian.EncounterSystem.Editor {
for(int o = 0; o < optionSet.options.Count; o++) {
var option = optionSet.options[o];
- if(option?.events == null) {
+ if(option == null) {
+ continue;
+ }
+
+ ValidateDialogLineRef(optionSet, encounter, $"options[{o}].text", option.text, issues);
+
+ if(option.events == null) {
continue;
}
@@ -190,6 +196,43 @@ namespace Jovian.EncounterSystem.Editor {
}
}
+ private static void ValidateDialogLineRef(EncounterDialogOptionSet optionSet, Encounter encounter, string path, DialogLineRef lineRef, List issues) {
+ var hasLibrary = lineRef.library != null;
+ var hasId = !string.IsNullOrEmpty(lineRef.id);
+ var hasInline = !string.IsNullOrEmpty(lineRef.inlineText);
+
+ if(!hasLibrary && !hasInline) {
+ issues.Add(new ValidationIssue {
+ asset = optionSet,
+ encounter = encounter,
+ path = path,
+ severity = ValidationSeverity.Warning,
+ message = "Dialog line is empty (no library reference and no inline text)."
+ });
+ return;
+ }
+
+ if(hasLibrary && hasId && string.IsNullOrEmpty(lineRef.library.Resolve(lineRef.id))) {
+ issues.Add(new ValidationIssue {
+ asset = optionSet,
+ encounter = encounter,
+ path = path,
+ severity = ValidationSeverity.Error,
+ message = $"DialogLineRef id '{lineRef.id}' not found in library '{lineRef.library.name}'."
+ });
+ }
+
+ if(hasLibrary && !hasId && !hasInline) {
+ issues.Add(new ValidationIssue {
+ asset = optionSet,
+ encounter = encounter,
+ path = path,
+ severity = ValidationSeverity.Warning,
+ message = "Library assigned but no id picked; will fall back to empty inline text."
+ });
+ }
+ }
+
private static void ValidateReward(Reward reward, List issues) {
if(reward == null) {
return;
diff --git a/README.md b/README.md
index 1c5d7ae..4548ca0 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,9 @@ Packages/com.jovian.encounter-system/
│ ├── IEncounterKind.cs ← marker interface + Combat/Quest/Social/Puzzle/...
│ ├── EncounterTable.cs ← ScriptableObject: list of encounters + roll helpers
│ ├── EncountersCollection.cs ← ScriptableObject: group of tables
-│ ├── EncounterDialogOptionSet.cs ← ScriptableObject: shared option list
+│ ├── EncounterDialogOptionSet.cs ← ScriptableObject: shared option list (auto-renames to id)
+│ ├── DialogLineLibrary.cs ← ScriptableObject: id → text registry for reusable lines
+│ ├── DialogLineRef.cs ← struct: library+id reference with inline fallback
│ ├── EncounterLink.cs ← cross-table reference (table + internalId)
│ ├── EncounterReference.cs ← MonoBehaviour scaffold wiring common UI fields
│ ├── EncounterRegistry.cs ← id → encounter lookup cache
@@ -26,6 +28,8 @@ Packages/com.jovian.encounter-system/
└── Editor/
├── SubclassSelectorDrawer.cs ← dropdown + inline children for [SerializeReference] fields
├── EncounterLinkDrawer.cs ← two-row table + encounter picker
+ ├── DialogLineRefDrawer.cs ← library+id picker with inline fallback and live preview
+ ├── EncounterDrawer.cs ← list element label shows encounter id
├── EncounterValidator.cs ← project-wide scan + "Validate All" menu + browser badges
└── EncounterBrowserWindow.cs ← Jovian → Encounters → Encounter Browser
```
@@ -90,6 +94,21 @@ resolver.Register((evt, ctx) => rewardApplier.Apply(evt.reward,
`rewardApplier` lives in game code — the package ships only the data types.
+### Reusable dialog lines via `DialogLineLibrary` + `DialogLineRef`
+
+A single `DialogLineLibrary` asset (or a handful split by topic) holds `{ id → text }` entries. Every `EncounterDialogOption.text` is a `DialogLineRef` struct that resolves in this order:
+
+1. If the library reference + id resolves, use that.
+2. Otherwise fall back to `inlineText`.
+
+The drawer shows all three inputs plus a live preview of the final text — WYSIWYG survives. Designers prototype inline and promote common lines to the library later without changing the field's type.
+
+```csharp
+resolver.Register((evt, ctx) => {
+ var text = option.text.Resolve(); // library lookup, inline fallback, null if both empty
+});
+```
+
### Cross-table references via `EncounterLink`
`EncounterLink` stores a table asset + stable `internalId` GUID. Rename-safe (the GUID doesn't change) and diffable. The custom drawer renders two dropdowns: first pick a table, then pick an encounter inside it.
@@ -141,7 +160,8 @@ encounterRegistry.GetEncounters().TryGetValue(id, out var encounter);
|-----------|-------------|
| Assets → Create → Jovian → Encounter System → Encounter Table | New table asset |
| Assets → Create → Jovian → Encounter System → Encounters Collection | New collection asset |
-| Assets → Create → Jovian → Encounter System → Dialog Option Set | New dialog option set |
+| Assets → Create → Jovian → Encounter System → Dialog Option Set | New dialog option set (auto-renames to id) |
+| Assets → Create → Jovian → Encounter System → Dialog Line Library | New shared dialog line library |
| Assets → Create → Jovian → Encounter System → Encounter Registry | New registry asset |
| Jovian → Encounters → Encounter Browser | Searchable designer browser |
| Jovian → Encounters → Validate All | Scan all tables/rewards for issues, print click-through report |