package game import ( "fmt" "strings" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/color" "thehouseoficarus/internal/combat" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" "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) { attackType := g.effectiveMobAttackType(p, mob) if attackType == "" { return } attRoll, defRoll, maxHit := g.calculateMobAttack(p, mob, attackType) if combat.HitCheck(attRoll, defRoll) { g.applyMobHit(sess, p, mob, attackType, maxHit) } else { g.applyMobMiss(sess, p, mob) } } func (g *Game) calculateMobAttack(p *player.Player, mob *world.MobInstance, mobAttackType string) (attRoll, defRoll, maxHit int) { _, _, defStyleBonus := combat.AttackStyleBonus(string(p.AttackStyle)) if mobAttackType == "" { mobAttackType = combat.DefaultAttackType } 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.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, attackType string, maxHit int) { dmg := combat.RollDamage(maxHit) dmg = g.applyTechProtection(p, attackType, 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 := g.Combat.Get(p.Name) if cs != nil && !cs.DamageWarningShown { cs.DamageWarningShown = true 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))) } } } p.HP -= dmg if p.HP < 0 { p.HP = 0 } p.StartRegen() g.AccountStore.SaveCharacter(p) attacker := mobDisplayNameCap(mob, true) mobName := mobDisplayName(mob, true) prefix := fmt.Sprintf("%s hits you for %s damage.", g.colorize(sess, "mob", attacker), g.colorize(sess, "damage_taken", g.dmgDisplay(sess, dmg))) 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) if ss, hasSS := g.safespot.Get(p.Name); hasSS && ss.HideCountdown > 0 { g.safespot.Delete(p.Name) sess.WriteLine(g.colorize(sess, "error", "You're hit while repositioning! You failed to hide.")) } if p.EscapeDir != "" { p.EscapeFailCount++ if p.EscapeFailCount >= 3 && p.HP > 0 { sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf("You power through %s's relentless attacks and just flee!", mobDisplayName(mob, false)))) g.beginEscapeMove(sess, p) } else { p.ClearEscape() p.WalkSequence = nil sess.WriteLine("Can't escape!") } } if p.Action != nil && p.Action.Type == behavior.TypeTriggerModule { g.cancelAction(p) p.AttackTimer = 0 sess.WriteLine("Your concentration is broken!") } } // 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, p *player.Player, mob *world.MobInstance) { attacker := mobDisplayNameCap(mob, true) sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf("%s misses you.", attacker))) if p.EscapeDir != "" { g.beginEscapeMove(sess, p) } } func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) { g.Combat.Leave(p.Name) if p.HP <= 0 { g.killPlayer(sess, p, mob) return } if mob == nil || mob.HP > 0 { return } isTask := mob.IsTask() p.Stats.RecordMobKill(mob.DefID) g.announceKill(sess, p, mob, isTask) g.awardKillDrops(sess, p, mob, isTask) // On Kill fires for both combat kills and task-mob completion (draining a // task mob's HP to zero is the "kill" that completes the work). itemMatch // is the "wielding" gate — on_kill entries with an item_id only fire if // the player currently has that item equipped in a weapon-hand slot. g.runTrigger(sess, p, mob.OnKill, p.RoomID, p.IsWielding, false) g.scheduleMobRespawn(mob) if p.EscapeDir != "" { g.beginEscapeMove(sess, p) return } 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) } 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))) } } } // 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 mob.Drops.Remains != "" { writeDrop(mob.Drops.Remains, 1) } for _, entry := range behavior.ResolveDropList(g.DataDir, mob.Drops.Loot) { if entry.ItemID == "" { continue } qty := entry.Quantity if qty <= 0 { qty = 1 } 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. func (g *Game) killPlayer(sess *net.Session, p *player.Player, mob *world.MobInstance) { if p.GodMode { p.HP = 1 sess.WriteLine(g.colorize(sess, "miss", "Your divine power prevents death.")) g.writePrompt(sess) return } g.Combat.Leave(p.Name) 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("Dead Man's Switch activates! %s takes %s damage!", mobDisplayName(mob, true), g.dmgDisplay(sess, retDmg))) } } } p.DeactivateAllTechs() p.Stats.RecordDeath() sess.WriteLine(g.colorize(sess, "death", "Oh dear, you are dead!")) p.Action = nil p.ClearMoveState() p.HazardTimer = 0 if ss, ok := g.safespot.Get(p.Name); ok { g.forceLeaveSafespot(sess, p, &ss, "") } g.dropItemsOnDeath(p) p.HP = p.MaxHP() p.RunEnergy = p.MaxRunEnergy() p.RoomID = g.StartingRoom g.AccountStore.SaveCharacter(p) if g.Hub != nil { g.Hub.EnterRoom(sess, p.RoomID) } g.doLook(sess) 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.Accuracy), 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.Accuracy), 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 } // stopCombat ends the player's combat if any. Combat.Leave no-ops when the // player is not engaged. func (g *Game) stopCombat(playerName string) { g.Combat.Leave(playerName) } // beginEscapeMove starts the actual movement after the escape gate (a mob // miss, a mob death, or the 3-strike "power through" relief) has fired. It // copies the stashed Escape* fields into the Move* fields, recomputes // moveTicks, sets MoveTicks for the normal 1-2 tick run delay, and records // whether completion should drain run energy. // // beginEscapeMove is only reachable when doMove's in-combat branch stashed the // escape, and that branch is itself gated on moveTicks returning > 0; the // Cape-of-Agility / GodMode 0-tick bypass never enters the stash path, so there // is no 0-tick branch to handle here. func (g *Game) beginEscapeMove(sess *net.Session, p *player.Player) { p.MoveDirection = p.EscapeDir p.MoveTarget = p.EscapeTarget p.MovePendingTrigger = p.EscapeTrigger isWalk := p.EscapeIsWalk p.ClearEscape() p.EscapeFailCount = 0 ticks, usesEnergy := g.moveTicks(p, isWalk) p.MoveUsesEnergy = usesEnergy p.MoveTicks = ticks }