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/combat_mob.go | 317 +++++++++++++++++++++++++++----------------- 1 file changed, 196 insertions(+), 121 deletions(-) (limited to 'internal/game/combat_mob.go') 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) } -- cgit v1.2.3