From abd612c15799f604e671e83dc7c410ed2b44185f Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Thu, 25 Jun 2026 15:40:48 -0400 Subject: slop refactor --- internal/game/combat_attack.go | 449 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 449 insertions(+) create mode 100644 internal/game/combat_attack.go (limited to 'internal/game/combat_attack.go') 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) +} -- cgit v1.2.3