aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/admin/static/admin.css2
-rw-r--r--internal/admin/static/mobeditor.js188
-rw-r--r--internal/behavior/behavior.go13
-rw-r--r--internal/behavior/store.go86
-rw-r--r--internal/config/config.go12
-rw-r--r--internal/game/combat_mob.go6
6 files changed, 253 insertions, 54 deletions
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'},
@@ -45,17 +50,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; },
fields: [
@@ -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 = '<p style="color:var(--danger)">Failed to load: ' + esc(e.message) + '</p>';
@@ -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 = '<div class="obj-core-row">';
- h += renderMobField(sec, sec.fields[0], data, -1, null, 'flex:1');
+ h += renderMobField(sec, byKey['name'], data, -1, null, 'flex:1');
h += '</div>';
- 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 += '<div class="obj-card-grid" style="margin-top:6px">';
+ h += renderMobField(sec, byKey['unique'], data, -1);
+ h += renderMobField(sec, byKey['protected'], data, -1);
+ h += renderMobField(sec, byKey['aggressive'], data, -1);
+ h += '</div>';
+ h += '<div class="obj-card-grid" style="margin-top:6px">';
+ h += renderMobField(sec, byKey['kind'], data, -1);
+ h += renderMobField(sec, byKey['respawn_ticks'], data, -1);
+ h += '</div>';
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 = '<div class="obj-subtable">';
h += '<div class="obj-subtable-header">' + esc(st.label) + '</div>';
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 = '<div class="obj-subtable">';
+ h += '<div class="obj-subtable-header">' + esc(st.label) + '</div>';
+ arr.forEach(function(item, idx) {
+ var kind = item.table ? 'table' : (item.item_id ? 'item' : (item._kind || 'item'));
+ h += '<div class="obj-sub-row">';
+ 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 += '<button class="btn btn-sm btn-danger obj-sub-remove" style="margin-bottom:3px" onclick="removeMobSubRow(\'' + sec.id + '\',\'' + st.key + '\',' + idx + ')">X</button>';
+ h += '</div>';
+ });
+ h += '<div style="display:flex;gap:6px">';
+ h += '<button class="btn btn-sm obj-sub-add" onclick="addMobLootRow(\'item\')">+ Add Item</button>';
+ h += '<button class="btn btn-sm obj-sub-add" onclick="addMobLootRow(\'table\')">+ Add Table</button>';
+ h += '</div>';
+ h += '</div>';
+ 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 = '<div class="ingredients-table" style="margin-top:10px">';
+ h += '<div class="ingredients-header-row">';
+ st.fields.forEach(function(f) {
+ if (f[2] === 'search') {
+ h += '<div class="ingredients-col ingredients-col-items">' + esc(f[1]) + '</div>';
+ } else {
+ h += '<div class="ingredients-col" style="width:' + mobTableColWidth(f) + 'px">' + esc(f[1]) + '</div>';
+ }
+ });
+ h += '<div class="ingredients-col ingredients-col-x"></div>';
+ h += '</div>';
+
+ arr.forEach(function(item, idx) {
+ h += '<div class="ingredients-ingredient"><div class="ingredients-pair">';
+ 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 += '<div class="ingredients-col ingredients-col-items"><div class="obj-field obj-search" style="width:100%"><div style="position:relative;width:100%">' +
+ '<input id="' + fid + '" value="' + escAttr(sval) + '" class="search-input" data-search-type="' + entity + '" placeholder="Search ' + entity + '...">' +
+ '<div class="search-results" style="display:none"></div></div></div></div>';
+ } 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 += '<div class="ingredients-col" style="width:' + mobTableColWidth(f) + 'px">' +
+ '<label class="obj-field"><input id="' + fid + '" value="' + escAttr(nval) + '"' + ph + '></label>' +
+ '</div>';
+ }
+ });
+ h += '<div class="ingredients-col ingredients-col-x"><button class="btn btn-sm btn-danger" style="padding:2px 6px;font-size:10px" onclick="removeMobSubRow(\'' + sec.id + '\',\'' + st.key + '\',' + idx + ')">X</button></div>';
+ h += '</div></div>';
+ });
+
+ h += '</div>';
+ h += '<button class="btn btn-sm obj-sub-add" onclick="addMobSubRow(\'' + sec.id + '\',\'' + st.key + '\')">' + esc(st.addLabel || '+ Add') + '</button>';
+ 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