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