// talktree.js — Full-featured conversation tree editor.
// Inline editing for all TalkNode and TalkOption fields.
// No prompt() dialogs. Clause-based condition editor, action forms.
(function() {
'use strict';
// ---- Path helpers ----
function talkRoot() { return window._talkData && window._talkData.talk; }
function talkNodes() { var r = talkRoot(); return r && r.nodes; }
function talkSync() { if (typeof window.onTalkTreeChange === 'function') window.onTalkTreeChange(window._talkData); }
function talkGet(path) {
var parts = path.split('.'), obj = talkRoot();
for (var i = 0; i < parts.length; i++) {
if (obj == null) return undefined;
var k = parts[i];
obj = /^\d+$/.test(k) ? obj[parseInt(k, 10)] : obj[k];
}
return obj;
}
function talkSet(path, value) {
var parts = path.split('.'), root = talkRoot();
for (var i = 0; i < parts.length - 1; i++) {
var k = parts[i];
if (/^\d+$/.test(k)) {
var idx = parseInt(k, 10);
if (root[idx] == null) root[idx] = /^\d+$/.test(parts[i + 1]) ? [] : {};
root = root[idx];
} else {
if (root[k] == null) root[k] = /^\d+$/.test(parts[i + 1]) ? [] : {};
root = root[k];
}
}
var lk = parts[parts.length - 1];
if (/^\d+$/.test(lk)) { root[parseInt(lk, 10)] = value; }
else { root[lk] = value; }
}
function talkDel(path) {
var parts = path.split('.'), root = talkRoot();
for (var i = 0; i < parts.length - 1; i++) {
if (root == null) return;
var k = parts[i];
root = /^\d+$/.test(k) ? root[parseInt(k, 10)] : root[k];
}
if (!root) return;
var lk = parts[parts.length - 1];
if (/^\d+$/.test(lk)) { root.splice(parseInt(lk, 10), 1); }
else { delete root[lk]; }
}
// ---- Clause types ----
var CLAUSE_TYPES = [
{ key: 'flag', label: 'Flag', hasValue: true },
{ key: 'player_flag', label: 'Player Flag', hasValue: true },
{ key: 'has_item', label: 'Has Item', hasValue: false },
{ key: 'min_credits', label: 'Min Credits', hasValue: false }
];
function clauseTypeLabel(key) {
for (var i = 0; i < CLAUSE_TYPES.length; i++) {
if (CLAUSE_TYPES[i].key === key) return CLAUSE_TYPES[i].label;
}
return key;
}
function clauseTypeHasValue(key) {
for (var i = 0; i < CLAUSE_TYPES.length; i++) {
if (CLAUSE_TYPES[i].key === key) return CLAUSE_TYPES[i].hasValue;
}
return false;
}
// Extract clauses from a condition object
function extractClauses(cond) {
if (!cond || typeof cond !== 'object' || Array.isArray(cond)) return { mode: 'none', clauses: [], groupKey: null };
if (cond.all_of && Array.isArray(cond.all_of) && cond.all_of.length > 0) {
return { mode: 'multi', clauses: cond.all_of.slice(), groupKey: 'all_of' };
}
if (cond.any_of && Array.isArray(cond.any_of) && cond.any_of.length > 0) {
return { mode: 'multi', clauses: cond.any_of.slice(), groupKey: 'any_of' };
}
// Single clause from flat fields
for (var i = 0; i < CLAUSE_TYPES.length; i++) {
if (cond[CLAUSE_TYPES[i].key] !== undefined) {
var c = {};
c[CLAUSE_TYPES[i].key] = cond[CLAUSE_TYPES[i].key];
if (cond.not) c.not = cond.not;
if (cond.value !== undefined) c.value = cond.value;
if (cond.has_item && CLAUSE_TYPES[i].key !== 'has_item') c.has_item = cond.has_item;
if (cond.player_flag && CLAUSE_TYPES[i].key !== 'player_flag') c.player_flag = cond.player_flag;
if (cond.flag && CLAUSE_TYPES[i].key !== 'flag') c.flag = cond.flag;
return { mode: 'single', clauses: [c], groupKey: null };
}
}
// Check for has_item or min_credits as the only key
for (var j = 0; j < CLAUSE_TYPES.length; j++) {
var ck = CLAUSE_TYPES[j].key;
if (cond[ck] !== undefined) {
var cc = {};
cc[ck] = cond[ck];
if (cond.not) cc.not = cond.not;
if (cond.value !== undefined) cc.value = cond.value;
return { mode: 'single', clauses: [cc], groupKey: null };
}
}
return { mode: 'none', clauses: [], groupKey: null };
}
// Determine the type key for a clause object
function clauseObjType(clause) {
for (var i = 0; i < CLAUSE_TYPES.length; i++) {
var k = CLAUSE_TYPES[i].key;
if (clause[k] !== undefined) return k;
}
return null;
}
// Build a full condition object from clauses
function buildCondition(clauses, groupKey) {
if (!clauses || clauses.length === 0) return null;
if (clauses.length === 1) {
var flat = {};
var c = clauses[0];
for (var k in c) { if (c[k] !== undefined) flat[k] = c[k]; }
return flat;
}
var cond = {};
cond[groupKey || 'all_of'] = clauses.map(function(c) {
var sub = {};
for (var k in c) { if (c[k] !== undefined) sub[k] = c[k]; }
return sub;
});
return cond;
}
// ---- Action field definitions ----
var ACTION_FIELDS = [
{ key: 'give_item', label: 'Give Item', type: 'item_search' },
{ key: 'take_item', label: 'Take Item', type: 'item_search' },
{ key: 'credits', label: 'Credits (+/-)', type: 'number' },
{ key: 'teleport', label: 'TP Room', type: 'number' },
{ key: 'heal', label: 'Heal HP', type: 'number' },
];
var ACTION_BOOLS = [];
// ---- Event delegation ----
function talkEvent(e) {
var el = e.target;
var path = el.getAttribute('data-tp');
if (!path) return;
var tag = el.tagName;
var type = el.type;
if (tag === 'INPUT' || tag === 'TEXTAREA') {
var val;
if (type === 'checkbox') { val = el.checked; }
else if (type === 'number') { var n = parseFloat(el.value); val = isNaN(n) ? 0 : n; }
else { val = el.value; }
talkSyncPath(path, val);
if (path.endsWith('.goto')) renderTalkTree(window._talkData);
} else if (tag === 'SELECT') {
talkSyncPath(path, el.value);
}
}
function talkSyncPath(path, val) {
talkSet(path, val);
talkSync();
}
function talkHandleClick(e) {
var el = e.target;
var action = el.getAttribute('data-action');
if (!action) return;
var nodeKey = el.getAttribute('data-nk');
var optIdx = el.getAttribute('data-oi');
var subIdx = el.getAttribute('data-si');
var subKey = el.getAttribute('data-sk');
var condGroupKey = el.getAttribute('data-cgk');
switch (action) {
case 'delete-node':
deleteNode(nodeKey);
break;
case 'add-option':
addOption(nodeKey);
break;
case 'delete-option':
deleteOption(nodeKey, parseInt(optIdx, 10));
break;
case 'add-node':
addNode();
break;
case 'add-message':
addMessageLine(nodeKey);
break;
case 'delete-message':
deleteMessageLine(nodeKey, parseInt(optIdx, 10));
break;
case 'add-condition':
toggleCondition(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null);
break;
case 'remove-condition':
removeCondition(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null);
break;
case 'add-action':
addAction(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null);
break;
case 'remove-action':
removeAction(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null);
break;
case 'add-clause':
addClause(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null, el.getAttribute('data-ct'));
break;
case 'remove-clause':
removeClause(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null, parseInt(subIdx, 10));
break;
case 'set-cond-group':
setCondGroup(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null, condGroupKey);
break;
case 'add-kv':
addKVPair(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null, el.getAttribute('data-kvk'));
break;
case 'remove-kv':
removeKVPair(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null, el.getAttribute('data-kvk'), subKey);
break;
}
}
// ---- Render entry point ----
window.renderTalkTree = function(data) {
var container = document.getElementById('talktree');
if (!container) return;
window._talkData = data;
window._talkOpenNodes = window._talkOpenNodes || new Set();
window._talkOpenNodes.add('start');
if (!data || !data.talk || !data.talk.nodes) {
container.innerHTML = '
No conversation tree defined.
';
return;
}
var nodes = data.talk.nodes;
var visited = {};
var html = '
';
html += '';
html += renderTreeNodes(nodes, 'start', visited);
var allKeys = Object.keys(nodes);
var orphanKeys = [];
for (var i = 0; i < allKeys.length; i++) {
if (!visited[allKeys[i]]) orphanKeys.push(allKeys[i]);
}
if (orphanKeys.length > 0) {
html += '
';
html += '
Unreferenced Nodes
';
html += '
These nodes are not reachable from start. Add a goto to them from another node\'s option.
';
for (var j = 0; j < orphanKeys.length; j++) {
html += renderOrphanNode(orphanKeys[j], nodes[orphanKeys[j]], nodes);
}
html += '
';
}
seen[rootKey] = true;
var previewText = getNodePreview(node);
var isOpen = window._talkOpenNodes.has(rootKey);
var html = '
';
// Header — fix: check for data-action before toggling
html += '
';
html += '';
html += '' + esc(rootKey) + '';
html += '' + esc((previewText || '').substring(0, 60)) + '';
html += '';
html += '';
html += '
';
// Body
html += '
';
html += renderNodeBody(rootKey, node, nodes);
html += '
'; // .talk-node-body
// Children
var childKeys = [];
if (node.goto && nodes[node.goto]) childKeys.push(node.goto);
if (node.options) {
node.options.forEach(function(opt) {
if (opt.goto && nodes[opt.goto] && childKeys.indexOf(opt.goto) < 0) childKeys.push(opt.goto);
});
}
if (childKeys.length > 0) {
html += '
';
childKeys.forEach(function(k) { html += renderTreeNodes(nodes, k, seen, indent + 1); });
html += '
';
}
html += '
'; // .talk-node
return html;
}
function getNodePreview(node) {
if (node.messages && node.messages.length) return node.messages[0];
return '';
}
// ---- Node body: condition-gated content ----
// Track open node state across re-renders
window._talkOpenNodes = window._talkOpenNodes || new Set();
function renderNodeBody(nodeKey, node, nodes) {
var ec = extractClauses(node.condition);
var hasCond = ec.mode !== 'none' || (node.condition && typeof node.condition === 'object' && !Array.isArray(node.condition));
var hasOpts = node.options && node.options.length > 0;
var html = '';
if (hasCond) {
html += '
';
html += '
' +
'Condition' +
'' +
'
';
html += '
';
html += renderConditionClauses(ec, nodeKey, null);
html += '';
html += renderMessagesSection(nodeKey, node);
html += renderOptionsSection(nodeKey, node);
html += '';
var nodeAction = node.action;
var actionPrefix = 'nodes.' + escAttr(nodeKey) + '.action';
if (nodeAction && typeof nodeAction === 'object' && !Array.isArray(nodeAction)) {
html += '
';
html += '
' +
'Action' +
'
';
html += '
';
html += renderActionForm(nodeAction, actionPrefix, nodeKey, null);
html += '
';
html += '
';
} else {
html += '';
}
html += '';
html += renderGotoFallback(nodeKey, node, 'If condition fails, go to');
html += '
'; // .talk-cond-card-body
html += '
'; // .talk-cond-card
} else {
html += '';
html += renderMessagesSection(nodeKey, node);
html += renderOptionsSection(nodeKey, node);
html += '';
html += renderActionSection(nodeKey, null, node.action);
if (!hasOpts) {
html += renderGotoFallback(nodeKey, node, 'Auto-advance to');
}
}
return html;
}
// ---- Goto fallback ----
function renderGotoFallback(nodeKey, node, label) {
label = label || 'Auto-advance to';
return '
' +
'' + esc(label) + '' +
'' +
'
';
}
// ---- Messages section ----
function renderMessagesSection(nodeKey, node) {
var msgs = node.messages || [];
var html = '
';
html += '
Messages
';
msgs.forEach(function(line, i) {
html += '
' +
'➔' +
'' +
'' +
'
';
});
html += '
' +
'➔' +
'' +
'' +
'
';
html += '
';
return html;
}
// ---- Condition clauses ----
function renderConditionClauses(ec, nodeKey, optIdx) {
var clauses = ec.clauses;
var groupKey = ec.groupKey || 'all_of';
var html = '';
// AND/OR mode selector (only when 2+ clauses)
if (clauses.length >= 2) {
html += '
';
CLAUSE_TYPES.forEach(function(ct) {
html += '';
});
html += '
';
return html;
}
function renderClauseRow(clause, pathPrefix, typeKey, nodeKey, optIdx, clauseIdx) {
var hasValue = clauseTypeHasValue(typeKey);
var html = '
';
html += '' + esc(clauseTypeLabel(typeKey)) + '';
if (typeKey === 'flag' || typeKey === 'player_flag') {
var notLabel = (clause.value !== undefined && clause.value !== '') ? 'Not' : 'Not Exists';
html += 'key:';
html += '';
html += '';
html += 'val:';
html += '';
} else if (typeKey === 'has_item') {
html += 'item:';
html += '';
html += '';
} else if (typeKey === 'min_credits') {
html += 'cr:';
html += '';
html += '';
}
html += '';
html += '
';
return html;
}
// ---- Clause add/remove/migrate ----
function getConditionFor(nodeKey, optIdx) {
var node = talkNodes()[nodeKey];
if (!node) return null;
if (optIdx !== null) {
if (!node.options || !node.options[optIdx]) return null;
return node.options[optIdx];
}
return node;
}
function condAddClause(target, typeKey, optIdx, nodeKey) {
var ec = extractClauses(target.condition);
var newClause = {};
newClause[typeKey] = '';
ec.clauses.push(newClause);
if (ec.clauses.length === 1) {
// First clause: use flat fields
target.condition = buildCondition(ec.clauses, null);
} else {
ec.groupKey = ec.groupKey || 'all_of';
target.condition = buildCondition(ec.clauses, ec.groupKey);
}
}
function addClause(nodeKey, optIdx, typeKey) {
var target = getConditionFor(nodeKey, optIdx);
if (!target) return;
if (!target.condition || typeof target.condition !== 'object' || Array.isArray(target.condition)) {
target.condition = {};
}
condAddClause(target, typeKey, optIdx, nodeKey);
renderTalkTree(window._talkData);
talkSync();
}
function removeClause(nodeKey, optIdx, clauseIdx) {
var target = getConditionFor(nodeKey, optIdx);
if (!target) return;
var ec = extractClauses(target.condition);
if (clauseIdx < 0 || clauseIdx >= ec.clauses.length) return;
ec.clauses.splice(clauseIdx, 1);
if (ec.clauses.length === 0) {
delete target.condition;
} else {
target.condition = buildCondition(ec.clauses, ec.groupKey);
}
renderTalkTree(window._talkData);
talkSync();
}
function setCondGroup(nodeKey, optIdx, newGroupKey) {
var target = getConditionFor(nodeKey, optIdx);
if (!target) return;
var ec = extractClauses(target.condition);
if (ec.clauses.length < 2) return;
ec.groupKey = newGroupKey;
target.condition = buildCondition(ec.clauses, ec.groupKey);
renderTalkTree(window._talkData);
talkSync();
}
// Called from onchange on the AND/OR select
window.talkCondGroupChange = function(select) {
var nodeKey = select.getAttribute('data-nk');
var optIdx = select.getAttribute('data-oi');
setCondGroup(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null, select.value);
};
// ---- Add/Remove whole Condition ----
function toggleCondition(nodeKey, optIdx) {
var target = getConditionFor(nodeKey, optIdx);
if (!target) return;
if (target.condition && typeof target.condition === 'object' && !Array.isArray(target.condition)) {
delete target.condition;
} else {
target.condition = {};
if (optIdx !== null) {
window._talkAutoOpenDetails = new Set();
window._talkAutoOpenDetails.add('cond_' + optIdx + '_' + escAttr(nodeKey));
}
}
renderTalkTree(window._talkData);
talkSync();
}
function removeCondition(nodeKey, optIdx) {
var target = getConditionFor(nodeKey, optIdx);
if (!target) return;
delete target.condition;
renderTalkTree(window._talkData);
talkSync();
}
// ---- Action editor ----
function renderActionSection(nodeKey, optIdx, action) {
var prefix = optIdx !== null ? 'nodes.' + escAttr(nodeKey) + '.options.' + optIdx + '.action' : 'nodes.' + escAttr(nodeKey) + '.action';
var hasAct = action && typeof action === 'object' && !Array.isArray(action);
if (!hasAct) {
return '