mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Turn the dead top housing tiers into something worth buying. All three
land without a schema bump, and TestCombatCharacterization stays
byte-identical (the balance corpus never sets the new fields).
T3 trophy room: treasure cap 3->4 at HouseTier>=3 (maxTreasuresForTier).
Enforced at the save gate only -- HouseTier is never written downward,
so a held 4th treasure below tier 3 is unreachable and a load-time cap in
computeAdvBonuses would just add a query for an impossible state. The
all-irreplaceable manual discard prompt now lists the 4th slot too.
T3 workshop: +5% craft success at HouseTier>=3, lifting the cap 0.95->0.98
so a maxed forager still gains. craftingSuccessRate takes a workshopBonus,
threaded through autoCraftConsumables and renderRecipesKnown.
Well-rested buff (replaces the plan's T4 "inn-quality rest", a verified
no-op -- home rest already equals inn rest). Home-only (the inn and a
tier-1 shack grant nothing), starts at T2 and grows through T4, expires at
the next long rest:
- Temp HP cushion (8/12/16% of MaxHP). TempHP was a dormant field; wired
into applyDnDHPScaling as MaxHP headroom -- additive and golden-safe.
- Bonus spell slots (+1/+2/+3 at the caster's highest slot level), the
real reward, lifting casters who trail on spell-pool richness.
applyLongRestSpellSlots resets the pool to base then folds in the
bonus; expiry is stateless (next rest's reset drops it).
Magnitudes are tunable defaults; revisit against the post-parties
re-baseline.
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
615 lines
20 KiB
Go
615 lines
20 KiB
Go
package plugin
|
||
|
||
import (
|
||
"fmt"
|
||
"log/slog"
|
||
"math/rand/v2"
|
||
"strings"
|
||
"time"
|
||
|
||
"maunium.net/go/mautrix/id"
|
||
)
|
||
|
||
// postCombatBookkeeping is the close-out every fight owes its character,
|
||
// whatever surface it was fought on: achievements, and the subclass state that
|
||
// outlives the fight (Berserker exhaustion, Grim Harvest's kill-heal).
|
||
//
|
||
// It exists because there are four close-outs — two auto-resolve
|
||
// (runDungeonCombat, runZoneCombatRoster) and two turn-based
|
||
// (finishCombatSession, finishPartyCombatSession) — and for a long time only
|
||
// the auto-resolve pair ran any of this. The same Elite kill therefore paid out
|
||
// differently depending on whether the player let it auto-resolve or fought it
|
||
// a round at a time. Route all four through here and the divergence cannot
|
||
// silently reopen.
|
||
//
|
||
// raged is whether the character's Berserker rage was active for this fight;
|
||
// mods carries the fight-start modifiers Grim Harvest reads. HP persistence is
|
||
// NOT done here — the turn-based paths already own their own HP writes.
|
||
func (p *AdventurePlugin) postCombatBookkeeping(
|
||
userID id.UserID, dndChar *DnDCharacter, raged bool, result CombatResult, mods CombatModifiers,
|
||
) {
|
||
p.grantCombatAchievements(userID, result)
|
||
if err := persistDnDPostCombatSubclass(dndChar, raged, result, mods); err != nil {
|
||
slog.Error("dnd: post-combat subclass persist", "user", userID, "err", err)
|
||
}
|
||
}
|
||
|
||
// grantCombatAchievements checks combat results for achievement-worthy moments.
|
||
func (p *AdventurePlugin) grantCombatAchievements(userID id.UserID, result CombatResult) {
|
||
if p.achievements == nil {
|
||
return
|
||
}
|
||
if result.PlayerWon && result.NearDeath {
|
||
p.achievements.GrantAchievement(userID, "combat_near_death")
|
||
}
|
||
if result.SniperKilled {
|
||
p.achievements.GrantAchievement(userID, "combat_sniper_kill")
|
||
}
|
||
if result.MistyHealed {
|
||
p.achievements.GrantAchievement(userID, "combat_misty_clutch")
|
||
}
|
||
for _, ev := range result.Events {
|
||
if ev.Action == "pet_whiff" {
|
||
p.achievements.GrantAchievement(userID, "combat_pet_save")
|
||
break
|
||
}
|
||
}
|
||
if checkDeathSaveEvent(result.Events) {
|
||
p.achievements.GrantAchievement(userID, "combat_death_save")
|
||
}
|
||
for _, ev := range result.Events {
|
||
if ev.Action == "use_consumable" {
|
||
p.achievements.GrantAchievement(userID, "combat_consumable_used")
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
// runDungeonCombat executes dungeon combat using the new simulation engine.
|
||
func (p *AdventurePlugin) runDungeonCombat(
|
||
userID id.UserID,
|
||
char *AdventureCharacter,
|
||
equip map[EquipmentSlot]*AdvEquipment,
|
||
loc *AdvLocation,
|
||
bonuses *AdvBonusSummary,
|
||
inPenaltyZone bool,
|
||
) CombatResult {
|
||
chatLvl := p.chatLevel(userID)
|
||
hasGrudge := char.GrudgeLocation == loc.Name
|
||
|
||
playerStats, playerMods := DerivePlayerStats(char, equip, bonuses, chatLvl, char.CurrentStreak, hasGrudge)
|
||
|
||
// Penalty zone Attack/Defense reduction (mirrors +5% death, -15% success
|
||
// from old system). The MaxHP penalty is applied below, after
|
||
// applyDnDPlayerLayer reseats MaxHP onto the dnd HP scale.
|
||
if inPenaltyZone {
|
||
playerStats.Attack = int(float64(playerStats.Attack) * 0.85)
|
||
playerStats.Defense = int(float64(playerStats.Defense) * 0.85)
|
||
}
|
||
|
||
// Load consumables from inventory and auto-select
|
||
consumables := p.loadConsumableInventory(userID)
|
||
enemyStats, enemyMods := DeriveDungeonMonsterStats(loc)
|
||
|
||
// All combat is D&D. Auto-migrate if needed.
|
||
dndChar, _, err := ensureDnDCharacterForCombat(userID, char)
|
||
if err != nil {
|
||
slog.Error("dnd: ensureDnDCharacterForCombat (dungeon) failed", "user", userID, "err", err)
|
||
return CombatResult{}
|
||
}
|
||
applyDnDPlayerLayer(&playerStats, dndChar)
|
||
applyDnDEquipmentLayer(&playerStats, dndChar, equip)
|
||
applyDnDHPScaling(&playerStats, dndChar)
|
||
if inPenaltyZone {
|
||
playerStats.MaxHP = int(float64(playerStats.MaxHP) * 0.90)
|
||
if playerStats.StartHP > 0 {
|
||
playerStats.StartHP = int(float64(playerStats.StartHP) * 0.90)
|
||
}
|
||
}
|
||
applyClassPassives(&playerStats, &playerMods, dndChar)
|
||
applyRacePassives(&playerStats, &playerMods, dndChar)
|
||
applySubclassPassives(&playerStats, &playerMods, dndChar)
|
||
applyMagicItemEffects(&playerStats, &playerMods, userID)
|
||
trySimAutoArm(dndChar)
|
||
if firedName, fired := armAbilityForFight(dndChar, &playerMods); fired {
|
||
slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName)
|
||
}
|
||
applyDnDDungeonMonsterLayer(&enemyStats, loc.Tier)
|
||
applyPendingCast(userID, dndChar, &playerStats, &playerMods, &enemyStats)
|
||
|
||
// Auto-consumable: panic heal only (see dnd_boss_consumables.go).
|
||
setupAutoHealFromInventory(consumables, &playerMods)
|
||
|
||
// Dungeon monsters T3+ can have abilities
|
||
var ability *MonsterAbility
|
||
switch {
|
||
case loc.Tier >= 5:
|
||
ability = &MonsterAbility{Name: "Abyssal Wrath", Phase: "any", ProcChance: 0.30, Effect: "enrage"}
|
||
case loc.Tier == 4:
|
||
ability = &MonsterAbility{Name: "Dark Curse", Phase: "clash", ProcChance: 0.25, Effect: "poison"}
|
||
case loc.Tier == 3:
|
||
ability = &MonsterAbility{Name: "Stone Skin", Phase: "opening", ProcChance: 0.20, Effect: "armor_break"}
|
||
}
|
||
|
||
displayName, _ := loadDisplayName(userID)
|
||
player := Combatant{
|
||
Name: displayName,
|
||
Stats: playerStats,
|
||
Mods: playerMods,
|
||
IsPlayer: true,
|
||
}
|
||
enemy := Combatant{
|
||
Name: loc.Denizens,
|
||
Stats: enemyStats,
|
||
Mods: enemyMods,
|
||
Ability: ability,
|
||
}
|
||
|
||
result := SimulateCombat(player, enemy, dungeonCombatPhases)
|
||
dumpCombatEventsIfDebug(fmt.Sprintf("dungeon:%s vs %s", loc.Name, displayName), result)
|
||
|
||
// Remove the actual heal items consumed during combat. No more
|
||
// burning Coal Bombs / Ink Vials on chump fights — those stay put
|
||
// until a player-driven use command lands.
|
||
consumeFiredHealingItems(userID, countHealEventsFired(result))
|
||
|
||
persistDnDHPAfterCombat(userID, result.PlayerEndHP)
|
||
p.postCombatBookkeeping(userID, dndChar, playerMods.BerserkerRage, result, playerMods)
|
||
|
||
if xp := dungeonCombatXP(result, loc.Tier); xp > 0 {
|
||
if _, err := p.grantDnDXP(userID, xp); err != nil {
|
||
slog.Error("dnd: grantDnDXP dungeon", "user", userID, "err", err)
|
||
}
|
||
}
|
||
_ = dndChar
|
||
|
||
return result
|
||
}
|
||
|
||
// loadCombatBonuses computes the AdvBonusSummary for combat stat derivation.
|
||
func (p *AdventurePlugin) loadCombatBonuses(userID id.UserID, char *AdventureCharacter) *AdvBonusSummary {
|
||
treasures, _ := loadAdvTreasureBonuses(userID)
|
||
buffs, _ := loadAdvActiveBuffs(userID)
|
||
hasGrudge := char.GrudgeLocation != ""
|
||
return computeAdvBonuses(treasures, buffs, char.CurrentStreak, hasGrudge)
|
||
}
|
||
|
||
// loadConsumableInventory scans inventory for items matching consumable definitions.
|
||
// If the player has sufficient foraging level, auto-crafts consumables from ingredients first.
|
||
func (p *AdventurePlugin) loadConsumableInventory(userID id.UserID) []ConsumableItem {
|
||
items, err := loadAdvInventory(userID)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
|
||
// Auto-craft if player has foraging level 10+
|
||
char, err := loadAdvCharacter(userID)
|
||
if err == nil && char.ForagingSkill >= 10 {
|
||
craftResults, updatedItems := autoCraftConsumables(userID, items, char.ForagingSkill, homeWorkshopCraftBonus(char.HouseTier))
|
||
if len(craftResults) > 0 {
|
||
items = updatedItems
|
||
for _, cr := range craftResults {
|
||
if cr.Success {
|
||
slog.Info("crafting: auto-crafted", "user", userID, "item", cr.Recipe.Result)
|
||
if p.achievements != nil {
|
||
p.achievements.GrantAchievement(userID, "adv_first_craft")
|
||
if cr.Recipe.Tier >= 5 {
|
||
p.achievements.GrantAchievement(userID, "adv_craft_t5")
|
||
}
|
||
}
|
||
} else {
|
||
slog.Info("crafting: failed", "user", userID, "item", cr.Recipe.Result)
|
||
}
|
||
}
|
||
// Store results for narrative rendering
|
||
p.storeCraftResults(userID, craftResults)
|
||
}
|
||
}
|
||
|
||
var consumables []ConsumableItem
|
||
for _, item := range items {
|
||
if item.Type != "consumable" {
|
||
continue
|
||
}
|
||
def := consumableDefByName(item.Name)
|
||
if def == nil {
|
||
continue
|
||
}
|
||
consumables = append(consumables, ConsumableItem{
|
||
InventoryID: item.ID,
|
||
Def: def,
|
||
})
|
||
}
|
||
return consumables
|
||
}
|
||
|
||
func (p *AdventurePlugin) storeCraftResults(userID id.UserID, results []CraftResult) {
|
||
p.craftResults.Store(string(userID), results)
|
||
}
|
||
|
||
func (p *AdventurePlugin) popCraftResults(userID id.UserID) []CraftResult {
|
||
val, ok := p.craftResults.LoadAndDelete(string(userID))
|
||
if !ok {
|
||
return nil
|
||
}
|
||
return val.([]CraftResult)
|
||
}
|
||
|
||
// prependCraftNarrative adds crafting results to the first combat phase message.
|
||
func (p *AdventurePlugin) prependCraftNarrative(userID id.UserID, messages []string) []string {
|
||
results := p.popCraftResults(userID)
|
||
if len(results) == 0 || len(messages) == 0 {
|
||
return messages
|
||
}
|
||
|
||
var lines []string
|
||
for _, cr := range results {
|
||
if cr.Success {
|
||
lines = append(lines, fmt.Sprintf("🧪 _Crafted **%s** from foraged ingredients._", cr.Recipe.Result))
|
||
} else {
|
||
lines = append(lines, fmt.Sprintf("💨 _Failed to craft %s — ingredients lost._", cr.Recipe.Result))
|
||
}
|
||
}
|
||
|
||
prefix := strings.Join(lines, "\n") + "\n\n"
|
||
messages[0] = prefix + messages[0]
|
||
return messages
|
||
}
|
||
|
||
// sendCombatMessages sends phase messages with delays, then a final message.
|
||
// Runs in a goroutine so it doesn't block the message handler.
|
||
// Returns a channel that is closed when all messages have been sent.
|
||
func (p *AdventurePlugin) sendCombatMessages(userID id.UserID, phaseMessages []string, finalMessage string) <-chan struct{} {
|
||
return p.sendCombatMessagesWithDelay(userID, phaseMessages, finalMessage, 5, 4) // 5–8s
|
||
}
|
||
|
||
// sendZoneCombatMessages is the zone/expedition variant — same staging as
|
||
// arena, but tighter pacing (2–3s) so dungeon advances feel snappier.
|
||
//
|
||
// Contract: returns a done channel that closes after the final DM has
|
||
// been sent. Callers that need to chain post-flush work (death follow-ups,
|
||
// hospital ads, tier countdowns) MUST consume it. Callers in the middle
|
||
// of a command-handler flow that have no post-flush work should explicitly
|
||
// discard with `_ = ...` — blocking on flush would stall the handler
|
||
// behind the 2–3s/message pacing, which is the whole point of streaming.
|
||
func (p *AdventurePlugin) sendZoneCombatMessages(userID id.UserID, phaseMessages []string, finalMessage string) <-chan struct{} {
|
||
return p.sendCombatMessagesWithDelay(userID, phaseMessages, finalMessage, 2, 2) // 2–3s
|
||
}
|
||
|
||
// sendCombatMessagesWithDelay is the shared streamer. base/jitter define
|
||
// the per-message delay window in seconds: delay = base + rand.IntN(jitter).
|
||
func (p *AdventurePlugin) sendCombatMessagesWithDelay(userID id.UserID, phaseMessages []string, finalMessage string, base, jitter int) <-chan struct{} {
|
||
done := make(chan struct{})
|
||
go func() {
|
||
defer close(done)
|
||
for _, msg := range phaseMessages {
|
||
_ = p.SendDM(userID, msg)
|
||
delay := base
|
||
if jitter > 0 {
|
||
delay += rand.IntN(jitter)
|
||
}
|
||
time.Sleep(time.Duration(delay) * time.Second)
|
||
}
|
||
_ = p.SendDM(userID, finalMessage)
|
||
}()
|
||
return done
|
||
}
|
||
|
||
// XPBonusParams holds inputs for the shared XP bonus pipeline.
|
||
type XPBonusParams struct {
|
||
BaseXP int
|
||
NearDeath bool
|
||
BonusMult float64 // from AdvBonusSummary.XPMultiplier (percentage, e.g. 15 = +15%)
|
||
Ironclad bool
|
||
OverlevelMult float64 // 1.0 = no penalty
|
||
}
|
||
|
||
// XPResult holds the final XP and a human-readable breakdown of bonuses applied.
|
||
type XPResult struct {
|
||
Total int
|
||
Breakdown string // e.g. "+15% near-death, +5% ironclad" — empty if no bonuses
|
||
}
|
||
|
||
// applyXPBonuses applies near-death, bonus multiplier, ironclad, and overlevel
|
||
// penalties to a base XP value. Used by both dungeon and adventure paths.
|
||
func applyXPBonuses(p XPBonusParams) XPResult {
|
||
xp := p.BaseXP
|
||
var parts []string
|
||
if p.NearDeath {
|
||
xp = int(float64(xp) * 1.15)
|
||
parts = append(parts, "+15% near-death")
|
||
}
|
||
if p.BonusMult != 0 {
|
||
xp = int(float64(xp) * (1 + p.BonusMult/100))
|
||
parts = append(parts, fmt.Sprintf("+%.0f%% bonus", p.BonusMult))
|
||
}
|
||
if p.Ironclad {
|
||
xp = int(float64(xp) * 1.05)
|
||
parts = append(parts, "+5% ironclad")
|
||
}
|
||
if p.OverlevelMult < 1.0 {
|
||
xp = max(1, int(float64(xp)*p.OverlevelMult))
|
||
penalty := int((1.0 - p.OverlevelMult) * 100)
|
||
parts = append(parts, fmt.Sprintf("-%d%% overlevel", penalty))
|
||
}
|
||
breakdown := ""
|
||
if len(parts) > 0 {
|
||
breakdown = strings.Join(parts, ", ")
|
||
}
|
||
return XPResult{Total: xp, Breakdown: breakdown}
|
||
}
|
||
|
||
// DeathTransitionParams holds inputs for the shared death state machine.
|
||
type DeathTransitionParams struct {
|
||
Char *AdventureCharacter
|
||
Equip map[EquipmentSlot]*AdvEquipment
|
||
Pet PetState // pet state for ditch-recovery roll; zero value disables
|
||
ChatLevel int
|
||
Location string // set as GrudgeLocation; empty = don't set
|
||
Source string // death source: "adventure" | "arena" — recorded on Kill()
|
||
DeathLocation string // human-readable death location for the daily report; falls back to Location
|
||
AllowPardon bool // chat level pardon (adventure only)
|
||
AllowSovereign bool // probability-band Sovereign reprieve (non-engine path)
|
||
EngineSaved bool // combat engine used Sovereign death save
|
||
}
|
||
|
||
// DeathTransitionResult describes what happened during the death transition.
|
||
type DeathTransitionResult struct {
|
||
Pardoned bool
|
||
Reprieved bool // Sovereign reprieve (non-engine)
|
||
PetRecovered bool
|
||
Died bool
|
||
}
|
||
|
||
// transitionDeath runs the shared death state machine: pardon → engine cooldown →
|
||
// Sovereign reprieve → kill + pet ditch recovery.
|
||
func transitionDeath(p DeathTransitionParams) DeathTransitionResult {
|
||
var r DeathTransitionResult
|
||
|
||
if p.AllowPardon && p.ChatLevel >= 20 && p.Char.PardonAvailable() && rand.Float64() < 0.33 {
|
||
r.Pardoned = true
|
||
now := time.Now().UTC()
|
||
p.Char.LastPardonUsed = &now
|
||
if p.Location != "" {
|
||
p.Char.GrudgeLocation = p.Location
|
||
}
|
||
return r
|
||
}
|
||
|
||
if p.EngineSaved {
|
||
now := time.Now().UTC()
|
||
p.Char.DeathReprieveLast = &now
|
||
}
|
||
|
||
if p.AllowSovereign && advEquippedArenaSets(p.Equip)["sovereign"] && p.Char.DeathReprieveAvailable() {
|
||
r.Reprieved = true
|
||
now := time.Now().UTC()
|
||
p.Char.DeathReprieveLast = &now
|
||
if p.Location != "" {
|
||
p.Char.GrudgeLocation = p.Location
|
||
}
|
||
for _, slot := range allSlots {
|
||
if eq, ok := p.Equip[slot]; ok {
|
||
eq.Condition = 1
|
||
}
|
||
}
|
||
return r
|
||
}
|
||
|
||
deathLoc := p.DeathLocation
|
||
if deathLoc == "" {
|
||
deathLoc = p.Location
|
||
}
|
||
p.Char.Kill(p.Source, deathLoc)
|
||
if p.Location != "" {
|
||
p.Char.GrudgeLocation = p.Location
|
||
}
|
||
r.Died = true
|
||
|
||
if petRollDitchRecovery(p.Pet) && p.Char.DeadUntil != nil {
|
||
reduced := time.Now().UTC().Add(petDitchRecoveryTime(p.Pet.Level))
|
||
p.Char.DeadUntil = &reduced
|
||
r.PetRecovered = true
|
||
}
|
||
|
||
return r
|
||
}
|
||
|
||
// checkDeathSaveEvent scans combat events for a Sovereign death save.
|
||
func checkDeathSaveEvent(events []CombatEvent) bool {
|
||
for _, ev := range events {
|
||
if ev.Action == "death_save" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// injectConsumableEvents prepends use_consumable events so the narrative shows which items were consumed.
|
||
// If items were available but skipped (trivial threat), a skip event is injected instead.
|
||
//
|
||
// Uses PlayerEntryHP / EnemyEntryHP — the actual HP each side entered combat
|
||
// with — so the pre-combat HP line reflects wounded carry-over honestly. Was
|
||
// using PlayerStartHP (= MaxHP), which made wounded entries display "47/47"
|
||
// and made any HP loss in opening look like the player took silent damage.
|
||
func injectConsumableEvents(result CombatResult, selected []ConsumableItem, inventorySize int) CombatResult {
|
||
entryHP := result.PlayerEntryHP
|
||
if entryHP == 0 {
|
||
entryHP = result.PlayerStartHP
|
||
}
|
||
enemyEntryHP := result.EnemyEntryHP
|
||
if enemyEntryHP == 0 {
|
||
enemyEntryHP = result.EnemyStartHP
|
||
}
|
||
if len(selected) == 0 {
|
||
if inventorySize > 0 {
|
||
result.Events = append([]CombatEvent{{
|
||
Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "consumable_skip",
|
||
PlayerHP: entryHP, EnemyHP: enemyEntryHP,
|
||
}}, result.Events...)
|
||
}
|
||
return result
|
||
}
|
||
var events []CombatEvent
|
||
for _, c := range selected {
|
||
events = append(events, CombatEvent{
|
||
Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "use_consumable",
|
||
PlayerHP: entryHP, EnemyHP: enemyEntryHP,
|
||
Desc: c.Def.Name,
|
||
})
|
||
}
|
||
result.Events = append(events, result.Events...)
|
||
return result
|
||
}
|
||
|
||
// combatResultToOutcome maps a CombatResult to an AdvOutcomeType for dungeon integration.
|
||
func combatResultToOutcome(result CombatResult) AdvOutcomeType {
|
||
if !result.PlayerWon {
|
||
return AdvOutcomeDeath
|
||
}
|
||
// Won with >85% HP remaining → exceptional
|
||
remainingPct := float64(result.PlayerEndHP) / float64(max(1, result.PlayerStartHP))
|
||
if remainingPct > 0.85 {
|
||
return AdvOutcomeExceptional
|
||
}
|
||
return AdvOutcomeSuccess
|
||
}
|
||
|
||
// combatDegradation derives equipment damage from actual combat events.
|
||
func combatDegradation(result CombatResult, equip map[EquipmentSlot]*AdvEquipment) map[EquipmentSlot]int {
|
||
damage := make(map[EquipmentSlot]int)
|
||
|
||
if !result.PlayerWon {
|
||
// Death: heavy degradation
|
||
for _, slot := range allSlots {
|
||
damage[slot] = 20
|
||
}
|
||
damage[SlotWeapon] = 30
|
||
damage[SlotArmor] = 30
|
||
return applyDegradationModifiers(damage, equip)
|
||
}
|
||
|
||
// Derive from events: each enemy hit on player → armor/helmet wear
|
||
// Each player hit → weapon wear, environmental → boots
|
||
for _, ev := range result.Events {
|
||
switch {
|
||
case ev.Actor == "enemy" && (ev.Action == "hit" || ev.Action == "crit"):
|
||
damage[SlotArmor] += 1 + rand.IntN(3)
|
||
damage[SlotHelmet] += 1 + rand.IntN(2)
|
||
case ev.Actor == "enemy" && ev.Action == "cleave":
|
||
damage[SlotArmor] += 2 + rand.IntN(3)
|
||
case ev.Actor == "enemy" && (ev.Action == "bonus_damage" || ev.Action == "aoe" || ev.Action == "execute" || ev.Action == "retaliate" || ev.Action == "max_hp_drain"):
|
||
damage[SlotArmor] += 1 + rand.IntN(2)
|
||
case ev.Actor == "enemy" && ev.Action == "lifesteal":
|
||
damage[SlotArmor] += 1 + rand.IntN(2)
|
||
case ev.Actor == "player" && (ev.Action == "hit" || ev.Action == "crit"):
|
||
damage[SlotWeapon] += 1
|
||
case ev.Actor == "environment":
|
||
damage[SlotBoots] += 1 + rand.IntN(2)
|
||
case ev.Action == "armor_break":
|
||
damage[SlotArmor] += 3
|
||
}
|
||
}
|
||
|
||
return applyDegradationModifiers(damage, equip)
|
||
}
|
||
|
||
func applyDegradationModifiers(damage map[EquipmentSlot]int, equip map[EquipmentSlot]*AdvEquipment) map[EquipmentSlot]int {
|
||
tempered := advEquippedArenaSets(equip)["tempered"]
|
||
for slot, dmg := range damage {
|
||
eq, ok := equip[slot]
|
||
if !ok {
|
||
continue
|
||
}
|
||
if tempered {
|
||
dmg = int(float64(dmg) * 0.75)
|
||
}
|
||
if eq.ActionsUsed >= 20 {
|
||
dmg = int(float64(dmg) * 0.8)
|
||
}
|
||
if dmg < 0 {
|
||
dmg = 0
|
||
}
|
||
damage[slot] = dmg
|
||
eq.Condition -= dmg
|
||
if eq.Condition < 0 {
|
||
eq.Condition = 0
|
||
}
|
||
}
|
||
return damage
|
||
}
|
||
|
||
// resolveDungeonAction runs a dungeon encounter through the combat engine
|
||
// and returns an AdvActionResult compatible with the existing adventure flow.
|
||
func (p *AdventurePlugin) resolveDungeonAction(
|
||
char *AdventureCharacter,
|
||
equip map[EquipmentSlot]*AdvEquipment,
|
||
loc *AdvLocation,
|
||
bonuses *AdvBonusSummary,
|
||
inPenaltyZone bool,
|
||
) *AdvActionResult {
|
||
result := &AdvActionResult{
|
||
Location: loc,
|
||
XPSkill: "combat",
|
||
}
|
||
|
||
combat := p.runDungeonCombat(char.UserID, char, equip, loc, bonuses, inPenaltyZone)
|
||
result.CombatLog = &combat
|
||
result.Outcome = combatResultToOutcome(combat)
|
||
|
||
// Misty condition repair (post-combat, same as arena)
|
||
now := time.Now().UTC()
|
||
if char.MistyBuffExpires != nil && now.Before(*char.MistyBuffExpires) {
|
||
if rand.Float64() < 0.20 {
|
||
npcRepairMostDamaged(char.UserID, equip, 5)
|
||
}
|
||
}
|
||
result.NearDeath = combat.NearDeath
|
||
|
||
// Overlevel penalty
|
||
skillLevel := advEffectiveSkill(char, loc.Activity, bonuses)
|
||
overlevelMult := advOverlevelMultiplier(skillLevel, loc)
|
||
|
||
// Loot on success/exceptional
|
||
if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
|
||
result.LootItems = generateAdvLoot(loc, result.Outcome == AdvOutcomeExceptional, bonuses.LootQuality)
|
||
if overlevelMult < 1.0 {
|
||
for i := range result.LootItems {
|
||
result.LootItems[i].Value = max(1, int64(float64(result.LootItems[i].Value)*overlevelMult))
|
||
}
|
||
}
|
||
for _, item := range result.LootItems {
|
||
result.TotalLootValue += item.Value
|
||
}
|
||
}
|
||
|
||
// XP calculation
|
||
xp := advXPForOutcome(loc.Activity, loc.Tier, result.Outcome)
|
||
if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
|
||
xp = advXPTable[loc.Activity][loc.Tier].Success
|
||
if result.Outcome == AdvOutcomeExceptional {
|
||
xp = advXPTable[loc.Activity][loc.Tier].Exceptional
|
||
}
|
||
}
|
||
xpResult := applyXPBonuses(XPBonusParams{
|
||
BaseXP: xp,
|
||
NearDeath: result.NearDeath,
|
||
BonusMult: bonuses.XPMultiplier,
|
||
Ironclad: advEquippedArenaSets(equip)["ironclad"],
|
||
OverlevelMult: overlevelMult,
|
||
})
|
||
result.XPGained = xpResult.Total
|
||
result.XPBreakdown = xpResult.Breakdown
|
||
|
||
// Equipment degradation from combat events
|
||
result.EquipDamage = combatDegradation(combat, equip)
|
||
result.EquipBroken = advCheckBrokenEquipment(equip)
|
||
|
||
// Increment actions_used for equipment mastery
|
||
for _, eq := range equip {
|
||
eq.ActionsUsed++
|
||
}
|
||
|
||
return result
|
||
}
|