Files
gogobee/internal/plugin/combat_bridge.go
prosolis 38a3693832 N7/B2: Renown — prestige past the L20 cap
Overflow XP that grantDnDXP used to drop at L20 now accumulates as Renown
on player_meta.renown_xp (cumulative, atomic +=; renown_level derived as
renown_xp/25000). The reward is prestige-only: a derived rank ladder
(Renowned→…→Eternal), a cosmetic ✦N marker on the sheet and leaderboard,
a games-room shout on rank promotion, and !level progress once capped.

Renown perks are the two combat-neutral economy levers only — +loot / +XP,
capped at a streak-30 grant's economic half (+15% / +20%). combat_stats.go
reads DeathModifier/SuccessBonus/ExceptionalBonus (which map to Defense/
Attack/CritRate) but never LootQuality/XPMultiplier, so renown pays out even
through loadCombatBonuses without moving the golden or the balance corpus.
The plan's "-death penalty" perk is deliberately dropped (it would inflate
Defense).

The overflow→renown conversion and the character save commit in one
transaction (saveDnDCharacterExec now takes an executor), so a crash can
neither drop the overflow nor double-credit it on the next grant.

Schema: renown_xp column, DEFAULT 0 correct for every existing row, no
bootstrap (journal_pages/epilogue_cleared pattern).

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 19:48:49 -07:00

620 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 != ""
b := computeAdvBonuses(treasures, buffs, char.CurrentStreak, hasGrudge)
// N7/B2 — renown pays out on loot/XP here too. Safe despite this feeding
// combat_stats: applyRenownBonuses touches only LootQuality/XPMultiplier,
// which combat stat derivation never reads (see adventure_renown.go).
applyRenownBonuses(b, char.RenownLevel())
return b
}
// 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) // 58s
}
// sendZoneCombatMessages is the zone/expedition variant — same staging as
// arena, but tighter pacing (23s) 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 23s/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) // 23s
}
// 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
}