From 8e0fe0230c65a9c72e643dbc33abc2470e950170 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sun, 3 May 2026 11:42:00 -0700 Subject: [PATCH] Death source tracking + combat threat/jitter fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adventure characters now record DeathSource ("adventure"|"arena") and DeathLocation when killed. Threaded through Kill() and transitionDeath. Daily report uses the recorded death location instead of misattributing arena deaths to the day's adventure log: when DeathSource != "adventure", the adventure block shows the alive icon and a separate "Later fell in {location}." line. Standout-loss flavor uses DeathLocation. Empty DeathSource (legacy rows) falls back to current behavior. - calcDamage applies a ±15% per-hit jitter, so successive hits in a phase no longer produce identical numbers. Previously every hit in a phase was flat (e.g. 17, 17, 17, 17) and mirror-symmetric between player/enemy, reading as scripted. - assessThreat rewritten to use the engine's actual penetration formula: damage-per-round each direction, then rounds-to-kill ratio. The old additive HP+Attack*3 model ignored Defense, so even fights could be rated trivial — players died after consumables were skipped with the "threat assessed as manageable" message. - SelectConsumables never skips on trivial when arenaRound > 0. Arena losses cost real money and equipment, so the cost of a wrong assessment is too high to gamble on; adventure-side fights still skip trivial threats to avoid wasting items on chump enemies. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/db/db.go | 2 ++ internal/plugin/adventure.go | 2 ++ internal/plugin/adventure_arena.go | 6 ++++- internal/plugin/adventure_character.go | 23 +++++++++++++---- internal/plugin/adventure_consumables.go | 31 +++++++++++++++++----- internal/plugin/adventure_render.go | 33 ++++++++++++++++++++---- internal/plugin/adventure_scheduler.go | 2 ++ internal/plugin/combat_bridge.go | 8 +++++- internal/plugin/combat_engine.go | 5 ++++ 9 files changed, 93 insertions(+), 19 deletions(-) diff --git a/internal/db/db.go b/internal/db/db.go index b1a16ac..8b89733 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -142,6 +142,8 @@ func runMigrations(d *sql.DB) error { `ALTER TABLE coop_dungeon_gifts ADD COLUMN applied_at DATETIME`, `ALTER TABLE coop_dungeon_gifts ADD COLUMN stack_lead_id INTEGER`, `ALTER TABLE adventure_characters ADD COLUMN crafts_succeeded INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE adventure_characters ADD COLUMN death_source TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE adventure_characters ADD COLUMN death_location TEXT NOT NULL DEFAULT ''`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 7184ad7..5e4d7d6 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -875,6 +875,8 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha Equip: equip, ChatLevel: p.chatLevel(char.UserID), Location: loc.Name, + Source: "adventure", + DeathLocation: loc.Name, AllowPardon: true, AllowSovereign: true, EngineSaved: engineDeathSaved, diff --git a/internal/plugin/adventure_arena.go b/internal/plugin/adventure_arena.go index 6ebfb14..3d3770f 100644 --- a/internal/plugin/adventure_arena.go +++ b/internal/plugin/adventure_arena.go @@ -414,7 +414,11 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c phaseMessages := RenderCombatLogArena(result, char.DisplayName, monster.Name) - dt := transitionDeath(DeathTransitionParams{Char: char}) + dt := transitionDeath(DeathTransitionParams{ + Char: char, + Source: "arena", + DeathLocation: "the Arena", + }) char.ArenaLosses++ char.CombatXP += arenaParticipationXP diff --git a/internal/plugin/adventure_character.go b/internal/plugin/adventure_character.go index 98ff61c..c10c543 100644 --- a/internal/plugin/adventure_character.go +++ b/internal/plugin/adventure_character.go @@ -96,6 +96,8 @@ type AdventureCharacter struct { AutoBabysit bool StreakDecayed bool CraftsSucceeded int + DeathSource string // "adventure" | "arena" | "" (legacy/unknown — treated as adventure) + DeathLocation string // human-readable location of last death; cleared on revive is not required } type AdvEquipment struct { @@ -249,12 +251,16 @@ func (c *AdventureCharacter) PardonAvailable() bool { return time.Since(*c.LastPardonUsed) >= 168*time.Hour } -// Kill marks the character as dead with a 6-hour respawn timer. -func (c *AdventureCharacter) Kill() { +// Kill marks the character as dead with a 6-hour respawn timer. source is +// "adventure" or "arena"; location is a human-readable place name used by the +// daily report and standout-loss flavor. +func (c *AdventureCharacter) Kill(source, location string) { c.Alive = false deadUntil := time.Now().UTC().Add(6 * time.Hour) c.DeadUntil = &deadUntil c.LastDeathDate = time.Now().UTC().Format("2006-01-02") + c.DeathSource = source + c.DeathLocation = location } // HasPet returns true if the player has an active pet (not chased away). @@ -446,7 +452,8 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) { pet_chased_away, pet_reactivated, pet_arrived, misty_encounter_count, misty_donated_count, thom_animal_line_fired, pet_supply_shop_unlocked, pet_level10_date, - pet_morning_defense, auto_babysit, streak_decayed, crafts_succeeded + pet_morning_defense, auto_babysit, streak_decayed, crafts_succeeded, + death_source, death_location FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan( &c.UserID, &c.DisplayName, &c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill, @@ -472,6 +479,7 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) { &c.MistyEncounterCount, &c.MistyDonatedCount, &thomAnimalLine, &petSupplyUnlocked, &c.PetLevel10Date, &petMorningDef, &autoBabysit, &streakDecayed, &c.CraftsSucceeded, + &c.DeathSource, &c.DeathLocation, ) if err != nil { return nil, err @@ -640,7 +648,9 @@ func saveAdvCharacter(char *AdventureCharacter) error { pet_morning_defense = ?, auto_babysit = ?, streak_decayed = ?, - crafts_succeeded = ? + crafts_succeeded = ?, + death_source = ?, + death_location = ? WHERE user_id = ?`, char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill, char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP, @@ -667,6 +677,7 @@ func saveAdvCharacter(char *AdventureCharacter) error { autoBabysit, streakDecayed, char.CraftsSucceeded, + char.DeathSource, char.DeathLocation, string(char.UserID), ) return err @@ -797,7 +808,8 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) { pet_chased_away, pet_reactivated, pet_arrived, misty_encounter_count, misty_donated_count, thom_animal_line_fired, pet_supply_shop_unlocked, pet_level10_date, - pet_morning_defense, auto_babysit, streak_decayed, crafts_succeeded + pet_morning_defense, auto_babysit, streak_decayed, crafts_succeeded, + death_source, death_location FROM adventure_characters`) if err != nil { return nil, err @@ -838,6 +850,7 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) { &c.MistyEncounterCount, &c.MistyDonatedCount, &thomAnimalLine, &petSupplyUnlocked, &c.PetLevel10Date, &petMorningDef, &autoBabysit, &streakDecayed, &c.CraftsSucceeded, + &c.DeathSource, &c.DeathLocation, ); err != nil { return nil, err } diff --git a/internal/plugin/adventure_consumables.go b/internal/plugin/adventure_consumables.go index 0e33213..3785ad7 100644 --- a/internal/plugin/adventure_consumables.go +++ b/internal/plugin/adventure_consumables.go @@ -86,17 +86,31 @@ const ( threatDangerous // > 1.0 ) +// assessThreat estimates rounds-to-kill in both directions using the same +// penetration model as the combat engine, then bands the threat by the ratio +// of player-RTK to enemy-RTK. Old additive HP+Attack model ignored Defense and +// rated even fights as "trivial" — players died after consumables were skipped. func assessThreat(player, enemy CombatStats) threatLevel { - playerPower := float64(player.MaxHP + player.Attack*3) - enemyPower := float64(enemy.MaxHP + enemy.Attack*3) - if playerPower == 0 { + const K = 40.0 + dprPlayer := float64(player.Attack) * (K / (K + float64(enemy.Defense))) + dprEnemy := float64(enemy.Attack) * (K / (K + float64(player.Defense))) + if dprPlayer < 1 { + dprPlayer = 1 + } + if dprEnemy < 1 { + dprEnemy = 1 + } + rtkEnemy := float64(enemy.MaxHP) / dprPlayer // rounds for player to kill enemy + rtkPlayer := float64(player.MaxHP) / dprEnemy // rounds for enemy to kill player + if rtkPlayer <= 0 { return threatDangerous } - ratio := enemyPower / playerPower + // ratio < 1: player wins the race (lower = more dominant). >1: enemy wins. + ratio := rtkEnemy / rtkPlayer switch { - case ratio < 0.4: + case ratio < 0.35: return threatTrivial - case ratio < 0.7: + case ratio < 0.65: return threatEasy case ratio < 1.0: return threatCompetitive @@ -111,7 +125,10 @@ func assessThreat(player, enemy CombatStats) threatLevel { // contentTier caps consumable tier to the content being fought (0 = no cap). func SelectConsumables(inventory []ConsumableItem, playerStats, enemyStats CombatStats, arenaRound int, contentTier int) []ConsumableItem { threat := assessThreat(playerStats, enemyStats) - if threat == threatTrivial { + // Arena losses cost real money + equipment durability — never skip there, + // even if the threat looks trivial. Adventure-side fights still skip + // trivial threats to avoid wasting items on chump enemies. + if threat == threatTrivial && arenaRound == 0 { return nil } diff --git a/internal/plugin/adventure_render.go b/internal/plugin/adventure_render.go index 7e8f7c7..00988a0 100644 --- a/internal/plugin/adventure_render.go +++ b/internal/plugin/adventure_render.go @@ -540,6 +540,8 @@ type AdvPlayerDaySummary struct { IsResting bool SummaryLine string HolidayActions int // 0 = not holiday or no action; 1 = took one; 2 = took both + DeathSource string + DeathLocation string } func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewardSummary, players []AdvPlayerDaySummary, holidayName string) string { @@ -611,12 +613,29 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa p := &players[i] if p.IsDead { dead = append(dead, *p) - // Dead players who acted today still show in the main section + // Dead players who acted today still show in the main section. + // If they died OUTSIDE the adventure (e.g. Arena), the adventure + // itself was successful — show the alive icon for that block and + // append a "later fell" note. Empty DeathSource is legacy and + // treated as adventure-death (current behavior). if p.Location != "" { - sb.WriteString(fmt.Sprintf("💀 **%s** — Combat Lv.%d | Mining Lv.%d | Forage Lv.%d | Fishing Lv.%d\n", - p.DisplayName, p.CombatLevel, p.MiningSkill, p.ForagingSkill, p.FishingSkill)) + diedOnAdventure := p.DeathSource == "" || p.DeathSource == "adventure" + icon := "💀" + if !diedOnAdventure { + icon = "⚔️" + } + sb.WriteString(fmt.Sprintf("%s **%s** — Combat Lv.%d | Mining Lv.%d | Forage Lv.%d | Fishing Lv.%d\n", + icon, p.DisplayName, p.CombatLevel, p.MiningSkill, p.ForagingSkill, p.FishingSkill)) sb.WriteString(fmt.Sprintf(" Went to: %s\n", p.Location)) - sb.WriteString(fmt.Sprintf(" Outcome: %s\n\n", p.SummaryLine)) + sb.WriteString(fmt.Sprintf(" Outcome: %s\n", p.SummaryLine)) + if !diedOnAdventure { + where := p.DeathLocation + if where == "" { + where = "elsewhere" + } + sb.WriteString(fmt.Sprintf(" 💀 Later fell in %s.\n", where)) + } + sb.WriteString("\n") if worstPlayer == nil { worstPlayer = p } @@ -725,9 +744,13 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa pool := SummaryStandoutDeath if len(pool) > 0 { line := pool[rand.IntN(len(pool))] + lossLoc := worstPlayer.DeathLocation + if lossLoc == "" { + lossLoc = worstPlayer.Location + } line = advSubstituteFlavor(line, map[string]string{ "{name}": worstPlayer.DisplayName, - "{location}": worstPlayer.Location, + "{location}": lossLoc, }) sb.WriteString(fmt.Sprintf("🏆 **Today's standout:** %s\n", line)) } diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index dd9bae3..2b2f2a5 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -233,6 +233,8 @@ func (p *AdventurePlugin) postDailySummary() { if !c.Alive { ps.IsDead = true + ps.DeathSource = c.DeathSource + ps.DeathLocation = c.DeathLocation if c.DeadUntil != nil { ps.DeadUntil = c.DeadUntil.Format("15:04") + " UTC" } diff --git a/internal/plugin/combat_bridge.go b/internal/plugin/combat_bridge.go index 1de75d4..53e458c 100644 --- a/internal/plugin/combat_bridge.go +++ b/internal/plugin/combat_bridge.go @@ -323,6 +323,8 @@ type DeathTransitionParams struct { Equip map[EquipmentSlot]*AdvEquipment 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 @@ -371,7 +373,11 @@ func transitionDeath(p DeathTransitionParams) DeathTransitionResult { return r } - p.Char.Kill() + deathLoc := p.DeathLocation + if deathLoc == "" { + deathLoc = p.Location + } + p.Char.Kill(p.Source, deathLoc) if p.Location != "" { p.Char.GrudgeLocation = p.Location } diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index 8862e7f..f083b97 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -644,12 +644,17 @@ func applyAbility(st *combatState, player, enemy *Combatant, phase *CombatPhase, // reduction rather than flat subtraction, so damage is never fully negated. // Formula: rawAtk * reduction where reduction = K / (K + effectiveDef). // K=40 means 40 defense halves incoming damage; 80 defense reduces by 67%. +// +// A ±15% per-hit jitter is applied so successive hits in the same phase don't +// produce identical numbers — the previous flat-damage output read as scripted. func calcDamage(attack int, atkWeight, dmgBonus float64, defense int, defWeight, dmgReduct float64) int { const K = 40.0 rawAtk := float64(attack) * atkWeight * (1 + dmgBonus) effectiveDef := float64(defense) * defWeight * dmgReduct reduction := K / (K + effectiveDef) dmg := rawAtk * reduction + jitter := 0.85 + rand.Float64()*0.30 // 0.85 .. 1.15 + dmg *= jitter if dmg < 1 { return 1 }