diff options
Diffstat (limited to 'internal')
56 files changed, 1098 insertions, 598 deletions
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 = '<div class="obj-inline-group">'; + h += '<span class="obj-inline-label">Atk Type</span>'; + h += '<div class="obj-card-grid" style="margin-top:4px">'; + ['stab','slash','crush'].forEach(function(m) { + h += '<label class="obj-field obj-check obj-narrow"><input type="radio" name="mob_atktype_melee" value="' + m + '"' + (melee === m ? ' checked' : '') + '> <span>' + m + '</span></label>'; + }); + h += '</div>'; + h += '<div class="obj-card-grid" style="margin-top:4px">'; + h += '<label class="obj-field obj-check obj-narrow"><input type="checkbox" id="mob_atktype_ranged"' + (hasRanged ? ' checked' : '') + '> <span>ranged</span></label>'; + h += '<label class="obj-field obj-check obj-narrow"><input type="checkbox" id="mob_atktype_science"' + (hasScience ? ' checked' : '') + '> <span>science</span></label>'; + h += '</div>'; + h += '</div>'; + 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 = '<div class="obj-card-grid" style="margin-top:6px">'; h += renderMobField(sec, byKey['kind'], data, -1); + h += renderMobField(sec, byKey['aggressive'], data, -1); + h += renderMobField(sec, byKey['respawn_ticks'], data, -1); h += '</div>'; + h += renderMobAttackTypeWidget(data); + h += '<div class="obj-inline-group">'; h += '<span class="obj-inline-label">Stats</span>'; h += '<div class="obj-card-grid" style="margin-top:4px">'; @@ -287,8 +340,11 @@ function renderMobCombatCard(data) { statKeys.forEach(function(k) { h += renderMobField(sec, byKey[k], data, -1); }); h += '</div>'; h += '<div class="obj-card-grid" style="margin-top:4px">'; - 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 += '</div>'; + h += '<div class="obj-card-grid" style="margin-top:4px">'; + var statKeys3 = ['stats.max_melee_hit','stats.max_ranged_hit','stats.max_science_hit']; + statKeys3.forEach(function(k) { h += renderMobField(sec, byKey[k], data, -1); }); h += '</div>'; h += '</div>'; @@ -310,6 +366,7 @@ function renderMobCombatCard(data) { h += '</div>'; h += '<div class="obj-card-grid" style="margin-top:4px">'; h += renderMobField(sec, byKey['stats.defenses.weakness'], data, -1); + h += renderMobField(sec, byKey['stats.defenses.weakness_percent'], data, -1); h += '</div>'; h += '</div>'; @@ -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 } |
