aboutsummaryrefslogtreecommitdiff
path: root/internal/game/combat_attack.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-25 15:40:48 -0400
committerhistoria <[not public]>2026-06-25 15:40:48 -0400
commitabd612c15799f604e671e83dc7c410ed2b44185f (patch)
tree0597ca92350de73aac2a7cf26b2f2d6595dac0cc /internal/game/combat_attack.go
parent2725e2927a1595c7b100d942d1f14146252adeb7 (diff)
downloadthehouseoficarus-abd612c15799f604e671e83dc7c410ed2b44185f.tar.gz
slop refactor
Diffstat (limited to 'internal/game/combat_attack.go')
-rw-r--r--internal/game/combat_attack.go449
1 files changed, 449 insertions, 0 deletions
diff --git a/internal/game/combat_attack.go b/internal/game/combat_attack.go
new file mode 100644
index 0000000..ee7dee8
--- /dev/null
+++ b/internal/game/combat_attack.go
@@ -0,0 +1,449 @@
+package game
+
+import (
+ "fmt"
+ "math/rand"
+ "sort"
+ "strconv"
+ "strings"
+
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/engine"
+ "thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+func (g *Game) doAttack(sess *net.Session, input string) {
+ p := sess.Player
+
+ if g.Combat.Get(p.Name) != nil {
+ sess.WriteLine("You are already in combat!")
+ return
+ }
+
+ if p.Action != nil {
+ g.cancelAction(p)
+ }
+
+ mob := g.findMob(sess, input, p.RoomID)
+ if mob == nil {
+ return
+ }
+
+ if mob.HP <= 0 {
+ if mob.IsTask() {
+ sess.WriteLine(fmt.Sprintf("%s is already complete.", mobDisplayName(mob, true)))
+ } else {
+ sess.WriteLine("That is already dead.")
+ }
+ return
+ }
+
+ if mob.Protected {
+ sess.WriteLine(fmt.Sprintf("You can't attack %s!", mobDisplayName(mob, true)))
+ return
+ }
+
+ if mob.AssassinLevel > 0 && p.Level(player.Assassin) < mob.AssassinLevel {
+ sess.WriteLine(fmt.Sprintf("You need Assassin level %d to attack %s.", mob.AssassinLevel, mobDisplayName(mob, true)))
+ return
+ }
+
+ if g.Combat.IsMobInCombat(mob.InstanceID) {
+ sess.WriteLine(fmt.Sprintf("%s is already engaged in combat!", mobDisplayName(mob, false)))
+ return
+ }
+
+ if itemID, ok := p.Equipment[item.SlotMainHand]; ok {
+ if def, err := g.ItemStore.Load(itemID); err == nil && def.WeaponType == item.WeaponRanged {
+ if _, hasAmmo := p.Equipment[item.SlotAmmo]; !hasAmmo || p.AmmoQty <= 0 {
+ sess.WriteLine("You don't have any ammo equipped.")
+ return
+ }
+ }
+ }
+
+ g.startCombat(sess, p, mob)
+}
+
+func (g *Game) findMob(sess *net.Session, input string, roomID int) *world.MobInstance {
+ lower := strings.ToLower(input)
+ mobs := g.MobStore.MobsInRoom(roomID)
+
+ idx := -1
+ name := lower
+ if dotPos := strings.Index(lower, "."); dotPos > 0 {
+ if n, err := strconv.Atoi(lower[:dotPos]); err == nil && n > 0 {
+ idx = n
+ name = lower[dotPos+1:]
+ }
+ }
+
+ var exact []*world.MobInstance
+ var prefix []*world.MobInstance
+ for _, m := range mobs {
+ q := m.MatchQuality(name)
+ if q == world.MatchExact {
+ exact = append(exact, m)
+ } else if q == world.MatchPrefix {
+ prefix = append(prefix, m)
+ }
+ }
+
+ candidates := exact
+ if len(candidates) == 0 {
+ candidates = prefix
+ }
+
+ if len(candidates) == 0 {
+ sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input))
+ return nil
+ }
+
+ sort.Slice(candidates, func(i, j int) bool {
+ return candidates[i].InstanceID < candidates[j].InstanceID
+ })
+
+ if idx > 0 {
+ if idx-1 < len(candidates) {
+ return candidates[idx-1]
+ }
+ return nil
+ }
+
+ if len(candidates) == 1 {
+ return candidates[0]
+ }
+
+ seen := make(map[string]bool)
+ for _, m := range candidates {
+ seen[mobDisplayName(m, false)] = true
+ }
+ if len(seen) > 1 {
+ sess.WriteLine("Which one?")
+ return nil
+ }
+ return candidates[0]
+}
+
+func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) {
+ g.cancelRest(p.Name)
+ g.cancelBgAction(p)
+
+ g.Combat.Enter(p.Name, mob.InstanceID)
+ p.AttackTimer = 0
+
+ isTask := mob.IsTask()
+ autotriggerActive := p.AutotriggerMod != ""
+
+ if !autotriggerActive {
+ attBonus, strBonus, defBonus := combat.AttackStyleBonus(string(p.AttackStyle))
+ var styleParts []string
+ if attBonus > 0 {
+ styleParts = append(styleParts, fmt.Sprintf("+%d atk", attBonus))
+ }
+ if strBonus > 0 {
+ styleParts = append(styleParts, fmt.Sprintf("+%d str", strBonus))
+ }
+ if defBonus > 0 {
+ styleParts = append(styleParts, fmt.Sprintf("+%d def", defBonus))
+ }
+ styleStr := ""
+ if len(styleParts) > 0 {
+ styleStr = " Style: " + string(p.AttackStyle) + " (" + strings.Join(styleParts, ", ") + ")"
+ }
+ if isTask {
+ sess.WriteLine(fmt.Sprintf("You begin to %s %s.%s", taskWorkVerb(mob), g.colorize(sess, "mob", mobDisplayName(mob, true)), styleStr))
+ } else {
+ sess.WriteLine(fmt.Sprintf("You attack %s!%s", g.colorize(sess, "mob", mobDisplayName(mob, true)), styleStr))
+ }
+ } else {
+ mod := GetMod(p.AutotriggerMod)
+ modName := p.AutotriggerMod
+ if mod != nil {
+ modName = mod.Name
+ }
+ if isTask {
+ sess.WriteLine(fmt.Sprintf("You begin to %s %s using %s!",
+ taskWorkVerb(mob),
+ g.colorize(sess, "mob", mobDisplayName(mob, true)),
+ g.colorize(sess, "science_mod", modName)))
+ } else {
+ sess.WriteLine(fmt.Sprintf("You attack %s with %s!",
+ g.colorize(sess, "mob", mobDisplayName(mob, true)),
+ g.colorize(sess, "science_mod", modName)))
+ }
+ }
+ if isTask {
+ g.broadcastAction(sess, "\n%s begins working on %s.", p.Name, mobDisplayName(mob, true))
+ } else {
+ g.broadcastAction(sess, "\n%s attacks %s!", p.Name, mobDisplayName(mob, true))
+ }
+
+ g.Ticks.Subscribe(1, func() bool {
+ cs := g.Combat.Get(p.Name)
+ if cs == nil || !cs.Active {
+ return false
+ }
+ currentMob := g.MobStore.GetInstance(cs.MobID)
+ if currentMob == nil || currentMob.HP <= 0 {
+ g.endCombat(sess, p, currentMob)
+ return false
+ }
+
+ p.AttackTimer++
+ if float64(p.AttackTimer) >= g.playerWeaponSpeed(p) {
+ p.AttackTimer = 0
+
+ if g.hasDeckEquipped(p) {
+ if p.AutotriggerMod != "" {
+ mod := GetMod(p.AutotriggerMod)
+ 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("\nYou'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",
+ "\nNo autotrigger set! Set a module with the 'autotrigger' command."))
+ g.playerAttackUnarmed(sess, p, currentMob)
+ }
+ } else {
+ sess.WriteLine(g.colorize(sess, "error",
+ "\nNo autotrigger set! Set a module with the 'autotrigger' command."))
+ g.playerAttackUnarmed(sess, p, currentMob)
+ }
+ } else {
+ if p.QueuedTrigger != "" {
+ mod := GetMod(p.QueuedTrigger)
+ p.QueuedTrigger = ""
+ if mod != nil && g.hasJunkCost(p, mod) {
+ g.scienceAttack(sess, p, currentMob, mod)
+ } else {
+ g.playerAttack(sess, p, currentMob)
+ }
+ } else {
+ g.playerAttack(sess, p, currentMob)
+ }
+ }
+ }
+
+ if currentMob.HP <= 0 {
+ g.endCombat(sess, p, currentMob)
+ return false
+ }
+ return true
+ })
+
+ if !isTask {
+ g.Ticks.Subscribe(engine.ToTicks(mob.Speed), func() bool {
+ cs := g.Combat.Get(p.Name)
+ if cs == nil || !cs.Active {
+ return false
+ }
+ currentMob := g.MobStore.GetInstance(cs.MobID)
+ if currentMob == nil || currentMob.HP <= 0 {
+ return false
+ }
+ g.mobAttack(sess, p, currentMob)
+ if p.HP <= 0 {
+ g.endCombat(sess, p, currentMob)
+ return false
+ }
+ return true
+ })
+ }
+}
+
+func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) {
+ if g.processConsumeQueue(p, sess) {
+ return
+ }
+
+ attackType, weaponType, attRoll, defRoll, maxHit := g.calculatePlayerAttackRoll(p, mob)
+
+ if g.isSafespotted(p.Name) && attackType != "ranged" && attackType != "science" {
+ if ss, ok := g.safespot.Get(p.Name); ok {
+ g.forceLeaveSafespot(sess, p, &ss, "You leave your cover to close in for melee!")
+ }
+ }
+
+ if combat.HitCheck(attRoll, defRoll) {
+ g.applyPlayerHit(sess, p, mob, attackType, weaponType, maxHit)
+ } else {
+ g.applyPlayerMiss(sess, p, mob, weaponType)
+ }
+}
+
+func (g *Game) calculatePlayerAttackRoll(p *player.Player, mob *world.MobInstance) (attackType string, weaponType item.WeaponType, attRoll int, defRoll int, maxHit int) {
+ attackType = "crush"
+ 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.WeaponType == item.WeaponRanged {
+ attackType = "ranged"
+ } else if def.WeaponType == item.WeaponScience {
+ attackType = "science"
+ }
+ weaponType = def.WeaponType
+ }
+ }
+
+ totals := g.playerEquipBonuses(p)
+ isRanged := weaponType == item.WeaponRanged
+
+ if isRanged {
+ 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)
+
+ 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)
+ } 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)
+
+ 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)
+ }
+ return
+}
+
+func (g *Game) playerAttackUnarmed(sess *net.Session, p *player.Player, mob *world.MobInstance) {
+ if g.processConsumeQueue(p, sess) {
+ return
+ }
+
+ attackType, _, attRoll, defRoll, maxHit := g.calculateUnarmedAttackRoll(p, mob)
+
+ if combat.HitCheck(attRoll, defRoll) {
+ g.applyPlayerHit(sess, p, mob, attackType, item.WeaponMelee, maxHit)
+ } else {
+ g.applyPlayerMiss(sess, p, mob, item.WeaponMelee)
+ }
+}
+
+func (g *Game) calculateUnarmedAttackRoll(p *player.Player, mob *world.MobInstance) (attackType string, weaponType item.WeaponType, attRoll int, defRoll int, maxHit int) {
+ attackType = "crush"
+ weaponType = item.WeaponMelee
+
+ 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)
+
+ 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)
+ return
+}
+
+func (g *Game) applyPlayerHit(sess *net.Session, p *player.Player, mob *world.MobInstance, attackType string, weaponType item.WeaponType, maxHit int) {
+ dmg := combat.RollDamage(maxHit)
+
+ mob.HP -= dmg
+ if mob.HP < 0 {
+ mob.HP = 0
+ }
+ if mob.FinishingBlow != "" && mob.HP <= 0 {
+ mob.HP = 1
+ }
+ if mob.HP < mob.MaxHP && mob.HP > 0 {
+ mob.StartRegen()
+ }
+
+ 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)))
+ }
+
+ isRanged := weaponType == item.WeaponRanged
+ gains := g.awardCombatXP(sess, p, dmg, isRanged)
+
+ if isRanged {
+ g.consumeAmmo(sess, p)
+ }
+
+ if mob.IsTask() {
+ 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
+ }
+ 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)
+ }
+ 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)
+ }
+
+ if isRanged && p.AmmoQty <= 0 {
+ sess.WriteLine("You've run out of ammo!")
+ g.Combat.Leave(p.Name)
+ }
+}
+
+func (g *Game) applyPlayerMiss(sess *net.Session, p *player.Player, mob *world.MobInstance, weaponType item.WeaponType) {
+ sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf("You miss %s.", mobDisplayName(mob, true))))
+
+ if weaponType == item.WeaponRanged {
+ g.consumeAmmo(sess, p)
+ if p.AmmoQty <= 0 {
+ sess.WriteLine("You've run out of ammo!")
+ g.Combat.Leave(p.Name)
+ }
+ }
+}
+
+func (g *Game) consumeAmmo(sess *net.Session, p *player.Player) {
+ ammoID := p.Equipment[item.SlotAmmo]
+
+ p.AmmoQty--
+ if p.AmmoQty <= 0 {
+ delete(p.Equipment, item.SlotAmmo)
+ p.AmmoQty = 0
+ }
+
+ if ammoID != "" {
+ if def, err := g.ItemStore.Load(ammoID); err == nil && def.Recoverable {
+ if rand.Float64() < 0.80 {
+ g.World.AddGroundItem(p.RoomID, def.ID, 1)
+ }
+ }
+ }
+
+ g.AccountStore.SaveCharacter(p)
+}