aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/admin/static/admin.css3
-rw-r--r--internal/admin/static/cardeditor.js74
-rw-r--r--internal/admin/static/hazardeditor.js98
-rw-r--r--internal/admin/static/mobeditor.js162
-rw-r--r--internal/game/sys_hazard.go36
-rw-r--r--internal/validate/checks.go16
-rw-r--r--internal/world/hazard.go98
7 files changed, 353 insertions, 134 deletions
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css
index 0ee66bb..798a2a2 100644
--- a/internal/admin/static/admin.css
+++ b/internal/admin/static/admin.css
@@ -290,6 +290,9 @@ g.ud-hover:hover text{font-weight:bold}
.seg-pill.seg-pill-3.seg-idx-0::before{transform:translateX(0)}
.seg-pill.seg-pill-3.seg-idx-1::before{transform:translateX(100%)}
.seg-pill.seg-pill-3.seg-idx-2::before{transform:translateX(200%)}
+.seg-pill.seg-pill-2::before{width:calc(100% / 2)}
+.seg-pill.seg-pill-2.seg-idx-0::before{transform:translateX(0)}
+.seg-pill.seg-pill-2.seg-idx-1::before{transform:translateX(100%)}
.seg-pill .seg-opt:focus-visible{outline:2px solid var(--accent);outline-offset:-1px;border-radius:3px;z-index:2}
.obj-color-row{display:flex;align-items:center;gap:4px}
.obj-color-row .color-swatch{flex-shrink:0}
diff --git a/internal/admin/static/cardeditor.js b/internal/admin/static/cardeditor.js
index 1b2b76b..f32ea7e 100644
--- a/internal/admin/static/cardeditor.js
+++ b/internal/admin/static/cardeditor.js
@@ -584,3 +584,77 @@ function ced_cleanOutput(obj) {
}
}
}
+
+function ced_unflattenObj(obj) {
+ var result = {};
+ Object.keys(obj).forEach(function(key) {
+ var parts = key.split('.');
+ var current = result;
+ for (var i = 0; i < parts.length - 1; i++) {
+ if (!current[parts[i]]) current[parts[i]] = {};
+ current = current[parts[i]];
+ }
+ current[parts[parts.length - 1]] = obj[key];
+ });
+ return result;
+}
+
+function ced_normalizeAtkTypes(v) {
+ if (Array.isArray(v)) return v.slice();
+ if (typeof v === 'string' && v) return [v];
+ return [];
+}
+
+function ced_renderAtkTypeWidget(data, widgetID, selectFnName) {
+ var combat = data.combat || {};
+ var types = ced_normalizeAtkTypes(combat.attack_types);
+ var melee = types.filter(function(t) { return t === 'stab' || t === 'slash' || t === 'crush'; })[0] || 'crush';
+ var hasRanged = types.indexOf('ranged') >= 0;
+ var hasScience = types.indexOf('science') >= 0;
+ var meleeOpts = ['stab','slash','crush'];
+ var activeIdx = meleeOpts.indexOf(melee);
+ if (activeIdx < 0) activeIdx = 2;
+
+ var h = '<div class="obj-inline-group ced-atktype" id="' + widgetID + '">';
+ h += '<span class="obj-inline-label">Atk Type</span>';
+ h += '<div class="seg-pill seg-pill-3 seg-idx-' + activeIdx + ' ced-atktype-pill" style="margin-top:4px">';
+ meleeOpts.forEach(function(t, i) {
+ h += '<button type="button" class="seg-opt' + (i === activeIdx ? ' seg-active' : '') + '" data-v="' + t + '" onclick="' + selectFnName + '(\'' + t + '\',this)">' + t + '</button>';
+ });
+ h += '</div>';
+ h += '<div class="obj-card-grid" style="margin-top:4px">';
+ h += '<label class="obj-field obj-check obj-narrow"><input type="checkbox" class="ced-atktype-ranged"' + (hasRanged ? ' checked' : '') + '> <span>ranged</span></label>';
+ h += '<label class="obj-field obj-check obj-narrow"><input type="checkbox" class="ced-atktype-science"' + (hasScience ? ' checked' : '') + '> <span>science</span></label>';
+ h += '</div>';
+ h += '</div>';
+ return h;
+}
+
+function ced_atkPillSelect(val, btn) {
+ var pill = btn.closest('.ced-atktype-pill');
+ if (!pill) return;
+ var opts = pill.querySelectorAll('.seg-opt');
+ var idx = -1;
+ opts.forEach(function(o, i) {
+ if (o.getAttribute('data-v') === val) {
+ o.classList.add('seg-active');
+ idx = i;
+ } else {
+ o.classList.remove('seg-active');
+ }
+ });
+ pill.className = pill.className.replace(/seg-idx-\d/g, 'seg-idx-' + idx);
+}
+
+function ced_collectAtkType(widgetID) {
+ var widget = document.getElementById(widgetID);
+ if (!widget) return ['crush'];
+ var active = widget.querySelector('.ced-atktype-pill .seg-opt.seg-active');
+ var melee = active ? active.getAttribute('data-v') : 'crush';
+ var out = [melee];
+ var rng = widget.querySelector('.ced-atktype-ranged');
+ var sci = widget.querySelector('.ced-atktype-science');
+ if (rng && rng.checked) out.push('ranged');
+ if (sci && sci.checked) out.push('science');
+ return out;
+}
diff --git a/internal/admin/static/hazardeditor.js b/internal/admin/static/hazardeditor.js
index e849512..89586ac 100644
--- a/internal/admin/static/hazardeditor.js
+++ b/internal/admin/static/hazardeditor.js
@@ -14,17 +14,26 @@ var HAZARD_SECTIONS = [
['hit_message', 'Hit Message', 'text', 'You cough and choke on the toxic gas!'],
['miss_message', 'Miss Message', 'text', 'You hold your breath and avoid the fumes.'],
['required_item', 'Required Item', 'search', 'e.g. gas_mask', '', null, 'items'],
- ['speed', 'Speed', 'number', '10', 'narrow'],
]
},
{
- id: 'combat', label: 'Combat',
- detect: function(d) { return d.attack_type || d.attack || d.attack_bonus || d.max_hit; },
+ id: 'combat', label: 'Combat', path: 'combat',
+ detect: function(d) { return d.combat; },
fields: [
- ['attack_type', 'Attack Type', 'select', 'stab|slash|crush|ranged|science'],
- ['attack', 'Attack', 'number', '5', 'narrow'],
- ['attack_bonus', 'Attack Bonus', 'number', '0', 'narrow'],
- ['max_hit', 'Max Hit', 'number', '3', 'narrow'],
+ ['stats.attack', 'Attack', 'number', '5', 'narrow'],
+ ['stats.strength', 'Strength', 'number', '5', 'narrow'],
+ ['stats.ranged', 'Ranged', 'number', '1', 'narrow'],
+ ['stats.science', 'Science', 'number', '1', 'narrow'],
+ ['stats.speed', 'Speed', 'number', '5', 'narrow'],
+ ['stats.max_melee_hit', 'Max Melee Hit', 'number', '2', 'narrow'],
+ ['stats.max_ranged_hit', 'Max Ranged Hit', 'number', '0', 'narrow'],
+ ['stats.max_science_hit', 'Max Science Hit', 'number', '0', 'narrow'],
+ ['stats.bonuses.attack_bonus', 'Atk Bonus', 'number', '0', 'narrow'],
+ ['stats.bonuses.strength_bonus', 'Str Bonus', 'number', '0', 'narrow'],
+ ['stats.bonuses.ranged_bonus', 'Rng Bonus', 'number', '0', 'narrow'],
+ ['stats.bonuses.ranged_strength_bonus', 'Rng Str Bonus', 'number', '0', 'narrow'],
+ ['stats.bonuses.science_bonus', 'Sci Bonus', 'number', '0', 'narrow'],
+ ['stats.bonuses.science_percent_bonus', 'Sci % Bonus', 'number', '0', 'narrow'],
]
}
];
@@ -129,11 +138,15 @@ function renderCard(sec, data) {
h += '</div>';
if (!collapsed) {
h += '<div class="obj-card-body">';
- h += '<div class="obj-card-grid">';
- sec.fields.forEach(function(f) {
- h += renderField(sec, f, data, -1);
- });
- h += '</div>';
+ if (sec.id === 'combat') {
+ h += renderHazardCombatCard(data);
+ } else {
+ h += '<div class="obj-card-grid">';
+ sec.fields.forEach(function(f) {
+ h += renderField(sec, f, data, -1);
+ });
+ h += '</div>';
+ }
h += '</div>';
}
h += '</div>';
@@ -158,7 +171,9 @@ function removeSection(id) {
var sec = HAZARD_SECTIONS.find(function(s) { return s.id === id; });
if (!sec) return;
ced_syncDOMToData(hazardData);
- if (sec.fields) sec.fields.forEach(function(f) { delete hazardData[f[0]]; });
+ if (sec.path) {
+ delete hazardData[sec.path];
+ }
delete addedSections[id];
renderHazardEditor(hazardID, hazardData);
}
@@ -171,10 +186,15 @@ function addSection() {
if (!sec) return;
if (id === 'combat') {
- hazardData.attack_type = '';
- hazardData.attack = 0;
- hazardData.attack_bonus = 0;
- hazardData.max_hit = 0;
+ hazardData.combat = {
+ attack_types: ['crush'],
+ stats: {
+ attack: 1, strength: 0, ranged: 0, science: 0,
+ speed: 5,
+ max_melee_hit: 0, max_ranged_hit: 0, max_science_hit: 0,
+ bonuses: {attack_bonus:0, strength_bonus:0, ranged_bonus:0, ranged_strength_bonus:0, science_bonus:0, science_percent_bonus:0}
+ }
+ };
}
addedSections[id] = true;
expandedCards[id] = true;
@@ -200,9 +220,16 @@ function saveHazard() {
});
}
+ if (sec.id === 'combat' && document.getElementById('hz_atktype')) {
+ sectionObj['attack_types'] = ced_collectAtkType('hz_atktype');
+ hasValues = true;
+ }
+
if (sec.always) {
Object.keys(sectionObj).forEach(function(k) { out[k] = sectionObj[k]; });
- } else if (hasValues) {
+ } else if (hasValues && sec.path) {
+ out[sec.path] = ced_unflattenObj(sectionObj);
+ } else if (hasValues && !sec.path) {
Object.keys(sectionObj).forEach(function(k) { out[k] = sectionObj[k]; });
}
});
@@ -271,3 +298,38 @@ function duplicateHazard() {
loadHazardEditor(newID);
}).catch(function(e) { notify('Duplicate failed: ' + e.message, 'error'); });
}
+
+function renderHazardCombatCard(data) {
+ var sec = HAZARD_SECTIONS.find(function(s) { return s.id === 'combat'; });
+ if (!sec) return '';
+ var byKey = {};
+ sec.fields.forEach(function(f) { byKey[f[0]] = f; });
+
+ var h = ced_renderAtkTypeWidget(data, 'hz_atktype', 'ced_atkPillSelect');
+
+ h += '<div class="obj-inline-group">';
+ h += '<span class="obj-inline-label">Stats</span>';
+ h += '<div class="obj-card-grid" style="margin-top:4px">';
+ var statKeys = ['stats.attack','stats.strength','stats.ranged','stats.science'];
+ statKeys.forEach(function(k) { h += renderField(sec, byKey[k], data, -1); });
+ h += '</div>';
+ h += '<div class="obj-card-grid" style="margin-top:4px">';
+ h += renderField(sec, byKey['stats.speed'], data, -1);
+ h += '</div>';
+ h += '<div class="obj-card-grid" style="margin-top:4px">';
+ var maxKeys = ['stats.max_melee_hit','stats.max_ranged_hit','stats.max_science_hit'];
+ maxKeys.forEach(function(k) { h += renderField(sec, byKey[k], data, -1); });
+ h += '</div>';
+ h += '</div>';
+
+ h += '<div class="obj-inline-group">';
+ h += '<span class="obj-inline-label">Bonuses</span>';
+ h += '<div class="obj-card-grid" style="margin-top:4px">';
+ var bonusKeys = ['stats.bonuses.attack_bonus','stats.bonuses.strength_bonus','stats.bonuses.ranged_bonus',
+ 'stats.bonuses.ranged_strength_bonus','stats.bonuses.science_bonus','stats.bonuses.science_percent_bonus'];
+ bonusKeys.forEach(function(k) { h += renderField(sec, byKey[k], data, -1); });
+ h += '</div>';
+ h += '</div>';
+
+ return h;
+}
diff --git a/internal/admin/static/mobeditor.js b/internal/admin/static/mobeditor.js
index 3934fcd..10b256e 100644
--- a/internal/admin/static/mobeditor.js
+++ b/internal/admin/static/mobeditor.js
@@ -22,7 +22,6 @@ var MOB_SECTIONS = [
id: 'combat', label: 'Combat Stats', path: 'combat',
detect: function(d) { return d.combat; },
fields: [
- ['kind', 'Kind', 'select', 'combat|task'],
['aggressive', 'Aggressive', 'checkbox'],
['respawn_ticks', 'Respawn Ticks', 'number', '30', 'narrow'],
['stats.hp', 'HP', 'number', '10', 'narrow'],
@@ -46,7 +45,7 @@ var MOB_SECTIONS = [
['stats.defenses.crush_defense', 'Crush Def', 'number', '0', 'narrow'],
['stats.defenses.science_defense', 'Sci Def', 'number', '0', 'narrow'],
['stats.defenses.ranged_defense', 'Rng Def', 'number', '0', 'narrow'],
- ['stats.defenses.weakness', 'Weakness', 'select', '|bio|eco|hydro|solar|chaos'],
+ ['stats.defenses.weakness', 'Weakness', 'select', '(none)|bio|eco|hydro|solar|chaos'],
['stats.defenses.weakness_percent', 'Weakness %', 'number', '50', 'narrow'],
['assassin_level', 'Assassin Lvl', 'number', '1', 'narrow'],
['finishing_blow', 'Finishing Blow', 'search', 'e.g. stake', '', null, 'items'],
@@ -211,6 +210,7 @@ function renderMobEditor(id, data) {
$('#editorMain').innerHTML = html;
ced_setupSearchFields();
setupMobProtectedToggle();
+ applyTaskVisibility();
MOB_SECTIONS.forEach(function(sec) {
if (sec.id === 'talk' && data.talk && data.talk.nodes) {
@@ -248,6 +248,8 @@ function renderMobCard(sec, data) {
h += renderMobCoreCard(data);
} else if (sec.id === 'combat') {
h += renderMobCombatCard(data);
+ } else if (sec.id === 'task') {
+ h += renderMobTaskCard(data);
} else if (sec.id === 'drops') {
h += renderMobDropsCard(data);
} else if (sec.isArray) {
@@ -305,43 +307,21 @@ function renderMobCoreCard(data) {
return h;
}
-// normalizeAttackTypes coerces a mob attack_types value (array) into an array of
-// strings, tolerating a legacy scalar string for display only.
-function normalizeAttackTypes(v) {
- if (Array.isArray(v)) return v.slice();
- if (typeof v === 'string' && v) return [v];
- return [];
-}
-
-// renderMobAttackTypeWidget renders the Atk Type selector: one required melee
-// type (pill: stab/slash/crush) plus optional ranged/science (checkboxes).
-function renderMobAttackTypeWidget(data) {
+function renderMobKindToggle(data) {
var combat = data.combat || {};
- var types = normalizeAttackTypes(combat.attack_types);
- var melee = types.filter(function(t) { return t === 'stab' || t === 'slash' || t === 'crush'; })[0] || 'crush';
- var hasRanged = types.indexOf('ranged') >= 0;
- var hasScience = types.indexOf('science') >= 0;
- var meleeOpts = ['stab','slash','crush'];
- var activeIdx = meleeOpts.indexOf(melee);
- if (activeIdx < 0) activeIdx = 2;
-
+ var kind = combat.kind === 'task' ? 'task' : 'combat';
+ var activeC = kind === 'combat';
var h = '<div class="obj-inline-group">';
- h += '<span class="obj-inline-label">Atk Type</span>';
- h += '<div class="seg-pill seg-pill-3 seg-idx-' + activeIdx + '" id="mob_atktype_pill" style="margin-top:4px">';
- meleeOpts.forEach(function(t, i) {
- h += '<button type="button" class="seg-opt' + (i === activeIdx ? ' seg-active' : '') + '" data-v="' + t + '" onclick="mobAtkPillSelect(\'' + t + '\',this)">' + t + '</button>';
- });
- h += '</div>';
- h += '<div class="obj-card-grid" style="margin-top:4px">';
- h += '<label class="obj-field obj-check obj-narrow"><input type="checkbox" id="mob_atktype_ranged"' + (hasRanged ? ' checked' : '') + '> <span>ranged</span></label>';
- h += '<label class="obj-field obj-check obj-narrow"><input type="checkbox" id="mob_atktype_science"' + (hasScience ? ' checked' : '') + '> <span>science</span></label>';
- h += '</div>';
- h += '</div>';
+ h += '<span class="obj-inline-label">Kind</span>';
+ h += '<div class="seg-pill seg-pill-2 seg-idx-' + (activeC ? 0 : 1) + '" id="mob_kind_pill" style="margin-top:4px">';
+ h += '<button type="button" class="seg-opt' + (activeC ? ' seg-active' : '') + '" data-v="combat" onclick="mobKindPillSelect(\'combat\',this)">Combat</button>';
+ h += '<button type="button" class="seg-opt' + (activeC ? '' : ' seg-active') + '" data-v="task" onclick="mobKindPillSelect(\'task\',this)">Task</button>';
+ h += '</div></div>';
return h;
}
-function mobAtkPillSelect(val, btn) {
- var pill = document.getElementById('mob_atktype_pill');
+function mobKindPillSelect(val, btn) {
+ var pill = document.getElementById('mob_kind_pill');
if (!pill) return;
var opts = pill.querySelectorAll('.seg-opt');
var idx = -1;
@@ -354,23 +334,25 @@ function mobAtkPillSelect(val, btn) {
}
});
pill.className = pill.className.replace(/seg-idx-\d/g, 'seg-idx-' + idx);
+ if (val === 'task') {
+ if (!mobData.combat) mobData.combat = {};
+ mobData.combat.kind = 'task';
+ if (!mobData.task) mobData.task = {verb:'work', progress_noun:'construction', complete_message:'The work is finished.'};
+ addedSections.task = true;
+ expandedCards.task = true;
+ } else {
+ if (mobData.combat) mobData.combat.kind = 'combat';
+ delete mobData.task;
+ delete addedSections.task;
+ }
+ renderCurrent();
}
-// collectMobAttackType reads the Atk Type widget into an array: the selected
-// melee type first, then any optional ranged/science types.
-function collectMobAttackType() {
- var melee = 'crush';
- var pill = document.getElementById('mob_atktype_pill');
- if (pill) {
- var active = pill.querySelector('.seg-opt.seg-active');
- if (active) melee = active.getAttribute('data-v');
- }
- var out = [melee];
- var rng = document.getElementById('mob_atktype_ranged');
- var sci = document.getElementById('mob_atktype_science');
- if (rng && rng.checked) out.push('ranged');
- if (sci && sci.checked) out.push('science');
- return out;
+function collectMobKind() {
+ var pill = document.getElementById('mob_kind_pill');
+ if (!pill) return 'combat';
+ var active = pill.querySelector('.seg-opt.seg-active');
+ return active ? active.getAttribute('data-v') : 'combat';
}
function renderMobCombatCard(data) {
@@ -379,28 +361,43 @@ function renderMobCombatCard(data) {
var byKey = {};
sec.fields.forEach(function(f) { byKey[f[0]] = f; });
var h = '<div class="obj-card-grid" style="margin-top:6px">';
- h += renderMobField(sec, byKey['kind'], data, -1);
- h += renderMobField(sec, byKey['aggressive'], data, -1);
+ h += renderMobKindToggle(data);
h += renderMobField(sec, byKey['respawn_ticks'], data, -1);
+ h += '<div class="mob-task-hide" style="display:contents">';
+ h += renderMobField(sec, byKey['aggressive'], data, -1);
+ h += '</div>';
h += '</div>';
- h += renderMobAttackTypeWidget(data);
+ h += '<div class="mob-task-hide" id="mob_atktype_wrap">';
+ h += ced_renderAtkTypeWidget(data, 'mob_atktype', 'ced_atkPillSelect');
+ h += '</div>';
h += '<div class="obj-inline-group">';
h += '<span class="obj-inline-label">Stats</span>';
h += '<div class="obj-card-grid" style="margin-top:4px">';
- var statKeys = ['stats.hp','stats.attack','stats.strength','stats.defense','stats.ranged','stats.science'];
- statKeys.forEach(function(k) { h += renderMobField(sec, byKey[k], data, -1); });
+ h += renderMobField(sec, byKey['stats.hp'], data, -1);
+ h += renderMobField(sec, byKey['stats.defense'], data, -1);
h += '</div>';
+ h += '<div class="mob-task-hide" style="display:contents">';
+ h += '<div class="obj-card-grid" style="margin-top:4px">';
+ var atkStatKeys = ['stats.attack','stats.strength','stats.ranged','stats.science'];
+ atkStatKeys.forEach(function(k) { h += renderMobField(sec, byKey[k], data, -1); });
+ h += '</div>';
+ h += '</div>';
+ h += '<div class="mob-task-hide" style="display:contents">';
h += '<div class="obj-card-grid" style="margin-top:4px">';
h += renderMobField(sec, byKey['stats.speed'], data, -1);
h += '</div>';
+ h += '</div>';
+ h += '<div class="mob-task-hide" style="display:contents">';
h += '<div class="obj-card-grid" style="margin-top:4px">';
var statKeys3 = ['stats.max_melee_hit','stats.max_ranged_hit','stats.max_science_hit'];
statKeys3.forEach(function(k) { h += renderMobField(sec, byKey[k], data, -1); });
h += '</div>';
h += '</div>';
+ h += '</div>';
+ h += '<div class="mob-task-hide" id="mob_bonuses_wrap">';
h += '<div class="obj-inline-group">';
h += '<span class="obj-inline-label">Bonuses</span>';
h += '<div class="obj-card-grid" style="margin-top:4px">';
@@ -409,6 +406,7 @@ function renderMobCombatCard(data) {
bonusKeys.forEach(function(k) { h += renderMobField(sec, byKey[k], data, -1); });
h += '</div>';
h += '</div>';
+ h += '</div>';
h += '<div class="obj-inline-group">';
h += '<span class="obj-inline-label">Defenses</span>';
@@ -438,6 +436,20 @@ function renderMobCombatCard(data) {
return h;
}
+function renderMobTaskCard(data) {
+ var sec = MOB_SECTIONS.find(function(s) { return s.id === 'task'; });
+ if (!sec) return '';
+ var byKey = {};
+ sec.fields.forEach(function(f) { byKey[f[0]] = f; });
+ var h = '<p style="color:#888;font-size:11px;margin:0 0 8px 0;line-height:1.5">Task mobs can be attacked but do not fight back. The Verb and Progress Noun appear in combat messages. Complete Message prints when the task is finished.</p>';
+ h += '<div class="obj-card-grid" style="margin-top:6px">';
+ h += renderMobField(sec, byKey['verb'], data, -1);
+ h += renderMobField(sec, byKey['progress_noun'], data, -1);
+ h += '</div>';
+ h += renderMobField(sec, byKey['complete_message'], data, -1, null, 'width:100%');
+ return h;
+}
+
// setupMobProtectedToggle hides the entire Combat card when Protected is checked.
function setupMobProtectedToggle() {
var sec = MOB_SECTIONS.find(function(s) { return s.id === 'core'; });
@@ -457,6 +469,12 @@ function setupMobProtectedToggle() {
apply();
}
+function applyTaskVisibility() {
+ var isTask = mobData && mobData.combat && mobData.combat.kind === 'task';
+ var hidden = document.querySelectorAll('.mob-task-hide');
+ hidden.forEach(function(el) { el.style.display = isTask ? 'none' : ''; });
+}
+
function renderMobField(sec, f, data, idx, subPath, optStyle) {
return ced_renderField(sec, f, data, idx, subPath, optStyle, cardPath, fieldID, getVal);
}
@@ -728,6 +746,9 @@ function removeMobSection(id) {
if (sec.inline) sec.inline.forEach(function(il) { if (il.key) delete mobData[il.key]; });
if (sec.subtables) sec.subtables.forEach(function(st) { delete mobData[st.key]; });
}
+ if (id === 'task' && mobData.combat && mobData.combat.kind === 'task') {
+ mobData.combat.kind = 'combat';
+ }
delete addedSections[id];
renderMobEditor(mobID, mobData);
}
@@ -805,22 +826,6 @@ function addMobLootRow(cardID, subKey, kind) {
renderCurrent();
}
-// ---- Nested object helpers ----
-
-function unflattenObj(obj) {
- var result = {};
- Object.keys(obj).forEach(function(key) {
- var parts = key.split('.');
- var current = result;
- for (var i = 0; i < parts.length - 1; i++) {
- if (!current[parts[i]]) current[parts[i]] = {};
- current = current[parts[i]];
- }
- current[parts[parts.length - 1]] = obj[key];
- });
- return result;
-}
-
// ---- Save ----
function saveMob() {
@@ -874,9 +879,20 @@ function saveMob() {
hasValues = ced_saveSectionFields(sec, sectionObj, cardPath, fieldID) || hasValues;
}
- if (sec.id === 'combat' && document.getElementById('mob_atktype_pill')) {
- sectionObj['attack_types'] = collectMobAttackType();
+ if (sec.id === 'combat') {
+ sectionObj['kind'] = collectMobKind();
hasValues = true;
+ if (sectionObj['kind'] !== 'task' && document.getElementById('mob_atktype')) {
+ sectionObj['attack_types'] = ced_collectAtkType('mob_atktype');
+ }
+ if (sectionObj['kind'] === 'task') {
+ var taskStrip = ['aggressive',
+ 'stats.attack','stats.strength','stats.ranged','stats.science',
+ 'stats.max_melee_hit','stats.max_ranged_hit','stats.max_science_hit',
+ 'stats.bonuses.attack_bonus','stats.bonuses.strength_bonus','stats.bonuses.science_bonus',
+ 'stats.bonuses.science_percent_bonus','stats.bonuses.ranged_bonus','stats.bonuses.ranged_strength_bonus'];
+ taskStrip.forEach(function(k) { delete sectionObj[k]; });
+ }
}
if (sec.subtables) {
@@ -901,7 +917,7 @@ function saveMob() {
if (sec.always) {
Object.keys(sectionObj).forEach(function(k) { out[k] = sectionObj[k]; });
} else if (hasValues && sec.path) {
- out[sec.path] = unflattenObj(sectionObj);
+ out[sec.path] = ced_unflattenObj(sectionObj);
} else if (hasValues && !sec.path) {
Object.keys(sectionObj).forEach(function(k) { out[k] = sectionObj[k]; });
}
diff --git a/internal/game/sys_hazard.go b/internal/game/sys_hazard.go
index 6e9c40d..40455fc 100644
--- a/internal/game/sys_hazard.go
+++ b/internal/game/sys_hazard.go
@@ -36,7 +36,7 @@ func (g *Game) HazardTick() {
continue
}
hz, err := g.World.LoadHazard(room.Hazard)
- if err != nil || hz == nil {
+ if err != nil || hz == nil || hz.Combat == nil {
p.HazardTimer = 0
continue
}
@@ -51,10 +51,7 @@ func (g *Game) HazardTick() {
continue
}
- speed := hz.Speed
- if speed <= 0 {
- speed = 5
- }
+ speed := hz.SpeedTicks()
p.HazardTimer++
if float64(p.HazardTimer) >= speed {
p.HazardTimer = 0
@@ -73,15 +70,25 @@ func (g *Game) hasEquipped(p *player.Player, itemID string) bool {
}
func (g *Game) rollHazard(sess *net.Session, p *player.Player, hz *world.HazardDef) {
- attackType := hz.AttackType
- if attackType == "" {
- attackType = combat.DefaultAttackType
- }
-
- attRoll := combat.AttackRoll(combat.NPCEffective(hz.Attack), hz.AttackBonus)
+ attackType := hz.AttackType()
_, _, defStyleBonus := combat.AttackStyleBonus(string(p.AttackStyle))
totals := g.playerEquipBonuses(p)
+
+ var attRoll int
+ var maxHit int
+ switch attackType {
+ case combat.AttackRanged:
+ attRoll = combat.AttackRoll(combat.NPCEffective(hz.Combat.Stats.Ranged), hz.Combat.Stats.Bonuses.RangedBonus)
+ maxHit = hz.Combat.Stats.MaxRangedHit
+ case combat.AttackScience:
+ attRoll = combat.AttackRoll(combat.ScienceEffective(hz.Combat.Stats.Science), hz.Combat.Stats.Bonuses.ScienceBonus)
+ maxHit = hz.EffectiveMaxScienceHit()
+ default:
+ attRoll = combat.AttackRoll(combat.NPCEffective(hz.Combat.Stats.Attack), hz.Combat.Stats.Bonuses.AttackBonus)
+ maxHit = hz.Combat.Stats.MaxMeleeHit
+ }
+
equipDef := combat.SelectBonus(attackType,
totals.StabDefense, totals.SlashDefense, totals.CrushDefense,
totals.ScienceDefense, totals.RangedDefense)
@@ -90,7 +97,7 @@ func (g *Game) rollHazard(sess *net.Session, p *player.Player, hz *world.HazardD
defStyleBonus), equipDef)
if combat.HitCheck(attRoll, defRoll) {
- g.applyHazardHit(sess, p, hz)
+ g.applyHazardHit(sess, p, hz, attackType, maxHit)
} else {
miss := hz.MissMessage
if miss == "" {
@@ -101,13 +108,12 @@ func (g *Game) rollHazard(sess *net.Session, p *player.Player, hz *world.HazardD
}
}
-func (g *Game) applyHazardHit(sess *net.Session, p *player.Player, hz *world.HazardDef) {
- maxHit := hz.MaxHit
+func (g *Game) applyHazardHit(sess *net.Session, p *player.Player, hz *world.HazardDef, attackType string, maxHit int) {
if maxHit < 1 {
maxHit = 1
}
dmg := combat.RollDamage(maxHit)
- dmg = g.damageAfterTechProtection(p, hz.AttackType, dmg)
+ dmg = g.damageAfterTechProtection(p, attackType, dmg)
if dmg < 0 {
dmg = 0
}
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index bbcb820..7ac6fa1 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -508,12 +508,16 @@ func validateHazards(s Source) []Issue {
})
continue
}
- if def.AttackType != "" && !combat.IsValidAttackType(def.AttackType) {
- issues = append(issues, Issue{
- Level: "ERROR",
- Type: "reference",
- Message: fmt.Sprintf("Hazard %q: invalid attack_type %q (must be stab/slash/crush/ranged/science)", id, def.AttackType),
- })
+ if def.Combat != nil {
+ for _, t := range def.Combat.AttackTypes {
+ if t != "" && !combat.IsValidAttackType(t) {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Hazard %q: invalid attack_type %q (must be stab/slash/crush/ranged/science)", id, t),
+ })
+ }
+ }
}
if def.RequiredItem != "" && !itemIDs[def.RequiredItem] {
issues = append(issues, Issue{
diff --git a/internal/world/hazard.go b/internal/world/hazard.go
index ac2a6a6..c08c2bb 100644
--- a/internal/world/hazard.go
+++ b/internal/world/hazard.go
@@ -7,31 +7,85 @@ import (
"gopkg.in/yaml.v3"
"thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/combat"
)
-// HazardDef describes an environmental threat attached to a room (a sandstorm,
-// solar radiation, falling rocks, etc.). It is a shared, ID-referenced
-// definition loaded from data/hazards/<id>.yaml, so one hazard can be reused
-// across a whole area and resolved by name for a future `examine` command.
-//
-// A hazard rolls an attack against everyone in the room every Speed ticks using
-// the SAME combat formulas as a mob: accuracy comes from Attack/AttackBonus, max
-// hit from MaxHit, and the player defends with their Defense level + equipment
-// bonus selected by AttackType (so existing armour and the Defense skill apply).
-// Tech protection (Kinetic Barrier / Projectile Screen / Neural Firewall) keys
-// off AttackType automatically.
+type HazardCombatBonuses struct {
+ AttackBonus int `yaml:"attack_bonus"`
+ StrengthBonus int `yaml:"strength_bonus"`
+ RangedBonus int `yaml:"ranged_bonus"`
+ RangedStrengthBonus int `yaml:"ranged_strength_bonus"`
+ ScienceBonus int `yaml:"science_bonus"`
+ SciencePercentBonus int `yaml:"science_percent_bonus"`
+}
+
+type HazardCombatStats struct {
+ Attack int `yaml:"attack"`
+ Strength int `yaml:"strength"`
+ Ranged int `yaml:"ranged"`
+ Science int `yaml:"science"`
+ Speed float64 `yaml:"speed"`
+ MaxMeleeHit int `yaml:"max_melee_hit"`
+ MaxRangedHit int `yaml:"max_ranged_hit"`
+ MaxScienceHit int `yaml:"max_science_hit"`
+ Bonuses HazardCombatBonuses `yaml:"bonuses"`
+}
+
+type HazardCombat struct {
+ AttackTypes []string `yaml:"attack_types"`
+ Stats HazardCombatStats `yaml:"stats"`
+}
+
+// HazardDef describes an environmental threat attached to a room. Its combat
+// block mirrors a mob's combat block (attack_types, stats with per-type max hits
+// and bonuses) but has NO defense stats — the hazard is never attacked, it only
+// attacks the player. Speed is read from combat.stats.speed (default 5).
type HazardDef struct {
- Name string `yaml:"name"`
- Description string `yaml:"description"`
- AttackType string `yaml:"attack_type"` // stab/slash/crush/ranged/science
- Attack int `yaml:"attack"`
- AttackBonus int `yaml:"attack_bonus"`
- MaxHit int `yaml:"max_hit"`
- Speed float64 `yaml:"speed"` // ticks between hazard rolls
- WarnMessage string `yaml:"warn_message"`
- HitMessage string `yaml:"hit_message"` // accepts a single %d for damage
- MissMessage string `yaml:"miss_message"`
- RequiredItem string `yaml:"required_item"` // equipped item that negates the hazard
+ Name string `yaml:"name"`
+ Description string `yaml:"description"`
+ Combat *HazardCombat `yaml:"combat,omitempty"`
+ WarnMessage string `yaml:"warn_message"`
+ HitMessage string `yaml:"hit_message"` // accepts a single %d for damage
+ MissMessage string `yaml:"miss_message"`
+ RequiredItem string `yaml:"required_item"` // equipped item that negates the hazard
+}
+
+// AttackType returns the hazard's active attack type: the first melee type from
+// attack_types, falling back to the first entry, defaulting to crush.
+func (d *HazardDef) AttackType() string {
+ if d.Combat == nil || len(d.Combat.AttackTypes) == 0 {
+ return combat.DefaultAttackType
+ }
+ for _, t := range d.Combat.AttackTypes {
+ if combat.IsMeleeType(t) {
+ return t
+ }
+ }
+ return d.Combat.AttackTypes[0]
+}
+
+// SpeedTicks returns the hazard's tick interval (default 5).
+func (d *HazardDef) SpeedTicks() float64 {
+ if d.Combat == nil || d.Combat.Stats.Speed <= 0 {
+ return 5
+ }
+ return d.Combat.Stats.Speed
+}
+
+// EffectiveMaxScienceHit applies the hazard's science percent bonus to its max
+// science hit.
+func (d *HazardDef) EffectiveMaxScienceHit() int {
+ if d.Combat == nil {
+ return 0
+ }
+ base := d.Combat.Stats.MaxScienceHit
+ if d.Combat.Stats.Bonuses.SciencePercentBonus > 0 {
+ base = int(float64(base) * (1.0 + float64(d.Combat.Stats.Bonuses.SciencePercentBonus)/100.0))
+ }
+ if base < 1 {
+ base = 1
+ }
+ return base
}
// LoadHazard returns the shared hazard definition for id, caching results.