From c14c2e403c75a913e1a6d962fb6fe64f013adae5 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Wed, 1 Jul 2026 22:30:31 -0400 Subject: fix: combat formulas and mob attack types (mobs switch attack styles if safespotting) --- internal/game/act.go | 14 +- internal/game/act_combine.go | 12 +- internal/game/act_farm.go | 22 +-- internal/game/act_gather.go | 28 ++-- internal/game/act_state.go | 10 +- internal/game/act_steal.go | 12 +- internal/game/act_use.go | 4 +- internal/game/cmd_attack.go | 2 +- internal/game/cmd_dig.go | 1 - internal/game/cmd_room_insert.go | 2 +- internal/game/cmd_smelt.go | 2 +- internal/game/cmd_stats.go | 29 +--- internal/game/cmd_trigger_combat.go | 34 +++- internal/game/cmd_trigger_enchant.go | 5 +- internal/game/cmd_trigger_utility.go | 2 +- internal/game/cmd_use.go | 2 +- internal/game/combat_attack.go | 79 ++++----- internal/game/combat_mob.go | 317 ++++++++++++++++++++++------------- internal/game/combat_mob_test.go | 61 +++++++ internal/game/core_equip.go | 2 +- internal/game/core_login_char.go | 23 ++- internal/game/core_utils.go | 21 +++ internal/game/look_target.go | 2 +- internal/game/map_test.go | 8 +- internal/game/render_map.go | 19 +-- internal/game/sys_hazard.go | 8 +- internal/game/sys_safespot.go | 20 ++- internal/game/sys_science.go | 25 +-- internal/game/sys_technology.go | 23 +-- internal/game/sys_triggers.go | 8 +- internal/game/tick.go | 6 +- 31 files changed, 492 insertions(+), 311 deletions(-) create mode 100644 internal/game/combat_mob_test.go (limited to 'internal/game') 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) } } -- cgit v1.2.3