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) 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.saveCharacter(p) attacker := mobDisplayNameCap(mob) 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) sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf("%s misses you.", attacker))) if p.EscapeDir != "" { g.beginEscapeMove(sess, p) } } // resolveMobDeath runs the shared mob-death path for a mob whose HP has // reached zero: kill bookkeeping, the victory broadcast, drops, on-kill // triggers, and respawn scheduling. It returns false when the mob is nil or // still alive. Every path that can reduce a mob to zero HP — a player // attack, a finishing blow, and Dead Man's Switch retribution — must route // through here, or the zeroed instance lingers in MobStore, is filtered out // of MobsInRoom (HP > 0), and never respawns. func (g *Game) resolveMobDeath(sess *net.Session, p *player.Player, mob *world.MobInstance) bool { if mob == nil || mob.HP > 0 { return false } 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) return true } 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 !g.resolveMobDeath(sess, p, mob) { return } 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) + " 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) { if mob == nil { return } if mob.SpawnedByTrigger { g.SubscribeTicks(10, func() bool { // Read the ID at fire time: a room-ID migration may have rewritten // it while the timer was pending. if id := mob.InstanceID; id != "" { g.MobStore.RemoveInstance(id) } return false }) return } respawnTicks := engine.ToTicks(mob.RespawnTicks) if respawnTicks <= 0 { respawnTicks = engine.ToTicks(30) } g.SubscribeTicks(respawnTicks, func() bool { // Re-read the current ID at fire time instead of capturing the string: // RekeyRooms rewrites an instance's key when its home room is rekeyed, // so a captured ID would miss, the mob would stay at HP 0 forever, and // SeedMobs would skip the (now-correct) key as already present. g.respawnMob(mob.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))) } } } // Retribution can zero the mob: run the same death path the normal // attack uses so it still drops loot and respawns instead of lingering // at HP 0 and being permanently removed from the room. if mob != nil && mob.HP <= 0 { g.resolveMobDeath(sess, p, mob) } 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.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}, } default: // A legacy or hand-edited save may omit attack_style; default to // accurate so melee combat never silently grants zero XP. gains = []xpGain{{string(player.Accuracy), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}} } } for _, gain := range gains { g.awardSkillXP(sess, p, player.SkillName(gain.Skill), gain.XP) } g.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, and either completes an instant 0-tick move (Cape of Agility or // GodMode acquired while the escape was pending) or sets MoveTicks for the // normal 1-2 tick run delay and records whether completion should drain run // energy. 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 if ticks <= 0 { // The player may have gained instant-move gear (Cape of Agility) or // GodMode after the escape was stashed but before the gate fired, so // moveTicks can now return 0 here even though doMove's stash branch // required >0 at stash time. Complete the move instead of leaving it // stuck at MoveTicks==0 forever. g.completeMove(sess, p) return } p.MoveTicks = ticks }