From 78b522d57a9ebc691e075523910d7cdaa06b6493 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Wed, 1 Jul 2026 18:36:21 -0400 Subject: feat: shop system removed from talk system, added per-item restock and variable pricing --- internal/admin/static/admin.css | 2 +- internal/admin/static/mobeditor.js | 188 ++++++++++++++++++++++++++++++++----- internal/behavior/behavior.go | 13 +-- internal/behavior/store.go | 86 ++++++++++++----- internal/config/config.go | 12 +++ internal/game/combat_mob.go | 6 +- 6 files changed, 253 insertions(+), 54 deletions(-) (limited to 'internal') diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css index 2bebf85..b44adcd 100644 --- a/internal/admin/static/admin.css +++ b/internal/admin/static/admin.css @@ -323,7 +323,7 @@ g.ud-hover:hover text{font-weight:bold} .obj-sub-row-line+.obj-sub-row-line{margin-top:4px} .obj-inline-compact .obj-card-grid{grid-template-columns:repeat(auto-fill,minmax(60px,1fr))!important} .obj-inline-compact .obj-narrow input{width:50px} -.ingredients-table{border:1px solid var(--border);border-radius:4px;overflow:hidden;margin-top:4px} +.ingredients-table{border:1px solid var(--border);border-radius:4px;overflow:hidden;margin-top:0} .ingredients-header-row{display:flex;gap:4px;padding:4px 6px;background:rgba(255,255,255,.03);font-size:9px;color:#888;text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid var(--border)} .ingredients-ingredient{border-bottom:1px solid rgba(255,255,255,.04)} .ingredients-ingredient:last-child{border-bottom:none} diff --git a/internal/admin/static/mobeditor.js b/internal/admin/static/mobeditor.js index 59eb16c..9928adb 100644 --- a/internal/admin/static/mobeditor.js +++ b/internal/admin/static/mobeditor.js @@ -10,6 +10,11 @@ var MOB_SECTIONS = [ fields: [ ['name', 'Name', 'text', 'spaceport attendant'], ['description', 'Description', 'textarea', 'A lanky man in a wrinkled grey uniform.'], + ['unique', 'Unique', 'checkbox'], + ['protected', 'Protected', 'checkbox'], + ['aggressive', 'Aggressive', 'checkbox'], + ['kind', 'Kind', 'select', 'combat|task'], + ['respawn_ticks', 'Respawn Ticks', 'number', '', 'narrow'], ], inline: [ {label:'Idle Descriptions', key:'idle_descriptions', type:'tags'}, @@ -44,17 +49,6 @@ var MOB_SECTIONS = [ ]}, ] }, - { - id: 'behavior', label: 'Behavior', - detect: function(d) { return d.aggressive || d.protected || d.unique || d.respawn_ticks || d.kind; }, - fields: [ - ['aggressive', 'Aggressive', 'checkbox'], - ['protected', 'Protected', 'checkbox'], - ['unique', 'Unique', 'checkbox'], - ['respawn_ticks', 'Respawn Ticks', 'number', '', 'narrow'], - ['kind', 'Kind', 'select', 'combat|task'], - ] - }, { id: 'steal', label: 'Steal', detect: function(d) { return d.steal_table || d.steal_level || d.steal_speed || d.steal_xp; }, @@ -96,14 +90,13 @@ var MOB_SECTIONS = [ ['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'], + {label:'Items', key:'items', addLabel:'+ Add Item', table:true, fields:[ + ['item_id', 'Item', 'search', '', '', null, 'items'], ['stock', 'Stock', 'number', '', 'narrow'], - ['restock_ticks', 'Restock Ticks', 'number', '', 'narrow'], + ['restock_ticks', 'Restock Ticks', 'number', '1000', 'narrow'], ]} ] }, @@ -111,15 +104,13 @@ var MOB_SECTIONS = [ id: 'drops', label: 'Drops', path: 'drops', detect: function(d) { return d.drops && (d.drops.remains || (d.drops.loot && d.drops.loot.length > 0)); }, fields: [ - ['remains', 'Remains', 'text', 'bones'], + ['remains', 'Remains', 'search', '', '', null, 'items'], ], subtables: [ {label:'Loot', key:'loot', fields:[ - ['item_id', 'Item ID', 'search', '', '', null, 'items'], + ['item_id', 'Item', 'search', '', '', null, 'items'], ['table', 'Table', 'search', '', '', null, 'drops'], ['weight', 'Weight', 'number', '', 'narrow'], - ['level', 'Level', 'number', '', 'narrow'], - ['xp', 'XP', 'number', '', 'narrow'], ['quantity', 'Quantity', 'number', '', 'narrow'], ]} ] @@ -149,6 +140,11 @@ function loadMob(id) { renderList(''); API.get('/api/mobs/' + encodeURIComponent(id)).then(function(data) { mobData = data; + // buys_anything defaults to true (Go treats absent as true); reflect that in + // the checkbox unless the shop explicitly opted out with false. + if (mobData.shop && mobData.shop.buys_anything === undefined) { + mobData.shop.buys_anything = true; + } renderMobEditor(id, data); }).catch(function(e) { $('#editorMain').innerHTML = '

Failed to load: ' + esc(e.message) + '

'; @@ -198,6 +194,7 @@ function renderMobEditor(id, data) { $('#editorMain').innerHTML = html; ced_setupSearchFields(); setupMobKindChange(); + setupMobProtectedToggle(); MOB_SECTIONS.forEach(function(sec) { if (sec.id === 'talk' && data.talk && data.talk.nodes) { @@ -291,16 +288,52 @@ function renderMobCard(sec, data) { function renderMobCoreCard(data) { var sec = MOB_SECTIONS[0]; + var byKey = {}; + sec.fields.forEach(function(f) { byKey[f[0]] = f; }); var h = '
'; - h += renderMobField(sec, sec.fields[0], data, -1, null, 'flex:1'); + h += renderMobField(sec, byKey['name'], data, -1, null, 'flex:1'); h += '
'; - h += renderMobField(sec, sec.fields[1], data, -1); + h += renderMobField(sec, byKey['description'], data, -1); sec.inline.forEach(function(il) { h += ced_renderTags(sec, il, data); }); + // Behavior fields (merged into Core). + h += '
'; + h += renderMobField(sec, byKey['unique'], data, -1); + h += renderMobField(sec, byKey['protected'], data, -1); + h += renderMobField(sec, byKey['aggressive'], data, -1); + h += '
'; + h += '
'; + h += renderMobField(sec, byKey['kind'], data, -1); + h += renderMobField(sec, byKey['respawn_ticks'], data, -1); + h += '
'; return h; } +// setupMobProtectedToggle hides Aggressive, Respawn Ticks and Kind while +// Protected is checked (protected mobs never attack, respawn, or work). Toggles +// CSS only — no re-render — so other unsaved field edits are preserved. +function setupMobProtectedToggle() { + var sec = MOB_SECTIONS.find(function(s) { return s.id === 'core'; }); + if (!sec) return; + var protEl = document.getElementById(mobFieldID(sec, 'protected', -1)); + if (!protEl) return; + function apply() { + var hide = protEl.checked; + ['aggressive', 'respawn_ticks', 'kind'].forEach(function(key) { + var el = document.getElementById(mobFieldID(sec, key, -1)); + if (!el) return; + var field = el.closest('.obj-field'); + if (field) field.style.display = hide ? 'none' : ''; + }); + } + if (!protEl._hasProtectedToggle) { + protEl._hasProtectedToggle = true; + protEl.addEventListener('change', apply); + } + apply(); +} + function renderMobField(sec, f, data, idx, subPath, optStyle) { return ced_renderField(sec, f, data, idx, subPath, optStyle, mobCardPath, mobFieldID, mobGetVal); } @@ -340,6 +373,12 @@ function renderMobSubtable(sec, st, data) { } else { arr = []; } + if (st.table) { + return renderMobTableSubtable(sec, st, arr); + } + if (sec.id === 'drops' && st.key === 'loot') { + return renderMobLootSubtable(sec, st, arr); + } var h = '
'; h += '
' + esc(st.label) + '
'; arr.forEach(function(item, idx) { @@ -360,6 +399,94 @@ function renderMobSubtable(sec, st, data) { return h; } +// renderMobLootSubtable renders the Drops loot list where each row is either an +// item drop (Item + Weight + Quantity) or a table drop (Table + Weight + Rolls). +// A table entry's "Rolls" is stored in the `quantity` field and means the number +// of independent draws from that table (see behavior.ResolveDropList). +function renderMobLootSubtable(sec, st, arr) { + var byKey = {}; + st.fields.forEach(function(f) { byKey[f[0]] = f; }); + var rollsField = ['quantity', 'Rolls', 'number', '', 'narrow']; + + var h = '
'; + h += '
' + esc(st.label) + '
'; + arr.forEach(function(item, idx) { + var kind = item.table ? 'table' : (item.item_id ? 'item' : (item._kind || 'item')); + h += '
'; + if (kind === 'table') { + h += renderMobSubField(sec, st.key, idx, byKey['table'], item.table); + h += renderMobSubField(sec, st.key, idx, byKey['weight'], item.weight); + h += renderMobSubField(sec, st.key, idx, rollsField, item.quantity); + } else { + h += renderMobSubField(sec, st.key, idx, byKey['item_id'], item.item_id); + h += renderMobSubField(sec, st.key, idx, byKey['weight'], item.weight); + h += renderMobSubField(sec, st.key, idx, byKey['quantity'], item.quantity); + } + h += ''; + h += '
'; + }); + h += '
'; + h += ''; + h += ''; + h += '
'; + h += '
'; + return h; +} + +// Column width (px) for a non-search field, sized to fit its header label. +function mobTableColWidth(f) { + return Math.max(60, esc(f[1]).length * 8 + 20); +} + +// renderMobTableSubtable renders a subtable in the ingredients-style table +// layout: a single column-header row (no card header) and one row per entry. +function renderMobTableSubtable(sec, st, arr) { + var h = '
'; + h += '
'; + st.fields.forEach(function(f) { + if (f[2] === 'search') { + h += '
' + esc(f[1]) + '
'; + } else { + h += '
' + esc(f[1]) + '
'; + } + }); + h += '
'; + h += '
'; + + arr.forEach(function(item, idx) { + h += '
'; + st.fields.forEach(function(f) { + var fid = mobFieldIDSub(sec, st.key, idx, f[0]); + var val = item[f[0]]; + if (f[2] === 'search') { + var entity = f[6] || 'items'; + var sval = (val === undefined || val === null) ? '' : String(val); + h += '
'; + } else { + // Numeric column. Blank a 0/unset value only when the field has a + // placeholder, so the placeholder (e.g. "1000") shows through. + var hasPh = !!f[3]; + var nval; + if (val === undefined || val === null || val === '') nval = ''; + else if (val === 0 && hasPh) nval = ''; + else nval = String(val); + var ph = hasPh ? ' placeholder="' + escAttr(f[3]) + '"' : ''; + h += '
' + + '' + + '
'; + } + }); + h += '
'; + h += '
'; + }); + + h += '
'; + h += ''; + return h; +} + // ---- Card toggle / section add-remove ---- function toggleMobCard(id) { @@ -390,7 +517,7 @@ function mobAddSection() { if (sec.path) { if (sec.id === 'drops') { - mobData[sec.path] = {remains:'', loot:[]}; + mobData[sec.path] = {remains:'bones', loot:[]}; } else if (sec.id === 'talk') { mobData[sec.path] = {nodes:{start:{messages:[],options:[]}}}; } else if (sec.id === 'shop') { @@ -420,10 +547,25 @@ function removeMobSubRow(cardID, subKey, idx) { renderCurrent(); } +// addMobLootRow appends an item- or table-kind loot entry. The ephemeral _kind +// marker is only used for rendering fresh empty rows; it's never saved (save +// rebuilds rows from the visible inputs) and is re-inferred from item_id/table +// on reload. +function addMobLootRow(kind) { + if (!mobData.drops) mobData.drops = {}; + if (!Array.isArray(mobData.drops.loot)) mobData.drops.loot = []; + if (kind === 'table') { + mobData.drops.loot.push({table:'', weight:0, quantity:1, _kind:'table'}); + } else { + mobData.drops.loot.push({item_id:'', weight:0, quantity:1, _kind:'item'}); + } + renderCurrent(); +} + // ---- Kind change handler (show/hide task fields) ---- function setupMobKindChange() { - var kindSel = document.getElementById(mobFieldID(MOB_SECTIONS.find(function(s){return s.id==='behavior';}), 'kind', -1)); + var kindSel = document.getElementById(mobFieldID(MOB_SECTIONS.find(function(s){return s.id==='core';}), 'kind', -1)); if (!kindSel) return; kindSel.addEventListener('change', function() { if (kindSel.value === 'task') { diff --git a/internal/behavior/behavior.go b/internal/behavior/behavior.go index 646495a..06e2715 100644 --- a/internal/behavior/behavior.go +++ b/internal/behavior/behavior.go @@ -66,7 +66,6 @@ type ShopConfig struct { 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"` } @@ -82,9 +81,14 @@ const ( ShopBuyPercentage = 40.0 ShopChangePercentage = 3.0 ShopMinSellPercent = 10.0 - ShopDefaultRestock = 100.0 ) +// ShopDefaultRestock is the fallback restock interval (in ticks) for a shop item +// that does not set its own restock_ticks. It is configurable via +// game_constants.shop_default_restock in config.yaml (wired up at startup); the +// value here is only the default used when no config is loaded. +var ShopDefaultRestock = 1000.0 + // EffectiveBuyPercentage returns BuyPercentage or the default. func (c *ShopConfig) EffectiveBuyPercentage() float64 { if c.BuyPercentage > 0 { @@ -120,14 +124,11 @@ func (c *ShopConfig) SellPrice(value, stock int) int { } // RestockInterval returns the effective restock interval (in ticks) for an item, -// honoring the per-item override, then the shop default, then the global default. +// honoring the per-item override, then the configurable 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) } diff --git a/internal/behavior/store.go b/internal/behavior/store.go index 38cc72b..8112e1c 100644 --- a/internal/behavior/store.go +++ b/internal/behavior/store.go @@ -126,7 +126,9 @@ func LoadDropTable(dataDir, id string) (*DropTableDef, error) { return &dt, nil } -func ResolveDrop(dataDir string, drops []DropEntry) *DropEntry { +// pickWeighted returns a single entry chosen by weight, without resolving any +// table reference on it. Returns nil if the list is empty or has no weight. +func pickWeighted(drops []DropEntry) *DropEntry { if len(drops) == 0 { return nil } @@ -142,29 +144,69 @@ func ResolveDrop(dataDir string, drops []DropEntry) *DropEntry { for i := range drops { cumulative += drops[i].Weight if roll < cumulative { - if drops[i].Table != "" { - sub, err := LoadDropTable(dataDir, drops[i].Table) - if err == nil { - if resolved := ResolveDrop(dataDir, sub.Drops); resolved != nil { - qty := drops[i].Quantity - if qty <= 0 { - qty = resolved.Quantity - } - if qty <= 0 { - qty = 1 - } - return &DropEntry{ - ItemID: resolved.ItemID, - Quantity: qty, - Depletes: drops[i].Depletes, - SuccessMessage: drops[i].SuccessMessage, - } - } - } - return nil - } return &drops[i] } } return &drops[0] } + +func ResolveDrop(dataDir string, drops []DropEntry) *DropEntry { + picked := pickWeighted(drops) + if picked == nil { + return nil + } + if picked.Table != "" { + sub, err := LoadDropTable(dataDir, picked.Table) + if err != nil { + return nil + } + resolved := ResolveDrop(dataDir, sub.Drops) + if resolved == nil { + return nil + } + qty := picked.Quantity + if qty <= 0 { + qty = resolved.Quantity + } + if qty <= 0 { + qty = 1 + } + return &DropEntry{ + ItemID: resolved.ItemID, + Quantity: qty, + Depletes: picked.Depletes, + SuccessMessage: picked.SuccessMessage, + } + } + return picked +} + +// ResolveDropList performs one weighted pick from drops. A plain item entry +// yields a single result. When the picked entry references a sub-table, its +// Quantity is treated as the number of INDEPENDENT rolls against that sub-table +// (default 1) — each roll may produce a different item — and all results are +// returned. Tables are just weighted lists of items; this is skill-independent. +func ResolveDropList(dataDir string, drops []DropEntry) []DropEntry { + picked := pickWeighted(drops) + if picked == nil { + return nil + } + if picked.Table == "" { + return []DropEntry{*picked} + } + sub, err := LoadDropTable(dataDir, picked.Table) + if err != nil { + return nil + } + rolls := picked.Quantity + if rolls <= 0 { + rolls = 1 + } + var out []DropEntry + for i := 0; i < rolls; i++ { + if resolved := ResolveDrop(dataDir, sub.Drops); resolved != nil && resolved.ItemID != "" { + out = append(out, *resolved) + } + } + return out +} diff --git a/internal/config/config.go b/internal/config/config.go index 9505cbb..9afc2d1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,6 +10,7 @@ type Config struct { TickLength int `yaml:"tick_length"` StartingRoom int `yaml:"starting_room"` StartupValidation ValidationConfig `yaml:"startup_validation"` + GameConstants GameConstants `yaml:"game_constants"` DefaultColors ColorsConfig `yaml:"default_colors"` TLS TLSConfig `yaml:"tls"` Telnet TelnetConfig `yaml:"telnet"` @@ -20,6 +21,14 @@ type Config struct { AdminHTTPS AdminHTTPSConfig `yaml:"admin_https"` } +// GameConstants holds tunable game-wide numbers that would otherwise be +// hardcoded in the engine. +type GameConstants struct { + // ShopDefaultRestock is the fallback restock interval (in ticks) for a shop + // item that does not set its own restock_ticks. + ShopDefaultRestock float64 `yaml:"shop_default_restock"` +} + type ColorsConfig map[string]string func DefaultColors() ColorsConfig { @@ -118,6 +127,9 @@ func Default() *Config { RootRooms: []int{1}, IgnoreUnreachable: []int{}, }, + GameConstants: GameConstants{ + ShopDefaultRestock: 1000, + }, DefaultColors: DefaultColors(), TLS: TLSConfig{ CertFile: "", diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go index 4cdd27b..c64e393 100644 --- a/internal/game/combat_mob.go +++ b/internal/game/combat_mob.go @@ -192,8 +192,10 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst } if len(mob.Drops.Loot) > 0 { - entry := behavior.ResolveDrop(g.DataDir, mob.Drops.Loot) - if entry != nil && entry.ItemID != "" { + for _, entry := range behavior.ResolveDropList(g.DataDir, mob.Drops.Loot) { + if entry.ItemID == "" { + continue + } qty := entry.Quantity if qty <= 0 { qty = 1 -- cgit v1.2.3