package game import ( "fmt" "sort" "strconv" "strings" "thehouseoficarus/internal/action" "thehouseoficarus/internal/combat" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/net" "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" ) func (g *Game) doAttack(sess *net.Session, input string) { p := sess.Player if combat.GetCombat(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 { 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 combat.IsMobInCombat(mob.InstanceID) { sess.WriteLine(fmt.Sprintf("%s is already engaged in combat!", mobDisplayName(mob, false))) return } if itemID, ok := p.Equipment[object.SlotMainHand]; ok { if def, err := g.ItemStore.Load(itemID); err == nil && def.WeaponType == object.WeaponRanged { if _, hasAmmo := p.Equipment[object.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.cancelBackgroundAction(p) combat.EnterCombat(p.Name, mob.InstanceID) p.AttackTimer = 0 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, ", ") + ")" } sess.WriteLine(fmt.Sprintf("\nYou attack %s!%s", g.colorize(sess, "mob_name", mobDisplayName(mob, true)), styleStr)) } else { mod := GetMod(p.AutotriggerMod) modName := p.AutotriggerMod if mod != nil { modName = mod.Name } sess.WriteLine(fmt.Sprintf("\nYou attack %s with %s!", g.colorize(sess, "mob_name", mobDisplayName(mob, true)), g.colorize(sess, "science_mod", modName))) } p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name} if g.Hub != nil { for _, other := range g.Hub.PlayersInRoom(p.RoomID) { if other != sess && other.Player != nil { other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s attacks %s!", p.Name, mobDisplayName(mob, true)))) } } } g.Ticks.Subscribe(1, func() bool { cs := combat.GetCombat(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 }) g.Ticks.Subscribe(engine.ToTicks(mob.Speed), func() bool { cs := combat.GetCombat(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) { p.ActionState = &ActionState{Type: ActionEating, TargetName: "food"} return } attackType, weaponType, attRoll, defRoll, maxHit := g.calculatePlayerAttackRoll(p, mob) 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 object.WeaponType, attRoll int, defRoll int, maxHit int) { attackType = "crush" if itemID, ok := p.Equipment[object.SlotMainHand]; ok { if def, err := g.ItemStore.Load(itemID); err == nil { if def.AttackType != "" { attackType = def.AttackType } else if def.WeaponType == object.WeaponRanged { attackType = "ranged" } else if def.WeaponType == object.WeaponScience { attackType = "science" } weaponType = def.WeaponType } } totals := g.playerEquipBonuses(p) isRanged := weaponType == object.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.AttackRoll(effectiveRanged, rangedBonus, equipAttack) mobDefBonus := combat.SelectDefenseBonus(attackType, mob.StabDefense, mob.SlashDefense, mob.CrushDefense, mob.ScienceDefense, mob.RangedDefense) defRoll = combat.DefenseRoll(mob.Defense, 0, mobDefBonus) maxHit = combat.MaxHit(effectiveRanged, rangedBonus, totals.RangedStrength) } else { attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle)) equipAttack := combat.SelectAttackBonus(attackType, totals.StabAttack, totals.SlashAttack, totals.CrushAttack, totals.ScienceAttack, totals.RangedAttack) effectiveAttack := p.Level(player.Attack) + g.techLevelBonus(p, "attack") + g.buffLevelBonus(p, "attack") attRoll = combat.AttackRoll(effectiveAttack, attBonus, equipAttack) mobDefBonus := combat.SelectDefenseBonus(attackType, mob.StabDefense, mob.SlashDefense, mob.CrushDefense, mob.ScienceDefense, mob.RangedDefense) defRoll = combat.DefenseRoll(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) { p.ActionState = &ActionState{Type: ActionEating, TargetName: "food"} return } attackType, _, attRoll, defRoll, maxHit := g.calculateUnarmedAttackRoll(p, mob) if combat.HitCheck(attRoll, defRoll) { g.applyPlayerHit(sess, p, mob, attackType, object.WeaponMelee, maxHit) } else { g.applyPlayerMiss(sess, p, mob, object.WeaponMelee) } } func (g *Game) calculateUnarmedAttackRoll(p *player.Player, mob *world.MobInstance) (attackType string, weaponType object.WeaponType, attRoll int, defRoll int, maxHit int) { attackType = "crush" weaponType = object.WeaponMelee attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle)) effectiveAttack := p.Level(player.Attack) + g.techLevelBonus(p, "attack") + g.buffLevelBonus(p, "attack") attRoll = combat.AttackRoll(effectiveAttack, attBonus, 0) mobDefBonus := combat.SelectDefenseBonus(attackType, mob.StabDefense, mob.SlashDefense, mob.CrushDefense, mob.ScienceDefense, mob.RangedDefense) defRoll = combat.DefenseRoll(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 object.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 == object.WeaponRanged gains := g.awardCombatXP(sess, p, dmg, isRanged) if isRanged { p.AmmoQty-- if p.AmmoQty <= 0 { delete(p.Equipment, object.SlotAmmo) p.AmmoQty = 0 } g.AccountStore.SaveCharacter(p) } 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, dmg)) if w2 := len(fmt.Sprintf(" %s hits you for %d damage.", attacker, dmg)); w2 > w { w = w2 } prefix := fmt.Sprintf(" You hit %s for %s damage.", g.colorize(sess, "mob_name", mobName), g.colorize(sess, "damage", fmt.Sprint(dmg))) hpPart := fmt.Sprintf("[%s/%dhp]", g.colorize(sess, "enemy_hp", fmt.Sprint(mob.HP)), mob.MaxHP) line := fmt.Sprintf("%-*s %s", w, prefix, hpPart) if p.OptionBool("xp_drops") && len(gains) > 0 { var parts []string for _, gain := range gains { parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)])) } line += g.colorize(sess, "xp", " ("+strings.Join(parts, ", ")+")") } sess.WriteLine(line) if isRanged && p.AmmoQty <= 0 { sess.WriteLine("You've run out of ammo!") combat.LeaveCombat(p.Name) } } func (g *Game) applyPlayerMiss(sess *net.Session, p *player.Player, mob *world.MobInstance, weaponType object.WeaponType) { sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf(" You miss %s.", mobDisplayName(mob, true)))) if weaponType == object.WeaponRanged { p.AmmoQty-- if p.AmmoQty <= 0 { delete(p.Equipment, object.SlotAmmo) p.AmmoQty = 0 g.AccountStore.SaveCharacter(p) sess.WriteLine("You've run out of ammo!") combat.LeaveCombat(p.Name) } } } func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) { attRoll, defRoll := g.calculateMobAttack(p, mob) if combat.HitCheck(attRoll, defRoll) { g.applyMobHit(sess, p, mob) } else { g.applyMobMiss(sess, mob) } } func (g *Game) calculateMobAttack(p *player.Player, mob *world.MobInstance) (attRoll int, defRoll int) { _, _, defStyleBonus := combat.AttackStyleBonus(string(p.AttackStyle)) mobAttackType := mob.AttackType if mobAttackType == "" { mobAttackType = "crush" } attRoll = combat.AttackRoll(mob.Attack, 0, mob.AttackBonus) totals := g.playerEquipBonuses(p) equipDef := combat.SelectDefenseBonus(mobAttackType, totals.StabDefense, totals.SlashDefense, totals.CrushDefense, totals.ScienceDefense, totals.RangedDefense) defRoll = combat.DefenseRoll(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) dmg := combat.RollDamage(maxHit) dmg = g.applyTechProtection(p, mob, dmg) if mob.DamageWithout != "" { hasProtection := false for _, itemID := range p.Equipment { if itemID == mob.DamageWithout { hasProtection = true break } } if !hasProtection { dmg = dmg * 3 / 2 if dmg < 1 { dmg = 1 } cs := combat.GetCombat(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 } sess.WriteLine(g.colorize(sess, "warning", fmt.Sprintf(" %s's attack is extra effective! Equip %s for protection.", attacker, fbName))) } } } p.HP -= dmg if p.HP < 0 { p.HP = 0 } p.StartRegen() g.AccountStore.SaveCharacter(p) attacker := mob.Name if !mob.Unique { attacker = "The " + mob.Name } mobName := mobDisplayName(mob, true) w := len(fmt.Sprintf(" %s hits you for %d damage.", attacker, dmg)) if w2 := len(fmt.Sprintf(" You hit %s for %d damage.", mobName, dmg)); w2 > w { w = w2 } prefix := fmt.Sprintf(" %s hits you for %s damage.", g.colorize(sess, "mob_name", attacker), g.colorize(sess, "damage", fmt.Sprint(dmg))) hpPart := fmt.Sprintf("[%s/%dhp]", g.colorize(sess, "character_hp", fmt.Sprint(p.HP)), p.MaxHP()) sess.WriteLine(fmt.Sprintf("%-*s %s", w, prefix, hpPart)) if p.MoveTicks > 0 { p.ClearMoveState() p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name} sess.WriteLine("Can't escape!") } } func (g *Game) applyMobMiss(sess *net.Session, mob *world.MobInstance) { attacker := mob.Name if !mob.Unique { attacker = "The " + mob.Name } sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf(" %s misses you.", attacker))) } func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) { combat.LeaveCombat(p.Name) p.ActionState = nil if p.HP <= 0 { if p.HasActiveTech("retribution") { retDef := GetTechDef("retribution") if retDef != nil && mob != nil && mob.HP > 0 { retDmg := int(float64(p.MaxHP()) * retDef.Effects.RetributionPct) if retDmg > 0 { mob.HP -= retDmg if mob.HP < 0 { mob.HP = 0 } sess.WriteLine(fmt.Sprintf("\nDead Man's Switch activates! %s takes %d damage!", mobDisplayName(mob, true), retDmg)) } } } p.DeactivateAllTechs() sess.WriteLine(g.colorize(sess, "death", "\nOh dear, you are dead!")) p.ClearMoveState() g.dropItemsOnDeath(p) p.HP = p.MaxHP() p.RoomID = 1 g.AccountStore.SaveCharacter(p) if g.Hub != nil { g.Hub.EnterRoom(sess, p.RoomID) } g.doLook(sess) g.writePrompt(sess) return } if mob != nil && mob.HP <= 0 { sess.WriteLine(g.colorize(sess, "victory", fmt.Sprintf("\nYou have defeated %s!", mobDisplayName(mob, true)))) g.onAssassinKill(sess, p, mob) if g.Hub != nil { for _, other := range g.Hub.PlayersInRoom(p.RoomID) { if other != sess && other.Player != nil { 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("\n%s has slain %s %s!", p.Name, mobDisplayName(mob, false), levelStr))) } } } if mob.Drops.Remains != "" { g.World.AddReservedGroundItem(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 } dropper := mob.Name if !mob.Unique { dropper = "The " + mob.Name } coloredName := g.itemColorize(sess, def, name) sess.WriteLine(fmt.Sprintf(" %s %s", g.colorize(sess, "drop_message", dropper+" drops:"), coloredName)) } if len(mob.Drops.Loot) > 0 { entry := action.ResolveDrop(g.DataDir, mob.Drops.Loot) if entry != nil && entry.ItemID != "" { qty := entry.Quantity if qty <= 0 { qty = 1 } g.World.AddReservedGroundItem(p.RoomID, entry.ItemID, qty, p.Name) def, _ := g.ItemStore.Load(entry.ItemID) name := entry.ItemID if def != nil { name = def.Name } dropper := mob.Name if !mob.Unique { dropper = "The " + mob.Name } coloredName := g.itemColorize(sess, def, name) if qty > 1 { sess.WriteLine(fmt.Sprintf(" %s %d x %s", g.colorize(sess, "drop_message", dropper+" drops:"), qty, coloredName)) } else { sess.WriteLine(fmt.Sprintf(" %s %s", g.colorize(sess, "drop_message", dropper+" drops:"), coloredName)) } } } respawnTicks := engine.ToTicks(mob.RespawnTicks) if respawnTicks <= 0 { respawnTicks = engine.ToTicks(30) } instanceID := mob.InstanceID g.Ticks.Subscribe(respawnTicks, func() bool { g.respawnMob(instanceID) return false }) g.writePrompt(sess) } } func (g *Game) awardCombatXP(sess *net.Session, p *player.Player, dmg int, isRanged bool) []xpGain { baseXP := dmg * 4 var gains []xpGain if isRanged { gains = []xpGain{ {string(player.Ranged), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}, } } else { switch p.AttackStyle { case player.Accurate: gains = []xpGain{{string(player.Attack), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}} case player.Aggressive: gains = []xpGain{{string(player.Strength), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}} case player.Defensive: gains = []xpGain{{string(player.Defense), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}} case player.Balanced: quarter := baseXP / 4 gains = []xpGain{ {string(player.Attack), quarter}, {string(player.Strength), quarter}, {string(player.Defense), quarter}, {string(player.Hitpoints), quarter}, } } } for _, gain := range gains { g.awardSkillXP(sess, p, player.SkillName(gain.Skill), gain.XP) } g.AccountStore.SaveCharacter(p) return gains } func (g *Game) stopCombat(playerName string) { if cs := combat.GetCombat(playerName); cs != nil { combat.LeaveCombat(playerName) } } func (g *Game) executeAttack(sess *net.Session, args []string, rawInput string) { if len(args) == 0 { p := sess.Player target := g.resolveDefaultMob(p.RoomID) if target == "" { sess.WriteLine("Attack what?") return } g.doAttack(sess, target) } else { g.doAttack(sess, strings.Join(args, " ")) } } func (g *Game) respawnMob(instanceID string) { inst := g.MobStore.GetInstance(instanceID) if inst == nil { return } homeRoom := inst.HomeRoomID inst.RoomID = homeRoom inst.HP = inst.MaxHP g.MobStore.RollIdleDescription(inst) if g.Hub != nil { for _, sess := range g.Hub.PlayersInRoom(homeRoom) { if p := sess.Player; p != nil && p.OptionBool("mob_spawn") { mobLvl := mobCombatLevel(inst) levelStr := g.levelColorize(sess, p.CombatLevel(), mobLvl, fmt.Sprintf("(level %d)", mobLvl)) sess.WriteLine(fmt.Sprintf("\n%s %s spawns in the area.", g.colorize(sess, "mob_name", mobDisplayName(inst, false)), levelStr)) } } } }