';
@@ -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 = '
';
+ 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