aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/admin/api_mobs.go1
-rw-r--r--internal/admin/api_objects.go1
-rw-r--r--internal/admin/static/admin.css30
-rw-r--r--internal/admin/static/mobeditor.js32
-rw-r--r--internal/admin/static/objecteditor.js7
-rw-r--r--internal/admin/static/talktree.js330
-rw-r--r--internal/behavior/behavior.go128
-rw-r--r--internal/behavior/types.go24
-rw-r--r--internal/game/act_steal.go10
-rw-r--r--internal/game/act_talk.go53
-rw-r--r--internal/game/cmd_registry.go284
-rw-r--r--internal/game/cmd_shop.go525
-rw-r--r--internal/game/game.go6
-rw-r--r--internal/net/server.go17
-rw-r--r--internal/validate/checks.go52
-rw-r--r--internal/world/mob.go154
16 files changed, 922 insertions, 732 deletions
diff --git a/internal/admin/api_mobs.go b/internal/admin/api_mobs.go
index da088d3..a575076 100644
--- a/internal/admin/api_mobs.go
+++ b/internal/admin/api_mobs.go
@@ -95,6 +95,7 @@ func (s *AdminServer) updateMob(w http.ResponseWriter, r *http.Request, id strin
http.Error(w, `{"error":"failed to write mob"}`, http.StatusInternalServerError)
return
}
+ m["_raw"] = string(newContent)
s.mobStore.ReloadDefs(s.dataDir)
s.undoStack.Push(ChangeDesc{
Description: fmt.Sprintf("Update mob %s", id),
diff --git a/internal/admin/api_objects.go b/internal/admin/api_objects.go
index 01a0803..7bcda1c 100644
--- a/internal/admin/api_objects.go
+++ b/internal/admin/api_objects.go
@@ -95,6 +95,7 @@ func (s *AdminServer) updateObject(w http.ResponseWriter, r *http.Request, id st
http.Error(w, `{"error":"failed to write object"}`, http.StatusInternalServerError)
return
}
+ m["_raw"] = string(newContent)
s.undoStack.Push(ChangeDesc{
Description: fmt.Sprintf("Update object %s", id),
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css
index 43354f9..2bebf85 100644
--- a/internal/admin/static/admin.css
+++ b/internal/admin/static/admin.css
@@ -1,5 +1,7 @@
:root{--bg:#1a1a2e;--panel:#16213e;--text:#e0e0e0;--accent:#0f3460;--border:#2a2a4a;--hover:#1a3a6e;--danger:#c0392b;--success:#27ae60;--warn:#f39c12;--input-bg:#0d1b36;--input-border:#3a3a6a}
*{box-sizing:border-box;margin:0;padding:0}
+input[type=number]{-moz-appearance:textfield}
+input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}
body{font-family:monospace;background:var(--bg);color:var(--text);min-height:100vh}
.nav{background:var(--panel);border-bottom:1px solid var(--border);padding:8px 16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.nav a{color:var(--text);text-decoration:none;padding:4px 10px;border-radius:3px;font-size:13px}
@@ -19,8 +21,6 @@ body{font-family:monospace;background:var(--bg);color:var(--text);min-height:100
.form-group{margin-bottom:10px}
.form-group label{display:block;font-size:11px;color:#aaa;margin-bottom:3px;text-transform:uppercase;letter-spacing:.5px}
.form-group input,.form-group textarea,.form-group select{width:100%;padding:6px 8px;font-size:13px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace}
-.form-group input[type=number]{-moz-appearance:textfield}
-.form-group input[type=number]::-webkit-inner-spin-button,.form-group input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}
.form-group textarea{resize:vertical;min-height:60px}
.form-group input[type=checkbox]{width:auto;margin-right:6px}
.btn{padding:6px 14px;border:none;border-radius:3px;cursor:pointer;font-size:12px;font-weight:bold;font-family:monospace}
@@ -76,14 +76,14 @@ body{font-family:monospace;background:var(--bg);color:var(--text);min-height:100
.talk-seq-line{display:flex;gap:6px;margin:2px 0 2px 10px;font-size:11px;color:#bbb;align-items:center}
.talk-seq-num{color:#666;min-width:14px;text-align:right;flex-shrink:0}
.talk-action-summary{color:#e8c47c;font-size:10px}
-.talk-options{margin-top:6px;padding-top:4px;border-top:1px solid rgba(255,255,255,.05)}
+.talk-options{margin-top:6px;padding-top:4px;padding-bottom:6px;border-top:1px solid rgba(255,255,255,.05)}
.talk-options-label{font-size:10px;color:#888;text-transform:uppercase;letter-spacing:.3px}
.talk-option{display:flex;align-items:center;gap:5px;margin:3px 0;padding:3px 6px;font-size:11px;border-left:1px solid #444;border-radius:0 3px 3px 0}
.talk-option-empty{color:#666;font-style:italic;border-left-color:transparent}
.talk-opt-arrow{color:#555;flex-shrink:0}
.talk-opt-text{color:var(--text);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.talk-opt-goto{color:#8cf;font-weight:bold;white-space:nowrap;min-width:30px}
-.talk-opt-tag{display:inline-block;padding:0 3px;font-size:9px;border-radius:2px;line-height:1.4;flex-shrink:0}
+.talk-opt-tag{display:inline-block;padding:1px 6px;font-size:9px;border-radius:2px;line-height:1.4;flex-shrink:0;cursor:pointer;border:1px solid transparent}
.talk-opt-tag-cond{background:rgba(100,160,255,.2);color:#6af}
.talk-opt-tag-act{background:rgba(232,196,124,.2);color:#e8c47c}
.talk-opt-del{padding:0 4px;font-size:10px;line-height:1.4;border:none;border-radius:2px;background:var(--danger);color:#fff;cursor:pointer;flex-shrink:0}
@@ -119,18 +119,14 @@ body{font-family:monospace;background:var(--bg);color:var(--text);min-height:100
.talk-kv-del{font-size:9px;padding:0 4px;line-height:1.4;border:none;border-radius:2px;background:var(--danger);color:#fff;cursor:pointer;font-family:monospace;flex-shrink:0}
.talk-action-block{margin-top:3px;padding:6px;background:rgba(255,255,255,.02);border:1px solid var(--border);border-radius:4px}
.talk-action-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:3px 8px}
-.talk-shop-block{margin-top:4px;padding:6px;background:rgba(232,196,124,.04);border:1px solid rgba(232,196,124,.15);border-radius:4px}
-.talk-shop-header{font-size:10px;color:#e8c47c;text-transform:uppercase;letter-spacing:.3px;margin-bottom:4px}
-.talk-shop-item{display:flex;gap:3px;align-items:center;margin-bottom:2px;padding:2px 4px;background:rgba(255,255,255,.02);border-radius:2px}
-.talk-shop-item .talk-input{flex:1;min-width:0;padding:2px 4px;font-size:10px}
-.talk-shop-item .talk-input-num{width:55px}
.talk-add-cond-btn{font-size:9px;padding:1px 6px;line-height:1.4;border:1px solid rgba(100,160,255,.25);border-radius:2px;background:transparent;color:#6af;cursor:pointer;font-family:monospace;margin-bottom:6px}
.talk-add-act-btn{font-size:9px;padding:1px 6px;line-height:1.4;border:1px solid rgba(232,196,124,.25);border-radius:2px;background:transparent;color:#e8c47c;cursor:pointer;font-family:monospace;margin-bottom:6px}
.talk-cond-card{border:1px solid rgba(100,160,255,.25);border-radius:5px;margin-bottom:6px;overflow:hidden}
.talk-cond-card-header{display:flex;align-items:center;justify-content:space-between;padding:6px 8px;background:rgba(100,160,255,.08);border-bottom:1px solid rgba(100,160,255,.15);font-size:10px;color:#aaa;text-transform:uppercase;letter-spacing:.3px}
.talk-cond-card-body{padding:6px 8px}
-.talk-cond-card-sep{border-top:1px solid rgba(100,160,255,.12);margin:1px 0}
+.talk-cond-card-body .talk-action-block{margin-top:0;border:none;padding:0;border-radius:0;background:transparent}
+.talk-cond-card-sep{border-top:1px solid rgba(100,160,255,.12);margin:1px 0 4px 0}
.talk-cond-mode-row{display:flex;align-items:center;gap:6px;margin-bottom:4px}
.talk-cond-mode-row .talk-prop-label{font-size:9px;color:#888;text-transform:uppercase;letter-spacing:.3px}
@@ -141,7 +137,7 @@ body{font-family:monospace;background:var(--bg);color:var(--text);min-height:100
.talk-clause-label{color:#888;font-size:9px;white-space:nowrap}
.talk-clause-del{padding:0 4px;font-size:9px;line-height:1.4;border:none;border-radius:2px;background:var(--danger);color:#fff;cursor:pointer;font-family:monospace;flex-shrink:0}
-.talk-clause-add-bar{display:flex;gap:4px;margin-top:4px;flex-wrap:wrap}
+.talk-clause-add-bar{display:flex;gap:4px;margin-top:4px;flex-wrap:wrap;padding-bottom:6px}
.talk-clause-add-btn{font-size:9px;padding:1px 6px;line-height:1.4;border:1px solid rgba(100,160,255,.2);border-radius:2px;background:transparent;color:#6af;cursor:pointer;font-family:monospace}
.talk-clause-add-btn:hover{background:rgba(100,160,255,.1)}
@@ -149,24 +145,29 @@ body{font-family:monospace;background:var(--bg);color:var(--text);min-height:100
.talk-goto-fallback .talk-prop-label{font-size:9px;color:#888;text-transform:uppercase;letter-spacing:.3px;white-space:nowrap;flex-shrink:0}
.talk-goto-fallback .talk-input{flex:1;min-width:0;padding:2px 4px;font-size:10px}
-.talk-input-num::-webkit-inner-spin-button,
-.talk-input-num::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}
-.talk-input-num{-moz-appearance:textfield}
.talk-add-node-row{display:flex;gap:4px;align-items:center;margin-top:8px}
.talk-add-node-row .talk-input{flex:1;max-width:200px}
.talk-add-node-row .talk-add-btn{flex-shrink:0}
.talk-opt-edit-row{display:flex;gap:4px;align-items:center;margin:3px 0;padding:3px 6px;font-size:11px}
.talk-opt-edit-row .talk-input{flex:1;min-width:0;padding:2px 4px;font-size:10px}
+.talk-opt-edit-row .talk-opt-text{flex:3}
+.talk-opt-edit-row .talk-opt-goto{flex:1;max-width:130px}
.talk-opt-edit-row .talk-opt-arrow{flex-shrink:0}
.talk-opt-edit-actions{display:flex;gap:2px;align-items:center;flex-shrink:0}
.talk-add-opt-row{display:flex;gap:4px;align-items:center;margin-top:4px;padding:0 6px}
.talk-add-opt-row .talk-input{flex:1;min-width:0;padding:2px 4px;font-size:10px}
+.talk-add-opt-row .talk-opt-text{flex:8}
+.talk-add-opt-row .talk-opt-goto{flex:1;max-width:130px}
.talk-add-opt-row .talk-add-btn{flex-shrink:0;margin-top:0}
+.talk-opt-gap{width:52px;flex-shrink:0}
.talk-seq-add-row{display:flex;gap:6px;align-items:center;margin:2px 0 2px 10px}
.talk-seq-add-row .talk-input{flex:1;padding:2px 4px;font-size:10px}
.talk-seq-add-row .talk-add-btn{flex-shrink:0;margin-top:0}
.talk-seq-del{font-size:9px;padding:0 4px;line-height:1.4;border:none;border-radius:2px;background:var(--danger);color:#fff;cursor:pointer;font-family:monospace;flex-shrink:0}
+#talktree .search-results{position:absolute;top:100%;left:0;right:0;max-height:160px;overflow-y:auto;background:var(--panel);border:1px solid var(--border);border-radius:3px;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,.4);margin-top:2px}
+#talktree .search-results .sr-item{padding:4px 8px;font-size:11px;cursor:pointer;color:var(--text);border-bottom:1px solid rgba(255,255,255,.03)}
+#talktree .search-results .sr-item:hover,#talktree .search-results .sr-item.active{background:var(--accent)}
.talk-orphans{margin-top:12px;padding-top:8px;border-top:1px dashed var(--border)}
.talk-orphans-label{font-size:10px;color:#f39c12;text-transform:uppercase;letter-spacing:.3px;margin-bottom:2px}
.talk-orphans-hint{font-size:9px;color:#888;margin-bottom:6px;font-style:italic}
@@ -247,7 +248,6 @@ g.ud-hover:hover text{font-weight:bold}
.exit-add{display:flex;gap:4px;align-items:center;margin-top:6px}
.exit-add select,.exit-add input{width:auto;flex:1;padding:3px 5px;font-size:11px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:2px;font-family:monospace}
.exit-add input[type=number]{-moz-appearance:textfield}
-.exit-add input[type=number]::-webkit-inner-spin-button,.exit-add input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}
.exit-add .exit-add-btn{flex-shrink:0;padding:3px 8px;font-size:11px;background:var(--accent);color:var(--text);border:1px solid #1a5a8e;border-radius:2px;cursor:pointer;font-family:monospace}
.exit-add .exit-add-btn:hover{background:var(--hover)}.panel-tab-content .entry-row{display:flex;gap:4px;align-items:flex-start;margin-bottom:6px;padding:4px;background:rgba(255,255,255,.02);border-radius:3px;border:1px solid transparent}
.panel-tab-content .entry-row:hover{border-color:var(--border)}
diff --git a/internal/admin/static/mobeditor.js b/internal/admin/static/mobeditor.js
index a65e578..59eb16c 100644
--- a/internal/admin/static/mobeditor.js
+++ b/internal/admin/static/mobeditor.js
@@ -90,6 +90,24 @@ var MOB_SECTIONS = [
fields: []
},
{
+ id: 'shop', label: 'Shop', path: 'shop',
+ detect: function(d) { return !!d.shop; },
+ fields: [
+ ['message', 'Greeting', 'text', 'What would you like to buy or sell?'],
+ ['buy_percentage', 'Buy %', 'number', '40', 'narrow'],
+ ['change_percentage', 'Change %', 'number', '3', 'narrow'],
+ ['restock_ticks', 'Restock Ticks', 'number', '', 'narrow'],
+ ['buys_anything', 'Buys Anything', 'checkbox'],
+ ],
+ subtables: [
+ {label:'Items', key:'items', addLabel:'+ Add Item', fields:[
+ ['item_id', 'Item ID', 'search', '', '', null, 'items'],
+ ['stock', 'Stock', 'number', '', 'narrow'],
+ ['restock_ticks', 'Restock Ticks', 'number', '', 'narrow'],
+ ]}
+ ]
+ },
+ {
id: 'drops', label: 'Drops', path: 'drops',
detect: function(d) { return d.drops && (d.drops.remains || (d.drops.loot && d.drops.loot.length > 0)); },
fields: [
@@ -197,7 +215,7 @@ function renderCurrent() {
function renderMobCard(sec, data) {
var collapsed = expandedCards[sec.id] === false;
- var h = '<div class="obj-card" data-card="' + sec.id + '">';
+ var h = '<div class="obj-card' + (sec.id === 'talk' ? ' obj-card-full' : '') + '" data-card="' + sec.id + '">';
h += '<div class="obj-card-header" onclick="toggleMobCard(\'' + sec.id + '\')">';
h += '<span class="obj-card-arrow">' + (collapsed ? '&#9654;' : '&#9660;') + '</span>';
h += '<span class="obj-card-label">' + sec.label + '</span>';
@@ -336,7 +354,7 @@ function renderMobSubtable(sec, st, data) {
h += '</div>';
});
if (!st.single || arr.length === 0) {
- h += '<button class="btn btn-sm obj-sub-add" onclick="addMobSubRow(\'' + sec.id + '\',\'' + st.key + '\')">+ Add Loot</button>';
+ h += '<button class="btn btn-sm obj-sub-add" onclick="addMobSubRow(\'' + sec.id + '\',\'' + st.key + '\')">' + esc(st.addLabel || '+ Add') + '</button>';
}
h += '</div>';
return h;
@@ -374,7 +392,9 @@ function mobAddSection() {
if (sec.id === 'drops') {
mobData[sec.path] = {remains:'', loot:[]};
} else if (sec.id === 'talk') {
- mobData[sec.path] = {nodes:{start:{message:'',options:[]}}};
+ mobData[sec.path] = {nodes:{start:{messages:[],options:[]}}};
+ } else if (sec.id === 'shop') {
+ mobData[sec.path] = {buy_percentage:40, change_percentage:3, buys_anything:true, items:[]};
} else {
mobData[sec.path] = {};
}
@@ -467,7 +487,11 @@ function saveMob() {
API.put('/api/mobs/' + encodeURIComponent(mobID), out).then(function(resp) {
notify('Mob ' + mobID + ' saved', 'success');
updateUndoBar();
- if (resp && resp._raw) mobData._raw = resp._raw;
+ if (resp && resp._raw) {
+ mobData._raw = resp._raw;
+ var pre = document.querySelector('#tabYaml pre');
+ if (pre) pre.textContent = mobData._raw;
+ }
}).catch(function(e) { notify('Save failed: ' + e.message, 'error'); });
}
diff --git a/internal/admin/static/objecteditor.js b/internal/admin/static/objecteditor.js
index c2374a7..4d02295 100644
--- a/internal/admin/static/objecteditor.js
+++ b/internal/admin/static/objecteditor.js
@@ -775,9 +775,14 @@ function saveObject() {
out.id = objectID;
- API.put('/api/objects/' + encodeURIComponent(objectID), out).then(function() {
+ API.put('/api/objects/' + encodeURIComponent(objectID), out).then(function(resp) {
notify('Object ' + objectID + ' saved', 'success');
updateUndoBar();
+ if (resp && resp._raw) {
+ objectData._raw = resp._raw;
+ var pre = document.querySelector('#tabYaml pre');
+ if (pre) pre.textContent = objectData._raw;
+ }
}).catch(function(e) { notify('Save failed: ' + e.message, 'error'); });
}
diff --git a/internal/admin/static/talktree.js b/internal/admin/static/talktree.js
index e7079f5..4758918 100644
--- a/internal/admin/static/talktree.js
+++ b/internal/admin/static/talktree.js
@@ -86,11 +86,11 @@
}
// Single clause from flat fields
for (var i = 0; i < CLAUSE_TYPES.length; i++) {
- if (cond[CLAUSE_TYPES[i].key] !== undefined && cond[CLAUSE_TYPES[i].key] !== '') {
+ 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 && cond.value !== '') c.value = cond.value;
+ 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;
@@ -100,11 +100,11 @@
// 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 && cond[ck] !== '') {
+ if (cond[ck] !== undefined) {
var cc = {};
cc[ck] = cond[ck];
if (cond.not) cc.not = cond.not;
- if (cond.value !== undefined && cond.value !== '') cc.value = cond.value;
+ if (cond.value !== undefined) cc.value = cond.value;
return { mode: 'single', clauses: [cc], groupKey: null };
}
}
@@ -115,7 +115,7 @@
function clauseObjType(clause) {
for (var i = 0; i < CLAUSE_TYPES.length; i++) {
var k = CLAUSE_TYPES[i].key;
- if (clause[k] !== undefined && clause[k] !== '') return k;
+ if (clause[k] !== undefined) return k;
}
return null;
}
@@ -126,13 +126,13 @@
if (clauses.length === 1) {
var flat = {};
var c = clauses[0];
- for (var k in c) { if (c[k] !== undefined && c[k] !== '') flat[k] = c[k]; }
+ 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 && c[k] !== '') sub[k] = c[k]; }
+ for (var k in c) { if (c[k] !== undefined) sub[k] = c[k]; }
return sub;
});
return cond;
@@ -141,21 +141,14 @@
// ---- Action field definitions ----
var ACTION_FIELDS = [
- { key: 'give_item', label: 'Give Item', type: 'text' },
- { key: 'take_item', label: 'Take Item', type: 'text' },
- { key: 'teleport', label: 'Teleport Room', type: 'number' },
+ { 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' },
- { key: 'cost', label: 'Cost (Credits)', type: 'number' },
- { key: 'reputation_cost', label: 'Rep Cost', type: 'number' },
];
- var ACTION_BOOLS = [
- { key: 'assign_task', label: 'Assign Task' },
- { key: 'skip_task', label: 'Skip Task' },
- { key: 'extend_task', label: 'Extend Task' },
- { key: 'sawmill', label: 'Sawmill' },
- { key: 'aps_node', label: 'APS Node' },
- ];
+ var ACTION_BOOLS = [];
// ---- Event delegation ----
@@ -173,6 +166,7 @@
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);
}
@@ -207,11 +201,11 @@
case 'add-node':
addNode();
break;
- case 'add-sequence':
- addSequenceLine(nodeKey);
+ case 'add-message':
+ addMessageLine(nodeKey);
break;
- case 'delete-sequence':
- deleteSequenceLine(nodeKey, parseInt(optIdx, 10));
+ case 'delete-message':
+ deleteMessageLine(nodeKey, parseInt(optIdx, 10));
break;
case 'add-condition':
toggleCondition(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null);
@@ -240,12 +234,6 @@
case 'remove-kv':
removeKVPair(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null, el.getAttribute('data-kvk'), subKey);
break;
- case 'add-shop-item':
- addShopItem(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null);
- break;
- case 'remove-shop-item':
- removeShopItem(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null, parseInt(subIdx, 10));
- break;
}
}
@@ -255,6 +243,8 @@
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 = '<p style="color:#888">No conversation tree defined.</p>';
return;
@@ -285,6 +275,9 @@
html += renderAddNodeRow();
html += '</div>';
container.innerHTML = html;
+ if (typeof ced_setupSearchFields === 'function') {
+ setTimeout(function() { ced_setupSearchFields(); }, 10);
+ }
};
// ---- Add/Delete Node ----
@@ -302,7 +295,7 @@
var key = input.value.trim();
if (!key) return;
if (talkNodes()[key]) { notify('Node "' + key + '" already exists.', 'error'); return; }
- talkNodes()[key] = { message: '', options: [] };
+ talkNodes()[key] = { messages: [], options: [] };
input.value = '';
renderTalkTree(window._talkData);
talkSync();
@@ -328,7 +321,7 @@
function renderOrphanNode(key, node, nodes) {
var h = '<div class="talk-node">';
- h += '<div class="talk-node-header" onclick="if(!event.target.closest(\'[data-action]\'))this.parentElement.parentElement.classList.toggle(\'open\')">';
+ h += '<div class="talk-node-header" onclick="if(!event.target.closest(\'[data-action]\'))talkToggleNode(\'' + escAttr(key) + '\',this.parentElement.parentElement)">';
h += '<span class="talk-node-arrow"></span>';
h += '<span class="talk-node-key">' + esc(key) + '</span>';
h += '<span class="talk-node-preview">' + esc((getNodePreview(node) || '').substring(0, 60)) + '</span>';
@@ -349,16 +342,17 @@
var node = nodes[rootKey];
if (!node) return '';
if (seen[rootKey]) {
- return '<div class="talk-node ref"><span class="talk-node-key">' + esc(rootKey) + '</span> <span class="talk-node-preview">(already shown)</span></div>';
+ return '<div class="talk-node ref"><span class="talk-node-key">' + esc(rootKey) + '</span> <a href="javascript:void(0)" onclick="document.getElementById(\'node-' + escAttr(rootKey) + '\').scrollIntoView({behavior:\'smooth\',block:\'center\'});return false" style="color:inherit;text-decoration:underline">(see above)</a></div>';
}
seen[rootKey] = true;
var previewText = getNodePreview(node);
+ var isOpen = window._talkOpenNodes.has(rootKey);
- var html = '<div class="talk-node' + (rootKey === 'start' ? ' open' : '') + '">';
+ var html = '<div class="talk-node' + (isOpen ? ' open' : '') + '" id="node-' + escAttr(rootKey) + '">';
// Header — fix: check for data-action before toggling
- html += '<div class="talk-node-header" onclick="if(!event.target.closest(\'[data-action]\'))this.parentElement.classList.toggle(\'open\')">';
+ html += '<div class="talk-node-header" onclick="if(!event.target.closest(\'[data-action]\'))talkToggleNode(\'' + escAttr(rootKey) + '\',this.parentElement)">';
html += '<span class="talk-node-arrow"></span>';
html += '<span class="talk-node-key">' + esc(rootKey) + '</span>';
html += '<span class="talk-node-preview">' + esc((previewText || '').substring(0, 60)) + '</span>';
@@ -390,16 +384,19 @@
}
function getNodePreview(node) {
- if (node.message) return typeof node.message === 'string' ? node.message : (Array.isArray(node.message) ? node.message[0] : '');
- if (node.sequence && node.sequence.length) return node.sequence[0];
+ 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';
+ 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 = '';
@@ -412,23 +409,38 @@
html += '<div class="talk-cond-card-body">';
html += renderConditionClauses(ec, nodeKey, null);
html += '<div class="talk-cond-card-sep"></div>';
- html += renderMessageSection(nodeKey, node);
- html += renderSequenceSection(nodeKey, node);
- html += renderActionSection(nodeKey, null, node.action);
+ html += renderMessagesSection(nodeKey, node);
html += renderOptionsSection(nodeKey, node);
+ html += '<div class="talk-cond-card-sep"></div>';
+
+ var nodeAction = node.action;
+ var actionPrefix = 'nodes.' + escAttr(nodeKey) + '.action';
+ if (nodeAction && typeof nodeAction === 'object' && !Array.isArray(nodeAction)) {
+ html += '<div class="talk-cond-card" style="margin-top:4px">';
+ html += '<div class="talk-cond-card-header">' +
+ '<span class="talk-prop-label">Action</span>' +
+ '<button class="talk-section-remove" data-action="remove-action" data-nk="' + escAttr(nodeKey) + '">Remove</button></div>';
+ html += '<div class="talk-cond-card-body">';
+ html += renderActionForm(nodeAction, actionPrefix, nodeKey, null);
+ html += '</div>';
+ html += '</div>';
+ } else {
+ html += '<button class="talk-add-act-btn" style="margin-top:4px" data-action="add-action" data-nk="' + escAttr(nodeKey) + '">+ Add Action</button>';
+ }
+
+ html += '<div class="talk-cond-card-sep"></div>';
+ html += renderGotoFallback(nodeKey, node, 'If condition fails, go to');
html += '</div>'; // .talk-cond-card-body
html += '</div>'; // .talk-cond-card
} else {
html += '<button class="talk-add-cond-btn" data-action="add-condition" data-nk="' + escAttr(nodeKey) + '">+ Add Condition</button>';
- html += renderMessageSection(nodeKey, node);
- html += renderSequenceSection(nodeKey, node);
- html += renderActionSection(nodeKey, null, node.action);
+ html += renderMessagesSection(nodeKey, node);
html += renderOptionsSection(nodeKey, node);
- }
-
- // Goto fallback — only when no options
- if (!node.options || node.options.length === 0) {
- html += renderGotoFallback(nodeKey, node);
+ html += '<div class="talk-cond-card-sep"></div>';
+ html += renderActionSection(nodeKey, null, node.action);
+ if (!hasOpts) {
+ html += renderGotoFallback(nodeKey, node, 'Auto-advance to');
+ }
}
return html;
@@ -436,39 +448,31 @@
// ---- Goto fallback ----
- function renderGotoFallback(nodeKey, node) {
+ function renderGotoFallback(nodeKey, node, label) {
+ label = label || 'Auto-advance to';
return '<div class="talk-goto-fallback">' +
- '<span class="talk-prop-label">Auto-advance to</span>' +
- '<input class="talk-input talk-input-sm" data-tp="nodes.' + escAttr(nodeKey) + '.goto" list="talkNodeKeys" value="' + escAttr(node.goto || '') + '" placeholder="Fallback node when no options are available...">' +
+ '<span class="talk-prop-label">' + esc(label) + '</span>' +
+ '<input class="talk-input talk-input-sm" data-tp="nodes.' + escAttr(nodeKey) + '.goto" list="talkNodeKeys" value="' + escAttr(node.goto || '') + '" placeholder="Fallback node...">' +
'</div>';
}
- // ---- Message / Sequence ----
-
- function renderMessageSection(nodeKey, node) {
- var msg = node.message || '';
- if (Array.isArray(msg)) msg = msg.join('\n');
- return '<div class="talk-section">' +
- '<div class="talk-section-header"><span class="talk-prop-label">Message</span></div>' +
- '<textarea class="talk-textarea" data-tp="nodes.' + escAttr(nodeKey) + '.message" rows="2" placeholder="NPC dialogue text...">' + esc(msg) + '</textarea>' +
- '</div>';
- }
+ // ---- Messages section ----
- function renderSequenceSection(nodeKey, node) {
- var seq = node.sequence || [];
+ function renderMessagesSection(nodeKey, node) {
+ var msgs = node.messages || [];
var html = '<div class="talk-section">';
- html += '<div class="talk-section-header"><span class="talk-prop-label">Sequence</span></div>';
- seq.forEach(function(line, i) {
+ html += '<div class="talk-section-header"><span class="talk-prop-label">Messages</span></div>';
+ msgs.forEach(function(line, i) {
html += '<div class="talk-seq-line">' +
- '<span class="talk-seq-num">' + (i + 1) + '</span>' +
- '<input class="talk-input talk-input-sm" data-tp="nodes.' + escAttr(nodeKey) + '.sequence.' + i + '" value="' + escAttr(line) + '" placeholder="Sequence line...">' +
- '<button class="talk-seq-del" data-action="delete-sequence" data-nk="' + escAttr(nodeKey) + '" data-oi="' + i + '">x</button>' +
+ '<span class="talk-opt-arrow">&#10132;</span>' +
+ '<input class="talk-input" data-tp="nodes.' + escAttr(nodeKey) + '.messages.' + i + '" value="' + escAttr(line) + '" placeholder="Message line...">' +
+ '<button class="talk-seq-del" data-action="delete-message" data-nk="' + escAttr(nodeKey) + '" data-oi="' + i + '">x</button>' +
'</div>';
});
html += '<div class="talk-seq-add-row">' +
- '<span class="talk-seq-num">' + (seq.length + 1) + '</span>' +
- '<input class="talk-input talk-input-sm" id="talkSeqAdd_' + escAttr(nodeKey) + '" placeholder="Add sequence line...">' +
- '<button class="talk-add-btn" data-action="add-sequence" data-nk="' + escAttr(nodeKey) + '">Add</button>' +
+ '<span class="talk-opt-arrow">&#10132;</span>' +
+ '<input class="talk-input" id="talkMsgAdd_' + escAttr(nodeKey) + '" placeholder="Add message line...">' +
+ '<button class="talk-add-btn" data-action="add-message" data-nk="' + escAttr(nodeKey) + '">Add</button>' +
'</div>';
html += '</div>';
return html;
@@ -518,15 +522,16 @@
html += '<span class="talk-clause-type">' + esc(clauseTypeLabel(typeKey)) + '</span>';
if (typeKey === 'flag' || typeKey === 'player_flag') {
+ var notLabel = (clause.value !== undefined && clause.value !== '') ? 'Not' : 'Not Exists';
html += '<span class="talk-clause-label">key:</span>';
html += '<input class="talk-input talk-input-sm" data-tp="' + escAttr(pathPrefix + '.' + typeKey) + '" value="' + escAttr(clause[typeKey] || '') + '" placeholder="Flag name...">';
- html += '<label class="talk-check-label"><input type="checkbox" data-tp="' + escAttr(pathPrefix + '.not') + '"' + (clause.not ? ' checked' : '') + '> Not</label>';
+ html += '<label class="talk-check-label"><input type="checkbox" data-tp="' + escAttr(pathPrefix + '.not') + '"' + (clause.not ? ' checked' : '') + '> ' + notLabel + '</label>';
html += '<span class="talk-clause-label">val:</span>';
html += '<input class="talk-input talk-input-sm" data-tp="' + escAttr(pathPrefix + '.value') + '" value="' + escAttr(clause.value || '') + '" placeholder="Value match..." style="max-width:80px">';
} else if (typeKey === 'has_item') {
html += '<span class="talk-clause-label">item:</span>';
html += '<input class="talk-input talk-input-sm" data-tp="' + escAttr(pathPrefix + '.has_item') + '" value="' + escAttr(clause.has_item || '') + '" placeholder="Item ID...">';
- html += '<label class="talk-check-label"><input type="checkbox" data-tp="' + escAttr(pathPrefix + '.not') + '"' + (clause.not ? ' checked' : '') + '> Not</label>';
+ html += '<label class="talk-check-label"><input type="checkbox" data-tp="' + escAttr(pathPrefix + '.not') + '"' + (clause.not ? ' checked' : '') + '> Not Exists</label>';
} else if (typeKey === 'min_credits') {
html += '<span class="talk-clause-label">cr:</span>';
html += '<input class="talk-input-num" data-tp="' + escAttr(pathPrefix + '.min_credits') + '" type="number" value="' + (clause.min_credits || 0) + '" placeholder="0">';
@@ -620,6 +625,10 @@
delete target.condition;
} else {
target.condition = {};
+ if (optIdx !== null) {
+ window._talkAutoOpenDetails = new Set();
+ window._talkAutoOpenDetails.add('cond_' + optIdx + '_' + escAttr(nodeKey));
+ }
}
renderTalkTree(window._talkData);
talkSync();
@@ -661,10 +670,21 @@
ACTION_FIELDS.forEach(function(f) {
var fpath = prefix + '.' + f.key;
var val = (action && action[f.key] !== undefined) ? action[f.key] : '';
- html += '<div class="talk-field-row">' +
- '<span class="talk-prop-label">' + esc(f.label) + '</span>' +
- '<input class="talk-input talk-input-sm" data-tp="' + escAttr(fpath) + '" type="' + f.type + '" value="' + escAttr(String(val)) + '">' +
- '</div>';
+ if (f.type === 'item_search') {
+ html += '<div class="talk-field-row">' +
+ '<span class="talk-prop-label">' + esc(f.label) + '</span>' +
+ '<div style="position:relative">' +
+ '<input class="talk-input talk-input-sm search-input" data-tp="' + escAttr(fpath) + '" data-search-type="items" value="' + escAttr(String(val)) + '" placeholder="Search items...">' +
+ '<div class="search-results" style="display:none"></div>' +
+ '</div>' +
+ '</div>';
+ } else {
+ var narrowStyle = (f.key === 'teleport' || f.key === 'heal') ? ' style="max-width:70px"' : '';
+ html += '<div class="talk-field-row">' +
+ '<span class="talk-prop-label">' + esc(f.label) + '</span>' +
+ '<input class="talk-input talk-input-sm"' + narrowStyle + ' data-tp="' + escAttr(fpath) + '" type="' + f.type + '" value="' + escAttr(String(val)) + '">' +
+ '</div>';
+ }
});
html += '</div>';
@@ -678,7 +698,6 @@
html += renderKVEditor(action, prefix, 'set_flags', 'Set Flags', nodeKey, optIdx);
html += renderKVEditor(action, prefix, 'set_player_flags', 'Set Player Flags', nodeKey, optIdx);
- html += renderShopEditor(action, prefix, nodeKey, optIdx);
html += '</div>'; // .talk-action-block
return html;
@@ -697,14 +716,14 @@
html += '</div>';
keys.forEach(function(k) {
html += '<div class="talk-kv-row">' +
+ '<input class="talk-input" data-nk="' + nkAttr + '"' + oiAttr + ' data-kvk="' + escAttr(key) + '" data-oldkey="' + escAttr(k) + '" onchange="talkRenameKV(this)" value="' + escAttr(k) + '" placeholder="key" style="max-width:150px">' +
'<input class="talk-input" data-tp="' + escAttr(prefix + '.' + key + '.' + k) + '" value="' + escAttr(String(kv[k])) + '" placeholder="value">' +
- '<span style="font-size:9px;color:#888;white-space:nowrap;min-width:60px">key: ' + esc(k) + '</span>' +
'<button class="talk-kv-del" data-action="remove-kv" data-nk="' + nkAttr + '"' + oiAttr + ' data-kvk="' + escAttr(key) + '" data-sk="' + escAttr(k) + '">x</button>' +
'</div>';
});
html += '<div class="talk-kv-row">' +
- '<input class="talk-input" id="' + escAttr(addKeyID) + '" placeholder="New key name" style="max-width:100px" data-nk="' + nkAttr + '"' + oiAttr + ' data-kvk="' + escAttr(key) + '">' +
- '<input class="talk-input" placeholder="Value" style="display:none">' +
+ '<input class="talk-input" id="' + escAttr(addKeyID) + '" placeholder="New key name" style="max-width:150px" data-nk="' + nkAttr + '"' + oiAttr + ' data-kvk="' + escAttr(key) + '">' +
+ '<input class="talk-input" id="' + escAttr(addKeyID) + '_val" placeholder="value" style="flex:1">' +
'<span style="font-size:9px;color:#888;white-space:nowrap;min-width:60px"></span>' +
'<button class="talk-kv-add" data-action="add-kv" data-nk="' + nkAttr + '"' + oiAttr + ' data-kvk="' + escAttr(key) + '">Add</button>' +
'</div>';
@@ -712,62 +731,30 @@
return html;
}
- function renderShopEditor(action, prefix, nodeKey, optIdx) {
- var shop = action && action.shop;
- var hasShop = shop && typeof shop === 'object' && !Array.isArray(shop);
- var html = '<div class="talk-shop-block">';
- html += '<div class="talk-shop-header">Shop</div>';
-
- if (hasShop) {
- html += '<div class="talk-field-row" style="margin-bottom:4px">' +
- '<span class="talk-prop-label">Shop Message</span>' +
- '<textarea class="talk-textarea" data-tp="' + escAttr(prefix + '.shop.message') + '" rows="2" style="min-height:24px">' + esc(shop.message || '') + '</textarea>' +
- '</div>';
-
- var items = shop.items || [];
- html += '<div class="talk-prop-label" style="margin-bottom:2px">Items</div>';
- items.forEach(function(item, si) {
- var ip = prefix + '.shop.items.' + si;
- html += '<div class="talk-shop-item">' +
- '<input class="talk-input" data-tp="' + escAttr(ip + '.item_id') + '" value="' + escAttr(item.item_id || '') + '" placeholder="Item ID">' +
- '<input class="talk-input-num" data-tp="' + escAttr(ip + '.buy_price') + '" type="number" value="' + (item.buy_price || 0) + '" placeholder="Buy" title="Buy price">' +
- '<input class="talk-input-num" data-tp="' + escAttr(ip + '.sell_price') + '" type="number" value="' + (item.sell_price || 0) + '" placeholder="Sell" title="Sell price">' +
- '<button class="talk-kv-del" data-action="remove-shop-item" data-nk="' + escAttr(nodeKey) + '"' + (optIdx !== null ? ' data-oi="' + optIdx + '"' : '') + ' data-si="' + si + '">x</button>' +
- '</div>';
- });
- html += '<button class="talk-kv-add" data-action="add-shop-item" data-nk="' + escAttr(nodeKey) + '"' + (optIdx !== null ? ' data-oi="' + optIdx + '"' : '') + '>+ Add Item</button>';
- } else {
- html += '<button class="talk-kv-add" data-action="add-shop-item" data-nk="' + escAttr(nodeKey) + '"' + (optIdx !== null ? ' data-oi="' + optIdx + '"' : '') + '>+ Add Shop</button>';
- }
-
- html += '</div>';
- return html;
- }
-
// ---- Options section ----
function renderOptionsSection(nodeKey, node) {
var options = node.options || [];
var html = '<div class="talk-options">';
- html += '<b class="talk-options-label">Options</b>';
+ html += '<span class="talk-options-label">Options</span>';
if (options.length > 0) {
options.forEach(function(opt, idx) {
html += '<div class="talk-opt-edit-row">';
- html += '<span class="talk-opt-arrow">&#10132;</span>';
- html += '<input class="talk-input" data-tp="nodes.' + escAttr(nodeKey) + '.options.' + idx + '.text" value="' + escAttr(opt.text || '') + '" placeholder="Option text...">';
- html += '<input class="talk-input" data-tp="nodes.' + escAttr(nodeKey) + '.options.' + idx + '.goto" list="talkNodeKeys" value="' + escAttr(opt.goto || '') + '" placeholder="Goto..." style="max-width:120px">';
+ html += '<span class="talk-seq-num">' + (idx + 1) + '</span>';
+ html += '<input class="talk-input talk-opt-text" data-tp="nodes.' + escAttr(nodeKey) + '.options.' + idx + '.text" value="' + escAttr(opt.text || '') + '" placeholder="Option text...">';
+ html += '<input class="talk-input talk-opt-goto" data-tp="nodes.' + escAttr(nodeKey) + '.options.' + idx + '.goto" list="talkNodeKeys" value="' + escAttr(opt.goto || '') + '" placeholder="Goto...">';
html += '<div class="talk-opt-edit-actions">';
if (opt.condition && typeof opt.condition === 'object' && !Array.isArray(opt.condition)) {
html += '<span class="talk-opt-tag talk-opt-tag-cond" title="Has condition" onclick="toggleOptionDetail(event,\'cond\',' + idx + ',\'' + escAttr(nodeKey) + '\')">cond</span>';
} else {
- html += '<button class="talk-add-cond-btn" style="margin:0;margin-bottom:0" data-action="add-condition" data-nk="' + escAttr(nodeKey) + '" data-oi="' + idx + '" title="Add condition">c</button>';
+ html += '<button class="talk-add-cond-btn" style="margin:0;margin-bottom:0" data-action="add-condition" data-nk="' + escAttr(nodeKey) + '" data-oi="' + idx + '" title="Add condition">cond</button>';
}
if (opt.action) {
html += '<span class="talk-opt-tag talk-opt-tag-act" title="Has action" onclick="toggleOptionDetail(event,\'act\',' + idx + ',\'' + escAttr(nodeKey) + '\')">act</span>';
} else {
- html += '<button class="talk-add-act-btn" style="margin:0;margin-bottom:0" data-action="add-action" data-nk="' + escAttr(nodeKey) + '" data-oi="' + idx + '" title="Add action">a</button>';
+ html += '<button class="talk-add-act-btn" style="margin:0;margin-bottom:0" data-action="add-action" data-nk="' + escAttr(nodeKey) + '" data-oi="' + idx + '" title="Add action">act</button>';
}
html += '</div>';
@@ -775,7 +762,9 @@
html += '</div>';
if (opt.condition && typeof opt.condition === 'object' && !Array.isArray(opt.condition)) {
- html += '<div class="talk-opt-detail" id="optDetail_cond_' + idx + '_' + escAttr(nodeKey) + '" style="display:none;margin-left:18px">';
+ var condDetailKey = 'cond_' + idx + '_' + escAttr(nodeKey);
+ var condOpen = window._talkAutoOpenDetails && window._talkAutoOpenDetails.has(condDetailKey) ? 'block' : 'none';
+ html += '<div class="talk-opt-detail" id="optDetail_' + condDetailKey + '" style="display:' + condOpen + ';margin-left:18px">';
html += '<div class="talk-cond-card" style="margin-top:2px">';
html += '<div class="talk-cond-card-header">Condition' +
'<button class="talk-section-remove" data-action="remove-condition" data-nk="' + escAttr(nodeKey) + '" data-oi="' + idx + '">Remove</button></div>';
@@ -786,12 +775,18 @@
html += '</div>';
}
if (opt.action) {
- html += '<div class="talk-opt-detail" id="optDetail_act_' + idx + '_' + escAttr(nodeKey) + '" style="display:none;margin-left:18px">';
- html += '<div class="talk-cond-card-header" style="border:1px solid var(--border);border-radius:5px 5px 0 0">' +
+ var actDetailKey = 'act_' + idx + '_' + escAttr(nodeKey);
+ var actOpen = window._talkAutoOpenDetails && window._talkAutoOpenDetails.has(actDetailKey) ? 'block' : 'none';
+ html += '<div class="talk-opt-detail" id="optDetail_' + actDetailKey + '" style="display:' + actOpen + ';margin-left:18px">';
+ html += '<div class="talk-cond-card" style="margin-top:2px">';
+ html += '<div class="talk-cond-card-header">' +
'<span class="talk-prop-label">Action</span>' +
'<button class="talk-section-remove" data-action="remove-action" data-nk="' + escAttr(nodeKey) + '" data-oi="' + idx + '">Remove</button></div>';
+ html += '<div class="talk-cond-card-body">';
html += renderActionForm(opt.action, 'nodes.' + escAttr(nodeKey) + '.options.' + idx + '.action', nodeKey, idx);
html += '</div>';
+ html += '</div>';
+ html += '</div>';
}
});
} else {
@@ -799,10 +794,10 @@
}
html += '<div class="talk-add-opt-row">' +
- '<span class="talk-opt-arrow" style="color:#555">&#10132;</span>' +
- '<input class="talk-input" id="talkOptText_' + escAttr(nodeKey) + '" placeholder="Option text...">' +
- '<input class="talk-input" id="talkOptGoto_' + escAttr(nodeKey) + '" list="talkNodeKeys" placeholder="Goto..." style="max-width:120px">' +
- '<span style="width:62px;flex-shrink:0"></span>' +
+ '<span class="talk-seq-num" style="color:#555">' + (options.length + 1) + '</span>' +
+ '<input class="talk-input talk-opt-text" id="talkOptText_' + escAttr(nodeKey) + '" placeholder="Option text...">' +
+ '<input class="talk-input talk-opt-goto" id="talkOptGoto_' + escAttr(nodeKey) + '" list="talkNodeKeys" placeholder="Goto...">' +
+ '<span class="talk-opt-gap"></span>' +
'<button class="talk-add-btn" data-action="add-option" data-nk="' + escAttr(nodeKey) + '">Add</button>' +
'</div>';
@@ -816,6 +811,15 @@
if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
};
+ window.talkToggleNode = function(nodeKey, nodeEl) {
+ var open = nodeEl.classList.toggle('open');
+ if (open) {
+ window._talkOpenNodes.add(nodeKey);
+ } else {
+ window._talkOpenNodes.delete(nodeKey);
+ }
+ };
+
// ---- Add/Delete Option ----
function addOption(nodeKey) {
@@ -843,22 +847,22 @@
// ---- Add/Delete Sequence Line ----
- function addSequenceLine(nodeKey) {
- var input = document.getElementById('talkSeqAdd_' + nodeKey);
+ function addMessageLine(nodeKey) {
+ var input = document.getElementById('talkMsgAdd_' + nodeKey);
var text = input ? input.value.trim() : '';
var node = talkNodes()[nodeKey];
if (!node) return;
- if (!node.sequence) node.sequence = [];
- node.sequence.push(text);
+ if (!node.messages) node.messages = [];
+ node.messages.push(text);
renderTalkTree(window._talkData);
talkSync();
}
- function deleteSequenceLine(nodeKey, idx) {
+ function deleteMessageLine(nodeKey, idx) {
var node = talkNodes()[nodeKey];
- if (!node || !node.sequence) return;
- node.sequence.splice(idx, 1);
- if (node.sequence.length === 0) delete node.sequence;
+ if (!node || !node.messages) return;
+ node.messages.splice(idx, 1);
+ if (node.messages.length === 0) delete node.messages;
renderTalkTree(window._talkData);
talkSync();
}
@@ -869,6 +873,10 @@
var target = getConditionFor(nodeKey, optIdx);
if (!target) return;
target.action = {};
+ if (optIdx !== null) {
+ window._talkAutoOpenDetails = new Set();
+ window._talkAutoOpenDetails.add('act_' + optIdx + '_' + escAttr(nodeKey));
+ }
renderTalkTree(window._talkData);
talkSync();
}
@@ -899,6 +907,11 @@
var input = document.getElementById(addKeyID);
var key = input ? input.value.trim() : '';
if (!key) return;
+ var valInput = document.getElementById(addKeyID + '_val');
+ var rawVal = valInput ? valInput.value.trim() : 'true';
+ var val = rawVal === 'true' ? true : rawVal === 'false' ? false : rawVal;
+ var numVal = parseFloat(rawVal);
+ if (!isNaN(numVal) && String(numVal) === rawVal) val = numVal;
var basePath = kvPath(nodeKey, optIdx, kvKey);
var obj = talkGet(basePath);
if (!obj || typeof obj !== 'object') {
@@ -906,7 +919,7 @@
obj = talkGet(basePath);
}
if (obj[key] !== undefined) { notify('Flag "' + key + '" already exists.', 'error'); return; }
- obj[key] = true;
+ obj[key] = val;
renderTalkTree(window._talkData);
talkSync();
}
@@ -923,31 +936,28 @@
talkSync();
}
- // ---- Add/Remove Shop Items ----
-
- function addShopItem(nodeKey, optIdx) {
- var actionPath = optIdx !== null ? 'nodes.' + nodeKey + '.options.' + optIdx + '.action' : 'nodes.' + nodeKey + '.action';
- var action = talkGet(actionPath);
- if (!action || typeof action !== 'object') {
- action = {};
- talkSet(actionPath, action);
+ window.talkRenameKV = function(el) {
+ var nodeKey = el.getAttribute('data-nk');
+ var optIdxStr = el.getAttribute('data-oi');
+ var optIdx = optIdxStr !== null ? parseInt(optIdxStr, 10) : null;
+ var kvKey = el.getAttribute('data-kvk');
+ var oldKey = el.getAttribute('data-oldkey');
+ var newKey = el.value.trim();
+ if (!newKey || newKey === oldKey) return;
+ var basePath = optIdx !== null ?
+ 'nodes.' + nodeKey + '.options.' + optIdx + '.action.' + kvKey :
+ 'nodes.' + nodeKey + '.action.' + kvKey;
+ var obj = talkGet(basePath);
+ if (!obj || obj[newKey] !== undefined) {
+ el.value = oldKey;
+ if (obj && obj[newKey] !== undefined) notify('Key "' + newKey + '" already exists.', 'error');
+ return;
}
- if (!action.shop) { action.shop = { items: [] }; }
- if (!action.shop.items) action.shop.items = [];
- action.shop.items.push({ item_id: '', buy_price: 0, sell_price: 0 });
- renderTalkTree(window._talkData);
- talkSync();
- }
-
- function removeShopItem(nodeKey, optIdx, idx) {
- var actionPath = optIdx !== null ? 'nodes.' + nodeKey + '.options.' + optIdx + '.action' : 'nodes.' + nodeKey + '.action';
- var action = talkGet(actionPath);
- if (!action || !action.shop || !action.shop.items) return;
- action.shop.items.splice(idx, 1);
- if (action.shop.items.length === 0) delete action.shop;
+ obj[newKey] = obj[oldKey];
+ delete obj[oldKey];
renderTalkTree(window._talkData);
talkSync();
- }
+ };
// ---- Global event listeners ----
@@ -988,10 +998,10 @@
return;
}
- if (el.id && el.id.startsWith('talkSeqAdd_')) {
+ if (el.id && el.id.startsWith('talkMsgAdd_')) {
e.preventDefault();
- var nk = el.id.replace('talkSeqAdd_', '');
- addSequenceLine(nk);
+ var nk = el.id.replace('talkMsgAdd_', '');
+ addMessageLine(nk);
return;
}
diff --git a/internal/behavior/behavior.go b/internal/behavior/behavior.go
index 2ef7fae..646495a 100644
--- a/internal/behavior/behavior.go
+++ b/internal/behavior/behavior.go
@@ -28,8 +28,7 @@ type TalkConfig struct {
}
type TalkNode struct {
- Message MessageList `yaml:"message"`
- Sequence []string `yaml:"sequence,omitempty"`
+ Messages []string `yaml:"messages,omitempty"`
Condition *Condition `yaml:"condition,omitempty"`
Options []TalkOption `yaml:"options"`
Action *NodeAction `yaml:"action"`
@@ -37,9 +36,9 @@ type TalkNode struct {
}
type TalkOption struct {
- Text string `yaml:"text"`
- Goto string `yaml:"goto"`
- Condition *Condition `yaml:"condition"`
+ Text string `yaml:"text"`
+ Goto string `yaml:"goto"`
+ Condition *Condition `yaml:"condition"`
Action *NodeAction `yaml:"action,omitempty"`
}
@@ -50,31 +49,102 @@ type NodeAction struct {
TakeItem string `yaml:"take_item"`
Teleport int `yaml:"teleport"`
Heal int `yaml:"heal"`
- Cost int `yaml:"cost"`
- Shop *ShopConfig `yaml:"shop"`
- AssignTask bool `yaml:"assign_task"`
- SkipTask bool `yaml:"skip_task"`
- ExtendTask bool `yaml:"extend_task"`
+ Credits int `yaml:"credits"`
ReputationCost int `yaml:"reputation_cost"`
- Sawmill bool `yaml:"sawmill"`
ApsNode bool `yaml:"aps_node"`
}
+// ShopConfig is a root-level mob property (mob YAML `shop:`). Buy prices are
+// always 100% of the item's value; sell prices follow the OSRS store formula:
+//
+// sell = value * max(10, buy_percentage - stock*change_percentage) / 100
+//
+// where stock is the shop's current holding of that item. BuyPercentage
+// defaults to 40 and ChangePercentage to 3 when unset (see ShopBuyPercentage /
+// ShopChangePercentage).
type ShopConfig struct {
- Message string `yaml:"message"`
- Items []ShopItem `yaml:"items"`
+ Message string `yaml:"message"`
+ BuyPercentage float64 `yaml:"buy_percentage"` // sell price at zero stock, % of value (default 40)
+ ChangePercentage float64 `yaml:"change_percentage"` // sell price drop per unit of stock, % of value (default 3)
+ RestockTicks float64 `yaml:"restock_ticks"` // shop-wide default restock interval
+ BuysAnything *bool `yaml:"buys_anything"` // buy items not in the list (default true)
+ Items []ShopItem `yaml:"items"`
}
type ShopItem struct {
- ItemID string `yaml:"item_id"`
- BuyPrice int `yaml:"buy_price"`
- SellPrice int `yaml:"sell_price"`
+ ItemID string `yaml:"item_id"`
+ Stock int `yaml:"stock"` // target/max stock; also starting stock
+ RestockTicks float64 `yaml:"restock_ticks"` // per-item restock interval (overrides shop default)
+}
+
+// Shop pricing defaults, applied when the YAML leaves a field at zero.
+const (
+ ShopBuyPercentage = 40.0
+ ShopChangePercentage = 3.0
+ ShopMinSellPercent = 10.0
+ ShopDefaultRestock = 100.0
+)
+
+// EffectiveBuyPercentage returns BuyPercentage or the default.
+func (c *ShopConfig) EffectiveBuyPercentage() float64 {
+ if c.BuyPercentage > 0 {
+ return c.BuyPercentage
+ }
+ return ShopBuyPercentage
+}
+
+// EffectiveChangePercentage returns ChangePercentage or the default.
+func (c *ShopConfig) EffectiveChangePercentage() float64 {
+ if c.ChangePercentage > 0 {
+ return c.ChangePercentage
+ }
+ return ShopChangePercentage
+}
+
+// Buys reports whether the shop will buy items not in its item list.
+func (c *ShopConfig) Buys() bool {
+ return c.BuysAnything == nil || *c.BuysAnything
+}
+
+// SellPrice computes the per-unit price the shop pays for an item of the given
+// value when the shop currently holds `stock` of it.
+func (c *ShopConfig) SellPrice(value, stock int) int {
+ if stock < 0 {
+ stock = 0
+ }
+ pct := c.EffectiveBuyPercentage() - float64(stock)*c.EffectiveChangePercentage()
+ if pct < ShopMinSellPercent {
+ pct = ShopMinSellPercent
+ }
+ return int(float64(value) * pct / 100.0)
+}
+
+// RestockInterval returns the effective restock interval (in ticks) for an item,
+// honoring the per-item override, then the shop default, then the global default.
+func (c *ShopConfig) RestockInterval(item *ShopItem) int {
+ if item != nil && item.RestockTicks > 0 {
+ return int(item.RestockTicks)
+ }
+ if c.RestockTicks > 0 {
+ return int(c.RestockTicks)
+ }
+ return int(ShopDefaultRestock)
+}
+
+// FindItem returns the configured shop item with the given id, or nil.
+func (c *ShopConfig) FindItem(itemID string) *ShopItem {
+ for i := range c.Items {
+ if c.Items[i].ItemID == itemID {
+ return &c.Items[i]
+ }
+ }
+ return nil
}
type Condition struct {
Flag string `yaml:"flag"`
Value any `yaml:"value"`
- Not bool `yaml:"not"`
+ Not bool `yaml:"not"`
PlayerFlag string `yaml:"player_flag"`
HasItem string `yaml:"has_item"`
MinCredits int `yaml:"min_credits"`
@@ -83,16 +153,16 @@ type Condition struct {
}
type UseConfig struct {
- StartMessage string `yaml:"start_message"`
- TicksPerCycle float64 `yaml:"ticks_per_cycle"`
- Consume map[string]int `yaml:"consume"`
- Reward DropEntry `yaml:"reward"`
- FailMessage string `yaml:"fail_message"`
- Success *SuccessFormula `yaml:"success"`
- Skill string `yaml:"skill"`
- Level int `yaml:"level"`
- XP int `yaml:"xp"`
- SuccessMessage string `yaml:"success_message"`
- EndMessage string `yaml:"end_message"`
- BroadcastMessage string `yaml:"broadcast_message"`
+ StartMessage string `yaml:"start_message"`
+ TicksPerCycle float64 `yaml:"ticks_per_cycle"`
+ Consume map[string]int `yaml:"consume"`
+ Reward DropEntry `yaml:"reward"`
+ FailMessage string `yaml:"fail_message"`
+ Success *SuccessFormula `yaml:"success"`
+ Skill string `yaml:"skill"`
+ Level int `yaml:"level"`
+ XP int `yaml:"xp"`
+ SuccessMessage string `yaml:"success_message"`
+ EndMessage string `yaml:"end_message"`
+ BroadcastMessage string `yaml:"broadcast_message"`
}
diff --git a/internal/behavior/types.go b/internal/behavior/types.go
index c59a95e..29e2631 100644
--- a/internal/behavior/types.go
+++ b/internal/behavior/types.go
@@ -1,10 +1,7 @@
package behavior
import (
- "math/rand"
"strings"
-
- "gopkg.in/yaml.v3"
)
func WordPrefixMatch(input, name string) bool {
@@ -224,24 +221,3 @@ type DropEntry struct {
type DropTableDef struct {
Drops []DropEntry `yaml:"drops"`
}
-
-type MessageList []string
-
-func (ml *MessageList) UnmarshalYAML(value *yaml.Node) error {
- if value.Kind == yaml.ScalarNode {
- var s string
- if err := value.Decode(&s); err != nil {
- return err
- }
- *ml = MessageList{s}
- return nil
- }
- return value.Decode((*[]string)(ml))
-}
-
-func (ml MessageList) Pick() string {
- if len(ml) == 0 {
- return ""
- }
- return ml[rand.Intn(len(ml))]
-}
diff --git a/internal/game/act_steal.go b/internal/game/act_steal.go
index 1c76474..f5d3dbf 100644
--- a/internal/game/act_steal.go
+++ b/internal/game/act_steal.go
@@ -28,7 +28,7 @@ func (g *Game) executeSteal(sess *net.Session, args []string, rawInput string) {
var stallGuardTalk = &behavior.TalkConfig{
Nodes: map[string]behavior.TalkNode{
"start": {
- Message: behavior.MessageList{"Caught you red-handed! You have three options, thief."},
+ Messages: []string{"Caught you red-handed! You have three options, thief."},
Options: []behavior.TalkOption{
{Text: "\"I'll pay a fine. (500 credits)\"", Goto: "bribe", Condition: &behavior.Condition{MinCredits: 500}},
{Text: "\"Take me to jail.\"", Goto: "jail"},
@@ -36,17 +36,17 @@ var stallGuardTalk = &behavior.TalkConfig{
},
},
"bribe": {
- Message: behavior.MessageList{"Smart choice. Hand over 500 credits and we'll forget this happened."},
- Action: &behavior.NodeAction{Cost: 500},
+ Messages: []string{"Smart choice. Hand over 500 credits and we'll forget this happened."},
+ Action: &behavior.NodeAction{Credits: -500},
Options: []behavior.TalkOption{{Text: "\"Fine, take it.\""}},
},
"jail": {
- Message: behavior.MessageList{"Off to the detention cell with you!"},
+ Messages: []string{"Off to the detention cell with you!"},
Action: &behavior.NodeAction{Teleport: 162},
Options: []behavior.TalkOption{{Text: "(You are dragged away)"}},
},
"fight": {
- Message: behavior.MessageList{"Then defend yourself!"},
+ Messages: []string{"Then defend yourself!"},
Action: &behavior.NodeAction{SetFlags: map[string]any{"guard_hostile": true}},
Options: []behavior.TalkOption{{Text: "(The guard attacks!)"}},
},
diff --git a/internal/game/act_talk.go b/internal/game/act_talk.go
index bd5a091..7cfdf7c 100644
--- a/internal/game/act_talk.go
+++ b/internal/game/act_talk.go
@@ -58,11 +58,6 @@ func (g *Game) startTalk(sess *net.Session, p *player.Player, obj *object.Object
}
func (g *Game) handleNodeAction(sess *net.Session, na *behavior.NodeAction) (intercepted bool) {
- if na.Shop != nil {
- g.applyNodeAction(sess, na)
- g.enterShop(sess, na.Shop)
- return true
- }
g.applyNodeAction(sess, na)
if v, ok := g.Flags.Get("guard_hostile"); ok && v != nil {
@@ -97,28 +92,24 @@ func (g *Game) showTalkNode(sess *net.Session, node behavior.TalkNode) {
for {
if node.Condition == nil || g.checkCondition(sess, node.Condition) {
- if len(node.Sequence) > 0 {
+ if len(node.Messages) > 0 {
if node.Action != nil {
if g.handleNodeAction(sess, node.Action) {
return
}
}
td.SeqIndex = 0
- msg := node.Sequence[0]
+ msg := node.Messages[0]
if msg != "" {
sess.WriteLine(color.ExpandDialogTags(g.colorMode(sess), dialogSpec, msg))
}
td.SeqIndex = 1
- if td.SeqIndex < len(node.Sequence) {
+ if td.SeqIndex < len(node.Messages) {
sess.State = net.StateTalkSequence
sess.Write(g.colorize(sess, "dialog_options", "[enter to continue]\n"))
return
}
} else {
- msg := node.Message.Pick()
- if msg != "" {
- sess.WriteLine(color.ExpandDialogTags(g.colorMode(sess), dialogSpec, msg))
- }
if node.Action != nil {
if g.handleNodeAction(sess, node.Action) {
return
@@ -301,14 +292,14 @@ func (g *Game) advanceTalk(sess *net.Session, p *player.Player) {
return
}
dialogSpec := g.resolveColor(sess, "dialog")
- if td.SeqIndex < len(node.Sequence) {
- msg := node.Sequence[td.SeqIndex]
+ if td.SeqIndex < len(node.Messages) {
+ msg := node.Messages[td.SeqIndex]
if msg != "" {
sess.WriteLine(color.ExpandDialogTags(g.colorMode(sess), dialogSpec, msg))
}
td.SeqIndex++
}
- if td.SeqIndex >= len(node.Sequence) {
+ if td.SeqIndex >= len(node.Messages) {
g.finishSequence(sess, node)
return
}
@@ -341,28 +332,12 @@ func (g *Game) advanceTalk(sess *net.Session, p *player.Player) {
}
}
-func (g *Game) enterShop(sess *net.Session, cfg *behavior.ShopConfig) {
- sess.Shop = cfg
- sess.State = net.StateShop
- g.showShopBrowse(sess)
- g.writeShopPrompt(sess)
-}
-
func (g *Game) applyNodeAction(sess *net.Session, na *behavior.NodeAction) {
p := sess.Player
if p == nil {
return
}
- if na.AssignTask {
- g.assignAssassinTask(sess, p)
- }
- if na.SkipTask {
- g.skipAssassinTask(sess, p)
- }
- if na.ExtendTask {
- g.extendAssassinTask(sess, p)
- }
if na.ReputationCost > 0 {
rep := getPlayerFlagInt(p, "assassin_reputation")
if rep < na.ReputationCost {
@@ -414,15 +389,19 @@ func (g *Game) applyNodeAction(sess *net.Session, na *behavior.NodeAction) {
g.AccountStore.SaveCharacter(p)
sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", na.Heal))
}
- if na.Cost > 0 {
- if p.Credits >= na.Cost {
- p.Credits -= na.Cost
+ if na.Credits != 0 {
+ if na.Credits < 0 {
+ if p.Credits >= -na.Credits {
+ p.Credits += na.Credits
+ g.AccountStore.SaveCharacter(p)
+ } else {
+ sess.WriteLine(fmt.Sprintf("You don't have enough credits. (Need %d, have %d)", -na.Credits, p.Credits))
+ }
+ } else {
+ p.Credits += na.Credits
g.AccountStore.SaveCharacter(p)
}
}
- if na.Sawmill {
- g.processSawmill(sess, p)
- }
if na.ApsNode {
g.setPlayerFlag(p, "aps_node_"+strconv.Itoa(p.RoomID), true)
}
diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go
index bc77936..1247fc3 100644
--- a/internal/game/cmd_registry.go
+++ b/internal/game/cmd_registry.go
@@ -15,147 +15,151 @@ type commandDef struct {
}
var commandRegistry = map[string]commandDef{
- "get": {(*Game).executeGet, ClassActive},
- "take": {(*Game).executeGet, ClassActive},
- "grab": {(*Game).executeGet, ClassActive},
- "pick": {(*Game).executeGet, ClassActive},
- "drop": {(*Game).executeDrop, ClassActive},
- "attack": {(*Game).executeAttack, ClassActive},
- "kill": {(*Game).executeAttack, ClassActive},
- "work": {(*Game).executeWork, ClassActive},
- "style": {(*Game).executeStyle, ClassInstant},
- "look": {(*Game).executeLook, ClassInstant},
- "l": {(*Game).executeLook, ClassInstant},
- "north": {(*Game).executeMove, ClassActive},
- "n": {(*Game).executeMove, ClassActive},
- "south": {(*Game).executeMove, ClassActive},
- "s": {(*Game).executeMove, ClassActive},
- "east": {(*Game).executeMove, ClassActive},
- "e": {(*Game).executeMove, ClassActive},
- "west": {(*Game).executeMove, ClassActive},
- "w": {(*Game).executeMove, ClassActive},
- "northeast": {(*Game).executeMove, ClassActive},
- "ne": {(*Game).executeMove, ClassActive},
- "northwest": {(*Game).executeMove, ClassActive},
- "nw": {(*Game).executeMove, ClassActive},
- "southeast": {(*Game).executeMove, ClassActive},
- "se": {(*Game).executeMove, ClassActive},
- "southwest": {(*Game).executeMove, ClassActive},
- "sw": {(*Game).executeMove, ClassActive},
- "up": {(*Game).executeMove, ClassActive},
- "u": {(*Game).executeMove, ClassActive},
- "down": {(*Game).executeMove, ClassActive},
- "d": {(*Game).executeMove, ClassActive},
- "say": {(*Game).executeSay, ClassInstant},
- "global": {(*Game).executeGlobal, ClassInstant},
- "who": {(*Game).executeWho, ClassInstant},
- "sc": {(*Game).executeScore, ClassInstant},
- "score": {(*Game).executeScore, ClassInstant},
- "skills": {(*Game).executeSkills, ClassInstant},
- "sk": {(*Game).executeSkills, ClassInstant},
- "task": {(*Game).executeTask, ClassInstant},
- "stats": {(*Game).executeStats, ClassInstant},
- "tech": {(*Game).executeTech, ClassInstant},
- "t": {(*Game).executeTech, ClassInstant},
- "i": {(*Game).executeInventory, ClassInstant},
- "inv": {(*Game).executeInventory, ClassInstant},
- "inventory": {(*Game).executeInventory, ClassInstant},
- "quit": {(*Game).executeQuit, ClassActive},
- "description": {(*Game).executeDescription, ClassInstant},
- "desc": {(*Game).executeDescription, ClassInstant},
- "option": {(*Game).executeOption, ClassInstant},
- "options": {(*Game).executeOption, ClassInstant},
- "color": {(*Game).executeColor, ClassInstant},
- "colors": {(*Game).executeColor, ClassInstant},
- "colortable": {(*Game).executeColortable, ClassInstant},
- "prompt": {(*Game).executePrompt, ClassInstant},
- "exits": {(*Game).executeExits, ClassInstant},
- "map": {(*Game).executeMap, ClassInstant},
- "symbol": {(*Game).executeSymbol, ClassInstant},
- "queued": {(*Game).executeQueued, ClassInstant},
- "help": {(*Game).executeHelp, ClassInstant},
- "mine": {(*Game).executeGather, ClassActive},
- "chop": {(*Game).executeGather, ClassActive},
- "fish": {(*Game).executeGather, ClassActive},
- "cut": {(*Game).executeGather, ClassActive},
- "pull": {(*Game).executeUse, ClassActive},
- "push": {(*Game).executeUse, ClassActive},
- "use": {(*Game).executeUse, ClassActive},
- "cook": {(*Game).executeCook, ClassActive},
- "smelt": {(*Game).executeSmelt, ClassActive},
- "smith": {(*Game).executeSmith, ClassActive},
- "craft": {(*Game).executeCraft, ClassActive},
- "eat": {(*Game).executeEat, ClassFree},
- "drink": {(*Game).executeDrink, ClassFree},
- "fletch": {(*Game).executeFletch, ClassFree},
- "clean": {(*Game).executeClean, ClassFree},
- "mix": {(*Game).executeMix, ClassActive},
- "id": {(*Game).executeIdentify, ClassActive},
- "identify": {(*Game).executeIdentify, ClassActive},
- "autotrigger": {(*Game).executeAutotrigger, ClassInstant},
- "auto": {(*Game).executeAutotrigger, ClassInstant},
- "mods": {(*Game).executeMods, ClassInstant},
- "modlist": {(*Game).executeMods, ClassInstant},
- "mod": {(*Game).executeMods, ClassInstant},
- "modules": {(*Game).executeMods, ClassInstant},
- "module": {(*Game).executeMods, ClassInstant},
- "sneak": {(*Game).executeSneak, ClassInstant},
- "trigger": {(*Game).executeTrigger, ClassActive},
- "home": {(*Game).executeHome, ClassInstant},
- "recall": {(*Game).executeHome, ClassInstant},
- "talk": {(*Game).executeTalk, ClassActive},
- "speak": {(*Game).executeTalk, ClassActive},
- "ask": {(*Game).executeTalk, ClassActive},
- "burn": {(*Game).executeBurn, ClassActive},
- "stoke": {(*Game).executeStoke, ClassActive},
- "alias": {(*Game).executeAlias, ClassInstant},
- "unalias": {(*Game).executeUnalias, ClassInstant},
- "search": {(*Game).executeSearch, ClassActive},
- "steal": {(*Game).executeSteal, ClassActive},
- "thieve": {(*Game).executeSteal, ClassActive},
- "plant": {(*Game).executePlant, ClassActive},
- "harvest": {(*Game).executeHarvest, ClassActive},
- "rake": {(*Game).executeRake, ClassActive},
- "water": {(*Game).executeWater, ClassActive},
- "cure": {(*Game).executeCure, ClassActive},
- "inspect": {(*Game).executeInspect, ClassInstant},
- "walk": {(*Game).executeWalk, ClassActive},
- "bank": {(*Game).executeBank, ClassActive},
- "construct": {(*Game).executeConstruct, ClassActive},
- "make": {(*Game).executeConstruct, ClassActive},
- "jack": {(*Game).executeJack, ClassActive},
- "jackin": {(*Game).executeJack, ClassActive},
- "eq": {(*Game).executeWear, ClassFree},
- "equipment": {(*Game).executeWear, ClassFree},
- "equip": {(*Game).executeWear, ClassFree},
- "wear": {(*Game).executeWear, ClassFree},
- "wield": {(*Game).executeWear, ClassFree},
- "remove": {(*Game).executeRemove, ClassFree},
- "unwear": {(*Game).executeRemove, ClassFree},
- "unwield": {(*Game).executeRemove, ClassFree},
- "aps": {(*Game).executeAps, ClassActive},
- "verbs": {(*Game).executeVerbs, ClassInstant},
- "what": {(*Game).executeVerbs, ClassInstant},
- "syntax": {(*Game).executeVerbs, ClassInstant},
- "commands": {(*Game).executeVerbs, ClassInstant},
- "stop": {(*Game).executeStop, ClassInstant},
- "hide": {(*Game).executeHide, ClassActive},
- "safespot": {(*Game).executeHide, ClassActive},
- "unhide": {(*Game).executeUnhide, ClassActive},
- "unsafespot": {(*Game).executeUnhide, ClassActive},
- "goto": {(*Game).executeGoto, ClassInstant},
- "summon": {(*Game).executeSummon, ClassInstant},
- "dig": {(*Game).executeDig, ClassInstant},
- "setflag": {(*Game).executeSetFlag, ClassInstant},
+ "get": {(*Game).executeGet, ClassActive},
+ "take": {(*Game).executeGet, ClassActive},
+ "grab": {(*Game).executeGet, ClassActive},
+ "pick": {(*Game).executeGet, ClassActive},
+ "drop": {(*Game).executeDrop, ClassActive},
+ "attack": {(*Game).executeAttack, ClassActive},
+ "kill": {(*Game).executeAttack, ClassActive},
+ "work": {(*Game).executeWork, ClassActive},
+ "style": {(*Game).executeStyle, ClassInstant},
+ "look": {(*Game).executeLook, ClassInstant},
+ "l": {(*Game).executeLook, ClassInstant},
+ "north": {(*Game).executeMove, ClassActive},
+ "n": {(*Game).executeMove, ClassActive},
+ "south": {(*Game).executeMove, ClassActive},
+ "s": {(*Game).executeMove, ClassActive},
+ "east": {(*Game).executeMove, ClassActive},
+ "e": {(*Game).executeMove, ClassActive},
+ "west": {(*Game).executeMove, ClassActive},
+ "w": {(*Game).executeMove, ClassActive},
+ "northeast": {(*Game).executeMove, ClassActive},
+ "ne": {(*Game).executeMove, ClassActive},
+ "northwest": {(*Game).executeMove, ClassActive},
+ "nw": {(*Game).executeMove, ClassActive},
+ "southeast": {(*Game).executeMove, ClassActive},
+ "se": {(*Game).executeMove, ClassActive},
+ "southwest": {(*Game).executeMove, ClassActive},
+ "sw": {(*Game).executeMove, ClassActive},
+ "up": {(*Game).executeMove, ClassActive},
+ "u": {(*Game).executeMove, ClassActive},
+ "down": {(*Game).executeMove, ClassActive},
+ "d": {(*Game).executeMove, ClassActive},
+ "say": {(*Game).executeSay, ClassInstant},
+ "global": {(*Game).executeGlobal, ClassInstant},
+ "who": {(*Game).executeWho, ClassInstant},
+ "sc": {(*Game).executeScore, ClassInstant},
+ "score": {(*Game).executeScore, ClassInstant},
+ "skills": {(*Game).executeSkills, ClassInstant},
+ "sk": {(*Game).executeSkills, ClassInstant},
+ "task": {(*Game).executeTask, ClassInstant},
+ "stats": {(*Game).executeStats, ClassInstant},
+ "tech": {(*Game).executeTech, ClassInstant},
+ "t": {(*Game).executeTech, ClassInstant},
+ "i": {(*Game).executeInventory, ClassInstant},
+ "inv": {(*Game).executeInventory, ClassInstant},
+ "inventory": {(*Game).executeInventory, ClassInstant},
+ "quit": {(*Game).executeQuit, ClassActive},
+ "description": {(*Game).executeDescription, ClassInstant},
+ "desc": {(*Game).executeDescription, ClassInstant},
+ "option": {(*Game).executeOption, ClassInstant},
+ "options": {(*Game).executeOption, ClassInstant},
+ "color": {(*Game).executeColor, ClassInstant},
+ "colors": {(*Game).executeColor, ClassInstant},
+ "colortable": {(*Game).executeColortable, ClassInstant},
+ "prompt": {(*Game).executePrompt, ClassInstant},
+ "exits": {(*Game).executeExits, ClassInstant},
+ "map": {(*Game).executeMap, ClassInstant},
+ "symbol": {(*Game).executeSymbol, ClassInstant},
+ "queued": {(*Game).executeQueued, ClassInstant},
+ "help": {(*Game).executeHelp, ClassInstant},
+ "mine": {(*Game).executeGather, ClassActive},
+ "chop": {(*Game).executeGather, ClassActive},
+ "fish": {(*Game).executeGather, ClassActive},
+ "cut": {(*Game).executeGather, ClassActive},
+ "pull": {(*Game).executeUse, ClassActive},
+ "push": {(*Game).executeUse, ClassActive},
+ "use": {(*Game).executeUse, ClassActive},
+ "cook": {(*Game).executeCook, ClassActive},
+ "smelt": {(*Game).executeSmelt, ClassActive},
+ "smith": {(*Game).executeSmith, ClassActive},
+ "craft": {(*Game).executeCraft, ClassActive},
+ "eat": {(*Game).executeEat, ClassFree},
+ "drink": {(*Game).executeDrink, ClassFree},
+ "fletch": {(*Game).executeFletch, ClassFree},
+ "clean": {(*Game).executeClean, ClassFree},
+ "mix": {(*Game).executeMix, ClassActive},
+ "id": {(*Game).executeIdentify, ClassActive},
+ "identify": {(*Game).executeIdentify, ClassActive},
+ "autotrigger": {(*Game).executeAutotrigger, ClassInstant},
+ "auto": {(*Game).executeAutotrigger, ClassInstant},
+ "mods": {(*Game).executeMods, ClassInstant},
+ "modlist": {(*Game).executeMods, ClassInstant},
+ "mod": {(*Game).executeMods, ClassInstant},
+ "modules": {(*Game).executeMods, ClassInstant},
+ "module": {(*Game).executeMods, ClassInstant},
+ "sneak": {(*Game).executeSneak, ClassInstant},
+ "trigger": {(*Game).executeTrigger, ClassActive},
+ "home": {(*Game).executeHome, ClassInstant},
+ "recall": {(*Game).executeHome, ClassInstant},
+ "talk": {(*Game).executeTalk, ClassActive},
+ "speak": {(*Game).executeTalk, ClassActive},
+ "ask": {(*Game).executeTalk, ClassActive},
+ "burn": {(*Game).executeBurn, ClassActive},
+ "stoke": {(*Game).executeStoke, ClassActive},
+ "alias": {(*Game).executeAlias, ClassInstant},
+ "unalias": {(*Game).executeUnalias, ClassInstant},
+ "search": {(*Game).executeSearch, ClassActive},
+ "steal": {(*Game).executeSteal, ClassActive},
+ "thieve": {(*Game).executeSteal, ClassActive},
+ "plant": {(*Game).executePlant, ClassActive},
+ "harvest": {(*Game).executeHarvest, ClassActive},
+ "rake": {(*Game).executeRake, ClassActive},
+ "water": {(*Game).executeWater, ClassActive},
+ "cure": {(*Game).executeCure, ClassActive},
+ "inspect": {(*Game).executeInspect, ClassInstant},
+ "walk": {(*Game).executeWalk, ClassActive},
+ "bank": {(*Game).executeBank, ClassActive},
+ "list": {(*Game).executeShopList, ClassInstant},
+ "browse": {(*Game).executeShopList, ClassInstant},
+ "buy": {(*Game).executeShopBuy, ClassInstant},
+ "sell": {(*Game).executeShopSell, ClassInstant},
+ "construct": {(*Game).executeConstruct, ClassActive},
+ "make": {(*Game).executeConstruct, ClassActive},
+ "jack": {(*Game).executeJack, ClassActive},
+ "jackin": {(*Game).executeJack, ClassActive},
+ "eq": {(*Game).executeWear, ClassFree},
+ "equipment": {(*Game).executeWear, ClassFree},
+ "equip": {(*Game).executeWear, ClassFree},
+ "wear": {(*Game).executeWear, ClassFree},
+ "wield": {(*Game).executeWear, ClassFree},
+ "remove": {(*Game).executeRemove, ClassFree},
+ "unwear": {(*Game).executeRemove, ClassFree},
+ "unwield": {(*Game).executeRemove, ClassFree},
+ "aps": {(*Game).executeAps, ClassActive},
+ "verbs": {(*Game).executeVerbs, ClassInstant},
+ "what": {(*Game).executeVerbs, ClassInstant},
+ "syntax": {(*Game).executeVerbs, ClassInstant},
+ "commands": {(*Game).executeVerbs, ClassInstant},
+ "stop": {(*Game).executeStop, ClassInstant},
+ "hide": {(*Game).executeHide, ClassActive},
+ "safespot": {(*Game).executeHide, ClassActive},
+ "unhide": {(*Game).executeUnhide, ClassActive},
+ "unsafespot": {(*Game).executeUnhide, ClassActive},
+ "goto": {(*Game).executeGoto, ClassInstant},
+ "summon": {(*Game).executeSummon, ClassInstant},
+ "dig": {(*Game).executeDig, ClassInstant},
+ "setflag": {(*Game).executeSetFlag, ClassInstant},
"setplayerflag": {(*Game).executeSetPlayerFlag, ClassInstant},
- "reload": {(*Game).executeReload, ClassInstant},
- "shutdown": {(*Game).executeShutdown, ClassInstant},
- "room": {(*Game).executeRoom, ClassInstant},
- "swapid": {(*Game).executeSwapID, ClassInstant},
- "undig": {(*Game).executeUndig, ClassInstant},
- "close": {(*Game).executeClose, ClassInstant},
- "god": {(*Game).executeGod, ClassInstant},
- "ungod": {(*Game).executeUnGod, ClassInstant},
+ "reload": {(*Game).executeReload, ClassInstant},
+ "shutdown": {(*Game).executeShutdown, ClassInstant},
+ "room": {(*Game).executeRoom, ClassInstant},
+ "swapid": {(*Game).executeSwapID, ClassInstant},
+ "undig": {(*Game).executeUndig, ClassInstant},
+ "close": {(*Game).executeClose, ClassInstant},
+ "god": {(*Game).executeGod, ClassInstant},
+ "ungod": {(*Game).executeUnGod, ClassInstant},
}
func classifyCommand(cmd string) CommandClass {
diff --git a/internal/game/cmd_shop.go b/internal/game/cmd_shop.go
index 69de750..e47c84d 100644
--- a/internal/game/cmd_shop.go
+++ b/internal/game/cmd_shop.go
@@ -8,39 +8,72 @@ import (
"thehouseoficarus/internal/behavior"
"thehouseoficarus/internal/item"
"thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
"thehouseoficarus/internal/ui"
+ "thehouseoficarus/internal/world"
)
-func (g *Game) handleShopInput(sess *net.Session, input string) {
- if sess.Player == nil {
- sess.State = net.StateGame
- g.writePrompt(sess)
+// ---- Command entry points ----
+
+func (g *Game) executeShopList(sess *net.Session, args []string, rawInput string) {
+ p := sess.Player
+ if p == nil {
return
}
-
- input = strings.TrimSpace(input)
- parts := strings.Fields(strings.ToLower(input))
- if len(parts) == 0 {
- g.showShopBrowse(sess)
+ mob := g.resolveShopMob(sess, p, strings.Join(args, " "))
+ if mob == nil {
return
}
+ g.showShopInventory(sess, mob)
+}
- cmd := parts[0]
+func (g *Game) executeShopBuy(sess *net.Session, args []string, rawInput string) {
+ p := sess.Player
+ if p == nil {
+ return
+ }
+ itemArgs, mobName := splitOnKeyword(args, "from")
+ qty, itemName, all := parseShopQty(itemArgs)
+ if itemName == "" {
+ sess.WriteLine("Buy what? Use 'list' to see what's for sale.")
+ return
+ }
+ mob := g.resolveShopMob(sess, p, mobName)
+ if mob == nil {
+ return
+ }
+ g.doShopBuy(sess, mob, itemName, qty, all)
+}
- switch cmd {
- case "buy":
- g.cmdShopBuy(sess, parts[1:])
- case "sell":
- g.cmdShopSell(sess, parts[1:])
- case "browse", "list":
- g.showShopBrowse(sess)
- case "leave", "bye", "exit", "quit":
- g.leaveShop(sess)
+func (g *Game) executeShopSell(sess *net.Session, args []string, rawInput string) {
+ p := sess.Player
+ if p == nil {
+ return
+ }
+ itemArgs, mobName := splitOnKeyword(args, "to")
+ qty, itemName, all := parseShopQty(itemArgs)
+ if itemName == "" {
+ sess.WriteLine("Sell what?")
+ return
+ }
+ mob := g.resolveShopMob(sess, p, mobName)
+ if mob == nil {
return
- default:
- sess.WriteLine("Commands: buy <item>, sell <item>, browse, leave")
}
- g.writeShopPrompt(sess)
+ g.doShopSell(sess, mob, itemName, qty, all)
+}
+
+// ---- Helpers ----
+
+// splitOnKeyword splits args around the first standalone keyword (e.g. "from",
+// "to"), returning the words before it and the joined words after it.
+func splitOnKeyword(args []string, kw string) (before []string, after string) {
+ for i, a := range args {
+ if strings.EqualFold(a, kw) {
+ return args[:i], strings.Join(args[i+1:], " ")
+ }
+ }
+ return args, ""
}
func parseShopQty(args []string) (int, string, bool) {
@@ -48,8 +81,7 @@ func parseShopQty(args []string) (int, string, bool) {
return 1, "", false
}
if strings.EqualFold(args[0], "all") {
- rest := strings.Join(args[1:], " ")
- return 0, rest, true
+ return 0, strings.Join(args[1:], " "), true
}
if n, err := strconv.Atoi(args[0]); err == nil && n > 0 {
return n, strings.Join(args[1:], " "), false
@@ -57,63 +89,100 @@ func parseShopQty(args []string) (int, string, bool) {
return 1, strings.Join(args, " "), false
}
-func (g *Game) cmdShopBuy(sess *net.Session, args []string) {
- cfg := g.shopConfig(sess)
- if cfg == nil {
- return
+// resolveShopMob finds the shop-owning mob in the player's room. If name is
+// empty and exactly one shop exists it is returned; multiple shops prompt
+// "Which shop?". A non-empty name matches a specific mob. Errors are written to
+// the session and nil is returned when resolution fails.
+func (g *Game) resolveShopMob(sess *net.Session, p *player.Player, name string) *world.MobInstance {
+ var shops []*world.MobInstance
+ for _, m := range g.MobStore.MobsInRoom(p.RoomID) {
+ if m.HasShop() {
+ shops = append(shops, m)
+ }
}
-
- qty, itemName, all := parseShopQty(args)
- if itemName == "" {
- sess.WriteLine("Buy what? Use 'browse' to see available items.")
- return
+ if len(shops) == 0 {
+ sess.WriteLine("There is no shop here.")
+ return nil
}
- g.doShopBuy(sess, itemName, qty, all)
-}
-
-func (g *Game) cmdShopSell(sess *net.Session, args []string) {
- cfg := g.shopConfig(sess)
- if cfg == nil {
- return
+ name = strings.TrimSpace(name)
+ if name != "" {
+ var matched []*world.MobInstance
+ for _, m := range shops {
+ if m.MatchQuality(name) > world.MatchNone {
+ matched = append(matched, m)
+ }
+ }
+ if len(matched) == 0 {
+ sess.WriteLine(fmt.Sprintf("There is no shop here run by \"%s\".", name))
+ return nil
+ }
+ if len(matched) > 1 {
+ g.promptWhichShop(sess, matched)
+ return nil
+ }
+ return matched[0]
}
- qty, itemName, all := parseShopQty(args)
- if itemName == "" {
- sess.WriteLine("Sell what?")
- return
+ if len(shops) == 1 {
+ return shops[0]
}
-
- g.doShopSell(sess, itemName, qty, all)
-}
-
-func (g *Game) writeShopPrompt(sess *net.Session) {
- sess.WritePrompt(g.colorize(sess, "dialog", "> "))
+ g.promptWhichShop(sess, shops)
+ return nil
}
-func (g *Game) shopConfig(sess *net.Session) *behavior.ShopConfig {
- cfg := sess.Shop
- return cfg
+func (g *Game) promptWhichShop(sess *net.Session, shops []*world.MobInstance) {
+ sess.WriteLine("Which shop? Try again naming one of:")
+ for _, m := range shops {
+ sess.WriteLine(" - " + m.Name)
+ }
}
-type shopMatch struct {
- si *behavior.ShopItem
- name string
- def *item.ItemDef
+type shopCandidate struct {
+ itemID string
+ def *item.ItemDef
+ name string
+ stock int
}
-func (g *Game) showShopBrowse(sess *net.Session) {
- cfg := g.shopConfig(sess)
- if cfg == nil {
- return
+// shopCandidates lists everything the shop currently offers for sale: its
+// configured items (even when out of stock) plus any dynamically-held items
+// with stock remaining.
+func (g *Game) shopCandidates(mob *world.MobInstance) []shopCandidate {
+ stock := g.MobStore.ShopStockSnapshot(mob.InstanceID)
+ seen := map[string]bool{}
+ var out []shopCandidate
+ add := func(id string) {
+ if id == "" || seen[id] {
+ return
+ }
+ seen[id] = true
+ def, _ := g.ItemStore.Load(id)
+ name := id
+ if def != nil {
+ name = def.Name
+ }
+ out = append(out, shopCandidate{itemID: id, def: def, name: name, stock: stock[id]})
+ }
+ for i := range mob.Shop.Items {
+ add(mob.Shop.Items[i].ItemID)
}
+ for id, n := range stock {
+ if n > 0 {
+ add(id)
+ }
+ }
+ return out
+}
- if cfg.Message != "" {
- sess.WriteLine(fmt.Sprintf("%s", g.colorize(sess, "dialog", cfg.Message)))
+func (g *Game) showShopInventory(sess *net.Session, mob *world.MobInstance) {
+ if mob.Shop.Message != "" {
+ sess.WriteLine(g.colorize(sess, "dialog", mob.Shop.Message))
}
- if len(cfg.Items) == 0 {
- sess.WriteLine("This shop has nothing for sale.")
+ candidates := g.shopCandidates(mob)
+ if len(candidates) == 0 {
+ sess.WriteLine(fmt.Sprintf("%s has nothing for sale.", mob.Name))
return
}
@@ -125,199 +194,113 @@ func (g *Game) showShopBrowse(sess *net.Session) {
}
t := ui.Table{
- Title: "Shop Inventory",
- Columns: []string{"Item", "Buy Price", "Sell Price"},
+ Title: mob.Name,
+ Columns: []string{"Item", "Stock", "Buy", "Sell"},
}
-
- for _, si := range cfg.Items {
- def, _ := g.ItemStore.Load(si.ItemID)
- itemName := si.ItemID
- if def != nil {
- itemName = def.Name
+ for _, c := range candidates {
+ value := 0
+ if c.def != nil {
+ value = c.def.Value
}
- buyStr := fmt.Sprintf("%d cr", si.BuyPrice)
sellStr := "\u2014"
- if si.SellPrice > 0 {
- sellStr = fmt.Sprintf("%d cr", si.SellPrice)
+ if mob.Shop.FindItem(c.itemID) != nil || mob.Shop.Buys() {
+ sellStr = fmt.Sprintf("%d cr", mob.Shop.SellPrice(value, c.stock))
}
t.Rows = append(t.Rows, []string{
- g.itemColorize(sess, def, itemName),
- g.colorize(sess, "credits_pickup", buyStr),
+ g.itemColorize(sess, c.def, c.name),
+ fmt.Sprintf("%d", c.stock),
+ g.colorize(sess, "credits_pickup", fmt.Sprintf("%d cr", value)),
g.colorize(sess, "credits_pickup", sellStr),
})
}
-
for _, line := range t.Render(unicode, wrapWidth) {
sess.WriteLine(line)
}
}
-func (g *Game) findShopMatches(input string, cfg *behavior.ShopConfig) []shopMatch {
- var matches []shopMatch
- for i := range cfg.Items {
- si := &cfg.Items[i]
- itemName := si.ItemID
- def, _ := g.ItemStore.Load(si.ItemID)
- if def != nil {
- itemName = def.Name
+func (g *Game) doShopBuy(sess *net.Session, mob *world.MobInstance, input string, qty int, all bool) {
+ var matches []shopCandidate
+ for _, c := range g.shopCandidates(mob) {
+ if behavior.WordPrefixMatch(input, c.name) {
+ matches = append(matches, c)
}
- if behavior.WordPrefixMatch(input, itemName) {
- matches = append(matches, shopMatch{si: si, name: itemName, def: def})
- }
- }
- return matches
-}
-
-func (g *Game) doShopBuy(sess *net.Session, input string, qty int, all bool) {
- cfg := g.shopConfig(sess)
- if cfg == nil {
- return
}
-
- p := sess.Player
-
- matches := g.findShopMatches(input, cfg)
if len(matches) == 0 {
- sess.WriteLine("That item isn't for sale here. Use 'browse' to see shop inventory.")
+ sess.WriteLine("That item isn't for sale here. Use 'list' to see the inventory.")
return
}
-
if len(matches) > 1 {
sess.WriteLine("That's ambiguous, which one?")
for _, m := range matches {
- sess.WriteLine(fmt.Sprintf(" - %s", g.itemColorize(sess, m.def, m.name)))
+ sess.WriteLine(" - " + g.itemColorize(sess, m.def, m.name))
}
return
}
- si := matches[0].si
- def := matches[0].def
-
- actualQty := qty
- if all || qty == 0 {
- affordable := p.Credits / si.BuyPrice
- if def != nil && def.Stackable {
- actualQty = affordable
- } else {
- freeSlots := p.FreeSlots()
- actualQty = affordable
- if actualQty > freeSlots {
- actualQty = freeSlots
- }
- }
+ c := matches[0]
+ p := sess.Player
+ price := 0
+ if c.def != nil {
+ price = c.def.Value
}
- if actualQty <= 0 {
- if p.Credits < si.BuyPrice {
- sess.WriteLine(fmt.Sprintf("You need %d credits to buy that (you have %d).", si.BuyPrice, p.Credits))
- } else {
- sess.WriteLine("Your inventory is full.")
- }
+ stock := g.MobStore.ShopStockOf(mob.InstanceID, c.itemID)
+ if stock <= 0 {
+ sess.WriteLine(fmt.Sprintf("%s is out of stock.", g.itemColorize(sess, c.def, c.name)))
return
}
- g.buyShopItem(sess, *si, actualQty)
-}
-
-func (g *Game) buyShopItem(sess *net.Session, si behavior.ShopItem, qty int) {
- p := sess.Player
-
- totalCost := si.BuyPrice * qty
- if p.Credits < totalCost {
- qty = p.Credits / si.BuyPrice
- if qty <= 0 {
- sess.WriteLine(fmt.Sprintf("You need %d credits to buy that (you have %d).", si.BuyPrice, p.Credits))
- return
- }
- totalCost = si.BuyPrice * qty
+ const unlimited = 1 << 30
+ want := qty
+ if all || qty == 0 {
+ want = unlimited
}
-
- def, _ := g.ItemStore.Load(si.ItemID)
- displayName := si.ItemID
- if def != nil {
- displayName = def.Name
+ if want > stock {
+ want = stock
}
-
- if def != nil && def.Stackable {
- for i := 0; i < 28; i++ {
- slot := p.InvSlot(i)
- if slot != nil && slot.ItemID == si.ItemID {
- p.Credits -= totalCost
- slot.Quantity += qty
- g.AccountStore.SaveCharacter(p)
- itemColor := g.itemColorize(sess, def, displayName)
- if qty == 1 {
- sess.WriteLine(fmt.Sprintf("You buy a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", si.BuyPrice))))
- } else {
- sess.WriteLine(fmt.Sprintf("You buy %d %s for %s credits.", qty, itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", totalCost))))
- }
- return
- }
- }
-
- freeSlot := p.FirstFreeSlot()
- if freeSlot == -1 {
- sess.WriteLine("Your inventory is full.")
- return
- }
-
- p.Credits -= totalCost
- p.SetInvSlot(freeSlot, g.newInventorySlot(si.ItemID, qty))
- g.AccountStore.SaveCharacter(p)
-
- itemColor := g.itemColorize(sess, def, displayName)
- if qty == 1 {
- sess.WriteLine(fmt.Sprintf("You buy a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", si.BuyPrice))))
- } else {
- sess.WriteLine(fmt.Sprintf("You buy %d %s for %s credits.", qty, itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", totalCost))))
- }
- return
+ capacity := g.shopInvCapacity(p, c.itemID, c.def)
+ if want > capacity {
+ want = capacity
}
-
- freeSlots := p.FreeSlots()
- if qty > freeSlots {
- if freeSlots == 0 {
- sess.WriteLine("Your inventory is full.")
- return
- }
- qty = freeSlots
- totalCost = si.BuyPrice * qty
- if p.Credits < totalCost {
- qty = p.Credits / si.BuyPrice
- totalCost = si.BuyPrice * qty
+ if price > 0 {
+ if afford := p.Credits / price; want > afford {
+ want = afford
}
}
- if qty <= 0 {
- if p.Credits < si.BuyPrice {
- sess.WriteLine(fmt.Sprintf("You need %d credits to buy that (you have %d).", si.BuyPrice, p.Credits))
- } else {
+ if want <= 0 {
+ switch {
+ case capacity <= 0:
sess.WriteLine("Your inventory is full.")
+ case price > 0 && p.Credits < price:
+ sess.WriteLine(fmt.Sprintf("You need %d credits to buy that (you have %d).", price, p.Credits))
+ default:
+ sess.WriteLine("You can't buy that right now.")
}
return
}
- p.Credits -= totalCost
- for j := 0; j < qty; j++ {
- freeSlot := p.FirstFreeSlot()
- p.SetInvSlot(freeSlot, g.newInventorySlot(si.ItemID, 1))
+ taken := g.MobStore.ShopTake(mob.InstanceID, c.itemID, want)
+ if taken <= 0 {
+ sess.WriteLine(fmt.Sprintf("%s is out of stock.", g.itemColorize(sess, c.def, c.name)))
+ return
}
+
+ total := price * taken
+ p.Credits -= total
+ g.giveShopItems(p, c.itemID, taken, c.def)
g.AccountStore.SaveCharacter(p)
- itemColor := g.itemColorize(sess, def, displayName)
- if qty == 1 {
- sess.WriteLine(fmt.Sprintf("You buy a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", si.BuyPrice))))
+ itemColor := g.itemColorize(sess, c.def, c.name)
+ credits := g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", total))
+ if taken == 1 {
+ sess.WriteLine(fmt.Sprintf("You buy a %s for %s credits.", itemColor, credits))
} else {
- sess.WriteLine(fmt.Sprintf("You buy %d %s for %s credits.", qty, itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", totalCost))))
+ sess.WriteLine(fmt.Sprintf("You buy %d %s for %s credits.", taken, itemColor, credits))
}
}
-func (g *Game) doShopSell(sess *net.Session, input string, qty int, all bool) {
- cfg := g.shopConfig(sess)
- if cfg == nil {
- return
- }
-
+func (g *Game) doShopSell(sess *net.Session, mob *world.MobInstance, input string, qty int, all bool) {
p := sess.Player
matches := g.findInventoryMatches(input, p)
@@ -325,35 +308,28 @@ func (g *Game) doShopSell(sess *net.Session, input string, qty int, all bool) {
sess.WriteLine("You don't have that item.")
return
}
-
if len(matches) > 1 {
sess.WriteLine("That's ambiguous, which one?")
for _, m := range matches {
def, _ := g.ItemStore.Load(m.ID)
- sess.WriteLine(fmt.Sprintf(" - %s", g.itemColorize(sess, def, m.Name)))
+ sess.WriteLine(" - " + g.itemColorize(sess, def, m.Name))
}
return
}
match := matches[0]
+ def, _ := g.ItemStore.Load(match.ID)
+ displayName := match.Name
+ itemColor := g.itemColorize(sess, def, displayName)
- var shopItem *behavior.ShopItem
- for i := range cfg.Items {
- si := &cfg.Items[i]
- if si.ItemID == match.ID && si.SellPrice > 0 {
- shopItem = si
- break
- }
+ if mob.Shop.FindItem(match.ID) == nil && !mob.Shop.Buys() {
+ sess.WriteLine(fmt.Sprintf("%s doesn't want to buy %s.", mob.Name, itemColor))
+ return
}
- if shopItem == nil {
- def, _ := g.ItemStore.Load(match.ID)
- displayName := match.ID
- if def != nil {
- displayName = def.Name
- }
- sess.WriteLine(fmt.Sprintf("The shop doesn't want to buy %s.", g.itemColorize(sess, def, displayName)))
- return
+ value := 0
+ if def != nil {
+ value = def.Value
}
totalInInv := p.CountItem(match.ID)
@@ -363,79 +339,66 @@ func (g *Game) doShopSell(sess *net.Session, input string, qty int, all bool) {
} else if actualQty > totalInInv {
actualQty = totalInInv
}
-
if actualQty <= 0 {
sess.WriteLine("You don't have that item.")
return
}
+ stock := g.MobStore.ShopStockOf(mob.InstanceID, match.ID)
+ total := 0
+ for k := 0; k < actualQty; k++ {
+ total += mob.Shop.SellPrice(value, stock+k)
+ }
+
p.RemoveItem(match.ID, actualQty)
- p.Credits += shopItem.SellPrice * actualQty
+ p.Credits += total
+ g.MobStore.ShopAdd(mob.InstanceID, match.ID, actualQty)
g.AccountStore.SaveCharacter(p)
- def, _ := g.ItemStore.Load(match.ID)
- displayName := match.Name
- itemColor := g.itemColorize(sess, def, displayName)
+ credits := g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", total))
if actualQty == 1 {
- sess.WriteLine(fmt.Sprintf("You sell a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", shopItem.SellPrice))))
+ sess.WriteLine(fmt.Sprintf("You sell a %s for %s credits.", itemColor, credits))
} else {
- totalCredits := shopItem.SellPrice * actualQty
- sess.WriteLine(fmt.Sprintf("You sell %d %s for %s credits.", actualQty, itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", totalCredits))))
+ sess.WriteLine(fmt.Sprintf("You sell %d %s for %s credits.", actualQty, itemColor, credits))
}
}
-func (g *Game) leaveShop(sess *net.Session) {
- sess.Shop = nil
- p := sess.Player
- if p == nil || p.Action == nil || p.Action.Type != "talk" {
- sess.State = net.StateGame
- if p != nil {
- g.cancelAction(p)
+// shopInvCapacity reports how many of an item the player can still carry.
+func (g *Game) shopInvCapacity(p *player.Player, itemID string, def *item.ItemDef) int {
+ if def != nil && def.Stackable {
+ for i := 0; i < 28; i++ {
+ if slot := p.InvSlot(i); slot != nil && slot.ItemID == itemID {
+ return 1 << 30
+ }
}
- g.writePrompt(sess)
- return
- }
-
- td, ok := p.Action.Data.(*behavior.TalkData)
- if !ok || td.Cfg == nil {
- sess.State = net.StateGame
- g.cancelAction(p)
- g.writePrompt(sess)
- return
+ if p.FirstFreeSlot() != -1 {
+ return 1 << 30
+ }
+ return 0
}
+ return p.FreeSlots()
+}
- node, ok := td.Cfg.Nodes[td.Node]
- if !ok {
- sess.State = net.StateGame
- g.cancelAction(p)
- g.writePrompt(sess)
+// giveShopItems places qty of an item into the player's inventory, stacking when
+// possible. Callers must ensure capacity via shopInvCapacity first.
+func (g *Game) giveShopItems(p *player.Player, itemID string, qty int, def *item.ItemDef) {
+ if def != nil && def.Stackable {
+ for i := 0; i < 28; i++ {
+ if slot := p.InvSlot(i); slot != nil && slot.ItemID == itemID {
+ slot.Quantity += qty
+ return
+ }
+ }
+ if slot := p.FirstFreeSlot(); slot != -1 {
+ p.SetInvSlot(slot, g.newInventorySlot(itemID, qty))
+ }
return
}
-
- for {
- visible := g.visibleOptions(sess, node.Options)
- if len(visible) > 0 {
- for i, opt := range visible {
- sess.WriteLine(fmt.Sprintf(" %d. %s", i+1, opt.Text))
- }
- sess.State = net.StateTalk
- g.reprompt(sess)
+ for j := 0; j < qty; j++ {
+ slot := p.FirstFreeSlot()
+ if slot == -1 {
return
}
-
- if node.Goto != "" {
- next, ok := td.Cfg.Nodes[node.Goto]
- if !ok {
- break
- }
- td.Node = node.Goto
- node = next
- } else {
- break
- }
+ p.SetInvSlot(slot, g.newInventorySlot(itemID, 1))
}
-
- sess.State = net.StateGame
- g.cancelAction(p)
- g.writePrompt(sess)
}
diff --git a/internal/game/game.go b/internal/game/game.go
index c30f9aa..4cd3b30 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -56,14 +56,14 @@ type Game struct {
queue *CommandQueue
safespot *SafespotManager
ValidationConfig config.ValidationConfig
- StartingRoom int
+ StartingRoom int
// Per-tick / per-session runtime bookkeeping.
charsMu sync.Mutex
loggedInChars map[string]*net.Session
restTimers map[string]uint64
guardWatchTimers map[string]int
- hackingStates map[string]*hacking.Session
+ hackingStates map[string]*hacking.Session
pendingDepletions []pendingDepletion
farmTickCounter int
enterMu sync.Mutex
@@ -162,8 +162,6 @@ func (g *Game) HandleSession(sess *net.Session, input string) {
g.handleDescChange(sess, input)
case net.StateTalk, net.StateTalkSequence:
g.handleTalkInput(sess, input)
- case net.StateShop:
- g.handleShopInput(sess, input)
case net.StateBank:
g.handleBankInput(sess, input)
case net.StateDropAllConfirm:
diff --git a/internal/net/server.go b/internal/net/server.go
index d212dbf..11339d7 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -14,7 +14,6 @@ import (
"sync"
"time"
- "thehouseoficarus/internal/behavior"
"thehouseoficarus/internal/color"
"thehouseoficarus/internal/config"
"thehouseoficarus/internal/player"
@@ -47,7 +46,6 @@ const (
StateCraftProduct
StateProductChoice
StateColorChoice
- StateShop
StateBank
StateHacking
StateDangerConfirm
@@ -68,7 +66,6 @@ type Session struct {
PendingBackground bool
Disconnecting bool
DisconnectTicks int
- Shop *behavior.ShopConfig
PendingUndigDir string
PendingUndigRoom int
promptVisible bool
@@ -76,14 +73,14 @@ type Session struct {
}
type Server struct {
- config *config.Config
- telnetLn net.Listener
- telnetTLSLn net.Listener
- httpServer *http.Server
- httpsServer *http.Server
+ config *config.Config
+ telnetLn net.Listener
+ telnetTLSLn net.Listener
+ httpServer *http.Server
+ httpsServer *http.Server
httpsTLSConfig *tls.Config
- hub *Hub
- handler func(*Session, string)
+ hub *Hub
+ handler func(*Session, string)
}
type Hub struct {
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index b35a3de..cdeee44 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -377,6 +377,44 @@ func validateMobs(s Source) []Issue {
id, def.FinishingBlow),
})
}
+
+ if def.Shop != nil {
+ if def.Shop.BuyPercentage < 0 || def.Shop.ChangePercentage < 0 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: shop buy_percentage/change_percentage must not be negative", id),
+ })
+ }
+ seenShop := map[string]bool{}
+ for _, si := range def.Shop.Items {
+ if si.ItemID == "" {
+ continue
+ }
+ if !itemIDs[si.ItemID] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: shop item %q does not exist", id, si.ItemID),
+ })
+ }
+ if seenShop[si.ItemID] {
+ issues = append(issues, Issue{
+ Level: "WARN",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: shop lists item %q more than once", id, si.ItemID),
+ })
+ }
+ seenShop[si.ItemID] = true
+ if si.Stock < 0 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: shop item %q has negative stock", id, si.ItemID),
+ })
+ }
+ }
+ }
}
return issues
@@ -865,7 +903,6 @@ func validateRoomWiring(s Source) []Issue {
return issues
}
-
// validateRoomGrid embeds the entire reachable world on a single 3D grid
// starting from the configured root rooms and reports when the exit layout
// cannot be embedded without conflict:
@@ -1170,18 +1207,5 @@ func validateNodeAction(prefix string, na *behavior.NodeAction, itemIDs map[stri
}
}
- if na.Shop != nil {
- for _, si := range na.Shop.Items {
- if si.ItemID != "" && !itemIDs[si.ItemID] {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "reference",
- Message: fmt.Sprintf("%s: shop item %q does not exist",
- prefix, si.ItemID),
- })
- }
- }
- }
-
return issues
}
diff --git a/internal/world/mob.go b/internal/world/mob.go
index cc2d2b2..58bf769 100644
--- a/internal/world/mob.go
+++ b/internal/world/mob.go
@@ -68,10 +68,13 @@ type MobDef struct {
CompleteMessage string `yaml:"complete_message"` // shown on completion
Talk *behavior.TalkConfig `yaml:"talk,omitempty"`
+ Shop *behavior.ShopConfig `yaml:"shop,omitempty"`
}
func (d *MobDef) IsTalkable() bool { return d.Talk != nil }
+func (d *MobDef) HasShop() bool { return d.Shop != nil }
+
func (d *MobDef) IsTask() bool { return d.Kind == "task" }
type MobInstance struct {
@@ -128,17 +131,26 @@ type MobInstance struct {
TalkConfig *behavior.TalkConfig
- Owner string
- OwnerOnly bool
- SpawnedByTrigger bool
- DespawnOnLeave bool
- DespawnRooms []int
- DespawnTicks float64
- DespawnCounter int
+ // Shop is the (shared, read-only) shop config from the def. Per-instance
+ // live stock is tracked in ShopStock; ShopRestock holds per-item countdown
+ // timers. All three are only mutated under MobStore.mu.
+ Shop *behavior.ShopConfig
+ ShopStock map[string]int
+ ShopRestock map[string]int
+
+ Owner string
+ OwnerOnly bool
+ SpawnedByTrigger bool
+ DespawnOnLeave bool
+ DespawnRooms []int
+ DespawnTicks float64
+ DespawnCounter int
}
func (m *MobInstance) IsTalkable() bool { return m.TalkConfig != nil }
+func (m *MobInstance) HasShop() bool { return m.Shop != nil }
+
func (m *MobInstance) IsTask() bool { return m.Kind == "task" }
func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []int, wanderInterval float64) *MobInstance {
@@ -146,7 +158,7 @@ func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []in
if def.Protected && hp <= 0 {
hp = 1
}
- return &MobInstance{
+ inst := &MobInstance{
InstanceID: instanceID,
DefID: def.ID,
Name: def.Name,
@@ -191,6 +203,20 @@ func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []in
CompleteMessage: def.CompleteMessage,
TalkConfig: def.Talk,
}
+ if def.Shop != nil {
+ inst.Shop = def.Shop
+ inst.ShopStock = make(map[string]int)
+ inst.ShopRestock = make(map[string]int)
+ for i := range def.Shop.Items {
+ it := &def.Shop.Items[i]
+ if it.ItemID == "" {
+ continue
+ }
+ inst.ShopStock[it.ItemID] = it.Stock
+ inst.ShopRestock[it.ItemID] = def.Shop.RestockInterval(it)
+ }
+ }
+ return inst
}
const (
@@ -330,6 +356,9 @@ func (s *MobStore) Tick() {
defer s.mu.Unlock()
for _, inst := range s.instances {
+ if inst.Shop != nil {
+ s.tickShopLocked(inst)
+ }
if inst.HP <= 0 || inst.HP >= inst.MaxHP {
inst.regenerateTick = 0
continue
@@ -347,6 +376,115 @@ func (s *MobStore) Tick() {
}
}
+// tickShopLocked advances restock timers for one shop instance. Current stock
+// converges toward each item's target (configured stock for listed items, 0 for
+// dynamically-acquired items) one unit per restock interval, in either
+// direction. Caller must hold s.mu.
+func (s *MobStore) tickShopLocked(inst *MobInstance) {
+ for id, cur := range inst.ShopStock {
+ target := 0
+ var cfgItem *behavior.ShopItem
+ if cfgItem = inst.Shop.FindItem(id); cfgItem != nil {
+ target = cfgItem.Stock
+ }
+ if cur == target {
+ if cur == 0 && cfgItem == nil {
+ delete(inst.ShopStock, id)
+ delete(inst.ShopRestock, id)
+ }
+ continue
+ }
+ c := inst.ShopRestock[id]
+ if c <= 0 {
+ c = inst.Shop.RestockInterval(cfgItem)
+ }
+ c--
+ if c <= 0 {
+ if cur < target {
+ cur++
+ } else {
+ cur--
+ }
+ inst.ShopStock[id] = cur
+ c = inst.Shop.RestockInterval(cfgItem)
+ }
+ inst.ShopRestock[id] = c
+ if cur == 0 && cfgItem == nil {
+ delete(inst.ShopStock, id)
+ delete(inst.ShopRestock, id)
+ }
+ }
+}
+
+// ShopStockSnapshot returns a copy of the instance's current stock map.
+func (s *MobStore) ShopStockSnapshot(instanceID string) map[string]int {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ inst := s.instances[instanceID]
+ if inst == nil || inst.ShopStock == nil {
+ return nil
+ }
+ out := make(map[string]int, len(inst.ShopStock))
+ for k, v := range inst.ShopStock {
+ out[k] = v
+ }
+ return out
+}
+
+// ShopStockOf returns the current stock the instance holds of an item.
+func (s *MobStore) ShopStockOf(instanceID, itemID string) int {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ inst := s.instances[instanceID]
+ if inst == nil || inst.ShopStock == nil {
+ return 0
+ }
+ return inst.ShopStock[itemID]
+}
+
+// ShopTake removes up to qty of an item from the shop's stock, returning the
+// amount actually taken (capped by availability). Used when a player buys.
+func (s *MobStore) ShopTake(instanceID, itemID string, qty int) int {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ inst := s.instances[instanceID]
+ if inst == nil || inst.ShopStock == nil {
+ return 0
+ }
+ have := inst.ShopStock[itemID]
+ if qty > have {
+ qty = have
+ }
+ if qty <= 0 {
+ return 0
+ }
+ inst.ShopStock[itemID] = have - qty
+ return qty
+}
+
+// ShopAdd adds qty of an item to the shop's stock. Used when a player sells.
+func (s *MobStore) ShopAdd(instanceID, itemID string, qty int) {
+ if qty <= 0 {
+ return
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ inst := s.instances[instanceID]
+ if inst == nil || inst.Shop == nil {
+ return
+ }
+ if inst.ShopStock == nil {
+ inst.ShopStock = make(map[string]int)
+ }
+ if inst.ShopRestock == nil {
+ inst.ShopRestock = make(map[string]int)
+ }
+ if _, ok := inst.ShopStock[itemID]; !ok {
+ inst.ShopRestock[itemID] = inst.Shop.RestockInterval(inst.Shop.FindItem(itemID))
+ }
+ inst.ShopStock[itemID] += qty
+}
+
func (s *MobStore) RollIdleDescription(inst *MobInstance) {
def, err := s.LoadDef(inst.DefID)
if err != nil {