diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/admin/api_mobs.go | 1 | ||||
| -rw-r--r-- | internal/admin/api_objects.go | 1 | ||||
| -rw-r--r-- | internal/admin/static/admin.css | 30 | ||||
| -rw-r--r-- | internal/admin/static/mobeditor.js | 32 | ||||
| -rw-r--r-- | internal/admin/static/objecteditor.js | 7 | ||||
| -rw-r--r-- | internal/admin/static/talktree.js | 330 | ||||
| -rw-r--r-- | internal/behavior/behavior.go | 128 | ||||
| -rw-r--r-- | internal/behavior/types.go | 24 | ||||
| -rw-r--r-- | internal/game/act_steal.go | 10 | ||||
| -rw-r--r-- | internal/game/act_talk.go | 53 | ||||
| -rw-r--r-- | internal/game/cmd_registry.go | 284 | ||||
| -rw-r--r-- | internal/game/cmd_shop.go | 525 | ||||
| -rw-r--r-- | internal/game/game.go | 6 | ||||
| -rw-r--r-- | internal/net/server.go | 17 | ||||
| -rw-r--r-- | internal/validate/checks.go | 52 | ||||
| -rw-r--r-- | internal/world/mob.go | 154 |
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 ? '▶' : '▼') + '</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">➔</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">➔</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">➔</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">➔</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 { |
