Worldshaper/Release/docs/dialogue-builder.html
2026-06-26 18:18:14 -04:00

2341 lines
91 KiB
HTML

<!doctype html>
<html lang="en" data-theme="azure">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dialogue Graph Builder Prototype</title>
<style>
:root {
color-scheme: dark;
--editor-shell-bg: #0A1020;
--editor-shell-fg: #D8E8FF;
--editor-menu-grad-1: #152645;
--editor-menu-grad-2: #10203C;
--editor-sidebar-bg: #0E1A33;
--editor-stage-bg: #060A14;
--editor-border: #2E426C;
--editor-border-strong: #3C5E95;
--editor-panel-bg: #121F3B;
--editor-panel-bg-alt: #132B4F;
--editor-panel-bg-elevated: #10284B;
--editor-panel-bg-hover: #1A3F6D;
--editor-control-bg: #1A345E;
--editor-control-bg-hover: #214679;
--editor-control-bg-active: #1E4B82;
--editor-control-border: #3C5E95;
--editor-control-fg: #D6E7FF;
--editor-muted: #9FB8E5;
--editor-muted-strong: #CFE2FF;
--editor-accent: #64AAF8;
--editor-accent-strong: #8FD0FF;
--editor-accent-soft: #22466E;
--editor-warn: #FFD166;
--editor-danger: #3C1A1A;
--editor-danger-border: #7F4C4C;
--editor-danger-hover: #5A2323;
--editor-status-ok: #B9CFEF;
--editor-status-error: #FF9E9E;
--editor-tab-shadow: rgba(3, 8, 18, 0.8);
}
html[data-theme="verdant"] {
--editor-shell-bg: #081510;
--editor-shell-fg: #DDF7EA;
--editor-menu-grad-1: #16352B;
--editor-menu-grad-2: #0E251E;
--editor-sidebar-bg: #0D221B;
--editor-stage-bg: #06100D;
--editor-border: #305847;
--editor-border-strong: #46806A;
--editor-panel-bg: #122A22;
--editor-panel-bg-alt: #17362C;
--editor-panel-bg-elevated: #133127;
--editor-panel-bg-hover: #21503F;
--editor-control-bg: #1B4335;
--editor-control-bg-hover: #245844;
--editor-control-bg-active: #2D7258;
--editor-control-border: #46806A;
--editor-control-fg: #DDF7EA;
--editor-muted: #A5D2BE;
--editor-muted-strong: #D2F0E0;
--editor-accent: #70D8A6;
--editor-accent-strong: #8AE8BE;
--editor-accent-soft: #275845;
--editor-warn: #F5D66D;
--editor-danger: #472123;
--editor-danger-border: #8B5559;
--editor-danger-hover: #633034;
--editor-status-ok: #C0E9D5;
--editor-status-error: #FFADAD;
--editor-tab-shadow: rgba(2, 10, 8, 0.76);
}
html[data-theme="ember"] {
--editor-shell-bg: #160C0B;
--editor-shell-fg: #FFE8D9;
--editor-menu-grad-1: #432018;
--editor-menu-grad-2: #24110F;
--editor-sidebar-bg: #21110E;
--editor-stage-bg: #100706;
--editor-border: #6A3B33;
--editor-border-strong: #9A5A4D;
--editor-panel-bg: #311A16;
--editor-panel-bg-alt: #3F211B;
--editor-panel-bg-elevated: #341B17;
--editor-panel-bg-hover: #5A2E26;
--editor-control-bg: #4A261F;
--editor-control-bg-hover: #67352B;
--editor-control-bg-active: #8B4937;
--editor-control-border: #9A5A4D;
--editor-control-fg: #FFE8D9;
--editor-muted: #E2B6A2;
--editor-muted-strong: #FFE0CF;
--editor-accent: #FFB36C;
--editor-accent-strong: #FFD08E;
--editor-accent-soft: #684133;
--editor-warn: #FFE17A;
--editor-danger: #512225;
--editor-danger-border: #A16063;
--editor-danger-hover: #6A2E32;
--editor-status-ok: #F6C8AF;
--editor-status-error: #FFB1A3;
--editor-tab-shadow: rgba(16, 6, 2, 0.76);
}
html[data-theme="amethyst"] {
--editor-shell-bg: #0F0B19;
--editor-shell-fg: #F0E6FF;
--editor-menu-grad-1: #2A1F45;
--editor-menu-grad-2: #171125;
--editor-sidebar-bg: #17112A;
--editor-stage-bg: #0A0712;
--editor-border: #4E4474;
--editor-border-strong: #7662A9;
--editor-panel-bg: #211A39;
--editor-panel-bg-alt: #2A2149;
--editor-panel-bg-elevated: #241D41;
--editor-panel-bg-hover: #3B3066;
--editor-control-bg: #35295D;
--editor-control-bg-hover: #473678;
--editor-control-bg-active: #5B4594;
--editor-control-border: #7662A9;
--editor-control-fg: #F0E6FF;
--editor-muted: #C6B3E6;
--editor-muted-strong: #E5D9FF;
--editor-accent: #C38BFF;
--editor-accent-strong: #DDB5FF;
--editor-accent-soft: #493C72;
--editor-warn: #F7D37E;
--editor-danger: #4A213F;
--editor-danger-border: #935C8A;
--editor-danger-hover: #632D56;
--editor-status-ok: #D9C5FF;
--editor-status-error: #FFB6DE;
--editor-tab-shadow: rgba(10, 6, 20, 0.78);
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: var(--editor-shell-bg);
color: var(--editor-shell-fg);
font-family: "Segoe UI", Arial, sans-serif;
font-size: 13px;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
.shell {
display: grid;
grid-template-rows: 40px minmax(0, 1fr);
width: 100vw;
height: 100vh;
background: var(--editor-shell-bg);
}
.menu-bar {
display: grid;
grid-template-columns: minmax(260px, 1fr) auto minmax(260px, 1fr);
align-items: center;
gap: 10px;
padding: 4px 8px;
border-bottom: 1px solid var(--editor-border-strong);
background: linear-gradient(180deg, var(--editor-menu-grad-1), var(--editor-menu-grad-2));
box-shadow: 0 8px 18px var(--editor-tab-shadow);
min-width: 0;
}
.menu-left,
.menu-right {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.menu-right {
justify-content: flex-end;
}
.menu-title {
font-size: 13px;
font-weight: 800;
color: var(--editor-muted-strong);
text-align: center;
white-space: nowrap;
}
.menu-btn,
.small-btn,
.choice-btn {
border: 1px solid var(--editor-control-border);
border-radius: 7px;
color: var(--editor-control-fg);
background: linear-gradient(180deg, var(--editor-control-bg-hover), var(--editor-control-bg));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.menu-btn {
min-height: 30px;
padding: 5px 11px;
font-weight: 700;
}
.menu-btn:hover,
.small-btn:hover,
.choice-btn:hover {
background: linear-gradient(180deg, var(--editor-control-bg-active), var(--editor-control-bg-hover));
}
.theme-preset-bar {
display: grid;
grid-template-columns: repeat(4, 40px);
justify-content: end;
gap: 9px;
margin-left: 8px;
min-width: 187px;
}
.theme-preset-btn {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border: 0;
border-radius: 8px;
background: transparent;
padding: 0;
}
.theme-preset-swatch {
position: relative;
width: 30px;
height: 30px;
border: 2px solid var(--editor-control-border);
border-radius: 8px;
background:
linear-gradient(135deg, var(--theme-swatch-a) 0 50%, var(--theme-swatch-b) 50% 100%);
transition: width 120ms ease, height 120ms ease, border-color 120ms ease;
overflow: hidden;
}
.theme-preset-swatch::before,
.theme-preset-swatch::after {
content: "";
position: absolute;
inset: 0;
}
.theme-preset-swatch::before {
background: linear-gradient(135deg, transparent 0 50%, var(--theme-swatch-c) 50% 75%, transparent 75% 100%);
}
.theme-preset-swatch::after {
background: linear-gradient(315deg, transparent 0 52%, var(--theme-swatch-d) 52% 100%);
opacity: 0.9;
}
.theme-preset-btn.active .theme-preset-swatch {
width: 36px;
height: 36px;
border-color: var(--editor-accent-strong);
}
.workspace {
display: grid;
grid-template-columns: 325px minmax(0, 1.25fr) minmax(390px, 0.8fr);
min-height: 0;
}
.sidebar,
.stage,
.inspector {
min-height: 0;
overflow: hidden;
}
.sidebar {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
border-right: 1px solid var(--editor-border-strong);
background: var(--editor-sidebar-bg);
}
.sidebar-head,
.inspector-head {
padding: 10px;
border-bottom: 1px solid var(--editor-border);
background: linear-gradient(180deg, var(--editor-panel-bg), var(--editor-sidebar-bg));
}
.eyebrow {
margin: 0 0 3px;
color: var(--editor-accent-strong);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
h1,
h2,
h3,
p {
margin-top: 0;
}
h1 {
margin-bottom: 4px;
font-size: 20px;
line-height: 1.1;
}
h2 {
margin-bottom: 8px;
font-size: 15px;
color: var(--editor-muted-strong);
}
h3 {
margin: 0;
font-size: 13px;
color: var(--editor-muted-strong);
}
.hint {
color: var(--editor-muted);
line-height: 1.4;
}
.palette-scroll,
.builder-scroll,
.inspector-scroll {
min-height: 0;
overflow: auto;
scrollbar-gutter: stable;
}
.palette-scroll {
padding: 10px;
}
.palette-section,
.panel,
.node-card,
.branch-card,
.choice-card {
border: 1px solid var(--editor-border);
border-radius: 8px;
background: var(--editor-panel-bg);
}
.palette-section {
margin-bottom: 10px;
padding: 9px;
}
.palette-section h2 {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 0 0 8px;
padding-bottom: 7px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.block-grid {
display: grid;
grid-template-columns: 1fr;
gap: 7px;
}
.block-button {
display: grid;
grid-template-columns: 24px 1fr;
align-items: center;
gap: 8px;
min-height: 38px;
width: 100%;
border: 1px solid var(--editor-control-border);
border-radius: 7px;
padding: 6px 8px;
text-align: left;
color: var(--editor-control-fg);
background: linear-gradient(180deg, var(--editor-control-bg), var(--editor-panel-bg-alt));
}
.block-button:hover {
background: linear-gradient(180deg, var(--editor-control-bg-hover), var(--editor-control-bg));
}
.block-icon {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 6px;
color: var(--editor-shell-bg);
background: var(--editor-accent);
font-weight: 900;
}
.block-meta {
display: block;
color: var(--editor-muted);
font-size: 11px;
line-height: 1.2;
}
.stage {
display: grid;
grid-template-rows: 32px minmax(0, 1fr);
background: var(--editor-stage-bg);
}
.meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 10px;
border-bottom: 1px solid var(--editor-border);
color: var(--editor-muted-strong);
background: #020611;
min-width: 0;
}
.builder-scroll {
padding: 12px;
}
.builder-layout {
display: grid;
gap: 10px;
}
.panel {
padding: 10px;
}
.dialogue-fields {
display: grid;
grid-template-columns: minmax(160px, 0.7fr) minmax(220px, 1fr) minmax(180px, 0.7fr);
gap: 8px;
align-items: end;
}
label {
display: grid;
gap: 4px;
color: var(--editor-muted);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
input,
select,
textarea {
width: 100%;
border: 1px solid var(--editor-control-border);
border-radius: 7px;
color: var(--editor-control-fg);
background: var(--editor-stage-bg);
padding: 7px 8px;
outline: none;
}
textarea {
resize: vertical;
min-height: 58px;
line-height: 1.4;
}
input:focus,
select:focus,
textarea:focus {
border-color: var(--editor-accent);
box-shadow: 0 0 0 2px var(--editor-drop-shadow, rgba(100, 170, 248, 0.25));
}
.drop-zone,
.drop-zone *,
.node-card input,
.node-card select,
.node-card textarea {
text-transform: none;
letter-spacing: 0;
}
.drop-zone {
min-height: 44px;
border: 1px dashed color-mix(in srgb, var(--editor-accent) 55%, var(--editor-border));
border-radius: 8px;
background: rgba(255, 255, 255, 0.025);
padding: 7px;
transition: background 120ms ease, border-color 120ms ease;
}
.drop-zone.empty::before {
content: attr(data-empty);
display: block;
color: var(--editor-muted);
font-size: 12px;
padding: 5px;
}
.drop-zone.drag-over {
border-color: var(--editor-accent-strong);
background: color-mix(in srgb, var(--editor-accent-soft) 48%, transparent);
}
.node-stack {
display: grid;
gap: 12px;
}
.node-card {
overflow: hidden;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.24);
}
.node-card.selected {
border-color: var(--editor-accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--editor-accent) 30%, transparent);
}
.node-head,
.branch-head,
.choice-head {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 8px;
padding: 9px;
border-bottom: 1px solid var(--editor-border);
background: linear-gradient(180deg, var(--editor-panel-bg-alt), var(--editor-panel-bg));
}
.node-head-main {
display: grid;
grid-template-columns: minmax(160px, 0.7fr) minmax(220px, 1fr);
gap: 8px;
min-width: 0;
}
.node-actions,
.branch-actions-top,
.choice-actions-top,
.row-actions {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
}
.small-btn {
min-height: 28px;
padding: 4px 8px;
font-size: 12px;
font-weight: 700;
}
.small-btn.danger {
border-color: var(--editor-danger-border);
background: linear-gradient(180deg, var(--editor-danger-hover), var(--editor-danger));
}
.small-btn.ghost {
background: transparent;
}
.node-body,
.branch-body,
.choice-body {
display: grid;
gap: 9px;
padding: 9px;
}
.branch-stack,
.choice-stack,
.logic-stack {
display: grid;
gap: 8px;
}
.branch-card {
background: color-mix(in srgb, var(--editor-panel-bg) 88%, var(--editor-accent-soft));
}
.branch-card.selected,
.choice-card.selected {
border-color: var(--editor-accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--editor-accent) 28%, transparent);
}
.choice-card {
background: var(--editor-panel-bg-elevated);
}
.logic-row {
display: grid;
grid-template-columns: minmax(90px, 0.55fr) minmax(130px, 0.85fr) minmax(72px, 0.45fr) auto;
align-items: end;
gap: 7px;
padding: 7px;
border: 1px solid var(--editor-border);
border-radius: 7px;
background: rgba(0, 0, 0, 0.16);
}
.action-row {
grid-template-columns: minmax(110px, 0.7fr) minmax(150px, 1fr) auto;
}
.logic-type {
display: inline-flex;
align-items: center;
min-height: 30px;
border-radius: 6px;
padding: 6px 8px;
color: var(--editor-shell-bg);
background: var(--editor-accent);
font-weight: 900;
overflow-wrap: anywhere;
}
.check-label {
display: flex;
align-items: center;
gap: 6px;
min-height: 30px;
color: var(--editor-muted-strong);
font-size: 12px;
text-transform: none;
letter-spacing: 0;
}
.check-label input {
width: auto;
}
.inspector {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
border-left: 1px solid var(--editor-border-strong);
background: var(--editor-sidebar-bg);
}
.inspector-scroll {
display: grid;
gap: 10px;
align-content: start;
padding: 10px;
}
.json-area {
min-height: 330px;
font-family: Consolas, "Courier New", monospace;
font-size: 12px;
white-space: pre;
}
.validation-list,
.log-list {
display: grid;
gap: 5px;
margin: 0;
padding: 0;
list-style: none;
}
.validation-list li,
.log-list li,
.state-chip {
border: 1px solid var(--editor-border);
border-radius: 7px;
background: rgba(0, 0, 0, 0.18);
padding: 6px 7px;
color: var(--editor-muted-strong);
}
.validation-list li.ok {
border-color: color-mix(in srgb, var(--editor-accent) 55%, var(--editor-border));
}
.validation-list li.warn {
border-color: var(--editor-warn);
color: var(--editor-warn);
}
.sim-screen {
display: grid;
gap: 9px;
border: 1px solid var(--editor-border-strong);
border-radius: 8px;
padding: 10px;
background: #020611;
}
.npc-line {
min-height: 82px;
border: 1px solid var(--editor-border);
border-radius: 8px;
padding: 10px;
color: var(--editor-muted-strong);
background: linear-gradient(180deg, var(--editor-panel-bg), var(--editor-stage-bg));
line-height: 1.45;
}
.choice-list {
display: grid;
gap: 7px;
}
.choice-btn {
min-height: 34px;
padding: 7px 9px;
text-align: left;
font-weight: 700;
}
.state-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.state-controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--editor-border);
border-radius: 999px;
padding: 4px 8px;
color: var(--editor-muted-strong);
background: var(--editor-panel-bg);
font-size: 12px;
white-space: nowrap;
}
.mini-note {
margin: 0;
color: var(--editor-muted);
font-size: 12px;
line-height: 1.35;
}
@media (max-width: 1250px) {
.workspace {
grid-template-columns: 300px minmax(0, 1fr);
}
.inspector {
display: none;
}
}
@media (max-width: 900px) {
.workspace {
grid-template-columns: 1fr;
}
.sidebar {
display: none;
}
.menu-bar {
grid-template-columns: 1fr;
height: auto;
}
.shell {
grid-template-rows: auto minmax(0, 1fr);
}
}
</style>
</head>
<body>
<div class="shell">
<header class="menu-bar">
<div class="menu-left">
<button class="menu-btn" type="button" onclick="window.location.href='/wiki'">Wiki</button>
<button class="menu-btn" id="resetExampleBtn" type="button">Reset Example</button>
<button class="menu-btn" id="copyJsonBtn" type="button">Copy JSON</button>
</div>
<div class="menu-title">Dialogue Graph Builder Prototype</div>
<div class="menu-right">
<span class="status-pill" id="statusPill">Declarative graph model</span>
<div class="theme-preset-bar" role="group" aria-label="Theme presets">
<button class="theme-preset-btn active" data-theme="azure" type="button" title="Azure theme" aria-label="Azure theme">
<span class="theme-preset-swatch" style="--theme-swatch-a:#17325d; --theme-swatch-b:#244c84; --theme-swatch-c:#7fd1ff; --theme-swatch-d:#10203c;"></span>
</button>
<button class="theme-preset-btn" data-theme="verdant" type="button" title="Verdant theme" aria-label="Verdant theme">
<span class="theme-preset-swatch" style="--theme-swatch-a:#17372E; --theme-swatch-b:#285B4A; --theme-swatch-c:#7CE0AF; --theme-swatch-d:#0E251E;"></span>
</button>
<button class="theme-preset-btn" data-theme="ember" type="button" title="Ember theme" aria-label="Ember theme">
<span class="theme-preset-swatch" style="--theme-swatch-a:#4F231C; --theme-swatch-b:#87412F; --theme-swatch-c:#FFB36C; --theme-swatch-d:#24110F;"></span>
</button>
<button class="theme-preset-btn" data-theme="amethyst" type="button" title="Amethyst theme" aria-label="Amethyst theme">
<span class="theme-preset-swatch" style="--theme-swatch-a:#342456; --theme-swatch-b:#5A3B8A; --theme-swatch-c:#D3A8FF; --theme-swatch-d:#171125;"></span>
</button>
</div>
</div>
</header>
<main class="workspace">
<aside class="sidebar">
<div class="sidebar-head">
<p class="eyebrow">Block Palette</p>
<h1>Drag Rules Into Story</h1>
<p class="hint">Drag blocks onto the builder, or click a block to add it to the selected branch or choice.</p>
</div>
<div class="palette-scroll">
<section class="palette-section">
<h2>Structure <span class="status-pill">graph</span></h2>
<div class="block-grid" id="structurePalette"></div>
</section>
<section class="palette-section">
<h2>Conditions <span class="status-pill">pure checks</span></h2>
<div class="block-grid" id="conditionPalette"></div>
</section>
<section class="palette-section">
<h2>System Actions <span class="status-pill">state changes</span></h2>
<div class="block-grid" id="actionPalette"></div>
</section>
</div>
</aside>
<section class="stage">
<div class="meta">
<span>Drop nodes, branches, choices, conditions, and actions here. First passing branch wins.</span>
<span id="selectionMeta">No selection</span>
</div>
<div class="builder-scroll">
<div class="builder-layout">
<section class="panel">
<p class="eyebrow">Runtime JSON</p>
<div class="dialogue-fields">
<label>Dialogue ID
<input data-edit="dialogue-id" id="dialogueIdInput" />
</label>
<label>Name
<input data-edit="dialogue-name" id="dialogueNameInput" />
</label>
<label>Start Node
<select data-edit="start-node" id="startNodeSelect"></select>
</label>
</div>
</section>
<section class="panel">
<div class="drop-zone" data-target="nodes" data-empty="Drop a Node block here to add a new conversation stop.">
<div class="node-stack" id="nodeStack"></div>
</div>
</section>
</div>
</div>
</section>
<aside class="inspector">
<div class="inspector-head">
<p class="eyebrow">Export + Simulator</p>
<h1>Prove The Story</h1>
<p class="hint">The simulator uses the same JSON: evaluate branch, run actions, show valid choices, move to next node.</p>
</div>
<div class="inspector-scroll">
<section class="panel">
<h2>Simulator</h2>
<div class="sim-screen">
<div class="npc-line" id="simLine"></div>
<div class="choice-list" id="simChoices"></div>
<div class="row-actions">
<button class="small-btn" id="simRestartBtn" type="button">Restart</button>
<button class="small-btn ghost" id="simContinueBtn" type="button">Continue</button>
</div>
</div>
</section>
<section class="panel">
<h2>Simulator State</h2>
<div class="state-grid" id="stateGrid"></div>
<div class="state-controls" style="margin-top:8px;">
<button class="small-btn" data-state-action="gold" type="button">+25 Gold</button>
<button class="small-btn" data-state-action="speech" type="button">+1 Speech</button>
<button class="small-btn" data-state-action="level" type="button">+1 Level</button>
<button class="small-btn" data-state-action="mage" type="button">Mage Rank 2</button>
<button class="small-btn" data-state-action="step2" type="button">Quest Step 2</button>
<button class="small-btn danger" data-state-action="clear" type="button">Clear State</button>
</div>
</section>
<section class="panel">
<h2>Validation</h2>
<ul class="validation-list" id="validationList"></ul>
</section>
<section class="panel">
<h2>Export JSON</h2>
<p class="mini-note">This box contains only the dialogue graph. Simulator state stays outside the content payload.</p>
<textarea class="json-area" id="jsonExport"></textarea>
<div class="row-actions" style="margin-top:8px;">
<button class="small-btn" id="importJsonBtn" type="button">Import From Textbox</button>
<button class="small-btn" id="formatJsonBtn" type="button">Format</button>
</div>
</section>
<section class="panel">
<h2>Event Log</h2>
<ul class="log-list" id="simLog"></ul>
</section>
</div>
</aside>
</main>
</div>
<script>
(function () {
const THEME_KEY = "tes8-dialogue-builder-theme";
const CONDITION_DEFS = [
{ type: "currency", label: "Currency", icon: "$", hint: "value: 25" },
{ type: "skill", label: "Skill", icon: "S", hint: "value: speech:3" },
{ type: "always", label: "Always", icon: "*", hint: "fallback branch" },
{ type: "item", label: "Item", icon: "I", hint: "value: ash_writ:1" },
{ type: "flag", label: "Flag", icon: "F", hint: "value: flag_id" },
{ type: "quest_started", label: "Quest Started", icon: "Q", hint: "value: 301" },
{ type: "quest_completed", label: "Quest Completed", icon: "C", hint: "value: 301" },
{ type: "quest_step_completed", label: "Quest Step Completed", icon: "2", hint: "value: 301, step: 2" },
{ type: "faction_rank", label: "Faction Rank", icon: "R", hint: "value: mages:2" },
{ type: "level", label: "Level", icon: "L", hint: "value: 3" },
];
const ACTION_DEFS = [
{ type: "grant_item", label: "Grant Item", icon: "+I", hint: "value: ash_writ:1" },
{ type: "grant_money", label: "Grant Money", icon: "+$", hint: "value: 40" },
{ type: "remove_item", label: "Remove Item", icon: "-I", hint: "value: ash_writ:1" },
{ type: "modify_mana_player", label: "Modify Mana", icon: "M", hint: "value: -5" },
{ type: "modify_health_player", label: "Modify Health", icon: "H", hint: "value: -8" },
{ type: "start_quest", label: "Start Quest", icon: "SQ", hint: "value: 301" },
{ type: "complete_quest", label: "Complete Quest", icon: "CQ", hint: "value: 301" },
];
const STRUCTURE_DEFS = [
{ kind: "node", type: "node", label: "Node", icon: "N", hint: "conversation stop" },
{ kind: "branch", type: "branch", label: "Branch", icon: "B", hint: "first passing branch wins" },
{ kind: "choice", type: "choice", label: "Choice", icon: ">", hint: "player option" },
];
const conditionTypes = new Set(CONDITION_DEFS.map((entry) => entry.type));
const actionTypes = new Set(ACTION_DEFS.map((entry) => entry.type));
let dialogue = createExampleDialogue();
let selected = { nodeId: dialogue.startNodeId, branchId: "offer", choiceId: "" };
let sim = createSimulator();
const els = {
structurePalette: document.getElementById("structurePalette"),
conditionPalette: document.getElementById("conditionPalette"),
actionPalette: document.getElementById("actionPalette"),
nodeStack: document.getElementById("nodeStack"),
dialogueIdInput: document.getElementById("dialogueIdInput"),
dialogueNameInput: document.getElementById("dialogueNameInput"),
startNodeSelect: document.getElementById("startNodeSelect"),
selectionMeta: document.getElementById("selectionMeta"),
jsonExport: document.getElementById("jsonExport"),
validationList: document.getElementById("validationList"),
simLine: document.getElementById("simLine"),
simChoices: document.getElementById("simChoices"),
simContinueBtn: document.getElementById("simContinueBtn"),
simLog: document.getElementById("simLog"),
stateGrid: document.getElementById("stateGrid"),
statusPill: document.getElementById("statusPill"),
};
function createExampleDialogue() {
return {
schemaVersion: 2,
id: "dlg_ember_gate",
name: "The Ember Gate",
startNodeId: "gate_captain",
nodes: [
{
id: "gate_captain",
title: "Captain Vela at the Ember Gate",
branches: [
{
id: "already_complete",
text: "Captain Vela nods as the Ember Gate opens. \"The road remembers who kept faith with it.\"",
when: [{ type: "quest_completed", value: "301", stepId: "", not: false }],
actions: [],
nextNodeId: "",
choices: [
{ id: "enter_city", text: "Enter the city.", when: [], actions: [], nextNodeId: "" },
],
},
{
id: "field_step_done",
text: "\"The scouts say the archive seal is broken. Bring me the writ and I can close this.\"",
when: [{ type: "quest_step_completed", value: "301", stepId: "2", not: false }],
actions: [],
nextNodeId: "",
choices: [
{ id: "return_archive", text: "Return to the archive.", when: [], actions: [], nextNodeId: "archive_door" },
],
},
{
id: "has_writ",
text: "\"That is the Ash Writ. You did it.\" Vela seals it, pays you, and waves the gate crew aside.",
when: [
{ type: "quest_started", value: "301", stepId: "", not: false },
{ type: "item", value: "ash_writ:1", stepId: "", not: false },
],
actions: [
{ type: "remove_item", value: "ash_writ:1" },
{ type: "complete_quest", value: "301" },
{ type: "grant_money", value: "40" },
],
nextNodeId: "",
choices: [
{ id: "thank_her", text: "Take the coin and enter.", when: [], actions: [], nextNodeId: "" },
],
},
{
id: "in_progress",
text: "\"No writ yet? The market archivists know where Sable Hall hid it. Ask carefully.\"",
when: [
{ type: "quest_started", value: "301", stepId: "", not: false },
{ type: "quest_completed", value: "301", stepId: "", not: true },
],
actions: [],
nextNodeId: "",
choices: [
{ id: "go_market", text: "Head back to the market.", when: [], actions: [], nextNodeId: "market_square" },
{ id: "try_gate_rank", text: "Invoke your mage rank.", when: [{ type: "faction_rank", value: "mages:2", stepId: "", not: false }], actions: [{ type: "grant_item", value: "ash_writ:1" }], nextNodeId: "gate_captain" },
],
},
{
id: "offer",
text: "\"The Ember Gate is closed. Bring me the Ash Writ from Sable Hall and I will open it.\"",
when: [{ type: "always", value: "", stepId: "", not: false }],
actions: [],
nextNodeId: "",
choices: [
{ id: "accept", text: "I will find the writ.", when: [], actions: [{ type: "start_quest", value: "301" }], nextNodeId: "market_square" },
{ id: "pay_toll", text: "Offer a 25 gold road pledge.", when: [{ type: "currency", value: "25", stepId: "", not: false }], actions: [{ type: "grant_item", value: "road_pledge:1" }], nextNodeId: "market_square" },
{ id: "walk_away", text: "Walk away.", when: [], actions: [], nextNodeId: "" },
],
},
],
},
{
id: "market_square",
title: "Sable Market",
branches: [
{
id: "has_map",
text: "With the clerk's map in hand, the route to Sable Hall is suddenly obvious.",
when: [{ type: "item", value: "archive_map:1", stepId: "", not: false }],
actions: [],
nextNodeId: "",
choices: [
{ id: "to_archive_from_map", text: "Follow the marked alley.", when: [], actions: [], nextNodeId: "archive_door" },
],
},
{
id: "apprentice_ready",
text: "The apprentice has marked the right shelf in chalk. The archive door should be easy now.",
when: [{ type: "item", value: "apprentice_note:1", stepId: "", not: false }],
actions: [],
nextNodeId: "",
choices: [
{ id: "to_archive_from_help", text: "Go to the archive.", when: [], actions: [], nextNodeId: "archive_door" },
],
},
{
id: "market_open",
text: "Sable Market is all smoke, lamps, and whispered directions. You need a clean lead before the guard changes.",
when: [{ type: "always", value: "", stepId: "", not: false }],
actions: [],
nextNodeId: "",
choices: [
{ id: "ask_apprentice", text: "Convince the archive apprentice.", when: [{ type: "skill", value: "speech:3", stepId: "", not: false }], actions: [{ type: "grant_item", value: "apprentice_note:1" }], nextNodeId: "market_square" },
{ id: "buy_map", text: "Buy a smudged archive map for 25 gold.", when: [{ type: "currency", value: "25", stepId: "", not: false }], actions: [{ type: "grant_item", value: "archive_map:1" }], nextNodeId: "market_square" },
{ id: "scale_wall", text: "Scale the rear wall and force the old lock.", when: [{ type: "level", value: "3", stepId: "", not: false }], actions: [{ type: "modify_health_player", value: "-8" }, { type: "grant_item", value: "shadow_key:1" }], nextNodeId: "archive_door" },
{ id: "guild_seal", text: "Show your Mage Guild seal.", when: [{ type: "faction_rank", value: "mages:2", stepId: "", not: false }], actions: [{ type: "grant_item", value: "guild_seal:1" }], nextNodeId: "archive_door" },
{ id: "return_gate_empty", text: "Return to Vela with nothing.", when: [], actions: [], nextNodeId: "gate_captain" },
],
},
],
},
{
id: "archive_door",
title: "Sable Hall Archive",
branches: [
{
id: "guild_solution",
text: "The door recognizes the guild seal. Inside, the Ash Writ floats above a blue flame.",
when: [{ type: "item", value: "guild_seal:1", stepId: "", not: false }],
actions: [{ type: "modify_mana_player", value: "-5" }, { type: "grant_item", value: "ash_writ:1" }],
nextNodeId: "gate_captain",
choices: [],
},
{
id: "guided_solution",
text: "The apprentice's chalk mark leads to a false shelf. You find the Ash Writ without waking the wards.",
when: [{ type: "item", value: "apprentice_note:1", stepId: "", not: false }],
actions: [{ type: "grant_item", value: "ash_writ:1" }],
nextNodeId: "gate_captain",
choices: [],
},
{
id: "key_solution",
text: "The shadow key fits, but the lock bites back. You still pull the Ash Writ free.",
when: [{ type: "item", value: "shadow_key:1", stepId: "", not: false }],
actions: [{ type: "remove_item", value: "shadow_key:1" }, { type: "modify_mana_player", value: "-4" }, { type: "grant_item", value: "ash_writ:1" }],
nextNodeId: "gate_captain",
choices: [],
},
{
id: "map_solution",
text: "The bought map is incomplete, but it gets you close enough to grab the Ash Writ as the wards wake.",
when: [{ type: "item", value: "archive_map:1", stepId: "", not: false }],
actions: [{ type: "modify_health_player", value: "-6" }, { type: "grant_item", value: "ash_writ:1" }],
nextNodeId: "gate_captain",
choices: [],
},
{
id: "fallback_trap",
text: "You guess wrong. The archive wards flare and the shelves rearrange themselves.",
when: [{ type: "always", value: "", stepId: "", not: false }],
actions: [{ type: "modify_health_player", value: "-10" }, { type: "modify_mana_player", value: "-5" }],
nextNodeId: "",
choices: [
{ id: "try_market_again", text: "Retreat and find a better lead.", when: [], actions: [], nextNodeId: "market_square" },
{ id: "limp_home", text: "Give up for now.", when: [], actions: [], nextNodeId: "" },
],
},
],
},
],
};
}
function createSimulator() {
return {
state: createInitialState(),
currentNodeId: "",
activeBranchId: "",
activeBranch: null,
ended: true,
log: [],
};
}
function createInitialState() {
return {
currency: 35,
level: 2,
health: 30,
mana: 18,
skills: { speech: 2, lockpicking: 1 },
inventory: { copper_ore: 3 },
flags: [],
quests: [],
factionRanks: { mages: 0, merchants: 1 },
};
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function makeId(prefix) {
return `${prefix}_${Math.random().toString(36).slice(2, 8)}`;
}
function slug(value, fallback) {
const clean = String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_]+/g, "_")
.replace(/^_+|_+$/g, "");
return clean || fallback;
}
function findNode(nodeId) {
return dialogue.nodes.find((node) => node.id === nodeId) || null;
}
function findBranch(nodeId, branchId) {
const node = findNode(nodeId);
return node ? node.branches.find((branch) => branch.id === branchId) || null : null;
}
function findChoice(nodeId, branchId, choiceId) {
const branch = findBranch(nodeId, branchId);
return branch ? branch.choices.find((choice) => choice.id === choiceId) || null : null;
}
function renderPalette() {
els.structurePalette.innerHTML = STRUCTURE_DEFS.map((entry) => renderBlockButton(entry.kind, entry.type, entry.icon, entry.label, entry.hint)).join("");
els.conditionPalette.innerHTML = CONDITION_DEFS.map((entry) => renderBlockButton("condition", entry.type, entry.icon, entry.label, entry.hint)).join("");
els.actionPalette.innerHTML = ACTION_DEFS.map((entry) => renderBlockButton("action", entry.type, entry.icon, entry.label, entry.hint)).join("");
}
function renderBlockButton(kind, type, icon, label, hint) {
return `
<button class="block-button" draggable="true" data-block-kind="${escapeAttr(kind)}" data-block-type="${escapeAttr(type)}" type="button">
<span class="block-icon">${escapeHtml(icon)}</span>
<span><strong>${escapeHtml(label)}</strong><span class="block-meta">${escapeHtml(hint)}</span></span>
</button>
`;
}
function renderAll(options) {
const keepJsonText = options && options.keepJsonText;
renderTopFields();
renderBuilder();
renderValidation();
if (!keepJsonText) {
els.jsonExport.value = JSON.stringify(dialogue, null, 2);
}
renderSimulator();
}
function renderTopFields() {
els.dialogueIdInput.value = dialogue.id || "";
els.dialogueNameInput.value = dialogue.name || "";
els.startNodeSelect.innerHTML = dialogue.nodes.map((node) => {
return `<option value="${escapeAttr(node.id)}"${node.id === dialogue.startNodeId ? " selected" : ""}>${escapeHtml(node.id)}</option>`;
}).join("");
const parts = [];
if (selected.nodeId) parts.push(`Node: ${selected.nodeId}`);
if (selected.branchId) parts.push(`Branch: ${selected.branchId}`);
if (selected.choiceId) parts.push(`Choice: ${selected.choiceId}`);
els.selectionMeta.textContent = parts.length ? parts.join(" / ") : "No selection";
}
function renderBuilder() {
els.nodeStack.innerHTML = dialogue.nodes.map(renderNode).join("");
markEmptyDropZones();
}
function renderNode(node) {
const isSelected = selected.nodeId === node.id && !selected.branchId;
return `
<article class="node-card ${isSelected ? "selected" : ""}" data-node-id="${escapeAttr(node.id)}">
<div class="node-head">
<div class="node-head-main">
<label>Node ID
<input data-edit="node-id" data-node-id="${escapeAttr(node.id)}" value="${escapeAttr(node.id)}" />
</label>
<label>Title
<input data-edit="node-title" data-node-id="${escapeAttr(node.id)}" value="${escapeAttr(node.title || "")}" />
</label>
</div>
<div class="node-actions">
${dialogue.startNodeId === node.id ? '<span class="status-pill">Start</span>' : `<button class="small-btn" data-action="set-start" data-node-id="${escapeAttr(node.id)}" type="button">Set Start</button>`}
<button class="small-btn" data-action="select-node" data-node-id="${escapeAttr(node.id)}" type="button">Select</button>
<button class="small-btn" data-action="add-branch" data-node-id="${escapeAttr(node.id)}" type="button">Add Branch</button>
<button class="small-btn danger" data-action="delete-node" data-node-id="${escapeAttr(node.id)}" type="button">Delete</button>
</div>
</div>
<div class="node-body">
<div class="drop-zone" data-target="node-branches" data-node-id="${escapeAttr(node.id)}" data-empty="Drop Branch here. Branches are checked top-to-bottom.">
<div class="branch-stack">
${node.branches.map((branch) => renderBranch(node.id, branch)).join("")}
</div>
</div>
</div>
</article>
`;
}
function renderBranch(nodeId, branch) {
const isSelected = selected.nodeId === nodeId && selected.branchId === branch.id && !selected.choiceId;
return `
<article class="branch-card ${isSelected ? "selected" : ""}" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branch.id)}">
<div class="branch-head">
<h3>Branch: ${escapeHtml(branch.id)}</h3>
<div class="branch-actions-top">
<button class="small-btn" data-action="select-branch" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branch.id)}" type="button">Select</button>
<button class="small-btn" data-action="move-branch-up" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branch.id)}" type="button">Up</button>
<button class="small-btn" data-action="move-branch-down" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branch.id)}" type="button">Down</button>
<button class="small-btn" data-action="add-choice" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branch.id)}" type="button">Add Choice</button>
<button class="small-btn danger" data-action="delete-branch" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branch.id)}" type="button">Delete</button>
</div>
</div>
<div class="branch-body">
<div class="dialogue-fields" style="grid-template-columns:minmax(150px,.45fr) minmax(220px,1fr) minmax(150px,.45fr);">
<label>Branch ID
<input data-edit="branch-id" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branch.id)}" value="${escapeAttr(branch.id)}" />
</label>
<label>Say
<textarea data-edit="branch-text" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branch.id)}">${escapeHtml(branch.text || "")}</textarea>
</label>
<label>Auto Next
<select data-edit="branch-next" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branch.id)}">
${renderNodeOptions(branch.nextNodeId || "")}
</select>
</label>
</div>
<label>When
<div class="drop-zone" data-target="branch-conditions" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branch.id)}" data-empty="Drop condition blocks here. Empty means always.">
<div class="logic-stack">${(branch.when || []).map((condition, index) => renderConditionRow("branch", nodeId, branch.id, "", condition, index)).join("")}</div>
</div>
</label>
<label>Do On Entry
<div class="drop-zone" data-target="branch-actions" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branch.id)}" data-empty="Drop action blocks here. Actions run once when this branch is entered.">
<div class="logic-stack">${(branch.actions || []).map((action, index) => renderActionRow("branch", nodeId, branch.id, "", action, index)).join("")}</div>
</div>
</label>
<label>Choices
<div class="drop-zone" data-target="branch-choices" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branch.id)}" data-empty="Drop Choice blocks here. Choices are filtered after the branch wins.">
<div class="choice-stack">${(branch.choices || []).map((choice) => renderChoice(nodeId, branch.id, choice)).join("")}</div>
</div>
</label>
</div>
</article>
`;
}
function renderChoice(nodeId, branchId, choice) {
const isSelected = selected.nodeId === nodeId && selected.branchId === branchId && selected.choiceId === choice.id;
return `
<article class="choice-card ${isSelected ? "selected" : ""}" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choice.id)}">
<div class="choice-head">
<h3>Choice: ${escapeHtml(choice.id)}</h3>
<div class="choice-actions-top">
<button class="small-btn" data-action="select-choice" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choice.id)}" type="button">Select</button>
<button class="small-btn danger" data-action="delete-choice" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choice.id)}" type="button">Delete</button>
</div>
</div>
<div class="choice-body">
<div class="dialogue-fields" style="grid-template-columns:minmax(130px,.45fr) minmax(220px,1fr) minmax(150px,.45fr);">
<label>Choice ID
<input data-edit="choice-id" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choice.id)}" value="${escapeAttr(choice.id)}" />
</label>
<label>Player Text
<input data-edit="choice-text" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choice.id)}" value="${escapeAttr(choice.text || "")}" />
</label>
<label>Go To
<select data-edit="choice-next" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choice.id)}">
${renderNodeOptions(choice.nextNodeId || "")}
</select>
</label>
</div>
<label>Choice Visible When
<div class="drop-zone" data-target="choice-conditions" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choice.id)}" data-empty="Drop condition blocks here. Empty means visible.">
<div class="logic-stack">${(choice.when || []).map((condition, index) => renderConditionRow("choice", nodeId, branchId, choice.id, condition, index)).join("")}</div>
</div>
</label>
<label>Choice Does
<div class="drop-zone" data-target="choice-actions" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choice.id)}" data-empty="Drop action blocks here. Actions run before Go To.">
<div class="logic-stack">${(choice.actions || []).map((action, index) => renderActionRow("choice", nodeId, branchId, choice.id, action, index)).join("")}</div>
</div>
</label>
</div>
</article>
`;
}
function renderConditionRow(scope, nodeId, branchId, choiceId, condition, index) {
const stepVisible = condition.type === "quest_step_completed" ? "" : "display:none;";
return `
<div class="logic-row">
<span class="logic-type">${escapeHtml(condition.type)}</span>
<label>Value
<input data-edit="condition-value" data-scope="${scope}" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choiceId)}" data-index="${index}" value="${escapeAttr(condition.value || "")}" />
</label>
<label style="${stepVisible}">Step
<input data-edit="condition-step" data-scope="${scope}" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choiceId)}" data-index="${index}" value="${escapeAttr(condition.stepId || "")}" />
</label>
<div class="row-actions">
<label class="check-label"><input data-edit="condition-not" data-scope="${scope}" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choiceId)}" data-index="${index}" type="checkbox"${condition.not ? " checked" : ""} /> Not</label>
<button class="small-btn danger" data-action="delete-condition" data-scope="${scope}" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choiceId)}" data-index="${index}" type="button">X</button>
</div>
</div>
`;
}
function renderActionRow(scope, nodeId, branchId, choiceId, action, index) {
return `
<div class="logic-row action-row">
<span class="logic-type">${escapeHtml(action.type)}</span>
<label>Value
<input data-edit="action-value" data-scope="${scope}" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choiceId)}" data-index="${index}" value="${escapeAttr(action.value || "")}" />
</label>
<div class="row-actions">
<button class="small-btn danger" data-action="delete-action" data-scope="${scope}" data-node-id="${escapeAttr(nodeId)}" data-branch-id="${escapeAttr(branchId)}" data-choice-id="${escapeAttr(choiceId)}" data-index="${index}" type="button">X</button>
</div>
</div>
`;
}
function renderNodeOptions(selectedValue) {
const options = ['<option value="">End conversation</option>'];
dialogue.nodes.forEach((node) => {
options.push(`<option value="${escapeAttr(node.id)}"${node.id === selectedValue ? " selected" : ""}>${escapeHtml(node.id)}</option>`);
});
return options.join("");
}
function markEmptyDropZones() {
document.querySelectorAll(".drop-zone").forEach((zone) => {
const hasContent = zone.querySelector(".node-card, .branch-card, .choice-card, .logic-row");
zone.classList.toggle("empty", !hasContent);
});
}
function addNode() {
const id = uniqueNodeId("node");
dialogue.nodes.push({
id,
title: "New Node",
branches: [createBranch("fallback", "This fallback keeps the conversation safe.", [{ type: "always", value: "", stepId: "", not: false }])],
});
selected = { nodeId: id, branchId: "", choiceId: "" };
renderAll();
}
function addBranch(nodeId, preferredConditionType) {
const node = findNode(nodeId);
if (!node) return;
const condition = preferredConditionType ? [createCondition(preferredConditionType)] : [{ type: "always", value: "", stepId: "", not: false }];
const branch = createBranch(uniqueBranchId(node, preferredConditionType || "branch"), "New branch text.", condition);
node.branches.push(branch);
selected = { nodeId, branchId: branch.id, choiceId: "" };
renderAll();
}
function createBranch(id, text, when) {
return { id, text, when: when || [], actions: [], nextNodeId: "", choices: [] };
}
function addChoice(nodeId, branchId) {
const branch = findBranch(nodeId, branchId);
if (!branch) return;
const id = uniqueChoiceId(branch, "choice");
branch.choices.push({ id, text: "New choice", when: [], actions: [], nextNodeId: "" });
selected = { nodeId, branchId, choiceId: id };
renderAll();
}
function createCondition(type) {
const defaults = {
currency: "25",
skill: "speech:3",
always: "",
item: "ash_writ:1",
flag: "apprentice_help",
quest_started: "301",
quest_completed: "301",
quest_step_completed: "301",
faction_rank: "mages:2",
level: "3",
};
return {
type,
value: defaults[type] || "",
stepId: type === "quest_step_completed" ? "2" : "",
not: false,
};
}
function createAction(type) {
const defaults = {
grant_item: "ash_writ:1",
grant_money: "40",
remove_item: "ash_writ:1",
modify_mana_player: "-5",
modify_health_player: "-8",
start_quest: "301",
complete_quest: "301",
};
return { type, value: defaults[type] || "" };
}
function addConditionToTarget(target, type) {
const condition = createCondition(type);
if (target === "branch") {
const branch = findBranch(selected.nodeId, selected.branchId);
if (branch) branch.when.push(condition);
} else {
const choice = findChoice(selected.nodeId, selected.branchId, selected.choiceId);
if (choice) choice.when.push(condition);
}
renderAll();
}
function addActionToTarget(target, type) {
const action = createAction(type);
if (target === "branch") {
const branch = findBranch(selected.nodeId, selected.branchId);
if (branch) branch.actions.push(action);
} else {
const choice = findChoice(selected.nodeId, selected.branchId, selected.choiceId);
if (choice) choice.actions.push(action);
}
renderAll();
}
function uniqueNodeId(seed) {
let base = slug(seed, "node");
let next = base;
let index = 2;
const ids = new Set(dialogue.nodes.map((node) => node.id));
while (ids.has(next)) {
next = `${base}_${index}`;
index += 1;
}
return next;
}
function uniqueBranchId(node, seed) {
let base = slug(seed, "branch");
let next = base;
let index = 2;
const ids = new Set(node.branches.map((branch) => branch.id));
while (ids.has(next)) {
next = `${base}_${index}`;
index += 1;
}
return next;
}
function uniqueChoiceId(branch, seed) {
let base = slug(seed, "choice");
let next = base;
let index = 2;
const ids = new Set(branch.choices.map((choice) => choice.id));
while (ids.has(next)) {
next = `${base}_${index}`;
index += 1;
}
return next;
}
function renameNode(oldId, rawNextId) {
const nextId = uniqueRenameId(dialogue.nodes.map((node) => node.id), oldId, rawNextId, "node");
if (nextId === oldId) return;
const node = findNode(oldId);
if (!node) return;
node.id = nextId;
if (dialogue.startNodeId === oldId) dialogue.startNodeId = nextId;
dialogue.nodes.forEach((entry) => {
entry.branches.forEach((branch) => {
if (branch.nextNodeId === oldId) branch.nextNodeId = nextId;
branch.choices.forEach((choice) => {
if (choice.nextNodeId === oldId) choice.nextNodeId = nextId;
});
});
});
if (selected.nodeId === oldId) selected.nodeId = nextId;
}
function renameBranch(nodeId, oldId, rawNextId) {
const node = findNode(nodeId);
if (!node) return;
const branch = findBranch(nodeId, oldId);
if (!branch) return;
const nextId = uniqueRenameId(node.branches.map((entry) => entry.id), oldId, rawNextId, "branch");
branch.id = nextId;
if (selected.branchId === oldId) selected.branchId = nextId;
}
function renameChoice(nodeId, branchId, oldId, rawNextId) {
const branch = findBranch(nodeId, branchId);
if (!branch) return;
const choice = findChoice(nodeId, branchId, oldId);
if (!choice) return;
const nextId = uniqueRenameId(branch.choices.map((entry) => entry.id), oldId, rawNextId, "choice");
choice.id = nextId;
if (selected.choiceId === oldId) selected.choiceId = nextId;
}
function uniqueRenameId(existingIds, oldId, rawNextId, fallback) {
const base = slug(rawNextId, fallback);
if (base === oldId) return oldId;
const ids = new Set(existingIds.filter((id) => id !== oldId));
let next = base;
let index = 2;
while (ids.has(next)) {
next = `${base}_${index}`;
index += 1;
}
return next;
}
function getConditionList(scope, nodeId, branchId, choiceId) {
if (scope === "choice") {
const choice = findChoice(nodeId, branchId, choiceId);
return choice ? choice.when : null;
}
const branch = findBranch(nodeId, branchId);
return branch ? branch.when : null;
}
function getActionList(scope, nodeId, branchId, choiceId) {
if (scope === "choice") {
const choice = findChoice(nodeId, branchId, choiceId);
return choice ? choice.actions : null;
}
const branch = findBranch(nodeId, branchId);
return branch ? branch.actions : null;
}
function moveBranch(nodeId, branchId, direction) {
const node = findNode(nodeId);
if (!node) return;
const index = node.branches.findIndex((branch) => branch.id === branchId);
const nextIndex = index + direction;
if (index < 0 || nextIndex < 0 || nextIndex >= node.branches.length) return;
const [branch] = node.branches.splice(index, 1);
node.branches.splice(nextIndex, 0, branch);
renderAll();
}
function deleteNode(nodeId) {
if (dialogue.nodes.length <= 1) {
setStatus("Keep at least one node.");
return;
}
dialogue.nodes = dialogue.nodes.filter((node) => node.id !== nodeId);
dialogue.nodes.forEach((node) => {
node.branches.forEach((branch) => {
if (branch.nextNodeId === nodeId) branch.nextNodeId = "";
branch.choices.forEach((choice) => {
if (choice.nextNodeId === nodeId) choice.nextNodeId = "";
});
});
});
if (dialogue.startNodeId === nodeId) dialogue.startNodeId = dialogue.nodes[0].id;
selected = { nodeId: dialogue.startNodeId, branchId: "", choiceId: "" };
renderAll();
}
function deleteBranch(nodeId, branchId) {
const node = findNode(nodeId);
if (!node || node.branches.length <= 1) {
setStatus("Keep at least one branch on each node.");
return;
}
node.branches = node.branches.filter((branch) => branch.id !== branchId);
selected = { nodeId, branchId: node.branches[0].id, choiceId: "" };
renderAll();
}
function deleteChoice(nodeId, branchId, choiceId) {
const branch = findBranch(nodeId, branchId);
if (!branch) return;
branch.choices = branch.choices.filter((choice) => choice.id !== choiceId);
selected = { nodeId, branchId, choiceId: "" };
renderAll();
}
function handleBlockClick(kind, type) {
if (kind === "node") {
addNode();
return;
}
if (kind === "branch") {
addBranch(selected.nodeId || dialogue.startNodeId);
return;
}
if (kind === "choice") {
if (!selected.branchId) {
const node = findNode(selected.nodeId || dialogue.startNodeId);
selected.branchId = node && node.branches[0] ? node.branches[0].id : "";
}
addChoice(selected.nodeId || dialogue.startNodeId, selected.branchId);
return;
}
if (kind === "condition") {
if (selected.choiceId) addConditionToTarget("choice", type);
else {
ensureSelectedBranch();
addConditionToTarget("branch", type);
}
return;
}
if (kind === "action") {
if (selected.choiceId) addActionToTarget("choice", type);
else {
ensureSelectedBranch();
addActionToTarget("branch", type);
}
}
}
function ensureSelectedBranch() {
if (selected.branchId) return;
const node = findNode(selected.nodeId || dialogue.startNodeId);
if (node && node.branches[0]) {
selected = { nodeId: node.id, branchId: node.branches[0].id, choiceId: "" };
}
}
function handleDrop(zone, payload) {
const target = zone.dataset.target;
const nodeId = zone.dataset.nodeId || selected.nodeId || dialogue.startNodeId;
const branchId = zone.dataset.branchId || selected.branchId;
const choiceId = zone.dataset.choiceId || selected.choiceId;
if (!payload) return;
if (target === "nodes" && payload.kind === "node") {
addNode();
return;
}
if (target === "node-branches") {
if (payload.kind === "branch") addBranch(nodeId);
if (payload.kind === "condition") addBranch(nodeId, payload.type);
return;
}
if (target === "branch-conditions" && payload.kind === "condition") {
const branch = findBranch(nodeId, branchId);
if (branch) branch.when.push(createCondition(payload.type));
selected = { nodeId, branchId, choiceId: "" };
renderAll();
return;
}
if (target === "branch-actions" && payload.kind === "action") {
const branch = findBranch(nodeId, branchId);
if (branch) branch.actions.push(createAction(payload.type));
selected = { nodeId, branchId, choiceId: "" };
renderAll();
return;
}
if (target === "branch-choices" && payload.kind === "choice") {
addChoice(nodeId, branchId);
return;
}
if (target === "choice-conditions" && payload.kind === "condition") {
const choice = findChoice(nodeId, branchId, choiceId);
if (choice) choice.when.push(createCondition(payload.type));
selected = { nodeId, branchId, choiceId };
renderAll();
return;
}
if (target === "choice-actions" && payload.kind === "action") {
const choice = findChoice(nodeId, branchId, choiceId);
if (choice) choice.actions.push(createAction(payload.type));
selected = { nodeId, branchId, choiceId };
renderAll();
}
}
function validateDialogue() {
const issues = [];
const nodeIds = new Set();
if (!dialogue.id) issues.push({ level: "warn", text: "Dialogue ID is empty." });
if (!dialogue.startNodeId) issues.push({ level: "warn", text: "Start node is empty." });
dialogue.nodes.forEach((node) => {
if (!node.id) issues.push({ level: "warn", text: "A node has an empty id." });
if (nodeIds.has(node.id)) issues.push({ level: "warn", text: `Duplicate node id: ${node.id}.` });
nodeIds.add(node.id);
});
if (!nodeIds.has(dialogue.startNodeId)) issues.push({ level: "warn", text: `Start node "${dialogue.startNodeId}" does not exist.` });
dialogue.nodes.forEach((node) => {
if (!node.branches || node.branches.length === 0) issues.push({ level: "warn", text: `${node.id} has no branches.` });
const hasFallback = (node.branches || []).some((branch) => (branch.when || []).length === 0 || (branch.when || []).some((condition) => condition.type === "always" && !condition.not));
if (!hasFallback) issues.push({ level: "warn", text: `${node.id} has no always fallback branch.` });
const branchIds = new Set();
(node.branches || []).forEach((branch) => {
if (branchIds.has(branch.id)) issues.push({ level: "warn", text: `${node.id} has duplicate branch id ${branch.id}.` });
branchIds.add(branch.id);
if (branch.nextNodeId && !nodeIds.has(branch.nextNodeId)) issues.push({ level: "warn", text: `${node.id}/${branch.id} auto-next points to missing node ${branch.nextNodeId}.` });
(branch.when || []).forEach((condition) => {
if (!conditionTypes.has(condition.type)) issues.push({ level: "warn", text: `${node.id}/${branch.id} uses unknown condition ${condition.type}.` });
});
(branch.actions || []).forEach((action) => {
if (!actionTypes.has(action.type)) issues.push({ level: "warn", text: `${node.id}/${branch.id} uses unknown action ${action.type}.` });
});
const choiceIds = new Set();
(branch.choices || []).forEach((choice) => {
if (choiceIds.has(choice.id)) issues.push({ level: "warn", text: `${node.id}/${branch.id} has duplicate choice id ${choice.id}.` });
choiceIds.add(choice.id);
if (choice.nextNodeId && !nodeIds.has(choice.nextNodeId)) issues.push({ level: "warn", text: `${node.id}/${branch.id}/${choice.id} points to missing node ${choice.nextNodeId}.` });
(choice.when || []).forEach((condition) => {
if (!conditionTypes.has(condition.type)) issues.push({ level: "warn", text: `${choice.id} uses unknown condition ${condition.type}.` });
});
(choice.actions || []).forEach((action) => {
if (!actionTypes.has(action.type)) issues.push({ level: "warn", text: `${choice.id} uses unknown action ${action.type}.` });
});
});
});
});
if (issues.length === 0) issues.push({ level: "ok", text: "Graph is deterministic: start exists, links resolve, condition/action keys are known." });
return issues;
}
function renderValidation() {
const issues = validateDialogue();
els.validationList.innerHTML = issues.map((issue) => `<li class="${issue.level}">${escapeHtml(issue.text)}</li>`).join("");
}
function enterNode(nodeId) {
const node = findNode(nodeId);
if (!node) {
sim.currentNodeId = "";
sim.activeBranch = null;
sim.activeBranchId = "";
sim.ended = true;
sim.log.unshift(`Missing node: ${nodeId}`);
renderSimulator();
return;
}
const branch = findActiveBranch(node, sim.state);
if (!branch) {
sim.currentNodeId = node.id;
sim.activeBranch = null;
sim.activeBranchId = "";
sim.ended = true;
sim.log.unshift(`No branch passed in ${node.id}.`);
renderSimulator();
return;
}
sim.currentNodeId = node.id;
sim.activeBranchId = branch.id;
sim.activeBranch = clone(branch);
sim.ended = false;
sim.log.unshift(`Entered ${node.id}/${branch.id}.`);
applyActions(sim.activeBranch.actions || [], sim.state, "branch");
renderSimulator();
}
function findActiveBranch(node, state) {
return (node.branches || []).find((branch) => conditionsPass(branch.when || [], state)) || null;
}
function conditionsPass(conditions, state) {
if (!conditions || conditions.length === 0) return true;
return conditions.every((condition) => {
const result = evaluateCondition(condition, state);
return condition.not ? !result : result;
});
}
function evaluateCondition(condition, state) {
const type = condition.type;
const value = String(condition.value || "").trim();
if (type === "always") return true;
if (type === "currency") return Number(state.currency || 0) >= parseNumber(value, 0);
if (type === "level") return Number(state.level || 0) >= parseNumber(value, 0);
if (type === "skill") {
const parsed = parseKeyNumber(value);
return Number(state.skills[parsed.key] || 0) >= parsed.amount;
}
if (type === "item") {
const parsed = parseKeyNumber(value);
return Number(state.inventory[parsed.key] || 0) >= parsed.amount;
}
if (type === "flag") return state.flags.includes(value);
if (type === "quest_started") {
const quest = findQuest(state, value);
return !!quest && quest.stepID > 0;
}
if (type === "quest_completed") {
const quest = findQuest(state, value);
return !!quest && quest.stepID === -1;
}
if (type === "quest_step_completed") {
const quest = findQuest(state, value);
const stepId = parseNumber(condition.stepId, 0);
return !!quest && (quest.stepID === -1 || (quest.completedSteps || []).includes(stepId));
}
if (type === "faction_rank") {
const parsed = parseKeyNumber(value);
return Number(state.factionRanks[parsed.key] || 0) >= parsed.amount;
}
return false;
}
function applyActions(actions, state, source) {
(actions || []).forEach((action) => {
const type = action.type;
const value = String(action.value || "").trim();
if (type === "grant_item") {
const parsed = parseKeyNumber(value);
state.inventory[parsed.key] = Number(state.inventory[parsed.key] || 0) + parsed.amount;
sim.log.unshift(`${source}: gained ${parsed.amount} ${parsed.key}.`);
} else if (type === "remove_item") {
const parsed = parseKeyNumber(value);
state.inventory[parsed.key] = Math.max(0, Number(state.inventory[parsed.key] || 0) - parsed.amount);
sim.log.unshift(`${source}: removed ${parsed.amount} ${parsed.key}.`);
} else if (type === "grant_money") {
const amount = parseNumber(value, 0);
state.currency = Number(state.currency || 0) + amount;
sim.log.unshift(`${source}: money changed by ${amount}.`);
} else if (type === "modify_mana_player") {
const amount = parseNumber(value, 0);
state.mana = Math.max(0, Number(state.mana || 0) + amount);
sim.log.unshift(`${source}: mana changed by ${amount}.`);
} else if (type === "modify_health_player") {
const amount = parseNumber(value, 0);
state.health = Math.max(0, Number(state.health || 0) + amount);
sim.log.unshift(`${source}: health changed by ${amount}.`);
} else if (type === "start_quest") {
const quest = ensureQuest(state, value);
if (quest.stepID !== -1) quest.stepID = Math.max(1, quest.stepID || 1);
sim.log.unshift(`${source}: quest ${value} started.`);
} else if (type === "complete_quest") {
const quest = ensureQuest(state, value);
quest.stepID = -1;
sim.log.unshift(`${source}: quest ${value} completed.`);
}
});
sim.log = sim.log.slice(0, 10);
}
function renderSimulator() {
const branch = sim.activeBranch;
if (!branch || sim.ended) {
els.simLine.textContent = sim.currentNodeId ? "Conversation ended." : "Press Restart to run the graph from the start node.";
els.simChoices.innerHTML = "";
els.simContinueBtn.disabled = true;
} else {
els.simLine.innerHTML = `<strong>${escapeHtml(sim.currentNodeId)} / ${escapeHtml(branch.id)}</strong><br>${escapeHtml(branch.text || "")}`;
const visibleChoices = (branch.choices || []).filter((choice) => conditionsPass(choice.when || [], sim.state));
if (visibleChoices.length > 0) {
els.simChoices.innerHTML = visibleChoices.map((choice, index) => {
return `<button class="choice-btn" data-sim-choice="${index}" type="button">${escapeHtml(choice.text || choice.id)}</button>`;
}).join("");
} else {
els.simChoices.innerHTML = branch.nextNodeId
? '<span class="status-pill">No choices. Use Continue for auto-next.</span>'
: '<span class="status-pill">No visible choices. Conversation can end here.</span>';
}
els.simContinueBtn.disabled = !branch.nextNodeId;
}
renderState();
els.simLog.innerHTML = sim.log.map((entry) => `<li>${escapeHtml(entry)}</li>`).join("");
}
function renderState() {
const state = sim.state;
const questText = state.quests.length
? state.quests.map((quest) => `${quest.questId}:${quest.stepID}${quest.completedSteps && quest.completedSteps.length ? `[${quest.completedSteps.join(",")}]` : ""}`).join(", ")
: "none";
const inventoryText = Object.keys(state.inventory).filter((key) => state.inventory[key] > 0).map((key) => `${key}:${state.inventory[key]}`).join(", ") || "none";
const flagsText = state.flags.join(", ") || "none";
const skillText = Object.keys(state.skills).map((key) => `${key}:${state.skills[key]}`).join(", ");
const factionText = Object.keys(state.factionRanks).map((key) => `${key}:${state.factionRanks[key]}`).join(", ");
els.stateGrid.innerHTML = [
`Gold: ${state.currency}`,
`Level: ${state.level}`,
`Health: ${state.health}`,
`Mana: ${state.mana}`,
`Skills: ${skillText}`,
`Factions: ${factionText}`,
`Items: ${inventoryText}`,
`Flags: ${flagsText}`,
`Quests: ${questText}`,
].map((text) => `<div class="state-chip">${escapeHtml(text)}</div>`).join("");
}
function clickChoice(index) {
const branch = sim.activeBranch;
if (!branch || sim.ended) return;
const visibleChoices = (branch.choices || []).filter((choice) => conditionsPass(choice.when || [], sim.state));
const choice = visibleChoices[index];
if (!choice) return;
sim.log.unshift(`Chose: ${choice.text || choice.id}.`);
applyActions(choice.actions || [], sim.state, "choice");
if (choice.nextNodeId) {
enterNode(choice.nextNodeId);
} else {
sim.ended = true;
renderSimulator();
}
}
function continueSim() {
const branch = sim.activeBranch;
if (branch && branch.nextNodeId) enterNode(branch.nextNodeId);
}
function findQuest(state, questId) {
return state.quests.find((quest) => String(quest.questId) === String(questId)) || null;
}
function ensureQuest(state, questId) {
let quest = findQuest(state, questId);
if (!quest) {
quest = { questId: String(questId), stepID: 0, completedSteps: [] };
state.quests.push(quest);
}
return quest;
}
function parseKeyNumber(value) {
const parts = String(value || "").split(":");
const key = String(parts[0] || "").trim();
const amount = parseNumber(parts[1], 1);
return { key, amount };
}
function parseNumber(value, fallback) {
const next = Number(String(value || "").trim());
return Number.isFinite(next) ? next : fallback;
}
function setStatus(message) {
els.statusPill.textContent = message;
window.setTimeout(() => {
els.statusPill.textContent = "Declarative graph model";
}, 2200);
}
function escapeHtml(value) {
return String(value == null ? "" : value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escapeAttr(value) {
return escapeHtml(value);
}
document.addEventListener("dragstart", (event) => {
const button = event.target.closest("[data-block-kind]");
if (!button) return;
const payload = {
kind: button.dataset.blockKind,
type: button.dataset.blockType,
};
event.dataTransfer.setData("application/json", JSON.stringify(payload));
event.dataTransfer.effectAllowed = "copy";
});
document.addEventListener("dragover", (event) => {
const zone = event.target.closest(".drop-zone");
if (!zone) return;
event.preventDefault();
zone.classList.add("drag-over");
});
document.addEventListener("dragleave", (event) => {
const zone = event.target.closest(".drop-zone");
if (!zone) return;
zone.classList.remove("drag-over");
});
document.addEventListener("drop", (event) => {
const zone = event.target.closest(".drop-zone");
if (!zone) return;
event.preventDefault();
zone.classList.remove("drag-over");
let payload = null;
try {
payload = JSON.parse(event.dataTransfer.getData("application/json") || "null");
} catch (_err) {}
handleDrop(zone, payload);
});
document.addEventListener("click", (event) => {
const block = event.target.closest("[data-block-kind]");
if (block) {
handleBlockClick(block.dataset.blockKind, block.dataset.blockType);
return;
}
const simChoice = event.target.closest("[data-sim-choice]");
if (simChoice) {
clickChoice(Number(simChoice.dataset.simChoice));
return;
}
const stateAction = event.target.closest("[data-state-action]");
if (stateAction) {
const action = stateAction.dataset.stateAction;
if (action === "gold") sim.state.currency += 25;
if (action === "speech") sim.state.skills.speech = Number(sim.state.skills.speech || 0) + 1;
if (action === "level") sim.state.level += 1;
if (action === "mage") sim.state.factionRanks.mages = 2;
if (action === "step2") {
const quest = ensureQuest(sim.state, "301");
quest.stepID = Math.max(quest.stepID, 1);
if (!quest.completedSteps.includes(2)) quest.completedSteps.push(2);
}
if (action === "clear") {
sim.state = createInitialState();
sim.log.unshift("State reset.");
}
if (sim.currentNodeId && !sim.ended) enterNode(sim.currentNodeId);
else renderSimulator();
return;
}
const actionButton = event.target.closest("[data-action]");
if (!actionButton) return;
const action = actionButton.dataset.action;
const nodeId = actionButton.dataset.nodeId || "";
const branchId = actionButton.dataset.branchId || "";
const choiceId = actionButton.dataset.choiceId || "";
if (action === "select-node") selected = { nodeId, branchId: "", choiceId: "" };
if (action === "select-branch") selected = { nodeId, branchId, choiceId: "" };
if (action === "select-choice") selected = { nodeId, branchId, choiceId };
if (action === "set-start") dialogue.startNodeId = nodeId;
if (action === "add-branch") addBranch(nodeId);
if (action === "add-choice") addChoice(nodeId, branchId);
if (action === "move-branch-up") moveBranch(nodeId, branchId, -1);
if (action === "move-branch-down") moveBranch(nodeId, branchId, 1);
if (action === "delete-node") deleteNode(nodeId);
if (action === "delete-branch") deleteBranch(nodeId, branchId);
if (action === "delete-choice") deleteChoice(nodeId, branchId, choiceId);
if (action === "delete-condition") {
const list = getConditionList(actionButton.dataset.scope, nodeId, branchId, choiceId);
if (list) list.splice(Number(actionButton.dataset.index), 1);
}
if (action === "delete-action") {
const list = getActionList(actionButton.dataset.scope, nodeId, branchId, choiceId);
if (list) list.splice(Number(actionButton.dataset.index), 1);
}
renderAll();
});
document.addEventListener("input", (event) => {
const input = event.target;
const edit = input.dataset ? input.dataset.edit : "";
if (!edit) return;
const nodeId = input.dataset.nodeId || "";
const branchId = input.dataset.branchId || "";
const choiceId = input.dataset.choiceId || "";
if (edit === "dialogue-id") dialogue.id = input.value;
if (edit === "dialogue-name") dialogue.name = input.value;
if (edit === "node-title") {
const node = findNode(nodeId);
if (node) node.title = input.value;
}
if (edit === "branch-text") {
const branch = findBranch(nodeId, branchId);
if (branch) branch.text = input.value;
}
if (edit === "branch-next") {
const branch = findBranch(nodeId, branchId);
if (branch) branch.nextNodeId = input.value;
}
if (edit === "choice-text") {
const choice = findChoice(nodeId, branchId, choiceId);
if (choice) choice.text = input.value;
}
if (edit === "choice-next") {
const choice = findChoice(nodeId, branchId, choiceId);
if (choice) choice.nextNodeId = input.value;
}
if (edit === "condition-value" || edit === "condition-step" || edit === "condition-not") {
const list = getConditionList(input.dataset.scope, nodeId, branchId, choiceId);
const condition = list ? list[Number(input.dataset.index)] : null;
if (condition) {
if (edit === "condition-value") condition.value = input.value;
if (edit === "condition-step") condition.stepId = input.value;
if (edit === "condition-not") condition.not = input.checked;
}
}
if (edit === "action-value") {
const list = getActionList(input.dataset.scope, nodeId, branchId, choiceId);
const action = list ? list[Number(input.dataset.index)] : null;
if (action) action.value = input.value;
}
if (edit !== "json") {
els.jsonExport.value = JSON.stringify(dialogue, null, 2);
renderValidation();
}
});
document.addEventListener("change", (event) => {
const input = event.target;
const edit = input.dataset ? input.dataset.edit : "";
if (edit === "start-node") {
dialogue.startNodeId = input.value;
renderAll();
}
if (edit === "node-id") {
renameNode(input.dataset.nodeId, input.value);
renderAll();
}
if (edit === "branch-id") {
renameBranch(input.dataset.nodeId, input.dataset.branchId, input.value);
renderAll();
}
if (edit === "choice-id") {
renameChoice(input.dataset.nodeId, input.dataset.branchId, input.dataset.choiceId, input.value);
renderAll();
}
});
document.getElementById("resetExampleBtn").addEventListener("click", () => {
dialogue = createExampleDialogue();
selected = { nodeId: dialogue.startNodeId, branchId: "offer", choiceId: "" };
sim = createSimulator();
enterNode(dialogue.startNodeId);
renderAll();
});
document.getElementById("copyJsonBtn").addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(JSON.stringify(dialogue, null, 2));
setStatus("JSON copied.");
} catch (_err) {
setStatus("Clipboard unavailable.");
}
});
document.getElementById("formatJsonBtn").addEventListener("click", () => {
try {
els.jsonExport.value = JSON.stringify(JSON.parse(els.jsonExport.value), null, 2);
} catch (_err) {
setStatus("JSON is not parseable.");
}
});
document.getElementById("importJsonBtn").addEventListener("click", () => {
try {
const parsed = JSON.parse(els.jsonExport.value);
if (!parsed || !Array.isArray(parsed.nodes)) throw new Error("Missing nodes array.");
dialogue = normalizeImportedDialogue(parsed);
selected = { nodeId: dialogue.startNodeId, branchId: "", choiceId: "" };
sim = createSimulator();
enterNode(dialogue.startNodeId);
renderAll();
setStatus("Imported JSON.");
} catch (err) {
setStatus(`Import failed: ${String(err.message || err).slice(0, 60)}`);
}
});
document.getElementById("simRestartBtn").addEventListener("click", () => {
sim = createSimulator();
enterNode(dialogue.startNodeId);
});
els.simContinueBtn.addEventListener("click", continueSim);
document.querySelectorAll(".theme-preset-btn").forEach((button) => {
button.addEventListener("click", () => {
const theme = button.dataset.theme || "azure";
document.documentElement.setAttribute("data-theme", theme);
document.querySelectorAll(".theme-preset-btn").forEach((entry) => {
entry.classList.toggle("active", entry === button);
});
try {
localStorage.setItem(THEME_KEY, theme);
} catch (_err) {}
});
});
function normalizeImportedDialogue(source) {
const normalized = {
schemaVersion: 2,
id: String(source.id || "dlg_imported"),
name: String(source.name || "Imported Dialogue"),
startNodeId: String(source.startNodeId || ""),
nodes: [],
};
normalized.nodes = source.nodes.map((node, nodeIndex) => ({
id: String(node.id || `node_${nodeIndex + 1}`),
title: String(node.title || node.id || `Node ${nodeIndex + 1}`),
branches: Array.isArray(node.branches) ? node.branches.map((branch, branchIndex) => ({
id: String(branch.id || `branch_${branchIndex + 1}`),
text: String(branch.text || ""),
when: Array.isArray(branch.when) ? branch.when.map((condition) => ({
type: String(condition.type || "always"),
value: String(condition.value || ""),
stepId: String(condition.stepId || ""),
not: !!condition.not,
})) : [],
actions: Array.isArray(branch.actions) ? branch.actions.map((action) => ({
type: String(action.type || ""),
value: String(action.value || ""),
})) : [],
nextNodeId: String(branch.nextNodeId || ""),
choices: Array.isArray(branch.choices) ? branch.choices.map((choice, choiceIndex) => ({
id: String(choice.id || `choice_${choiceIndex + 1}`),
text: String(choice.text || ""),
when: Array.isArray(choice.when) ? choice.when.map((condition) => ({
type: String(condition.type || "always"),
value: String(condition.value || ""),
stepId: String(condition.stepId || ""),
not: !!condition.not,
})) : [],
actions: Array.isArray(choice.actions) ? choice.actions.map((action) => ({
type: String(action.type || ""),
value: String(action.value || ""),
})) : [],
nextNodeId: String(choice.nextNodeId || ""),
})) : [],
})) : [],
}));
if (!normalized.startNodeId && normalized.nodes[0]) normalized.startNodeId = normalized.nodes[0].id;
normalized.nodes.forEach((node) => {
if (node.branches.length === 0) {
node.branches.push(createBranch("fallback", "", [{ type: "always", value: "", stepId: "", not: false }]));
}
});
return normalized;
}
try {
const savedTheme = localStorage.getItem(THEME_KEY);
if (savedTheme) {
document.documentElement.setAttribute("data-theme", savedTheme);
document.querySelectorAll(".theme-preset-btn").forEach((entry) => {
entry.classList.toggle("active", entry.dataset.theme === savedTheme);
});
}
} catch (_err) {}
renderPalette();
enterNode(dialogue.startNodeId);
renderAll();
})();
</script>
</body>
</html>