From c14c2e403c75a913e1a6d962fb6fe64f013adae5 Mon Sep 17 00:00:00 2001
From: historia <[not public]>
Date: Wed, 1 Jul 2026 22:30:31 -0400
Subject: fix: combat formulas and mob attack types (mobs switch attack styles
if safespotting)
---
internal/admin/api_courses.go | 4 +-
internal/admin/api_items.go | 4 +-
internal/admin/api_map.go | 10 +-
internal/admin/api_mobs.go | 4 +-
internal/admin/api_modules.go | 4 +-
internal/admin/api_objects.go | 4 +-
internal/admin/api_rooms.go | 16 +-
internal/admin/api_techs.go | 4 +-
internal/admin/static/mobeditor.js | 81 +++++++-
internal/admin/undo.go | 12 +-
internal/behavior/types.go | 94 ++++-----
internal/combat/formulas.go | 44 +++--
internal/combat/types.go | 36 ++++
internal/combat/types_test.go | 30 +++
internal/config/config.go | 24 +--
internal/game/act.go | 14 +-
internal/game/act_combine.go | 12 +-
internal/game/act_farm.go | 22 +--
internal/game/act_gather.go | 28 +--
internal/game/act_state.go | 10 +-
internal/game/act_steal.go | 12 +-
internal/game/act_use.go | 4 +-
internal/game/cmd_attack.go | 2 +-
internal/game/cmd_dig.go | 1 -
internal/game/cmd_room_insert.go | 2 +-
internal/game/cmd_smelt.go | 2 +-
internal/game/cmd_stats.go | 29 +--
internal/game/cmd_trigger_combat.go | 34 +++-
internal/game/cmd_trigger_enchant.go | 5 +-
internal/game/cmd_trigger_utility.go | 2 +-
internal/game/cmd_use.go | 2 +-
internal/game/combat_attack.go | 79 ++++----
internal/game/combat_mob.go | 317 +++++++++++++++++++------------
internal/game/combat_mob_test.go | 61 ++++++
internal/game/core_equip.go | 2 +-
internal/game/core_login_char.go | 23 ++-
internal/game/core_utils.go | 21 ++
internal/game/look_target.go | 2 +-
internal/game/map_test.go | 8 +-
internal/game/render_map.go | 19 +-
internal/game/sys_hazard.go | 8 +-
internal/game/sys_safespot.go | 20 +-
internal/game/sys_science.go | 25 +--
internal/game/sys_technology.go | 23 +--
internal/game/sys_triggers.go | 8 +-
internal/game/tick.go | 6 +-
internal/item/item.go | 58 +++---
internal/item/store.go | 21 +-
internal/object/store.go | 19 +-
internal/validate/checks.go | 59 +++++-
internal/validate/grid_test.go | 14 +-
internal/validate/mob_attacktype_test.go | 31 +++
internal/world/mob.go | 268 +++++++++++++++-----------
internal/world/room.go | 36 ++--
internal/world/trigger.go | 8 +-
internal/world/world.go | 8 +-
56 files changed, 1098 insertions(+), 598 deletions(-)
create mode 100644 internal/combat/types.go
create mode 100644 internal/combat/types_test.go
create mode 100644 internal/game/combat_mob_test.go
create mode 100644 internal/validate/mob_attacktype_test.go
(limited to 'internal')
diff --git a/internal/admin/api_courses.go b/internal/admin/api_courses.go
index facb8f2..64218cb 100644
--- a/internal/admin/api_courses.go
+++ b/internal/admin/api_courses.go
@@ -25,8 +25,8 @@ func (s *AdminServer) handleCourses(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"error": "invalid json"})
return
}
- id, _ := m["id"].(string)
- if strings.TrimSpace(id) == "" {
+ id, ok := m["id"].(string)
+ if !ok || strings.TrimSpace(id) == "" {
writeJSON(w, map[string]any{"error": "missing id"})
return
}
diff --git a/internal/admin/api_items.go b/internal/admin/api_items.go
index 721a6f8..617908c 100644
--- a/internal/admin/api_items.go
+++ b/internal/admin/api_items.go
@@ -137,8 +137,8 @@ func (s *AdminServer) createItem(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest)
return
}
- id, _ := m["id"].(string)
- if strings.TrimSpace(id) == "" {
+ id, ok := m["id"].(string)
+ if !ok || strings.TrimSpace(id) == "" {
http.Error(w, `{"error":"missing item id"}`, http.StatusBadRequest)
return
}
diff --git a/internal/admin/api_map.go b/internal/admin/api_map.go
index 561c1e8..e546a39 100644
--- a/internal/admin/api_map.go
+++ b/internal/admin/api_map.go
@@ -41,11 +41,11 @@ func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) {
}, nil, nil)
type RoomEntry struct {
- ID int `json:"id"`
- X int `json:"x"`
- Y int `json:"y"`
- Name string `json:"name"`
- Color string `json:"color"`
+ ID int `json:"id"`
+ X int `json:"x"`
+ Y int `json:"y"`
+ Name string `json:"name"`
+ Color string `json:"color"`
}
type LinkEntry struct {
diff --git a/internal/admin/api_mobs.go b/internal/admin/api_mobs.go
index a575076..0646753 100644
--- a/internal/admin/api_mobs.go
+++ b/internal/admin/api_mobs.go
@@ -138,8 +138,8 @@ func (s *AdminServer) createMob(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest)
return
}
- id, _ := m["id"].(string)
- if strings.TrimSpace(id) == "" {
+ id, ok := m["id"].(string)
+ if !ok || strings.TrimSpace(id) == "" {
http.Error(w, `{"error":"missing mob id"}`, http.StatusBadRequest)
return
}
diff --git a/internal/admin/api_modules.go b/internal/admin/api_modules.go
index 9fedccd..e468c79 100644
--- a/internal/admin/api_modules.go
+++ b/internal/admin/api_modules.go
@@ -25,8 +25,8 @@ func (s *AdminServer) handleModules(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"error": "invalid json"})
return
}
- id, _ := m["id"].(string)
- if strings.TrimSpace(id) == "" {
+ id, ok := m["id"].(string)
+ if !ok || strings.TrimSpace(id) == "" {
writeJSON(w, map[string]any{"error": "missing id"})
return
}
diff --git a/internal/admin/api_objects.go b/internal/admin/api_objects.go
index 7bcda1c..340c87c 100644
--- a/internal/admin/api_objects.go
+++ b/internal/admin/api_objects.go
@@ -137,8 +137,8 @@ func (s *AdminServer) createObject(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest)
return
}
- id, _ := m["id"].(string)
- if strings.TrimSpace(id) == "" {
+ id, ok := m["id"].(string)
+ if !ok || strings.TrimSpace(id) == "" {
http.Error(w, `{"error":"missing object id"}`, http.StatusBadRequest)
return
}
diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go
index 76bb11c..cf23761 100644
--- a/internal/admin/api_rooms.go
+++ b/internal/admin/api_rooms.go
@@ -374,14 +374,14 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) {
cA, okA := grid.Coord[body.From]
cB, okB := grid.Coord[body.To]
- if !okA || !okB {
- writeJSON(w, map[string]any{"error": "one or both rooms are not reachable from the seed room"})
- return
- }
- if cA[2] != cB[2] {
- writeJSON(w, map[string]any{"error": "rooms must be on same z-level"})
- return
- }
+ if !okA || !okB {
+ writeJSON(w, map[string]any{"error": "one or both rooms are not reachable from the seed room"})
+ return
+ }
+ if cA[2] != cB[2] {
+ writeJSON(w, map[string]any{"error": "rooms must be on same z-level"})
+ return
+ }
dx, dy := cB[0]-cA[0], cB[1]-cA[1]
for d, delta := range world.DirectionDeltas3D {
if delta[0] == dx && delta[1] == dy && delta[2] == 0 {
diff --git a/internal/admin/api_techs.go b/internal/admin/api_techs.go
index bdb8295..0d3e201 100644
--- a/internal/admin/api_techs.go
+++ b/internal/admin/api_techs.go
@@ -25,8 +25,8 @@ func (s *AdminServer) handleTechs(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"error": "invalid json"})
return
}
- id, _ := m["id"].(string)
- if strings.TrimSpace(id) == "" {
+ id, ok := m["id"].(string)
+ if !ok || strings.TrimSpace(id) == "" {
writeJSON(w, map[string]any{"error": "missing id"})
return
}
diff --git a/internal/admin/static/mobeditor.js b/internal/admin/static/mobeditor.js
index 3d6f4d4..4895c6f 100644
--- a/internal/admin/static/mobeditor.js
+++ b/internal/admin/static/mobeditor.js
@@ -22,16 +22,18 @@ var MOB_SECTIONS = [
detect: function(d) { return d.combat; },
fields: [
['kind', 'Kind', 'select', 'combat|task'],
+ ['aggressive', 'Aggressive', 'checkbox'],
+ ['respawn_ticks', 'Respawn Ticks', 'number', '', 'narrow'],
['stats.hp', 'HP', 'number', '', 'narrow'],
['stats.attack', 'Attack', 'number', '', 'narrow'],
['stats.strength', 'Strength', 'number', '', 'narrow'],
['stats.defense', 'Defense', 'number', '', 'narrow'],
['stats.ranged', 'Ranged', 'number', '', 'narrow'],
['stats.science', 'Science', 'number', '', 'narrow'],
- ['stats.attack_type', 'Atk Type', 'select', 'stab|slash|crush|ranged|science'],
['stats.speed', 'Speed', 'number', '', 'narrow'],
- ['stats.aggressive', 'Aggressive', 'checkbox'],
- ['stats.respawn_ticks', 'Respawn Ticks', 'number', '', 'narrow'],
+ ['stats.max_melee_hit', 'Max Melee Hit', 'number', '', 'narrow'],
+ ['stats.max_ranged_hit', 'Max Ranged Hit', 'number', '', 'narrow'],
+ ['stats.max_science_hit', 'Max Science Hit', 'number', '', 'narrow'],
['stats.bonuses.attack_bonus', 'Atk Bonus', 'number', '', 'narrow'],
['stats.bonuses.strength_bonus', 'Str Bonus', 'number', '', 'narrow'],
['stats.bonuses.science_bonus', 'Sci Bonus', 'number', '', 'narrow'],
@@ -44,6 +46,7 @@ var MOB_SECTIONS = [
['stats.defenses.science_defense', 'Sci Def', 'number', '', 'narrow'],
['stats.defenses.ranged_defense', 'Rng Def', 'number', '', 'narrow'],
['stats.defenses.weakness', 'Weakness', 'text'],
+ ['stats.defenses.weakness_percent', 'Weakness %', 'number', '', 'narrow'],
['assassin_level', 'Assassin Lvl', 'number', '', 'narrow'],
['finishing_blow', 'Finishing Blow', 'search', '', '', null, 'items'],
['damage_without', 'Damage Without', 'search', '', '', null, 'items'],
@@ -271,6 +274,52 @@ 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 (radio: stab/slash/crush) plus optional ranged/science (checkboxes).
+function renderMobAttackTypeWidget(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 h = '
';
+ return h;
+}
+
+// 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 r = document.querySelector('input[name="mob_atktype_melee"]:checked');
+ if (r) melee = r.value;
+ 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 renderMobCombatCard(data) {
var sec = MOB_SECTIONS.find(function(s) { return s.id === 'combat'; });
if (!sec) return '';
@@ -278,8 +327,12 @@ function renderMobCombatCard(data) {
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 += renderMobField(sec, byKey['respawn_ticks'], data, -1);
h += '
';
+ h += renderMobAttackTypeWidget(data);
+
h += '';
h += '
Stats';
h += '
';
@@ -287,8 +340,11 @@ function renderMobCombatCard(data) {
statKeys.forEach(function(k) { h += renderMobField(sec, byKey[k], data, -1); });
h += '
';
h += '
';
- var statKeys2 = ['stats.attack_type','stats.speed','stats.aggressive','stats.respawn_ticks'];
- statKeys2.forEach(function(k) { h += renderMobField(sec, byKey[k], data, -1); });
+ h += renderMobField(sec, byKey['stats.speed'], data, -1);
+ 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 += '
';
@@ -310,6 +366,7 @@ function renderMobCombatCard(data) {
h += '';
h += '';
h += renderMobField(sec, byKey['stats.defenses.weakness'], data, -1);
+ h += renderMobField(sec, byKey['stats.defenses.weakness_percent'], data, -1);
h += '
';
h += '';
@@ -523,12 +580,15 @@ function mobAddSection() {
if (id === 'combat') {
mobData[sec.path] = {
kind: 'combat',
+ aggressive: false,
+ attack_types: ['crush'],
+ respawn_ticks: 30,
stats: {
hp: 1, attack: 1, strength: 1, defense: 1,
- attack_type: 'crush', speed: 5,
- aggressive: false, respawn_ticks: 30,
+ speed: 5,
+ max_melee_hit: 0, max_ranged_hit: 0, max_science_hit: 0,
bonuses: {attack_bonus:0, strength_bonus:0, science_bonus:0, science_percent_bonus:0, ranged_bonus:0, ranged_strength_bonus:0},
- defenses: {stab_defense:0, slash_defense:0, crush_defense:0, science_defense:0, ranged_defense:0}
+ defenses: {stab_defense:0, slash_defense:0, crush_defense:0, science_defense:0, ranged_defense:0, weakness_percent:0}
},
size: 'small'
};
@@ -610,6 +670,11 @@ function saveMob() {
hasValues = ced_saveSectionFields(sec, sectionObj, mobCardPath, mobFieldID) || hasValues;
}
+ if (sec.id === 'combat' && document.querySelector('input[name="mob_atktype_melee"]')) {
+ sectionObj['attack_types'] = collectMobAttackType();
+ hasValues = true;
+ }
+
if (sec.subtables) {
sec.subtables.forEach(function(st) {
if (ced_saveSubtable(sec, st, sectionObj, mobCardPath, mobFieldIDSub)) hasValues = true;
diff --git a/internal/admin/undo.go b/internal/admin/undo.go
index 8f75d30..fb91a15 100644
--- a/internal/admin/undo.go
+++ b/internal/admin/undo.go
@@ -42,12 +42,12 @@ func (c ChangeDesc) ShortDesc() string {
}
type UndoInfo struct {
- CanUndo bool `json:"can_undo"`
- CanRedo bool `json:"can_redo"`
- UndoDesc string `json:"undo_desc"`
- RedoDesc string `json:"redo_desc"`
- StackSize int `json:"stack_size"`
- RedoSize int `json:"redo_size"`
+ CanUndo bool `json:"can_undo"`
+ CanRedo bool `json:"can_redo"`
+ UndoDesc string `json:"undo_desc"`
+ RedoDesc string `json:"redo_desc"`
+ StackSize int `json:"stack_size"`
+ RedoSize int `json:"redo_size"`
}
type UndoStack struct {
diff --git a/internal/behavior/types.go b/internal/behavior/types.go
index 29e2631..5fdebdb 100644
--- a/internal/behavior/types.go
+++ b/internal/behavior/types.go
@@ -28,30 +28,30 @@ func WordPrefixMatch(input, name string) bool {
type ActionType string
const (
- TypeGather ActionType = "gather"
- TypeSteal ActionType = "steal"
- TypeTalk ActionType = "talk"
- TypeUse ActionType = "use"
- TypeBurn ActionType = "burn"
- TypeStoke ActionType = "stoke"
- TypeSearch ActionType = "search"
- TypeIdentify ActionType = "identify"
- TypePlant ActionType = "plant"
- TypeHarvest ActionType = "harvest"
- TypeRake ActionType = "rake"
- TypeWater ActionType = "water"
- TypeCure ActionType = "cure"
- TypeObstacle ActionType = "obstacle"
- TypeFletch ActionType = "fletch"
- TypeClean ActionType = "cleaning"
- TypeCook ActionType = "cook"
- TypeSmelt ActionType = "smelting"
- TypeSmith ActionType = "smith"
- TypeCraft ActionType = "craft"
- TypeCombine ActionType = "combine"
- TypeMix ActionType = "mix"
- TypeConstruct ActionType = "construct"
- TypeTriggerModule ActionType = "trigger_module"
+ TypeGather ActionType = "gather"
+ TypeSteal ActionType = "steal"
+ TypeTalk ActionType = "talk"
+ TypeUse ActionType = "use"
+ TypeBurn ActionType = "burn"
+ TypeStoke ActionType = "stoke"
+ TypeSearch ActionType = "search"
+ TypeIdentify ActionType = "identify"
+ TypePlant ActionType = "plant"
+ TypeHarvest ActionType = "harvest"
+ TypeRake ActionType = "rake"
+ TypeWater ActionType = "water"
+ TypeCure ActionType = "cure"
+ TypeObstacle ActionType = "obstacle"
+ TypeFletch ActionType = "fletch"
+ TypeClean ActionType = "cleaning"
+ TypeCook ActionType = "cook"
+ TypeSmelt ActionType = "smelting"
+ TypeSmith ActionType = "smith"
+ TypeCraft ActionType = "craft"
+ TypeCombine ActionType = "combine"
+ TypeMix ActionType = "mix"
+ TypeConstruct ActionType = "construct"
+ TypeTriggerModule ActionType = "trigger_module"
TypeUseInteraction ActionType = "use_interaction"
)
@@ -72,23 +72,23 @@ func (a *Action) Advance() bool {
}
type GatherData struct {
- ObjDefID string
- InstanceKey string
- InstanceIdx int
- Wait float64
- DepleteTimer bool
- Step int
- Verb string
- ToolName string
+ ObjDefID string
+ InstanceKey string
+ InstanceIdx int
+ Wait float64
+ DepleteTimer bool
+ Step int
+ Verb string
+ ToolName string
}
type ProductionData struct {
- ItemID string
- Phase int
- TicksPerCycle float64
- StartMessage string
- EndMessage string
- Remaining int
+ ItemID string
+ Phase int
+ TicksPerCycle float64
+ StartMessage string
+ EndMessage string
+ Remaining int
}
type StealData struct {
@@ -175,20 +175,20 @@ type SearchData struct {
}
type CombineData struct {
- ItemID string
- Phase int
- StepIndex int
- Remaining int
- StartMessage string
- EndMessage string
+ ItemID string
+ Phase int
+ StepIndex int
+ Remaining int
+ StartMessage string
+ EndMessage string
TicksPerCycle float64
}
type FletchData struct {
- ItemID string
- Phase int
+ ItemID string
+ Phase int
TicksPerCycle float64
- Remaining int
+ Remaining int
}
type CleanData struct {
diff --git a/internal/combat/formulas.go b/internal/combat/formulas.go
index f4e1ba2..f165923 100644
--- a/internal/combat/formulas.go
+++ b/internal/combat/formulas.go
@@ -2,8 +2,24 @@ package combat
import "math/rand"
-func EffectiveRoll(level int, styleBonus int, equipBonus int) int {
- effective := level + styleBonus + 8
+func PlayerEffective(level, styleBonus int) int {
+ return level + styleBonus + 8
+}
+
+func NPCEffective(level int) int {
+ return level + 9
+}
+
+// ScienceEffective is the effective-level formula for science attacks. It is
+// intentionally the same as NPCEffective (level + 9) and is applied to BOTH the
+// attacker and defender of a science-mod exchange (see scienceAttack), giving a
+// symmetric science-vs-science model with no attack styles. It is kept as a
+// distinct function to document that intent independently of NPCEffective.
+func ScienceEffective(level int) int {
+ return level + 9
+}
+
+func AttackRoll(effective, equipBonus int) int {
return effective * (equipBonus + 64)
}
@@ -17,13 +33,11 @@ func HitChance(attackRoll, defenseRoll int) float64 {
}
func HitCheck(attackRoll, defenseRoll int) bool {
- chance := HitChance(attackRoll, defenseRoll)
- return rand.Float64() < chance
+ return rand.Float64() < HitChance(attackRoll, defenseRoll)
}
-func MaxHit(level int, styleBonus int, equipBonus int) int {
- effective := level + styleBonus + 8
- hit := (effective * (equipBonus + 64)) / 512
+func MaxHit(effective, equipStrBonus int) int {
+ hit := (effective*(equipStrBonus+64) + 320) / 640
if hit < 1 {
hit = 1
}
@@ -34,7 +48,11 @@ func RollDamage(maxHit int) int {
if maxHit <= 0 {
return 0
}
- return 1 + rand.Intn(maxHit)
+ d := rand.Intn(maxHit + 1)
+ if d < 1 {
+ d = 1
+ }
+ return d
}
func AttackStyleBonus(style string) (attack, strength, defense int) {
@@ -69,15 +87,15 @@ func RangedStyleBonus(style string) (ranged, defense int) {
func SelectBonus(attackType string, stab, slash, crush, science, ranged int) int {
switch attackType {
- case "stab":
+ case AttackStab:
return stab
- case "slash":
+ case AttackSlash:
return slash
- case "crush":
+ case AttackCrush:
return crush
- case "science":
+ case AttackScience:
return science
- case "ranged":
+ case AttackRanged:
return ranged
default:
return crush
diff --git a/internal/combat/types.go b/internal/combat/types.go
new file mode 100644
index 0000000..bb2a897
--- /dev/null
+++ b/internal/combat/types.go
@@ -0,0 +1,36 @@
+package combat
+
+// Attack type identifiers shared across combat, mobs, weapons, hazards and
+// validation. These are the canonical string values stored in YAML.
+const (
+ AttackStab = "stab"
+ AttackSlash = "slash"
+ AttackCrush = "crush"
+ AttackRanged = "ranged"
+ AttackScience = "science"
+
+ // DefaultAttackType is the fallback when no attack type is specified.
+ DefaultAttackType = AttackCrush
+)
+
+// MeleeAttackTypes lists the melee attack types (exactly one of which a mob
+// must have).
+var MeleeAttackTypes = []string{AttackStab, AttackSlash, AttackCrush}
+
+// AllAttackTypes lists every valid attack type.
+var AllAttackTypes = []string{AttackStab, AttackSlash, AttackCrush, AttackRanged, AttackScience}
+
+// IsMeleeType reports whether t is a melee attack type.
+func IsMeleeType(t string) bool {
+ return t == AttackStab || t == AttackSlash || t == AttackCrush
+}
+
+// IsValidAttackType reports whether t is any recognized attack type.
+func IsValidAttackType(t string) bool {
+ switch t {
+ case AttackStab, AttackSlash, AttackCrush, AttackRanged, AttackScience:
+ return true
+ default:
+ return false
+ }
+}
diff --git a/internal/combat/types_test.go b/internal/combat/types_test.go
new file mode 100644
index 0000000..a999eb6
--- /dev/null
+++ b/internal/combat/types_test.go
@@ -0,0 +1,30 @@
+package combat
+
+import "testing"
+
+func TestIsMeleeType(t *testing.T) {
+ melee := []string{AttackStab, AttackSlash, AttackCrush}
+ for _, m := range melee {
+ if !IsMeleeType(m) {
+ t.Errorf("IsMeleeType(%q) = false, want true", m)
+ }
+ }
+ for _, nm := range []string{AttackRanged, AttackScience, "", "magic"} {
+ if IsMeleeType(nm) {
+ t.Errorf("IsMeleeType(%q) = true, want false", nm)
+ }
+ }
+}
+
+func TestIsValidAttackType(t *testing.T) {
+ for _, v := range AllAttackTypes {
+ if !IsValidAttackType(v) {
+ t.Errorf("IsValidAttackType(%q) = false, want true", v)
+ }
+ }
+ for _, iv := range []string{"", "magic", "melee"} {
+ if IsValidAttackType(iv) {
+ t.Errorf("IsValidAttackType(%q) = true, want false", iv)
+ }
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 9afc2d1..fd0638d 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -7,18 +7,18 @@ import (
)
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"`
- TelnetTLS TelnetTLSConfig `yaml:"telnet_tls"`
- HTTP HTTPConfig `yaml:"http"`
- HTTPS HTTPSConfig `yaml:"https"`
- AdminHTTP AdminHTTPConfig `yaml:"admin_http"`
- AdminHTTPS AdminHTTPSConfig `yaml:"admin_https"`
+ 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"`
+ TelnetTLS TelnetTLSConfig `yaml:"telnet_tls"`
+ HTTP HTTPConfig `yaml:"http"`
+ HTTPS HTTPSConfig `yaml:"https"`
+ AdminHTTP AdminHTTPConfig `yaml:"admin_http"`
+ AdminHTTPS AdminHTTPSConfig `yaml:"admin_https"`
}
// GameConstants holds tunable game-wide numbers that would otherwise be
diff --git a/internal/game/act.go b/internal/game/act.go
index 6d4a208..9e07886 100644
--- a/internal/game/act.go
+++ b/internal/game/act.go
@@ -236,13 +236,13 @@ func (g *Game) AdvanceActions() {
g.advanceObstacle(sess, p)
case behavior.TypeTalk:
g.advanceTalk(sess, p)
- case behavior.TypeTriggerModule:
- g.advanceTriggerModule(sess, p)
- case behavior.TypeUseInteraction:
- g.advanceUseInteraction(sess, p)
- case behavior.TypeCombine:
- g.advanceCombine(sess, p)
- default:
+ case behavior.TypeTriggerModule:
+ g.advanceTriggerModule(sess, p)
+ case behavior.TypeUseInteraction:
+ g.advanceUseInteraction(sess, p)
+ case behavior.TypeCombine:
+ g.advanceCombine(sess, p)
+ default:
if productionActionTypes[string(p.Action.Type)] {
g.advanceProduction(sess, p)
}
diff --git a/internal/game/act_combine.go b/internal/game/act_combine.go
index db7fee1..3d744db 100644
--- a/internal/game/act_combine.go
+++ b/internal/game/act_combine.go
@@ -32,12 +32,12 @@ func (g *Game) startCombine(sess *net.Session, p *player.Player, item *item.Item
TargetID: item.ID,
TargetName: outputName,
Data: &behavior.CombineData{
- ItemID: item.ID,
- Phase: 0,
- StepIndex: 0,
- Remaining: count,
- StartMessage: startMsg,
- EndMessage: endMsg,
+ ItemID: item.ID,
+ Phase: 0,
+ StepIndex: 0,
+ Remaining: count,
+ StartMessage: startMsg,
+ EndMessage: endMsg,
TicksPerCycle: craft.TicksPerCycle,
},
WaitLeft: engine.ToTicks(1),
diff --git a/internal/game/act_farm.go b/internal/game/act_farm.go
index 6060dca..8ed0d1b 100644
--- a/internal/game/act_farm.go
+++ b/internal/game/act_farm.go
@@ -113,8 +113,8 @@ func (g *Game) advanceFarmGrowth(sess *net.Session, p *player.Player) {
if diseased {
g.setPlayerFlag(p, prefix+"_dead", true)
g.setPlayerFlag(p, prefix+"_diseased", false)
- sess.WriteLine(g.colorize(sess, "farm_disease",
- fmt.Sprintf("Your %s has died from disease!", g.seedDisplayName(sess, seedID))))
+ sess.WriteLine(g.colorize(sess, "farm_disease",
+ fmt.Sprintf("Your %s has died from disease!", g.seedDisplayName(sess, seedID))))
g.AccountStore.SaveCharacter(p)
continue
}
@@ -138,23 +138,23 @@ func (g *Game) advanceFarmGrowth(sess *net.Session, p *player.Player) {
g.setPlayerFlag(p, prefix+"_stage", stage)
g.setPlayerFlag(p, prefix+"_ready", true)
g.setPlayerFlag(p, prefix+"_watered", false)
- sess.WriteLine(g.colorize(sess, "farm_grow",
- fmt.Sprintf("Your %s is fully grown and ready to harvest!",
- g.seedDisplayName(sess, seedID))))
+ sess.WriteLine(g.colorize(sess, "farm_grow",
+ fmt.Sprintf("Your %s is fully grown and ready to harvest!",
+ g.seedDisplayName(sess, seedID))))
} else {
if !watered && rand.Float64() < 0.10 {
g.setPlayerFlag(p, prefix+"_stage", stage)
g.setPlayerFlag(p, prefix+"_diseased", true)
g.setPlayerFlag(p, prefix+"_watered", false)
- sess.WriteLine(g.colorize(sess, "farm_disease",
- fmt.Sprintf("Your %s has become diseased!",
- g.seedDisplayName(sess, seedID))))
+ sess.WriteLine(g.colorize(sess, "farm_disease",
+ fmt.Sprintf("Your %s has become diseased!",
+ g.seedDisplayName(sess, seedID))))
} else {
g.setPlayerFlag(p, prefix+"_stage", stage)
g.setPlayerFlag(p, prefix+"_watered", false)
- sess.WriteLine(g.colorize(sess, "farm_grow",
- fmt.Sprintf("Your %s has grown to stage %d/%d.",
- g.seedDisplayName(sess, seedID), stage, maxStages)))
+ sess.WriteLine(g.colorize(sess, "farm_grow",
+ fmt.Sprintf("Your %s has grown to stage %d/%d.",
+ g.seedDisplayName(sess, seedID), stage, maxStages)))
}
}
g.AccountStore.SaveCharacter(p)
diff --git a/internal/game/act_gather.go b/internal/game/act_gather.go
index c34c6ae..f7a63f6 100644
--- a/internal/game/act_gather.go
+++ b/internal/game/act_gather.go
@@ -114,12 +114,12 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje
}
d := &behavior.GatherData{
- ObjDefID: obj.ID,
- InstanceKey: g.World.ObjStateKey(st.RoomID, st.DefID, st.Index),
- InstanceIdx: st.Index + 1,
- Wait: wait,
- Verb: verb,
- ToolName: toolName,
+ ObjDefID: obj.ID,
+ InstanceKey: g.World.ObjStateKey(st.RoomID, st.DefID, st.Index),
+ InstanceIdx: st.Index + 1,
+ Wait: wait,
+ Verb: verb,
+ ToolName: toolName,
}
if cfg.DepleteTimer > 0 {
d.DepleteTimer = true
@@ -225,14 +225,14 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
}
g.AccountStore.SaveCharacter(p)
- msg := drop.SuccessMessage
- coloredName := g.itemColorize(sess, itemDef, itemName)
- if msg == "" {
- msg = fmt.Sprintf("You manage to get some %s.", coloredName)
- } else {
- msg = strings.ReplaceAll(msg, "%n", coloredName)
- msg = color.ExpandTags(g.colorMode(sess), msg)
- }
+ msg := drop.SuccessMessage
+ coloredName := g.itemColorize(sess, itemDef, itemName)
+ if msg == "" {
+ msg = fmt.Sprintf("You manage to get some %s.", coloredName)
+ } else {
+ msg = strings.ReplaceAll(msg, "%n", coloredName)
+ msg = color.ExpandTags(g.colorMode(sess), msg)
+ }
if xp > 0 && p.OptionBool("xp_drops") {
msg += g.formatXpDropSingle(sess, p, player.SkillName(cfg.Skill), xp)
}
diff --git a/internal/game/act_state.go b/internal/game/act_state.go
index c4ccaba..efc5f7d 100644
--- a/internal/game/act_state.go
+++ b/internal/game/act_state.go
@@ -81,11 +81,11 @@ func (g *Game) playerActionDisplay(p *player.Player) string {
return "mixing some " + a.TargetName
case behavior.TypeConstruct:
return "constructing some " + a.TargetName
- case behavior.TypeTriggerModule:
- return "triggering " + a.TargetName
- case behavior.TypeUseInteraction:
- return "using " + a.TargetName
- }
+ case behavior.TypeTriggerModule:
+ return "triggering " + a.TargetName
+ case behavior.TypeUseInteraction:
+ return "using " + a.TargetName
+ }
}
if a := p.BackgroundAction; a != nil {
diff --git a/internal/game/act_steal.go b/internal/game/act_steal.go
index f5d3dbf..a64bc96 100644
--- a/internal/game/act_steal.go
+++ b/internal/game/act_steal.go
@@ -37,18 +37,18 @@ var stallGuardTalk = &behavior.TalkConfig{
},
"bribe": {
Messages: []string{"Smart choice. Hand over 500 credits and we'll forget this happened."},
- Action: &behavior.NodeAction{Credits: -500},
- Options: []behavior.TalkOption{{Text: "\"Fine, take it.\""}},
+ Action: &behavior.NodeAction{Credits: -500},
+ Options: []behavior.TalkOption{{Text: "\"Fine, take it.\""}},
},
"jail": {
Messages: []string{"Off to the detention cell with you!"},
- Action: &behavior.NodeAction{Teleport: 162},
- Options: []behavior.TalkOption{{Text: "(You are dragged away)"}},
+ Action: &behavior.NodeAction{Teleport: 162},
+ Options: []behavior.TalkOption{{Text: "(You are dragged away)"}},
},
"fight": {
Messages: []string{"Then defend yourself!"},
- Action: &behavior.NodeAction{SetFlags: map[string]any{"guard_hostile": true}},
- Options: []behavior.TalkOption{{Text: "(The guard attacks!)"}},
+ Action: &behavior.NodeAction{SetFlags: map[string]any{"guard_hostile": true}},
+ Options: []behavior.TalkOption{{Text: "(The guard attacks!)"}},
},
},
}
diff --git a/internal/game/act_use.go b/internal/game/act_use.go
index 46ca304..dcddd52 100644
--- a/internal/game/act_use.go
+++ b/internal/game/act_use.go
@@ -81,8 +81,8 @@ func (g *Game) advanceUse(sess *net.Session, p *player.Player) {
skillLevel := p.Level(player.SkillName(cfg.Skill))
chance := behavior.SuccessChance(*cfg.Success, skillLevel, cfg.Level)
if rand.Float64() >= chance {
- if cfg.FailMessage != "" {
- sess.WriteLine(cfg.FailMessage)
+ if cfg.FailMessage != "" {
+ sess.WriteLine(cfg.FailMessage)
}
p.Action.WaitLeft = engine.ToTicks(cfg.TicksPerCycle)
return
diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go
index d60bb05..16e7587 100644
--- a/internal/game/cmd_attack.go
+++ b/internal/game/cmd_attack.go
@@ -43,7 +43,7 @@ func (g *Game) respawnMob(instanceID string) {
return
}
homeRoom := inst.HomeRoomID
- inst.RoomID = homeRoom
+ g.MobStore.SetInstanceRoom(instanceID, homeRoom)
inst.HP = inst.MaxHP
g.MobStore.RollIdleDescription(inst)
diff --git a/internal/game/cmd_dig.go b/internal/game/cmd_dig.go
index 0691b90..96a5421 100644
--- a/internal/game/cmd_dig.go
+++ b/internal/game/cmd_dig.go
@@ -263,7 +263,6 @@ func findNextRoomID(currentRoomPath string) (int, error) {
}
}
-
func (g *Game) buildGridFrom(fromRoomID int) (coord map[int][3]int, roomAt map[[3]int]int) {
roomIndex := g.World.RoomIndex()
rg := world.BuildGrid(fromRoomID,
diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go
index 6164e50..5c24d60 100644
--- a/internal/game/cmd_room_insert.go
+++ b/internal/game/cmd_room_insert.go
@@ -124,7 +124,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) {
{Text: "A featureless room."},
},
Exits: map[world.ExitDir]world.ExitDef{
- dir: {Room: targetID},
+ dir: {Room: targetID},
oppositeDir: {Room: p.RoomID},
},
}
diff --git a/internal/game/cmd_smelt.go b/internal/game/cmd_smelt.go
index 4500c64..5f7a7b3 100644
--- a/internal/game/cmd_smelt.go
+++ b/internal/game/cmd_smelt.go
@@ -23,7 +23,7 @@ func (g *Game) doSmelt(sess *net.Session, input string) {
return
}
- items := g.CraftIndex.BySubtype("smelting")
+ items := g.CraftIndex.BySubtype("smelting")
if input == "" {
g.showSmeltMenu(sess, p, items, stationDefID, stationName)
diff --git a/internal/game/cmd_stats.go b/internal/game/cmd_stats.go
index 0430c6e..6942272 100644
--- a/internal/game/cmd_stats.go
+++ b/internal/game/cmd_stats.go
@@ -17,27 +17,10 @@ func (g *Game) doStats(sess *net.Session) {
p := sess.Player
totals := g.playerEquipBonuses(p)
- attackType := "crush"
weaponName := "unarmed"
- var weaponType item.EquipmentType
- var weaponDef *item.ItemDef
- if itemID, ok := p.Equipment[item.SlotMainHand]; ok {
- if def, err := g.ItemStore.Load(itemID); err == nil {
- weaponDef = def
- weaponName = def.Name
- weaponType = def.EquipmentType()
- if def.AttackType() != "" {
- attackType = def.AttackType()
- } else if def.EquipmentType() == item.EquipRangedWeapon {
- attackType = "ranged"
- } else if def.EquipmentType() == item.EquipScienceWeapon {
- attackType = "science"
- }
- }
- }
-
+ attackType, weaponType, weaponDef := g.resolvePlayerAttackType(p)
if weaponDef != nil {
- weaponName = g.itemColorize(sess, weaponDef, weaponName)
+ weaponName = g.itemColorize(sess, weaponDef, weaponDef.Name)
}
sess.WriteLines(
"",
@@ -60,15 +43,15 @@ func (g *Game) doStats(sess *net.Session) {
var attRoll, maxHitVal int
if weaponType == item.EquipRangedWeapon {
rangedBonus, _ := combat.RangedStyleBonus(string(p.AttackStyle))
- attRoll = combat.EffectiveRoll(p.Level(player.Ranged), rangedBonus, totals.RangedAttack)
- maxHitVal = combat.MaxHit(p.Level(player.Ranged), rangedBonus, totals.RangedStrength)
+ attRoll = combat.AttackRoll(combat.PlayerEffective(p.Level(player.Ranged), rangedBonus), totals.RangedAttack)
+ maxHitVal = combat.MaxHit(combat.PlayerEffective(p.Level(player.Ranged), rangedBonus), totals.RangedStrength)
} else {
attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle))
equipAtt := combat.SelectBonus(attackType,
totals.StabAttack, totals.SlashAttack, totals.CrushAttack,
totals.ScienceAttack, totals.RangedAttack)
- attRoll = combat.EffectiveRoll(p.Level(player.Accuracy), attBonus, equipAtt)
- maxHitVal = combat.MaxHit(p.Level(player.Strength), strBonus, totals.StrengthBonus)
+ attRoll = combat.AttackRoll(combat.PlayerEffective(p.Level(player.Accuracy), attBonus), equipAtt)
+ maxHitVal = combat.MaxHit(combat.PlayerEffective(p.Level(player.Strength), strBonus), totals.StrengthBonus)
}
sess.WriteLines(
diff --git a/internal/game/cmd_trigger_combat.go b/internal/game/cmd_trigger_combat.go
index cce9781..ecb4fbb 100644
--- a/internal/game/cmd_trigger_combat.go
+++ b/internal/game/cmd_trigger_combat.go
@@ -10,6 +10,14 @@ import (
"thehouseoficarus/internal/world"
)
+// scienceAttack resolves a player science-mod attack against a mob.
+//
+// Unlike melee/ranged (which roll the player's accuracy vs the mob's Defense),
+// science uses a symmetric science-vs-science model: both the attack roll and
+// the mob's defense roll use ScienceEffective, keyed off the player's Science
+// level and the mob's Science stat (plus ScienceDefense) respectively — the
+// mob's Defense stat is not involved. Science has no attack styles, so no style
+// bonus applies.
func (g *Game) scienceAttack(sess *net.Session, p *player.Player, mob *world.MobInstance, mod *ModDef) bool {
if p.Level(player.Science) < mod.Level {
sess.WriteLine(fmt.Sprintf("You need level %d science to trigger %s.", mod.Level, mod.Name))
@@ -24,17 +32,31 @@ func (g *Game) scienceAttack(sess *net.Session, p *player.Player, mob *world.Mob
equipSciBonus := g.totalScienceAttack(p)
effectiveScience := p.Level(player.Science) + g.techLevelBonus(p, "science") + g.buffLevelBonus(p, "science")
- attRoll := combat.EffectiveRoll(effectiveScience, 0, equipSciBonus)
+ attRoll := combat.AttackRoll(combat.ScienceEffective(effectiveScience), equipSciBonus)
- mobSciDef := mob.ScienceDefense
- defRoll := combat.EffectiveRoll(mob.Defense, 0, mobSciDef)
+ defRoll := combat.AttackRoll(combat.ScienceEffective(mob.Science), mob.ScienceDefense)
- if mob.Weakness == mod.Element {
- attRoll = attRoll * 13 / 10
+ if mob.Weakness == mod.Element && mob.WeaknessPercent > 0 {
+ attRoll = int(float64(attRoll) * (1.0 + float64(mob.WeaknessPercent)/100.0))
}
if combat.HitCheck(attRoll, defRoll) {
- dmg := combat.RollDamage(mod.MaxHit)
+ totals := g.playerEquipBonuses(p)
+ maxHit := mod.MaxHit
+ if totals.ScienceDamage > 0 {
+ maxHit = int(float64(maxHit) * (1.0 + float64(totals.ScienceDamage)/100.0))
+ }
+ // Weakness is an additive bonus computed off the mod's base MaxHit (not
+ // the science-damage-boosted maxHit), so equipment and weakness stack
+ // additively. With no science-damage gear this equals a ×(1+pct) boost,
+ // matching the multiplicative weakness applied to the attack roll above.
+ if mob.Weakness == mod.Element && mob.WeaknessPercent > 0 {
+ maxHit += int(float64(mod.MaxHit) * float64(mob.WeaknessPercent) / 100.0)
+ }
+ if maxHit < 1 {
+ maxHit = 1
+ }
+ dmg := combat.RollDamage(maxHit)
if dmg < 0 {
dmg = 0
}
diff --git a/internal/game/cmd_trigger_enchant.go b/internal/game/cmd_trigger_enchant.go
index 0279b4e..62a3c48 100644
--- a/internal/game/cmd_trigger_enchant.go
+++ b/internal/game/cmd_trigger_enchant.go
@@ -41,6 +41,7 @@ func (g *Game) triggerEnchant(sess *net.Session, p *player.Player, mod *ModDef,
g.cancelAction(p)
}
+ inputID := inv.ItemID
inv.ItemID = outputID
g.triggerModReward(sess, p, mod, 1.0)
@@ -50,8 +51,8 @@ func (g *Game) triggerEnchant(sess *net.Session, p *player.Player, mod *ModDef,
if outputDef != nil {
outputName = outputDef.Name
}
- inputDef, _ := g.ItemStore.Load(inv.ItemID)
- inputName := inv.ItemID
+ inputDef, _ := g.ItemStore.Load(inputID)
+ inputName := inputID
if inputDef != nil {
inputName = inputDef.Name
}
diff --git a/internal/game/cmd_trigger_utility.go b/internal/game/cmd_trigger_utility.go
index 5a4dcc7..d06551a 100644
--- a/internal/game/cmd_trigger_utility.go
+++ b/internal/game/cmd_trigger_utility.go
@@ -170,7 +170,7 @@ func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef
}
var match *item.ItemDef
- for _, item := range g.CraftIndex.BySubtype("smelting") {
+ for _, item := range g.CraftIndex.BySubtype("smelting") {
if craftMatchesEntry(item.FirstCraft(), inv.ItemID) {
match = item
break
diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go
index 72de3d6..8540074 100644
--- a/internal/game/cmd_use.go
+++ b/internal/game/cmd_use.go
@@ -417,7 +417,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName,
}
func (g *Game) isGrimyHerb(itemID string) bool {
- items := g.CraftIndex.BySubtype("cleaning")
+ items := g.CraftIndex.BySubtype("cleaning")
for _, item := range items {
for _, e := range item.FirstCraft().Ingredients {
for _, id := range e.Items {
diff --git a/internal/game/combat_attack.go b/internal/game/combat_attack.go
index ddc3b8f..6c314ff 100644
--- a/internal/game/combat_attack.go
+++ b/internal/game/combat_attack.go
@@ -8,7 +8,6 @@ import (
"strings"
"thehouseoficarus/internal/behavior"
- "thehouseoficarus/internal/color"
"thehouseoficarus/internal/combat"
"thehouseoficarus/internal/engine"
"thehouseoficarus/internal/item"
@@ -213,13 +212,13 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn
if mod != nil && g.hasJunkCost(p, mod) {
g.scienceAttack(sess, p, currentMob, mod)
} else if mod != nil {
- sess.WriteLine(g.colorize(sess, "error",
- fmt.Sprintf("You're out of junk for %s!! You flail with your fists in desperation!", mod.Name)))
+ sess.WriteLine(g.colorize(sess, "error",
+ fmt.Sprintf("You're out of junk for %s!! You flail with your fists in desperation!", mod.Name)))
g.playerAttackUnarmed(sess, p, currentMob)
} else {
p.AutotriggerMod = ""
- sess.WriteLine(g.colorize(sess, "error",
- "No autotrigger set! Set a module with the 'autotrigger' command."))
+ sess.WriteLine(g.colorize(sess, "error",
+ "No autotrigger set! Set a module with the 'autotrigger' command."))
g.playerAttackUnarmed(sess, p, currentMob)
}
} else {
@@ -289,20 +288,29 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI
}
}
-func (g *Game) calculatePlayerAttackRoll(p *player.Player, mob *world.MobInstance) (attackType string, weaponType item.EquipmentType, attRoll int, defRoll int, maxHit int) {
- attackType = "crush"
+// resolvePlayerAttackType determines the player's attack type and main-hand
+// weapon type (and the weapon def, if any) from their equipped weapon,
+// defaulting to crush when unarmed or when the weapon specifies no type.
+func (g *Game) resolvePlayerAttackType(p *player.Player) (attackType string, weaponType item.EquipmentType, def *item.ItemDef) {
+ attackType = combat.DefaultAttackType
if itemID, ok := p.Equipment[item.SlotMainHand]; ok {
- if def, err := g.ItemStore.Load(itemID); err == nil {
- if def.AttackType() != "" {
- attackType = def.AttackType()
- } else if def.EquipmentType() == item.EquipRangedWeapon {
- attackType = "ranged"
- } else if def.EquipmentType() == item.EquipScienceWeapon {
- attackType = "science"
+ if d, err := g.ItemStore.Load(itemID); err == nil {
+ def = d
+ weaponType = d.EquipmentType()
+ if at := d.AttackType(); at != "" {
+ attackType = at
+ } else if weaponType == item.EquipRangedWeapon {
+ attackType = combat.AttackRanged
+ } else if weaponType == item.EquipScienceWeapon {
+ attackType = combat.AttackScience
}
- weaponType = def.EquipmentType()
}
}
+ return
+}
+
+func (g *Game) calculatePlayerAttackRoll(p *player.Player, mob *world.MobInstance) (attackType string, weaponType item.EquipmentType, attRoll int, defRoll int, maxHit int) {
+ attackType, weaponType, _ = g.resolvePlayerAttackType(p)
totals := g.playerEquipBonuses(p)
isRanged := weaponType == item.EquipRangedWeapon
@@ -311,26 +319,26 @@ func (g *Game) calculatePlayerAttackRoll(p *player.Player, mob *world.MobInstanc
rangedBonus, _ := combat.RangedStyleBonus(string(p.AttackStyle))
equipAttack := totals.RangedAttack
effectiveRanged := p.Level(player.Ranged) + g.techLevelBonus(p, "ranged") + g.buffLevelBonus(p, "ranged")
- attRoll = combat.EffectiveRoll(effectiveRanged, rangedBonus, equipAttack)
+ attRoll = combat.AttackRoll(combat.PlayerEffective(effectiveRanged, rangedBonus), equipAttack)
mobDefBonus := combat.SelectBonus(attackType,
mob.StabDefense, mob.SlashDefense, mob.CrushDefense,
mob.ScienceDefense, mob.RangedDefense)
- defRoll = combat.EffectiveRoll(mob.Defense, 0, mobDefBonus)
- maxHit = combat.MaxHit(effectiveRanged, rangedBonus, totals.RangedStrength)
+ defRoll = combat.AttackRoll(combat.NPCEffective(mob.Defense), mobDefBonus)
+ maxHit = combat.MaxHit(combat.PlayerEffective(effectiveRanged, rangedBonus), totals.RangedStrength)
} else {
attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle))
equipAttack := combat.SelectBonus(attackType,
totals.StabAttack, totals.SlashAttack, totals.CrushAttack,
totals.ScienceAttack, totals.RangedAttack)
effectiveAccuracy := p.Level(player.Accuracy) + g.techLevelBonus(p, "accuracy") + g.buffLevelBonus(p, "accuracy")
- attRoll = combat.EffectiveRoll(effectiveAccuracy, attBonus, equipAttack)
+ attRoll = combat.AttackRoll(combat.PlayerEffective(effectiveAccuracy, attBonus), equipAttack)
mobDefBonus := combat.SelectBonus(attackType,
mob.StabDefense, mob.SlashDefense, mob.CrushDefense,
mob.ScienceDefense, mob.RangedDefense)
- defRoll = combat.EffectiveRoll(mob.Defense, 0, mobDefBonus)
- maxHit = combat.MaxHit(p.Level(player.Strength)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus, totals.StrengthBonus)
+ defRoll = combat.AttackRoll(combat.NPCEffective(mob.Defense), mobDefBonus)
+ maxHit = combat.MaxHit(combat.PlayerEffective(p.Level(player.Strength)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus), totals.StrengthBonus)
}
return
}
@@ -355,13 +363,13 @@ func (g *Game) calculateUnarmedAttackRoll(p *player.Player, mob *world.MobInstan
attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle))
effectiveAccuracy := p.Level(player.Accuracy) + g.techLevelBonus(p, "accuracy") + g.buffLevelBonus(p, "accuracy")
- attRoll = combat.EffectiveRoll(effectiveAccuracy, attBonus, 0)
+ attRoll = combat.AttackRoll(combat.PlayerEffective(effectiveAccuracy, attBonus), 0)
mobDefBonus := combat.SelectBonus(attackType,
mob.StabDefense, mob.SlashDefense, mob.CrushDefense,
mob.ScienceDefense, mob.RangedDefense)
- defRoll = combat.EffectiveRoll(mob.Defense, 0, mobDefBonus)
- maxHit = combat.MaxHit(p.Level(player.Strength)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus, 0)
+ defRoll = combat.AttackRoll(combat.NPCEffective(mob.Defense), mobDefBonus)
+ maxHit = combat.MaxHit(combat.PlayerEffective(p.Level(player.Strength)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus), 0)
return
}
@@ -380,14 +388,9 @@ func (g *Game) applyPlayerHit(sess *net.Session, p *player.Player, mob *world.Mo
}
if mob.FinishingBlow != "" && mob.HP == 1 {
- fbDef, _ := g.ItemStore.Load(mob.FinishingBlow)
- fbName := mob.FinishingBlow
- if fbDef != nil {
- fbName = fbDef.Name
- }
sess.WriteLine(g.colorize(sess, "warning",
fmt.Sprintf("%s resists death! Use %s on it to finish it off.",
- mobDisplayName(mob, false), fbName)))
+ mobDisplayName(mob, false), g.itemDisplayName(mob.FinishingBlow))))
}
isRanged := weaponType == item.EquipRangedWeapon
@@ -401,19 +404,11 @@ func (g *Game) applyPlayerHit(sess *net.Session, p *player.Player, mob *world.Mo
g.writeTaskProgress(sess, p, mob, dmg, gains)
} else {
mobName := mobDisplayName(mob, true)
- attacker := mob.Name
- if !mob.Unique {
- attacker = "The " + mob.Name
- }
- w := len(fmt.Sprintf("You hit %s for %d damage.", mobName, 999))
- if w2 := len(fmt.Sprintf("%s hits you for %d damage.", attacker, 999)); w2 > w {
- w = w2
- }
+ attacker := mobDisplayNameCap(mob, true)
prefix := fmt.Sprintf("You hit %s for %s damage.", g.colorize(sess, "mob", mobName), g.colorize(sess, "damage_dealt", fmt.Sprint(dmg)))
- visLen := color.VisibleLen(prefix)
- if visLen < w {
- prefix += strings.Repeat(" ", w-visLen+1)
- }
+ prefix = padCombatPrefix(prefix,
+ fmt.Sprintf("You hit %s for %d damage.", mobName, 999),
+ fmt.Sprintf("%s hits you for %d damage.", attacker, 999))
hpSuffix := fmt.Sprintf("%s [%2d/%d]", g.hpBar(sess, mob.HP, mob.MaxHP), mob.HP, mob.MaxHP)
line := prefix + hpSuffix + g.formatXpDrop(sess, p, gains)
sess.WriteLine(line)
diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go
index c64e393..a3fc040 100644
--- a/internal/game/combat_mob.go
+++ b/internal/game/combat_mob.go
@@ -13,42 +13,114 @@ import (
"thehouseoficarus/internal/world"
)
+// mobMeleeType returns the mob's single melee attack type (stab/slash/crush),
+// defaulting to crush if the mob has none configured.
+func mobMeleeType(mob *world.MobInstance) string {
+ for _, t := range mob.AttackTypes {
+ if combat.IsMeleeType(t) {
+ return t
+ }
+ }
+ return combat.DefaultAttackType
+}
+
+// mobEffectiveMaxScienceHit returns the mob's max science hit with its science
+// percent bonus applied, matching the calculation used in calculateMobAttack.
+func mobEffectiveMaxScienceHit(mob *world.MobInstance) int {
+ base := mob.MaxScienceHit
+ if mob.SciencePercentBonus > 0 {
+ base = int(float64(base) * (1.0 + float64(mob.SciencePercentBonus)/100.0))
+ }
+ if base < 1 {
+ base = 1
+ }
+ return base
+}
+
+// mobStrongestRangedScience returns whichever of "ranged"/"science" the mob
+// possesses with the higher max hit, or "" if the mob has neither.
+func mobStrongestRangedScience(mob *world.MobInstance) string {
+ hasRanged, hasScience := false, false
+ for _, t := range mob.AttackTypes {
+ switch t {
+ case combat.AttackRanged:
+ hasRanged = true
+ case combat.AttackScience:
+ hasScience = true
+ }
+ }
+ switch {
+ case hasRanged && hasScience:
+ if mobEffectiveMaxScienceHit(mob) > mob.MaxRangedHit {
+ return combat.AttackScience
+ }
+ return combat.AttackRanged
+ case hasRanged:
+ return combat.AttackRanged
+ case hasScience:
+ return combat.AttackScience
+ default:
+ return ""
+ }
+}
+
+// effectiveMobAttackType resolves which single attack type the mob uses for an
+// attack against this player right now. Normally the mob uses its melee type;
+// when the player is safespotted from melee the mob switches to its strongest
+// ranged/science type. Returns "" if the mob cannot attack (melee blocked and no
+// ranged/science fallback).
+func (g *Game) effectiveMobAttackType(p *player.Player, mob *world.MobInstance) string {
+ if g.isSafespotted(p.Name) && g.safespotBlocksMelee(p, mob) {
+ return mobStrongestRangedScience(mob)
+ }
+ return mobMeleeType(mob)
+}
+
func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) {
- if g.isSafespotted(p.Name) && g.safespotBlocksMob(p, mob) {
+ attackType := g.effectiveMobAttackType(p, mob)
+ if attackType == "" {
return
}
- attRoll, defRoll := g.calculateMobAttack(p, mob)
+ attRoll, defRoll, maxHit := g.calculateMobAttack(p, mob, attackType)
if combat.HitCheck(attRoll, defRoll) {
- g.applyMobHit(sess, p, mob)
+ g.applyMobHit(sess, p, mob, attackType, maxHit)
} else {
g.applyMobMiss(sess, mob)
}
}
-func (g *Game) calculateMobAttack(p *player.Player, mob *world.MobInstance) (attRoll int, defRoll int) {
+func (g *Game) calculateMobAttack(p *player.Player, mob *world.MobInstance, mobAttackType string) (attRoll, defRoll, maxHit int) {
_, _, defStyleBonus := combat.AttackStyleBonus(string(p.AttackStyle))
- mobAttackType := mob.AttackType
if mobAttackType == "" {
- mobAttackType = "crush"
+ mobAttackType = combat.DefaultAttackType
}
- attRoll = combat.EffectiveRoll(mob.Attack, 0, mob.AttackBonus)
+ switch mobAttackType {
+ case combat.AttackRanged:
+ attRoll = combat.AttackRoll(combat.NPCEffective(mob.Ranged), mob.RangedBonus)
+ maxHit = mob.MaxRangedHit
+ case combat.AttackScience:
+ attRoll = combat.AttackRoll(combat.ScienceEffective(mob.Science), mob.ScienceBonus)
+ maxHit = mobEffectiveMaxScienceHit(mob)
+ default:
+ attRoll = combat.AttackRoll(combat.NPCEffective(mob.Attack), mob.AttackBonus)
+ maxHit = mob.MaxMeleeHit
+ }
totals := g.playerEquipBonuses(p)
equipDef := combat.SelectBonus(mobAttackType,
totals.StabDefense, totals.SlashDefense, totals.CrushDefense,
totals.ScienceDefense, totals.RangedDefense)
- defRoll = combat.EffectiveRoll(p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"), defStyleBonus, equipDef)
+ defRoll = combat.AttackRoll(combat.PlayerEffective(p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"), defStyleBonus), equipDef)
return
}
-func (g *Game) applyMobHit(sess *net.Session, p *player.Player, mob *world.MobInstance) {
- maxHit := combat.MaxHit(mob.Strength, 0, mob.StrengthBonus)
+func (g *Game) applyMobHit(sess *net.Session, p *player.Player, mob *world.MobInstance, attackType string, maxHit int) {
dmg := combat.RollDamage(maxHit)
- dmg = g.applyTechProtection(p, mob, dmg)
+ dmg = g.applyTechProtection(p, attackType, dmg)
if mob.DamageWithout != "" {
hasProtection := false
@@ -66,15 +138,8 @@ func (g *Game) applyMobHit(sess *net.Session, p *player.Player, mob *world.MobIn
cs := g.Combat.Get(p.Name)
if cs != nil && !cs.DamageWarningShown {
cs.DamageWarningShown = true
- fbDef, _ := g.ItemStore.Load(mob.DamageWithout)
- fbName := mob.DamageWithout
- if fbDef != nil {
- fbName = fbDef.Name
- }
- attacker := mob.Name
- if !mob.Unique {
- attacker = "The " + mob.Name
- }
+ fbName := g.itemDisplayName(mob.DamageWithout)
+ attacker := mobDisplayNameCap(mob, true)
sess.WriteLine(g.colorize(sess, "warning",
fmt.Sprintf(" %s's attack is extra effective! Equip %s for protection.",
attacker, fbName)))
@@ -89,20 +154,12 @@ func (g *Game) applyMobHit(sess *net.Session, p *player.Player, mob *world.MobIn
p.StartRegen()
g.AccountStore.SaveCharacter(p)
- attacker := mob.Name
- if !mob.Unique {
- attacker = "The " + mob.Name
- }
+ attacker := mobDisplayNameCap(mob, true)
mobName := mobDisplayName(mob, true)
- w := len(fmt.Sprintf("%s hits you for %d damage.", attacker, 999))
- if w2 := len(fmt.Sprintf("You hit %s for %d damage.", mobName, 999)); w2 > w {
- w = w2
- }
prefix := fmt.Sprintf("%s hits you for %s damage.", g.colorize(sess, "mob", attacker), g.colorize(sess, "damage_taken", fmt.Sprint(dmg)))
- visLen := color.VisibleLen(prefix)
- if visLen < w {
- prefix += strings.Repeat(" ", w-visLen+1)
- }
+ prefix = padCombatPrefix(prefix,
+ fmt.Sprintf("%s hits you for %d damage.", attacker, 999),
+ fmt.Sprintf("You hit %s for %d damage.", mobName, 999))
hpSuffix := fmt.Sprintf("%s [%2d/%d]", g.hpBar(sess, p.HP, p.MaxHP()), p.HP, p.MaxHP())
sess.WriteLine(prefix + hpSuffix)
@@ -123,11 +180,23 @@ func (g *Game) applyMobHit(sess *net.Session, p *player.Player, mob *world.MobIn
}
}
-func (g *Game) applyMobMiss(sess *net.Session, mob *world.MobInstance) {
- attacker := mob.Name
- if !mob.Unique {
- attacker = "The " + mob.Name
+// padCombatPrefix right-pads a colored damage line so a trailing HP bar aligns
+// with the mirror-image line (player-hit vs mob-hit). plainSelf/plainOther are
+// the uncolored versions of both lines (using a 999 damage placeholder) and are
+// used only to compute the alignment width.
+func padCombatPrefix(prefix, plainSelf, plainOther string) string {
+ w := len(plainSelf)
+ if len(plainOther) > w {
+ w = len(plainOther)
+ }
+ if visLen := color.VisibleLen(prefix); visLen < w {
+ prefix += strings.Repeat(" ", w-visLen+1)
}
+ return prefix
+}
+
+func (g *Game) applyMobMiss(sess *net.Session, mob *world.MobInstance) {
+ attacker := mobDisplayNameCap(mob, true)
sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf("%s misses you.", attacker)))
}
@@ -139,102 +208,108 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
return
}
- if mob != nil && mob.HP <= 0 {
- isTask := mob.IsTask()
- p.Stats.RecordMobKill(mob.DefID)
+ if mob == nil || mob.HP > 0 {
+ return
+ }
- if isTask {
- complete := mob.CompleteMessage
- if complete == "" {
- complete = fmt.Sprintf("You finish your work on %s!", mobDisplayName(mob, true))
- }
+ isTask := mob.IsTask()
+ p.Stats.RecordMobKill(mob.DefID)
+
+ g.announceKill(sess, p, mob, isTask)
+ g.awardKillDrops(sess, p, mob, isTask)
+ g.scheduleMobRespawn(mob)
+ g.writePrompt(sess)
+}
+
+// announceKill prints the victory line to the killer and broadcasts to the room.
+func (g *Game) announceKill(sess *net.Session, p *player.Player, mob *world.MobInstance, isTask bool) {
+ if isTask {
+ complete := mob.CompleteMessage
+ if complete == "" {
+ complete = fmt.Sprintf("You finish your work on %s!", mobDisplayName(mob, true))
+ }
sess.WriteLine(g.colorize(sess, "victory", complete))
} else {
sess.WriteLine(g.colorize(sess, "victory", fmt.Sprintf("You have defeated %s!", mobDisplayName(mob, true))))
- g.onAssassinKill(sess, p, mob)
- }
+ g.onAssassinKill(sess, p, mob)
+ }
- if g.Hub != nil {
- for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
- if other != sess && other.Player != nil {
- if isTask {
- other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("%s finishes working on %s.", p.Name, mobDisplayName(mob, false))))
- } else {
- op := other.Player
- mobLvl := mobCombatLevel(mob)
- levelStr := g.levelColorize(other, op.CombatLevel(), mobLvl, fmt.Sprintf("(level %d)", mobLvl))
- other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("%s has slain %s %s!", p.Name, mobDisplayName(mob, false), levelStr)))
- }
- }
- }
+ if g.Hub == nil {
+ return
+ }
+ for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
+ if other == sess || other.Player == nil {
+ continue
+ }
+ if isTask {
+ other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("%s finishes working on %s.", p.Name, mobDisplayName(mob, false))))
+ } else {
+ mobLvl := mobCombatLevel(mob)
+ levelStr := g.levelColorize(other, other.Player.CombatLevel(), mobLvl, fmt.Sprintf("(level %d)", mobLvl))
+ other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("%s has slain %s %s!", p.Name, mobDisplayName(mob, false), levelStr)))
}
+ }
+}
- dropLabel := func() string {
- if isTask {
- return "You receive:"
- }
- dropper := mob.Name
- if !mob.Unique {
- dropper = "The " + mob.Name
- }
- return dropper + " drops:"
- }()
-
- if mob.Drops.Remains != "" {
- g.World.AddReservedItem(p.RoomID, mob.Drops.Remains, 1, p.Name)
- def, _ := g.ItemStore.Load(mob.Drops.Remains)
- name := mob.Drops.Remains
- if def != nil {
- name = def.Name
- }
- coloredName := g.itemColorize(sess, def, name)
+// awardKillDrops resolves the mob's remains and loot table onto the ground,
+// reserved for the killer, and reports each drop.
+func (g *Game) awardKillDrops(sess *net.Session, p *player.Player, mob *world.MobInstance, isTask bool) {
+ dropLabel := "You receive:"
+ if !isTask {
+ dropLabel = mobDisplayNameCap(mob, true) + " drops:"
+ }
+
+ writeDrop := func(itemID string, qty int) {
+ g.World.AddReservedItem(p.RoomID, itemID, qty, p.Name)
+ def, _ := g.ItemStore.Load(itemID)
+ name := itemID
+ if def != nil {
+ name = def.Name
+ }
+ coloredName := g.itemColorize(sess, def, name)
+ if qty > 1 {
+ sess.WriteLine(fmt.Sprintf("%s %d x %s", g.colorize(sess, "drop_message", dropLabel), qty, coloredName))
+ } else {
sess.WriteLine(fmt.Sprintf("%s %s", g.colorize(sess, "drop_message", dropLabel), coloredName))
}
+ }
- if len(mob.Drops.Loot) > 0 {
- for _, entry := range behavior.ResolveDropList(g.DataDir, mob.Drops.Loot) {
- if entry.ItemID == "" {
- continue
- }
- qty := entry.Quantity
- if qty <= 0 {
- qty = 1
- }
- g.World.AddReservedItem(p.RoomID, entry.ItemID, qty, p.Name)
- def, _ := g.ItemStore.Load(entry.ItemID)
- name := entry.ItemID
- if def != nil {
- name = def.Name
- }
- coloredName := g.itemColorize(sess, def, name)
- if qty > 1 {
- sess.WriteLine(fmt.Sprintf("%s %d x %s", g.colorize(sess, "drop_message", dropLabel), qty, coloredName))
- } else {
- sess.WriteLine(fmt.Sprintf("%s %s", g.colorize(sess, "drop_message", dropLabel), coloredName))
- }
- }
- }
+ if mob.Drops.Remains != "" {
+ writeDrop(mob.Drops.Remains, 1)
+ }
- respawnTicks := engine.ToTicks(mob.RespawnTicks)
- if respawnTicks <= 0 {
- respawnTicks = engine.ToTicks(30)
+ for _, entry := range behavior.ResolveDropList(g.DataDir, mob.Drops.Loot) {
+ if entry.ItemID == "" {
+ continue
}
- instanceID := mob.InstanceID
- if mob.SpawnedByTrigger {
- g.Ticks.Subscribe(10, func() bool {
- g.MobStore.RemoveInstance(instanceID)
- return false
- })
- } else {
- g.Ticks.Subscribe(respawnTicks, func() bool {
- g.respawnMob(instanceID)
- return false
- })
+ qty := entry.Quantity
+ if qty <= 0 {
+ qty = 1
}
- g.writePrompt(sess)
+ writeDrop(entry.ItemID, qty)
}
}
+// scheduleMobRespawn removes trigger-spawned mobs or schedules a normal respawn.
+func (g *Game) scheduleMobRespawn(mob *world.MobInstance) {
+ instanceID := mob.InstanceID
+ if mob.SpawnedByTrigger {
+ g.Ticks.Subscribe(10, func() bool {
+ g.MobStore.RemoveInstance(instanceID)
+ return false
+ })
+ return
+ }
+ respawnTicks := engine.ToTicks(mob.RespawnTicks)
+ if respawnTicks <= 0 {
+ respawnTicks = engine.ToTicks(30)
+ }
+ g.Ticks.Subscribe(respawnTicks, func() bool {
+ g.respawnMob(instanceID)
+ return false
+ })
+}
+
// killPlayer handles a player death from any source (combat or a room hazard).
// mob may be nil (e.g. a hazard kill); it is only used for Dead Man's Switch
// retribution.
@@ -256,8 +331,8 @@ func (g *Game) killPlayer(sess *net.Session, p *player.Player, mob *world.MobIns
if mob.HP < 0 {
mob.HP = 0
}
- sess.WriteLine(fmt.Sprintf("Dead Man's Switch activates! %s takes %d damage!",
- mobDisplayName(mob, true), retDmg))
+ sess.WriteLine(fmt.Sprintf("Dead Man's Switch activates! %s takes %d damage!",
+ mobDisplayName(mob, true), retDmg))
}
}
}
@@ -316,8 +391,8 @@ func (g *Game) awardCombatXP(sess *net.Session, p *player.Player, dmg int, isRan
return gains
}
+// stopCombat ends the player's combat if any. Combat.Leave no-ops when the
+// player is not engaged.
func (g *Game) stopCombat(playerName string) {
- if cs := g.Combat.Get(playerName); cs != nil {
- g.Combat.Leave(playerName)
- }
+ g.Combat.Leave(playerName)
}
diff --git a/internal/game/combat_mob_test.go b/internal/game/combat_mob_test.go
new file mode 100644
index 0000000..28427b7
--- /dev/null
+++ b/internal/game/combat_mob_test.go
@@ -0,0 +1,61 @@
+package game
+
+import (
+ "testing"
+
+ "thehouseoficarus/internal/world"
+)
+
+func TestMobMeleeType(t *testing.T) {
+ cases := []struct {
+ name string
+ types []string
+ want string
+ }{
+ {"single crush", []string{"crush"}, "crush"},
+ {"melee plus ranged", []string{"stab", "ranged"}, "stab"},
+ {"ranged first", []string{"ranged", "slash"}, "slash"},
+ {"no melee defaults crush", []string{"ranged"}, "crush"},
+ {"empty defaults crush", nil, "crush"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ mob := &world.MobInstance{AttackTypes: c.types}
+ if got := mobMeleeType(mob); got != c.want {
+ t.Errorf("mobMeleeType(%v) = %q, want %q", c.types, got, c.want)
+ }
+ })
+ }
+}
+
+func TestMobStrongestRangedScience(t *testing.T) {
+ cases := []struct {
+ name string
+ types []string
+ maxRanged int
+ maxScience int
+ sciencePct int
+ want string
+ }{
+ {"melee only", []string{"crush"}, 0, 0, 0, ""},
+ {"ranged only", []string{"crush", "ranged"}, 5, 0, 0, "ranged"},
+ {"science only", []string{"crush", "science"}, 0, 7, 0, "science"},
+ {"both science higher", []string{"crush", "ranged", "science"}, 5, 8, 0, "science"},
+ {"both ranged higher", []string{"crush", "ranged", "science"}, 10, 8, 0, "ranged"},
+ {"both tie prefers ranged", []string{"crush", "ranged", "science"}, 8, 8, 0, "ranged"},
+ {"science percent tips it", []string{"crush", "ranged", "science"}, 10, 8, 50, "science"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ mob := &world.MobInstance{
+ AttackTypes: c.types,
+ MaxRangedHit: c.maxRanged,
+ MaxScienceHit: c.maxScience,
+ SciencePercentBonus: c.sciencePct,
+ }
+ if got := mobStrongestRangedScience(mob); got != c.want {
+ t.Errorf("mobStrongestRangedScience() = %q, want %q", got, c.want)
+ }
+ })
+ }
+}
diff --git a/internal/game/core_equip.go b/internal/game/core_equip.go
index a297466..7a1c1bc 100644
--- a/internal/game/core_equip.go
+++ b/internal/game/core_equip.go
@@ -33,7 +33,7 @@ func (g *Game) playerEquipBonuses(p *player.Player) item.ItemStats {
func (g *Game) playerWeaponSpeed(p *player.Player) float64 {
if itemID, ok := p.Equipment[item.SlotMainHand]; ok {
def, err := g.ItemStore.Load(itemID)
- if err == nil && def.Speed()> 0 {
+ if err == nil && def.Speed() > 0 {
return def.Speed()
}
}
diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go
index b1d3d26..90c06be 100644
--- a/internal/game/core_login_char.go
+++ b/internal/game/core_login_char.go
@@ -51,7 +51,13 @@ func (g *Game) handleNewCharName(sess *net.Session, input string) {
return
}
- acc, _ := g.AccountStore.LoadAccount(sess.Account.Name)
+ acc, err := g.AccountStore.LoadAccount(sess.Account.Name)
+ if err != nil {
+ sess.WriteLine(fmt.Sprintf("Error creating character: %v", err))
+ sess.State = net.StateMenu
+ g.showMenu(sess)
+ return
+ }
acc.Characters = append(acc.Characters, name)
g.AccountStore.SaveAccount(acc)
sess.Account.Characters = acc.Characters
@@ -188,7 +194,12 @@ func (g *Game) handleRenameCharName(sess *net.Session, input string) {
g.AccountStore.SaveCharacter(p)
}
- acc, _ := g.AccountStore.LoadAccount(sess.Account.Name)
+ acc, err := g.AccountStore.LoadAccount(sess.Account.Name)
+ if err != nil {
+ sess.WriteLine(fmt.Sprintf("Error renaming: %v", err))
+ g.showMenu(sess)
+ return
+ }
for i, c := range acc.Characters {
if c == oldName {
acc.Characters[i] = newName
@@ -250,7 +261,13 @@ func (g *Game) handleDeleteChar(sess *net.Session, input string) {
return
}
- acc, _ := g.AccountStore.LoadAccount(sess.Account.Name)
+ acc, err := g.AccountStore.LoadAccount(sess.Account.Name)
+ if err != nil {
+ sess.WriteLine(fmt.Sprintf("Error deleting: %v", err))
+ sess.PendingChar = ""
+ g.showMenu(sess)
+ return
+ }
var newChars []string
for _, c := range acc.Characters {
if c != sess.PendingChar {
diff --git a/internal/game/core_utils.go b/internal/game/core_utils.go
index 4c087e8..2855bcf 100644
--- a/internal/game/core_utils.go
+++ b/internal/game/core_utils.go
@@ -44,6 +44,27 @@ func mobDisplayName(m *world.MobInstance, definite bool) string {
return "a " + m.Name
}
+// mobDisplayNameCap is like mobDisplayName but capitalizes the article for use
+// at the start of a sentence (e.g. "The goblin hits you.").
+func mobDisplayNameCap(m *world.MobInstance, definite bool) string {
+ if m.Unique {
+ return m.Name
+ }
+ if definite {
+ return "The " + m.Name
+ }
+ return "A " + m.Name
+}
+
+// itemDisplayName returns an item's display name for the given id, falling back
+// to the id itself if the item cannot be loaded.
+func (g *Game) itemDisplayName(itemID string) string {
+ if def, err := g.ItemStore.Load(itemID); err == nil && def != nil {
+ return def.Name
+ }
+ return itemID
+}
+
func mobCombatLevel(m *world.MobInstance) int {
base := float64(m.Defense+m.MaxHP) / 4.0
melee := float64(m.Attack+m.Strength) / 4.0
diff --git a/internal/game/look_target.go b/internal/game/look_target.go
index a93d969..bf130be 100644
--- a/internal/game/look_target.go
+++ b/internal/game/look_target.go
@@ -351,7 +351,7 @@ func (g *Game) showItemStats(sess *net.Session, def *item.ItemDef) {
if def.AttackType() != "" {
sess.WriteLine(fmt.Sprintf("Attack type: %s", def.AttackType()))
}
- if def.Speed()> 0 {
+ if def.Speed() > 0 {
sess.WriteLine(fmt.Sprintf("Speed: %.0f", def.Speed()))
}
if len(def.Requirements) > 0 {
diff --git a/internal/game/map_test.go b/internal/game/map_test.go
index 712202b..159214c 100644
--- a/internal/game/map_test.go
+++ b/internal/game/map_test.go
@@ -83,10 +83,10 @@ func TestMapConnectorGlyphs(t *testing.T) {
wantAbsent: []string{"X", "<", ">"},
},
{
- name: "outward open, inward blocked -> outward arrow (dist rules)",
- room1: "name: One\nexits:\n east: 2\n",
- room2: "name: Two\nexits:\n west: 1\n north: 3\n",
- room3: condSouth,
+ name: "outward open, inward blocked -> outward arrow (dist rules)",
+ room1: "name: One\nexits:\n east: 2\n",
+ room2: "name: Two\nexits:\n west: 1\n north: 3\n",
+ room3: condSouth,
flagOpen: false,
// dist[room1]=0, dist[room2]=1, dist[room3]=2.
// Link 2↔3: near=2 (dist1), far=3 (dist2).
diff --git a/internal/game/render_map.go b/internal/game/render_map.go
index b4b8c93..179277a 100644
--- a/internal/game/render_map.go
+++ b/internal/game/render_map.go
@@ -10,15 +10,15 @@ import (
)
type mapGlyphs struct {
- topLeft, topRight rune
- bottomLeft, bottomRight rune
- side rune
- topFill rune
- connectorH, connectorV rune
- upArrow, downArrow rune
- leftArrow, rightArrow rune
- upRight, upLeft rune
- downRight, downLeft rune
+ topLeft, topRight rune
+ bottomLeft, bottomRight rune
+ side rune
+ topFill rune
+ connectorH, connectorV rune
+ upArrow, downArrow rune
+ leftArrow, rightArrow rune
+ upRight, upLeft rune
+ downRight, downLeft rune
connectorNE, connectorNW rune
}
@@ -203,7 +203,6 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string
}
}
-
cur, _ := loadRoom(g, roomID)
if cur != nil {
_, hasUp := exitTarget(cur, world.Up)
diff --git a/internal/game/sys_hazard.go b/internal/game/sys_hazard.go
index 971da99..6e9c40d 100644
--- a/internal/game/sys_hazard.go
+++ b/internal/game/sys_hazard.go
@@ -75,19 +75,19 @@ 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 = "crush"
+ attackType = combat.DefaultAttackType
}
- attRoll := combat.EffectiveRoll(hz.Attack, 0, hz.AttackBonus)
+ attRoll := combat.AttackRoll(combat.NPCEffective(hz.Attack), hz.AttackBonus)
_, _, defStyleBonus := combat.AttackStyleBonus(string(p.AttackStyle))
totals := g.playerEquipBonuses(p)
equipDef := combat.SelectBonus(attackType,
totals.StabDefense, totals.SlashDefense, totals.CrushDefense,
totals.ScienceDefense, totals.RangedDefense)
- defRoll := combat.EffectiveRoll(
+ defRoll := combat.AttackRoll(combat.PlayerEffective(
p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"),
- defStyleBonus, equipDef)
+ defStyleBonus), equipDef)
if combat.HitCheck(attRoll, defRoll) {
g.applyHazardHit(sess, p, hz)
diff --git a/internal/game/sys_safespot.go b/internal/game/sys_safespot.go
index cdb954b..41fe19f 100644
--- a/internal/game/sys_safespot.go
+++ b/internal/game/sys_safespot.go
@@ -50,7 +50,11 @@ func (g *Game) safespotBlocksHazard(p *player.Player) bool {
return g.isSafespotted(p.Name)
}
-func (g *Game) safespotBlocksMob(p *player.Player, mob *world.MobInstance) bool {
+// safespotBlocksMelee reports whether the player's active safespot shields them
+// from this mob's melee attacks (based on cover size vs mob size). It does NOT
+// consider the mob's attack types — a mob whose melee is blocked may still be
+// able to attack with ranged/science (see effectiveMobAttackType).
+func (g *Game) safespotBlocksMelee(p *player.Player, mob *world.MobInstance) bool {
ss, ok := g.safespot.Get(p.Name)
if !ok || !ss.Active || ss.HideCountdown > 0 {
return false
@@ -61,15 +65,17 @@ func (g *Game) safespotBlocksMob(p *player.Player, mob *world.MobInstance) bool
return false
}
- if !blocksMob(objDef.Safespot.MaxBlockSize, mob.Size) {
- return false
- }
+ return blocksMob(objDef.Safespot.MaxBlockSize, mob.Size)
+}
- if mob.AttackType == "ranged" || mob.AttackType == "science" {
+// safespotBlocksMob reports whether the player's active safespot fully prevents
+// this mob from attacking at all. This is true only when the safespot blocks the
+// mob's melee AND the mob has no ranged/science attack type to switch to.
+func (g *Game) safespotBlocksMob(p *player.Player, mob *world.MobInstance) bool {
+ if !g.safespotBlocksMelee(p, mob) {
return false
}
-
- return true
+ return mobStrongestRangedScience(mob) == ""
}
func (g *Game) sessionInRoom(name string, roomSessions []*net.Session) *net.Session {
diff --git a/internal/game/sys_science.go b/internal/game/sys_science.go
index bded8c4..a616b42 100644
--- a/internal/game/sys_science.go
+++ b/internal/game/sys_science.go
@@ -25,15 +25,15 @@ const (
)
type ModDef struct {
- ID string `yaml:"id"`
- Name string `yaml:"name"`
- Level int `yaml:"level"`
- MaxHit int `yaml:"max_hit"`
- BaseXP int `yaml:"base_xp"`
- JunkCost map[string]int `yaml:"junk_cost"`
- Category ModCategory `yaml:"category"`
- Element string `yaml:"element"`
- Destination int `yaml:"destination"`
+ ID string `yaml:"id"`
+ Name string `yaml:"name"`
+ Level int `yaml:"level"`
+ MaxHit int `yaml:"max_hit"`
+ BaseXP int `yaml:"base_xp"`
+ JunkCost map[string]int `yaml:"junk_cost"`
+ Category ModCategory `yaml:"category"`
+ Element string `yaml:"element"`
+ Destination int `yaml:"destination"`
Sequence []behavior.ModTriggerStep `yaml:"sequence"`
}
@@ -44,8 +44,7 @@ var modByID map[string]*ModDef
func (g *Game) LoadMods() error {
dir := filepath.Join(g.DataDir, "modules")
AllMods = nil
- modByID = make(map[string]*ModDef)
- behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error {
+ if err := behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error {
var m ModDef
if err := yaml.Unmarshal(data, &m); err != nil {
return nil
@@ -53,7 +52,9 @@ func (g *Game) LoadMods() error {
m.ID = id
AllMods = append(AllMods, &m)
return nil
- })
+ }); err != nil {
+ return err
+ }
modByID = make(map[string]*ModDef, len(AllMods))
for _, m := range AllMods {
modByID[m.ID] = m
diff --git a/internal/game/sys_technology.go b/internal/game/sys_technology.go
index 9e6a635..c7214af 100644
--- a/internal/game/sys_technology.go
+++ b/internal/game/sys_technology.go
@@ -8,9 +8,9 @@ import (
"gopkg.in/yaml.v3"
"thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/combat"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
- "thehouseoficarus/internal/world"
)
type TechEffects struct {
@@ -44,8 +44,7 @@ var techByID map[string]*TechDef
func (g *Game) LoadTechs() error {
dir := filepath.Join(g.DataDir, "techs")
AllTechs = nil
- techByID = make(map[string]*TechDef)
- behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error {
+ if err := behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error {
var t TechDef
if err := yaml.Unmarshal(data, &t); err != nil {
return nil
@@ -53,7 +52,9 @@ func (g *Game) LoadTechs() error {
t.ID = id
AllTechs = append(AllTechs, &t)
return nil
- })
+ }); err != nil {
+ return err
+ }
techByID = make(map[string]*TechDef, len(AllTechs))
for _, t := range AllTechs {
techByID[t.ID] = t
@@ -199,8 +200,8 @@ func (g *Game) tryRecharge(sess *net.Session, p *player.Player, input string) bo
return false
}
-func (g *Game) applyTechProtection(p *player.Player, mob *world.MobInstance, dmg int) int {
- return g.damageAfterTechProtection(p, mob.AttackType, dmg)
+func (g *Game) applyTechProtection(p *player.Player, attackType string, dmg int) int {
+ return g.damageAfterTechProtection(p, attackType, dmg)
}
// damageAfterTechProtection reduces incoming damage if the player has the
@@ -220,16 +221,16 @@ func (g *Game) damageAfterTechProtection(p *player.Player, attackType string, dm
}
if attackType == "" {
- attackType = "crush"
+ attackType = combat.DefaultAttackType
}
var protectTechID string
- switch attackType {
- case "stab", "slash", "crush":
+ switch {
+ case combat.IsMeleeType(attackType):
protectTechID = "protect_melee"
- case "ranged":
+ case attackType == combat.AttackRanged:
protectTechID = "protect_ranged"
- case "science":
+ case attackType == combat.AttackScience:
protectTechID = "protect_science"
}
diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go
index cd1bbd3..7c77fc9 100644
--- a/internal/game/sys_triggers.go
+++ b/internal/game/sys_triggers.go
@@ -267,8 +267,8 @@ func (g *Game) spawnWorldTriggerMob(cfg *world.SpawnMobConfig, roomID int) {
inst.ResetDespawnCounter(engine.ToTicks(cfg.DespawnTicks))
if g.Hub != nil {
for _, sess := range g.Hub.PlayersInRoom(roomID) {
- sess.WriteLine(g.colorize(sess, "broadcast",
- mobDisplayName(inst, true)+" appears!"))
+ sess.WriteLine(g.colorize(sess, "broadcast",
+ mobDisplayName(inst, true)+" appears!"))
}
}
}
@@ -287,8 +287,8 @@ func (g *Game) despawnTriggerMobs(mobID string, owner string) {
}
if g.Hub != nil {
for _, sess := range g.Hub.PlayersInRoom(inst.RoomID) {
- sess.WriteLine(g.colorize(sess, "broadcast",
- fmt.Sprintf("%s vanishes.", mobDisplayName(inst, true))))
+ sess.WriteLine(g.colorize(sess, "broadcast",
+ fmt.Sprintf("%s vanishes.", mobDisplayName(inst, true))))
}
}
g.MobStore.RemoveInstance(inst.InstanceID)
diff --git a/internal/game/tick.go b/internal/game/tick.go
index eba8438..4db5b58 100644
--- a/internal/game/tick.go
+++ b/internal/game/tick.go
@@ -111,7 +111,7 @@ func (g *Game) WanderTick() {
toRoom: toRoom,
level: mobCombatLevel(inst),
})
- inst.RoomID = toRoom
+ g.MobStore.SetInstanceRoom(inst.InstanceID, toRoom)
}
// Object wandering (fishing spots, etc.)
@@ -270,8 +270,8 @@ func (g *Game) TechTick() {
if p.Battery <= 0 {
p.Battery = 0
p.DeactivateAllTechs()
- sess.WriteLine(g.colorize(sess, "tech_depleted",
- "Your battery is depleted! All tech has been disabled."))
+ sess.WriteLine(g.colorize(sess, "tech_depleted",
+ "Your battery is depleted! All tech has been disabled."))
g.writePrompt(sess)
}
}
diff --git a/internal/item/item.go b/internal/item/item.go
index 8edae92..77f462a 100644
--- a/internal/item/item.go
+++ b/internal/item/item.go
@@ -91,25 +91,25 @@ type ItemDef struct {
Tool *ToolDef `yaml:"tool,omitempty"`
Requirements map[string]int `yaml:"requirements,omitempty"`
- Quality int `yaml:"quality"`
- MaxQuality int `yaml:"max_quality"`
+ Quality int `yaml:"quality"`
+ MaxQuality int `yaml:"max_quality"`
Craft CraftList `yaml:"craft,omitempty"`
- SearchTable string `yaml:"search_table"`
- SearchTicks float64 `yaml:"search_ticks"`
- SearchMessage string `yaml:"search_message"`
+ SearchTable string `yaml:"search_table"`
+ SearchTicks float64 `yaml:"search_ticks"`
+ SearchMessage string `yaml:"search_message"`
HealValue int `yaml:"heal_value"`
EatMessage string `yaml:"eat_message"`
- FarmPatchType string `yaml:"farm_patch_type"`
- FarmLevel int `yaml:"farm_level"`
- FarmPlantXP int `yaml:"farm_plant_xp"`
- FarmHarvestXP int `yaml:"farm_harvest_xp"`
- FarmStages int `yaml:"farm_stages"`
- FarmProduct string `yaml:"farm_product"`
- FarmMinYield int `yaml:"farm_min_yield"`
- FarmMaxYield int `yaml:"farm_max_yield"`
+ FarmPatchType string `yaml:"farm_patch_type"`
+ FarmLevel int `yaml:"farm_level"`
+ FarmPlantXP int `yaml:"farm_plant_xp"`
+ FarmHarvestXP int `yaml:"farm_harvest_xp"`
+ FarmStages int `yaml:"farm_stages"`
+ FarmProduct string `yaml:"farm_product"`
+ FarmMinYield int `yaml:"farm_min_yield"`
+ FarmMaxYield int `yaml:"farm_max_yield"`
PotionEffect string `yaml:"potion_effect"`
PotionBonus int `yaml:"potion_bonus"`
@@ -276,22 +276,22 @@ type SuccessFormula struct {
}
type CraftDef struct {
- Type string `yaml:"type"`
- Subtype string `yaml:"subtype,omitempty"`
- Level int `yaml:"level"`
- XP int `yaml:"xp"`
- TicksPerCycle float64 `yaml:"ticks_per_cycle"`
- Station []string `yaml:"station"`
- Tool string `yaml:"tool"`
- Ingredients []IngredientEntry `yaml:"ingredients"`
- OutputQty int `yaml:"output_qty"`
- Fail string `yaml:"fail"`
- SuccessMessage string `yaml:"success_message"`
- FailMessage string `yaml:"fail_message"`
- StartMessage string `yaml:"start_message"`
- EndMessage string `yaml:"end_message"`
- Steps []CraftStep `yaml:"steps"`
- Success *SuccessFormula `yaml:"success"`
+ Type string `yaml:"type"`
+ Subtype string `yaml:"subtype,omitempty"`
+ Level int `yaml:"level"`
+ XP int `yaml:"xp"`
+ TicksPerCycle float64 `yaml:"ticks_per_cycle"`
+ Station []string `yaml:"station"`
+ Tool string `yaml:"tool"`
+ Ingredients []IngredientEntry `yaml:"ingredients"`
+ OutputQty int `yaml:"output_qty"`
+ Fail string `yaml:"fail"`
+ SuccessMessage string `yaml:"success_message"`
+ FailMessage string `yaml:"fail_message"`
+ StartMessage string `yaml:"start_message"`
+ EndMessage string `yaml:"end_message"`
+ Steps []CraftStep `yaml:"steps"`
+ Success *SuccessFormula `yaml:"success"`
}
func (c *CraftDef) EffectiveSkill() string {
diff --git a/internal/item/store.go b/internal/item/store.go
index b6e5567..e50e6e9 100644
--- a/internal/item/store.go
+++ b/internal/item/store.go
@@ -4,12 +4,14 @@ import (
"fmt"
"os"
"path/filepath"
+ "sync"
"gopkg.in/yaml.v3"
"thehouseoficarus/internal/behavior"
)
type ItemStore struct {
+ mu sync.Mutex
dataDir string
pathIndex map[string]string
cache map[string]*ItemDef
@@ -25,6 +27,8 @@ func NewItemStore(dataDir string) *ItemStore {
}
func (s *ItemStore) Load(id string) (*ItemDef, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
if def, ok := s.cache[id]; ok {
return def, nil
}
@@ -46,6 +50,8 @@ func (s *ItemStore) Load(id string) (*ItemDef, error) {
}
func (s *ItemStore) LoadAll() ([]*ItemDef, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
dir := filepath.Join(s.dataDir, "items")
var defs []*ItemDef
err := behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error {
@@ -65,12 +71,21 @@ func (s *ItemStore) LoadAll() ([]*ItemDef, error) {
return defs, err
}
+// PathIndex returns a copy of the id→path index, safe to iterate concurrently.
func (s *ItemStore) PathIndex() map[string]string {
- return s.pathIndex
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ out := make(map[string]string, len(s.pathIndex))
+ for id, p := range s.pathIndex {
+ out[id] = p
+ }
+ return out
}
func (s *ItemStore) IDSet() map[string]bool {
- ids := make(map[string]bool)
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ ids := make(map[string]bool, len(s.pathIndex))
for id := range s.pathIndex {
ids[id] = true
}
@@ -78,6 +93,8 @@ func (s *ItemStore) IDSet() map[string]bool {
}
func (s *ItemStore) Reload(dataDir string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
s.cache = make(map[string]*ItemDef)
s.pathIndex = behavior.BuildPathIndex(filepath.Join(dataDir, "items"))
}
diff --git a/internal/object/store.go b/internal/object/store.go
index 61cb007..997c324 100644
--- a/internal/object/store.go
+++ b/internal/object/store.go
@@ -4,12 +4,14 @@ import (
"fmt"
"os"
"path/filepath"
+ "sync"
"gopkg.in/yaml.v3"
"thehouseoficarus/internal/behavior"
)
type ObjectStore struct {
+ mu sync.Mutex
dataDir string
pathIndex map[string]string
cache map[string]*ObjectDef
@@ -24,6 +26,8 @@ func NewObjectStore(dataDir string) *ObjectStore {
}
func (s *ObjectStore) Load(id string) (*ObjectDef, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
if def, ok := s.cache[id]; ok {
return def, nil
}
@@ -44,12 +48,21 @@ func (s *ObjectStore) Load(id string) (*ObjectDef, error) {
return &def, nil
}
+// PathIndex returns a copy of the id→path index, safe to iterate concurrently.
func (s *ObjectStore) PathIndex() map[string]string {
- return s.pathIndex
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ out := make(map[string]string, len(s.pathIndex))
+ for id, p := range s.pathIndex {
+ out[id] = p
+ }
+ return out
}
func (s *ObjectStore) IDSet() map[string]bool {
- ids := make(map[string]bool)
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ ids := make(map[string]bool, len(s.pathIndex))
for id := range s.pathIndex {
ids[id] = true
}
@@ -57,6 +70,8 @@ func (s *ObjectStore) IDSet() map[string]bool {
}
func (s *ObjectStore) Reload(dataDir string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
s.cache = make(map[string]*ObjectDef)
s.pathIndex = behavior.BuildPathIndex(filepath.Join(dataDir, "objects"))
}
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index a8f973f..ccf996c 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -7,6 +7,7 @@ import (
"thehouseoficarus/internal/behavior"
"thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/combat"
"thehouseoficarus/internal/world"
)
@@ -292,6 +293,57 @@ func validateRoomObjectCollisions(roomID int, entries []roomObjEntry) []Issue {
return issues
}
+// validateMobAttackTypes enforces the mob attack_types spec: a non-empty list
+// containing exactly one melee type (stab/slash/crush) plus at most one each of
+// the optional ranged/science types, with no invalid or duplicate entries.
+func validateMobAttackTypes(id string, types []string) []Issue {
+ var issues []Issue
+
+ if len(types) == 0 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: missing attack_types (must be a list with exactly one of stab/slash/crush, optionally plus ranged/science)", id),
+ })
+ return issues
+ }
+
+ meleeCount := 0
+ seen := map[string]bool{}
+ for _, t := range types {
+ if !combat.IsValidAttackType(t) {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: invalid attack_types entry %q (must be stab/slash/crush/ranged/science)", id, t),
+ })
+ continue
+ }
+ if seen[t] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: duplicate attack_types entry %q", id, t),
+ })
+ continue
+ }
+ seen[t] = true
+ if combat.IsMeleeType(t) {
+ meleeCount++
+ }
+ }
+
+ if meleeCount != 1 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: attack_types must contain exactly one melee type (stab/slash/crush), found %d", id, meleeCount),
+ })
+ }
+
+ return issues
+}
+
func validateMobs(s Source) []Issue {
var issues []Issue
itemIDs := s.Items.IDSet()
@@ -336,6 +388,10 @@ func validateMobs(s Source) []Issue {
})
}
+ if def.Combat != nil && def.Combat.Kind != "task" {
+ issues = append(issues, validateMobAttackTypes(id, def.Combat.AttackTypes)...)
+ }
+
if def.Drops.Remains != "" && !itemIDs[def.Drops.Remains] {
issues = append(issues, Issue{
Level: "ERROR",
@@ -427,7 +483,6 @@ func validateMobs(s Source) []Issue {
func validateHazards(s Source) []Issue {
var issues []Issue
itemIDs := s.Items.IDSet()
- validTypes := map[string]bool{"stab": true, "slash": true, "crush": true, "ranged": true, "science": true}
for id := range s.World.HazardIndex() {
def, err := s.World.LoadHazard(id)
@@ -439,7 +494,7 @@ func validateHazards(s Source) []Issue {
})
continue
}
- if def.AttackType != "" && !validTypes[def.AttackType] {
+ if def.AttackType != "" && !combat.IsValidAttackType(def.AttackType) {
issues = append(issues, Issue{
Level: "ERROR",
Type: "reference",
diff --git a/internal/validate/grid_test.go b/internal/validate/grid_test.go
index 716cec1..e40c50b 100644
--- a/internal/validate/grid_test.go
+++ b/internal/validate/grid_test.go
@@ -141,13 +141,13 @@ func TestGrid3DTwistUpDown(t *testing.T) {
// path2: 1 east→A(1,0,0) up→B(1,0,1) east→C(2,0,1) south→4 wants (2,1,1)
// 4 already at (1,1,1) from path1 → twist.
rooms := map[int]string{
- 1: "exits:\n up: 2\n east: 6\n",
- 2: "exits:\n south: 3\n down: 1\n",
- 3: "exits:\n east: 4\n north: 2\n",
- 4: "name: four\n",
- 6: "exits:\n up: 7\n west: 1\n",
- 7: "exits:\n east: 8\n down: 6\n",
- 8: "exits:\n south: 4\n west: 7\n",
+ 1: "exits:\n up: 2\n east: 6\n",
+ 2: "exits:\n south: 3\n down: 1\n",
+ 3: "exits:\n east: 4\n north: 2\n",
+ 4: "name: four\n",
+ 6: "exits:\n up: 7\n west: 1\n",
+ 7: "exits:\n east: 8\n down: 6\n",
+ 8: "exits:\n south: 4\n west: 7\n",
}
issues := runGridCheck(t, rooms, 1)
if !containsMsg(issues, "Grid twist") {
diff --git a/internal/validate/mob_attacktype_test.go b/internal/validate/mob_attacktype_test.go
new file mode 100644
index 0000000..1464d3f
--- /dev/null
+++ b/internal/validate/mob_attacktype_test.go
@@ -0,0 +1,31 @@
+package validate
+
+import "testing"
+
+func TestValidateMobAttackTypes(t *testing.T) {
+ cases := []struct {
+ name string
+ types []string
+ wantError bool
+ }{
+ {"single melee", []string{"crush"}, false},
+ {"melee plus ranged", []string{"stab", "ranged"}, false},
+ {"melee plus both", []string{"slash", "ranged", "science"}, false},
+ {"empty", nil, true},
+ {"no melee", []string{"ranged"}, true},
+ {"two melee", []string{"stab", "crush"}, true},
+ {"invalid entry", []string{"crush", "magic"}, true},
+ {"duplicate", []string{"crush", "ranged", "ranged"}, true},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ issues := validateMobAttackTypes("test_mob", c.types)
+ if c.wantError && len(issues) == 0 {
+ t.Errorf("validateMobAttackTypes(%v): expected error, got none", c.types)
+ }
+ if !c.wantError && len(issues) != 0 {
+ t.Errorf("validateMobAttackTypes(%v): expected no error, got %v", c.types, issues)
+ }
+ })
+ }
+}
diff --git a/internal/world/mob.go b/internal/world/mob.go
index 19ec935..05650c0 100644
--- a/internal/world/mob.go
+++ b/internal/world/mob.go
@@ -40,47 +40,51 @@ type MobCombatBonuses struct {
}
type MobCombatDefenses struct {
- StabDefense int `yaml:"stab_defense"`
- SlashDefense int `yaml:"slash_defense"`
- CrushDefense int `yaml:"crush_defense"`
- ScienceDefense int `yaml:"science_defense"`
- RangedDefense int `yaml:"ranged_defense"`
- Weakness string `yaml:"weakness"`
+ StabDefense int `yaml:"stab_defense"`
+ SlashDefense int `yaml:"slash_defense"`
+ CrushDefense int `yaml:"crush_defense"`
+ ScienceDefense int `yaml:"science_defense"`
+ RangedDefense int `yaml:"ranged_defense"`
+ Weakness string `yaml:"weakness"`
+ WeaknessPercent int `yaml:"weakness_percent"`
}
type MobCombatStats struct {
- HP int `yaml:"hp"`
- Attack int `yaml:"attack"`
- Strength int `yaml:"strength"`
- Defense int `yaml:"defense"`
- Ranged int `yaml:"ranged"`
- Science int `yaml:"science"`
- AttackType string `yaml:"attack_type"`
- Speed float64 `yaml:"speed"`
- Aggressive bool `yaml:"aggressive"`
- RespawnTicks float64 `yaml:"respawn_ticks"`
- Bonuses MobCombatBonuses `yaml:"bonuses"`
- Defenses MobCombatDefenses `yaml:"defenses"`
+ HP int `yaml:"hp"`
+ Attack int `yaml:"attack"`
+ Strength int `yaml:"strength"`
+ Defense int `yaml:"defense"`
+ 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 MobCombatBonuses `yaml:"bonuses"`
+ Defenses MobCombatDefenses `yaml:"defenses"`
}
type MobCombat struct {
- Kind string `yaml:"kind"`
- Stats MobCombatStats `yaml:"stats"`
- AssassinLevel int `yaml:"assassin_level"`
- FinishingBlow string `yaml:"finishing_blow"`
- DamageWithout string `yaml:"damage_without"`
- Size string `yaml:"size"`
- CombatDescriptions []string `yaml:"combat_descriptions"`
+ Kind string `yaml:"kind"`
+ AttackTypes []string `yaml:"attack_types"`
+ Aggressive bool `yaml:"aggressive"`
+ RespawnTicks float64 `yaml:"respawn_ticks"`
+ Stats MobCombatStats `yaml:"stats"`
+ AssassinLevel int `yaml:"assassin_level"`
+ FinishingBlow string `yaml:"finishing_blow"`
+ DamageWithout string `yaml:"damage_without"`
+ Size string `yaml:"size"`
+ CombatDescriptions []string `yaml:"combat_descriptions"`
}
type MobDef struct {
- ID string `yaml:"id"`
- Name string `yaml:"name"`
- Description string `yaml:"description"`
- IdleDescriptions []string `yaml:"idle_descriptions"`
- Protected bool `yaml:"protected"`
- Unique bool `yaml:"unique"`
- Drops MobDropTable `yaml:"drops"`
+ ID string `yaml:"id"`
+ Name string `yaml:"name"`
+ Description string `yaml:"description"`
+ IdleDescriptions []string `yaml:"idle_descriptions"`
+ Protected bool `yaml:"protected"`
+ Unique bool `yaml:"unique"`
+ Drops MobDropTable `yaml:"drops"`
Steal *MobSteal `yaml:"steal,omitempty"`
Task *MobTask `yaml:"task,omitempty"`
@@ -98,35 +102,39 @@ func (d *MobDef) HasShop() bool { return d.Shop != nil }
func (d *MobDef) IsTask() bool { return d.Combat != nil && d.Combat.Kind == "task" }
type MobInstance struct {
- InstanceID string
- DefID string
- Name string
- Description string
- HP int
- MaxHP int
- Attack int
- Strength int
- Defense int
- Ranged int
- Science int
- Speed float64
- Aggressive bool
- Protected bool
- Unique bool
- RespawnTicks float64
- RoomID int
- HomeRoomID int
- Drops MobDropTable
- IdleDescription string
- CombatDescriptions []string
- WanderRooms []int
- WanderInterval float64
- WanderTickCounter int
- regenerateTick int
+ InstanceID string
+ DefID string
+ Name string
+ Description string
+ HP int
+ MaxHP int
+ Attack int
+ Strength int
+ Defense int
+ Ranged int
+ Science int
+ Speed float64
+ Aggressive bool
+ Protected bool
+ Unique bool
+ RespawnTicks float64
+ RoomID int
+ HomeRoomID int
+ Drops MobDropTable
+ IdleDescription string
+ CombatDescriptions []string
+ WanderRooms []int
+ WanderInterval float64
+ WanderTickCounter int
+ regenerateTick int
AttackBonus int
StrengthBonus int
- AttackType string
+ AttackTypes []string
+
+ RangedBonus int
+ ScienceBonus int
+ SciencePercentBonus int
StabDefense int
SlashDefense int
@@ -134,11 +142,16 @@ type MobInstance struct {
ScienceDefense int
RangedDefense int
- Weakness string
- StealTable string
- StealLevel int
- StealXP int
- StealSpeed float64
+ Weakness string
+ WeaknessPercent int
+
+ MaxMeleeHit int
+ MaxRangedHit int
+ MaxScienceHit int
+ StealTable string
+ StealLevel int
+ StealXP int
+ StealSpeed float64
AssassinLevel int
FinishingBlow string
@@ -186,13 +199,20 @@ func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []in
respawnTicks := 0.0
attackBonus := 0
strengthBonus := 0
- attackType := ""
+ rangedBonus := 0
+ scienceBonus := 0
+ sciencePercentBonus := 0
+ var attackType []string
stabDef := 0
slashDef := 0
crushDef := 0
scienceDef := 0
rangedDef := 0
weakness := ""
+ weaknessPercent := 0
+ maxMeleeHit := 0
+ maxRangedHit := 0
+ maxScienceHit := 0
assassinLevel := 0
finishingBlow := ""
damageWithout := ""
@@ -208,6 +228,10 @@ func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []in
stealSpeed := 0.0
if def.Combat != nil {
+ aggressive = def.Combat.Aggressive
+ respawnTicks = def.Combat.RespawnTicks
+ attackType = append([]string(nil), def.Combat.AttackTypes...)
+
cs := def.Combat.Stats
hp = cs.HP
attack = cs.Attack
@@ -216,17 +240,21 @@ func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []in
ranged = cs.Ranged
science = cs.Science
speed = cs.Speed
- aggressive = cs.Aggressive
- respawnTicks = cs.RespawnTicks
attackBonus = cs.Bonuses.AttackBonus
strengthBonus = cs.Bonuses.StrengthBonus
- attackType = cs.AttackType
+ rangedBonus = cs.Bonuses.RangedBonus
+ scienceBonus = cs.Bonuses.ScienceBonus
+ sciencePercentBonus = cs.Bonuses.SciencePercentBonus
stabDef = cs.Defenses.StabDefense
slashDef = cs.Defenses.SlashDefense
crushDef = cs.Defenses.CrushDefense
scienceDef = cs.Defenses.ScienceDefense
rangedDef = cs.Defenses.RangedDefense
weakness = cs.Defenses.Weakness
+ weaknessPercent = cs.Defenses.WeaknessPercent
+ maxMeleeHit = cs.MaxMeleeHit
+ maxRangedHit = cs.MaxRangedHit
+ maxScienceHit = cs.MaxScienceHit
assassinLevel = def.Combat.AssassinLevel
finishingBlow = def.Combat.FinishingBlow
@@ -253,50 +281,57 @@ func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []in
hp = 1
}
inst := &MobInstance{
- InstanceID: instanceID,
- DefID: def.ID,
- Name: def.Name,
- Description: def.Description,
- HP: hp,
- MaxHP: hp,
- Attack: attack,
- Strength: strength,
- Defense: defense,
- Ranged: ranged,
- Science: science,
- Speed: speed,
- Aggressive: aggressive,
- Protected: def.Protected,
- Unique: def.Unique,
- RespawnTicks: respawnTicks,
- RoomID: roomID,
- HomeRoomID: roomID,
- WanderRooms: wanderRooms,
- WanderInterval: wanderInterval,
- Drops: def.Drops,
- AttackBonus: attackBonus,
- StrengthBonus: strengthBonus,
- AttackType: attackType,
- StabDefense: stabDef,
- SlashDefense: slashDef,
- CrushDefense: crushDef,
- ScienceDefense: scienceDef,
- RangedDefense: rangedDef,
- Weakness: weakness,
- StealTable: stealTable,
- StealLevel: stealLevel,
- StealXP: stealXP,
- StealSpeed: stealSpeed,
- AssassinLevel: assassinLevel,
- FinishingBlow: finishingBlow,
- DamageWithout: damageWithout,
- Size: size,
- Kind: kind,
- Verb: verb,
- ProgressNoun: progressNoun,
- CompleteMessage: completeMessage,
- CombatDescriptions: combatDescriptions,
- TalkConfig: def.Talk,
+ InstanceID: instanceID,
+ DefID: def.ID,
+ Name: def.Name,
+ Description: def.Description,
+ HP: hp,
+ MaxHP: hp,
+ Attack: attack,
+ Strength: strength,
+ Defense: defense,
+ Ranged: ranged,
+ Science: science,
+ Speed: speed,
+ Aggressive: aggressive,
+ Protected: def.Protected,
+ Unique: def.Unique,
+ RespawnTicks: respawnTicks,
+ RoomID: roomID,
+ HomeRoomID: roomID,
+ WanderRooms: wanderRooms,
+ WanderInterval: wanderInterval,
+ Drops: def.Drops,
+ AttackBonus: attackBonus,
+ StrengthBonus: strengthBonus,
+ AttackTypes: attackType,
+ RangedBonus: rangedBonus,
+ ScienceBonus: scienceBonus,
+ SciencePercentBonus: sciencePercentBonus,
+ StabDefense: stabDef,
+ SlashDefense: slashDef,
+ CrushDefense: crushDef,
+ ScienceDefense: scienceDef,
+ RangedDefense: rangedDef,
+ Weakness: weakness,
+ WeaknessPercent: weaknessPercent,
+ MaxMeleeHit: maxMeleeHit,
+ MaxRangedHit: maxRangedHit,
+ MaxScienceHit: maxScienceHit,
+ StealTable: stealTable,
+ StealLevel: stealLevel,
+ StealXP: stealXP,
+ StealSpeed: stealSpeed,
+ AssassinLevel: assassinLevel,
+ FinishingBlow: finishingBlow,
+ DamageWithout: damageWithout,
+ Size: size,
+ Kind: kind,
+ Verb: verb,
+ ProgressNoun: progressNoun,
+ CompleteMessage: completeMessage,
+ CombatDescriptions: combatDescriptions,
+ TalkConfig: def.Talk,
}
if def.Shop != nil {
inst.Shop = def.Shop
@@ -434,6 +469,17 @@ func (s *MobStore) AllInstances() []*MobInstance {
return out
}
+// SetInstanceRoom updates a mob instance's room under the store lock, so it is
+// safe against concurrent readers (MobsInRoom, RemoveMobsInRoom) in other
+// goroutines.
+func (s *MobStore) SetInstanceRoom(instanceID string, roomID int) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if inst := s.instances[instanceID]; inst != nil {
+ inst.RoomID = roomID
+ }
+}
+
func (s *MobStore) MobsInRoom(roomID int) []*MobInstance {
s.mu.Lock()
defer s.mu.Unlock()
diff --git a/internal/world/room.go b/internal/world/room.go
index 7cc1f08..9c2e52f 100644
--- a/internal/world/room.go
+++ b/internal/world/room.go
@@ -202,24 +202,24 @@ func (ro *RoomObject) UnmarshalYAML(value *yaml.Node) error {
func (ro RoomObject) MarshalYAML() (interface{}, error) {
if ro.Local != nil {
type inlineObj struct {
- Name string `yaml:"name"`
- Aliases []string `yaml:"aliases,omitempty"`
- Color string `yaml:"color,omitempty"`
- Hidden bool `yaml:"hidden,omitempty"`
- InRoomDescription string `yaml:"inroom_description,omitempty"`
- RemovalItem string `yaml:"removal_item,omitempty"`
- Description behavior.DescList `yaml:"description,omitempty"`
- UseInteractions []object.UseInteraction `yaml:"use_interactions,omitempty"`
- StealTable string `yaml:"steal_table,omitempty"`
- StealLevel int `yaml:"steal_level,omitempty"`
- StealXP int `yaml:"steal_xp,omitempty"`
- StealSpeed float64 `yaml:"steal_speed,omitempty"`
- GuardMob string `yaml:"guard_mob,omitempty"`
- Gather *behavior.GatherConfig `yaml:"gather,omitempty"`
- Talk *behavior.TalkConfig `yaml:"talk,omitempty"`
- Use *behavior.UseConfig `yaml:"use,omitempty"`
- Safespot *object.SafespotConfig `yaml:"safespot,omitempty"`
- OnLook *behavior.NodeAction `yaml:"on_look,omitempty"`
+ Name string `yaml:"name"`
+ Aliases []string `yaml:"aliases,omitempty"`
+ Color string `yaml:"color,omitempty"`
+ Hidden bool `yaml:"hidden,omitempty"`
+ InRoomDescription string `yaml:"inroom_description,omitempty"`
+ RemovalItem string `yaml:"removal_item,omitempty"`
+ Description behavior.DescList `yaml:"description,omitempty"`
+ UseInteractions []object.UseInteraction `yaml:"use_interactions,omitempty"`
+ StealTable string `yaml:"steal_table,omitempty"`
+ StealLevel int `yaml:"steal_level,omitempty"`
+ StealXP int `yaml:"steal_xp,omitempty"`
+ StealSpeed float64 `yaml:"steal_speed,omitempty"`
+ GuardMob string `yaml:"guard_mob,omitempty"`
+ Gather *behavior.GatherConfig `yaml:"gather,omitempty"`
+ Talk *behavior.TalkConfig `yaml:"talk,omitempty"`
+ Use *behavior.UseConfig `yaml:"use,omitempty"`
+ Safespot *object.SafespotConfig `yaml:"safespot,omitempty"`
+ OnLook *behavior.NodeAction `yaml:"on_look,omitempty"`
}
def := ro.Local
return inlineObj{
diff --git a/internal/world/trigger.go b/internal/world/trigger.go
index 7c06be4..8e74ff7 100644
--- a/internal/world/trigger.go
+++ b/internal/world/trigger.go
@@ -33,10 +33,10 @@ type TriggerStep struct {
}
type SpawnMobConfig struct {
- ID string `yaml:"id"`
- OwnerOnly bool `yaml:"owner_only"`
- DespawnOnLeave bool `yaml:"despawn_on_leave"`
- DespawnRooms []int `yaml:"despawn_rooms"`
+ ID string `yaml:"id"`
+ OwnerOnly bool `yaml:"owner_only"`
+ DespawnOnLeave bool `yaml:"despawn_on_leave"`
+ DespawnRooms []int `yaml:"despawn_rooms"`
DespawnTicks float64 `yaml:"despawn_ticks"`
}
diff --git a/internal/world/world.go b/internal/world/world.go
index 3dfa99b..b582faf 100644
--- a/internal/world/world.go
+++ b/internal/world/world.go
@@ -37,7 +37,9 @@ func New(dataDir string) *World {
}
func (w *World) LoadRoom(id int) (*Room, error) {
+ w.mu.Lock()
path, ok := w.roomPathIndex[id]
+ w.mu.Unlock()
if !ok {
return nil, fmt.Errorf("read room %d: no such room", id)
}
@@ -119,7 +121,9 @@ func (w *World) SeedGroundItems(roomID int) {
}
func (w *World) RoomIndex() map[int]bool {
- ids := make(map[int]bool)
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ ids := make(map[int]bool, len(w.roomPathIndex))
for id := range w.roomPathIndex {
ids[id] = true
}
@@ -139,6 +143,8 @@ func (w *World) AddRoomPath(id int, path string) {
}
func (w *World) GetRoomPath(id int) (string, bool) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
path, ok := w.roomPathIndex[id]
return path, ok
}
--
cgit v1.2.3