aboutsummaryrefslogtreecommitdiff
path: root/internal/game/combat_mob.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-01 22:30:31 -0400
committerhistoria <[not public]>2026-07-01 22:30:31 -0400
commitc14c2e403c75a913e1a6d962fb6fe64f013adae5 (patch)
tree75070293fe2535b594ac72e501cbf08a3f1146e0 /internal/game/combat_mob.go
parent8eec7b75ff9c8c368b252373db45fb891732d2ca (diff)
downloadthehouseoficarus-c14c2e403c75a913e1a6d962fb6fe64f013adae5.tar.gz
fix: combat formulas and mob attack types (mobs switch attack styles if safespotting)
Diffstat (limited to 'internal/game/combat_mob.go')
-rw-r--r--internal/game/combat_mob.go317
1 files changed, 196 insertions, 121 deletions
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)
}