mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Death source tracking + combat threat/jitter fixes
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 applied_at DATETIME`,
|
||||||
`ALTER TABLE coop_dungeon_gifts ADD COLUMN stack_lead_id INTEGER`,
|
`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 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 {
|
for _, stmt := range columnMigrations {
|
||||||
if _, err := d.Exec(stmt); err != nil {
|
if _, err := d.Exec(stmt); err != nil {
|
||||||
|
|||||||
@@ -875,6 +875,8 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
|
|||||||
Equip: equip,
|
Equip: equip,
|
||||||
ChatLevel: p.chatLevel(char.UserID),
|
ChatLevel: p.chatLevel(char.UserID),
|
||||||
Location: loc.Name,
|
Location: loc.Name,
|
||||||
|
Source: "adventure",
|
||||||
|
DeathLocation: loc.Name,
|
||||||
AllowPardon: true,
|
AllowPardon: true,
|
||||||
AllowSovereign: true,
|
AllowSovereign: true,
|
||||||
EngineSaved: engineDeathSaved,
|
EngineSaved: engineDeathSaved,
|
||||||
|
|||||||
@@ -414,7 +414,11 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c
|
|||||||
|
|
||||||
phaseMessages := RenderCombatLogArena(result, char.DisplayName, monster.Name)
|
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.ArenaLosses++
|
||||||
char.CombatXP += arenaParticipationXP
|
char.CombatXP += arenaParticipationXP
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ type AdventureCharacter struct {
|
|||||||
AutoBabysit bool
|
AutoBabysit bool
|
||||||
StreakDecayed bool
|
StreakDecayed bool
|
||||||
CraftsSucceeded int
|
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 {
|
type AdvEquipment struct {
|
||||||
@@ -249,12 +251,16 @@ func (c *AdventureCharacter) PardonAvailable() bool {
|
|||||||
return time.Since(*c.LastPardonUsed) >= 168*time.Hour
|
return time.Since(*c.LastPardonUsed) >= 168*time.Hour
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kill marks the character as dead with a 6-hour respawn timer.
|
// Kill marks the character as dead with a 6-hour respawn timer. source is
|
||||||
func (c *AdventureCharacter) Kill() {
|
// "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
|
c.Alive = false
|
||||||
deadUntil := time.Now().UTC().Add(6 * time.Hour)
|
deadUntil := time.Now().UTC().Add(6 * time.Hour)
|
||||||
c.DeadUntil = &deadUntil
|
c.DeadUntil = &deadUntil
|
||||||
c.LastDeathDate = time.Now().UTC().Format("2006-01-02")
|
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).
|
// 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,
|
pet_chased_away, pet_reactivated, pet_arrived,
|
||||||
misty_encounter_count, misty_donated_count,
|
misty_encounter_count, misty_donated_count,
|
||||||
thom_animal_line_fired, pet_supply_shop_unlocked, pet_level10_date,
|
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(
|
FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan(
|
||||||
&c.UserID, &c.DisplayName,
|
&c.UserID, &c.DisplayName,
|
||||||
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
|
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
|
||||||
@@ -472,6 +479,7 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
|
|||||||
&c.MistyEncounterCount, &c.MistyDonatedCount,
|
&c.MistyEncounterCount, &c.MistyDonatedCount,
|
||||||
&thomAnimalLine, &petSupplyUnlocked, &c.PetLevel10Date,
|
&thomAnimalLine, &petSupplyUnlocked, &c.PetLevel10Date,
|
||||||
&petMorningDef, &autoBabysit, &streakDecayed, &c.CraftsSucceeded,
|
&petMorningDef, &autoBabysit, &streakDecayed, &c.CraftsSucceeded,
|
||||||
|
&c.DeathSource, &c.DeathLocation,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -640,7 +648,9 @@ func saveAdvCharacter(char *AdventureCharacter) error {
|
|||||||
pet_morning_defense = ?,
|
pet_morning_defense = ?,
|
||||||
auto_babysit = ?,
|
auto_babysit = ?,
|
||||||
streak_decayed = ?,
|
streak_decayed = ?,
|
||||||
crafts_succeeded = ?
|
crafts_succeeded = ?,
|
||||||
|
death_source = ?,
|
||||||
|
death_location = ?
|
||||||
WHERE user_id = ?`,
|
WHERE user_id = ?`,
|
||||||
char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill,
|
char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill,
|
||||||
char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP,
|
char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP,
|
||||||
@@ -667,6 +677,7 @@ func saveAdvCharacter(char *AdventureCharacter) error {
|
|||||||
autoBabysit,
|
autoBabysit,
|
||||||
streakDecayed,
|
streakDecayed,
|
||||||
char.CraftsSucceeded,
|
char.CraftsSucceeded,
|
||||||
|
char.DeathSource, char.DeathLocation,
|
||||||
string(char.UserID),
|
string(char.UserID),
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
@@ -797,7 +808,8 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
|
|||||||
pet_chased_away, pet_reactivated, pet_arrived,
|
pet_chased_away, pet_reactivated, pet_arrived,
|
||||||
misty_encounter_count, misty_donated_count,
|
misty_encounter_count, misty_donated_count,
|
||||||
thom_animal_line_fired, pet_supply_shop_unlocked, pet_level10_date,
|
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`)
|
FROM adventure_characters`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -838,6 +850,7 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
|
|||||||
&c.MistyEncounterCount, &c.MistyDonatedCount,
|
&c.MistyEncounterCount, &c.MistyDonatedCount,
|
||||||
&thomAnimalLine, &petSupplyUnlocked, &c.PetLevel10Date,
|
&thomAnimalLine, &petSupplyUnlocked, &c.PetLevel10Date,
|
||||||
&petMorningDef, &autoBabysit, &streakDecayed, &c.CraftsSucceeded,
|
&petMorningDef, &autoBabysit, &streakDecayed, &c.CraftsSucceeded,
|
||||||
|
&c.DeathSource, &c.DeathLocation,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,17 +86,31 @@ const (
|
|||||||
threatDangerous // > 1.0
|
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 {
|
func assessThreat(player, enemy CombatStats) threatLevel {
|
||||||
playerPower := float64(player.MaxHP + player.Attack*3)
|
const K = 40.0
|
||||||
enemyPower := float64(enemy.MaxHP + enemy.Attack*3)
|
dprPlayer := float64(player.Attack) * (K / (K + float64(enemy.Defense)))
|
||||||
if playerPower == 0 {
|
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
|
return threatDangerous
|
||||||
}
|
}
|
||||||
ratio := enemyPower / playerPower
|
// ratio < 1: player wins the race (lower = more dominant). >1: enemy wins.
|
||||||
|
ratio := rtkEnemy / rtkPlayer
|
||||||
switch {
|
switch {
|
||||||
case ratio < 0.4:
|
case ratio < 0.35:
|
||||||
return threatTrivial
|
return threatTrivial
|
||||||
case ratio < 0.7:
|
case ratio < 0.65:
|
||||||
return threatEasy
|
return threatEasy
|
||||||
case ratio < 1.0:
|
case ratio < 1.0:
|
||||||
return threatCompetitive
|
return threatCompetitive
|
||||||
@@ -111,7 +125,10 @@ func assessThreat(player, enemy CombatStats) threatLevel {
|
|||||||
// contentTier caps consumable tier to the content being fought (0 = no cap).
|
// contentTier caps consumable tier to the content being fought (0 = no cap).
|
||||||
func SelectConsumables(inventory []ConsumableItem, playerStats, enemyStats CombatStats, arenaRound int, contentTier int) []ConsumableItem {
|
func SelectConsumables(inventory []ConsumableItem, playerStats, enemyStats CombatStats, arenaRound int, contentTier int) []ConsumableItem {
|
||||||
threat := assessThreat(playerStats, enemyStats)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -540,6 +540,8 @@ type AdvPlayerDaySummary struct {
|
|||||||
IsResting bool
|
IsResting bool
|
||||||
SummaryLine string
|
SummaryLine string
|
||||||
HolidayActions int // 0 = not holiday or no action; 1 = took one; 2 = took both
|
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 {
|
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]
|
p := &players[i]
|
||||||
if p.IsDead {
|
if p.IsDead {
|
||||||
dead = append(dead, *p)
|
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 != "" {
|
if p.Location != "" {
|
||||||
sb.WriteString(fmt.Sprintf("💀 **%s** — Combat Lv.%d | Mining Lv.%d | Forage Lv.%d | Fishing Lv.%d\n",
|
diedOnAdventure := p.DeathSource == "" || p.DeathSource == "adventure"
|
||||||
p.DisplayName, p.CombatLevel, p.MiningSkill, p.ForagingSkill, p.FishingSkill))
|
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(" 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 {
|
if worstPlayer == nil {
|
||||||
worstPlayer = p
|
worstPlayer = p
|
||||||
}
|
}
|
||||||
@@ -725,9 +744,13 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
|
|||||||
pool := SummaryStandoutDeath
|
pool := SummaryStandoutDeath
|
||||||
if len(pool) > 0 {
|
if len(pool) > 0 {
|
||||||
line := pool[rand.IntN(len(pool))]
|
line := pool[rand.IntN(len(pool))]
|
||||||
|
lossLoc := worstPlayer.DeathLocation
|
||||||
|
if lossLoc == "" {
|
||||||
|
lossLoc = worstPlayer.Location
|
||||||
|
}
|
||||||
line = advSubstituteFlavor(line, map[string]string{
|
line = advSubstituteFlavor(line, map[string]string{
|
||||||
"{name}": worstPlayer.DisplayName,
|
"{name}": worstPlayer.DisplayName,
|
||||||
"{location}": worstPlayer.Location,
|
"{location}": lossLoc,
|
||||||
})
|
})
|
||||||
sb.WriteString(fmt.Sprintf("🏆 **Today's standout:** %s\n", line))
|
sb.WriteString(fmt.Sprintf("🏆 **Today's standout:** %s\n", line))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,6 +233,8 @@ func (p *AdventurePlugin) postDailySummary() {
|
|||||||
|
|
||||||
if !c.Alive {
|
if !c.Alive {
|
||||||
ps.IsDead = true
|
ps.IsDead = true
|
||||||
|
ps.DeathSource = c.DeathSource
|
||||||
|
ps.DeathLocation = c.DeathLocation
|
||||||
if c.DeadUntil != nil {
|
if c.DeadUntil != nil {
|
||||||
ps.DeadUntil = c.DeadUntil.Format("15:04") + " UTC"
|
ps.DeadUntil = c.DeadUntil.Format("15:04") + " UTC"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -323,6 +323,8 @@ type DeathTransitionParams struct {
|
|||||||
Equip map[EquipmentSlot]*AdvEquipment
|
Equip map[EquipmentSlot]*AdvEquipment
|
||||||
ChatLevel int
|
ChatLevel int
|
||||||
Location string // set as GrudgeLocation; empty = don't set
|
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)
|
AllowPardon bool // chat level pardon (adventure only)
|
||||||
AllowSovereign bool // probability-band Sovereign reprieve (non-engine path)
|
AllowSovereign bool // probability-band Sovereign reprieve (non-engine path)
|
||||||
EngineSaved bool // combat engine used Sovereign death save
|
EngineSaved bool // combat engine used Sovereign death save
|
||||||
@@ -371,7 +373,11 @@ func transitionDeath(p DeathTransitionParams) DeathTransitionResult {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
p.Char.Kill()
|
deathLoc := p.DeathLocation
|
||||||
|
if deathLoc == "" {
|
||||||
|
deathLoc = p.Location
|
||||||
|
}
|
||||||
|
p.Char.Kill(p.Source, deathLoc)
|
||||||
if p.Location != "" {
|
if p.Location != "" {
|
||||||
p.Char.GrudgeLocation = p.Location
|
p.Char.GrudgeLocation = p.Location
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -644,12 +644,17 @@ func applyAbility(st *combatState, player, enemy *Combatant, phase *CombatPhase,
|
|||||||
// reduction rather than flat subtraction, so damage is never fully negated.
|
// reduction rather than flat subtraction, so damage is never fully negated.
|
||||||
// Formula: rawAtk * reduction where reduction = K / (K + effectiveDef).
|
// Formula: rawAtk * reduction where reduction = K / (K + effectiveDef).
|
||||||
// K=40 means 40 defense halves incoming damage; 80 defense reduces by 67%.
|
// 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 {
|
func calcDamage(attack int, atkWeight, dmgBonus float64, defense int, defWeight, dmgReduct float64) int {
|
||||||
const K = 40.0
|
const K = 40.0
|
||||||
rawAtk := float64(attack) * atkWeight * (1 + dmgBonus)
|
rawAtk := float64(attack) * atkWeight * (1 + dmgBonus)
|
||||||
effectiveDef := float64(defense) * defWeight * dmgReduct
|
effectiveDef := float64(defense) * defWeight * dmgReduct
|
||||||
reduction := K / (K + effectiveDef)
|
reduction := K / (K + effectiveDef)
|
||||||
dmg := rawAtk * reduction
|
dmg := rawAtk * reduction
|
||||||
|
jitter := 0.85 + rand.Float64()*0.30 // 0.85 .. 1.15
|
||||||
|
dmg *= jitter
|
||||||
if dmg < 1 {
|
if dmg < 1 {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user