From 93a412aed2b11406399504c81d6d19ed1341908a Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Mon, 13 Jul 2026 15:07:28 -0400 Subject: feat(admin): hide irrelevant combat stats for task mobs, show relevant combat stats for hazards --- internal/admin/static/admin.css | 3 + internal/admin/static/cardeditor.js | 74 ++++++++++++++++ internal/admin/static/hazardeditor.js | 98 ++++++++++++++++---- internal/admin/static/mobeditor.js | 162 +++++++++++++++++++--------------- internal/game/sys_hazard.go | 36 ++++---- internal/validate/checks.go | 16 ++-- internal/world/hazard.go | 98 +++++++++++++++----- 7 files changed, 353 insertions(+), 134 deletions(-) (limited to 'internal') 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 = '
'; + h += 'Atk Type'; + h += '
'; + meleeOpts.forEach(function(t, i) { + h += ''; + }); + h += '
'; + h += '
'; + h += ''; + h += ''; + h += '
'; + h += '
'; + 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 += ''; if (!collapsed) { h += '
'; - h += '
'; - sec.fields.forEach(function(f) { - h += renderField(sec, f, data, -1); - }); - h += '
'; + if (sec.id === 'combat') { + h += renderHazardCombatCard(data); + } else { + h += '
'; + sec.fields.forEach(function(f) { + h += renderField(sec, f, data, -1); + }); + h += '
'; + } h += '
'; } h += ''; @@ -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 += '
'; + h += 'Stats'; + h += '
'; + var statKeys = ['stats.attack','stats.strength','stats.ranged','stats.science']; + statKeys.forEach(function(k) { h += renderField(sec, byKey[k], data, -1); }); + h += '
'; + h += '
'; + h += renderField(sec, byKey['stats.speed'], data, -1); + h += '
'; + h += '
'; + 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 += '
'; + h += '
'; + + h += '
'; + h += 'Bonuses'; + h += '
'; + 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 += '
'; + h += '
'; + + 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 = '
'; - h += 'Atk Type'; - h += '
'; - meleeOpts.forEach(function(t, i) { - h += ''; - }); - h += '
'; - h += '
'; - h += ''; - h += ''; - h += '
'; - h += '
'; + h += 'Kind'; + h += '
'; + h += ''; + h += ''; + h += '
'; 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 = '
'; - 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 += '
'; + h += renderMobField(sec, byKey['aggressive'], data, -1); + h += '
'; h += '
'; - h += renderMobAttackTypeWidget(data); + h += '
'; + h += ced_renderAtkTypeWidget(data, 'mob_atktype', 'ced_atkPillSelect'); + h += '
'; h += '
'; h += 'Stats'; h += '
'; - 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 += '
'; + h += '
'; + h += '
'; + var atkStatKeys = ['stats.attack','stats.strength','stats.ranged','stats.science']; + atkStatKeys.forEach(function(k) { h += renderMobField(sec, byKey[k], data, -1); }); + h += '
'; + h += '
'; + h += '
'; h += '
'; h += renderMobField(sec, byKey['stats.speed'], data, -1); h += '
'; + h += '
'; + h += '
'; h += '
'; 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 += '
'; h += '
'; + h += '
'; + h += '
'; h += '
'; h += 'Bonuses'; h += '
'; @@ -409,6 +406,7 @@ function renderMobCombatCard(data) { bonusKeys.forEach(function(k) { h += renderMobField(sec, byKey[k], data, -1); }); h += '
'; h += '
'; + h += '
'; h += '
'; h += 'Defenses'; @@ -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 = '

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.

'; + h += '
'; + h += renderMobField(sec, byKey['verb'], data, -1); + h += renderMobField(sec, byKey['progress_noun'], data, -1); + h += '
'; + 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/.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. -- cgit v1.2.3