From 68b2f8b7a5522c137c0e6232e4c2f3c2589d41c4 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:50:28 -0700 Subject: [PATCH] Add chat level perks: XP bonus, shop flavor, rare drop nudge, death pardon (Adventure 2.5 steps 3-6) - XP bonus: +5% per 10 chat levels (cap +25% at 50+), applies to all adventure and arena XP - Shop flavor: shopkeeper greeting changes at chat levels 10/30/50, replacing random flavor for recognized players - Rare drop nudge: +0.5% per 10 chat levels (cap +2.5%) applied additively to treasure and masterwork drop rates - Death pardon: chat level 20+ gets 33% chance to survive death once per 7 days (converts to bad-failure, quiet DM). Does not apply in arena. Fires before Sovereign Death's Reprieve check. - New adventure_chat_perks.go with perk calculation helpers - LastPardonUsed field + migration on adventure_characters Co-Authored-By: Claude Opus 4.6 --- internal/db/db.go | 1 + internal/plugin/adventure.go | 59 +++++++++++++++++-------- internal/plugin/adventure_arena.go | 5 ++- internal/plugin/adventure_character.go | 23 ++++++++-- internal/plugin/adventure_chat_perks.go | 38 ++++++++++++++++ internal/plugin/adventure_masterwork.go | 5 ++- internal/plugin/adventure_shop.go | 11 +++-- internal/plugin/adventure_shop_test.go | 6 +-- internal/plugin/adventure_treasure.go | 3 +- 9 files changed, 120 insertions(+), 31 deletions(-) create mode 100644 internal/plugin/adventure_chat_perks.go diff --git a/internal/db/db.go b/internal/db/db.go index 63b2e9e..a5ebc46 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -85,6 +85,7 @@ func runMigrations(d *sql.DB) error { `ALTER TABLE adventure_characters ADD COLUMN last_death_date TEXT NOT NULL DEFAULT ''`, `ALTER TABLE adventure_characters ADD COLUMN combat_actions_used INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE adventure_characters ADD COLUMN harvest_actions_used INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE adventure_characters ADD COLUMN last_pardon_used DATETIME`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 0336177..282c434 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -368,7 +368,7 @@ func (p *AdventurePlugin) handleShopCmd(ctx MessageContext, args string) error { p.shopSessionStart(ctx.Sender) if category == "" { - text := luigiShopGreeting(ctx.Sender, equip, balance, showAll) + text := luigiShopGreeting(ctx.Sender, equip, balance, showAll, p.chatLevel(ctx.Sender)) p.pending.Store(string(ctx.Sender), &advPendingInteraction{ Type: "shop_category", Data: &advPendingShopCategory{ShowAll: showAll}, @@ -768,6 +768,11 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha // Select flavor text result.FlavorText, result.FlavorKey = p.selectFlavorText(char, result) + // Chat level XP bonus + if bonus := chatLevelXPBonus(p.chatLevel(char.UserID)); bonus > 0 { + result.XPGained = int(float64(result.XPGained) * (1.0 + bonus)) + } + // Apply XP switch result.XPSkill { case "combat": @@ -788,28 +793,41 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha // Handle death deathReprieved := false + pardonFired := false if result.Outcome == AdvOutcomeDeath { - // Sovereign set: Death's Reprieve — survive lethal outcome - if advEquippedArenaSets(equip)["sovereign"] && char.DeathReprieveAvailable() { + // Chat level death pardon (does NOT apply in arena — arena uses separate code path) + chatLvl := p.chatLevel(char.UserID) + if chatLvl >= 20 && char.PardonAvailable() && rand.Float64() < 0.33 { + pardonFired = true deathReprieved = true now := time.Now().UTC() - char.DeathReprieveLast = &now + char.LastPardonUsed = &now + result.Outcome = AdvOutcomeEmpty char.GrudgeLocation = loc.Name - // Gear absorbs the blow — all equipment set to 1 condition - for _, slot := range allSlots { - if eq, ok := equip[slot]; ok { - eq.Condition = 1 + } + if !pardonFired { + // Sovereign set: Death's Reprieve — survive lethal outcome + if advEquippedArenaSets(equip)["sovereign"] && char.DeathReprieveAvailable() { + deathReprieved = true + now := time.Now().UTC() + char.DeathReprieveLast = &now + char.GrudgeLocation = loc.Name + // Gear absorbs the blow — all equipment set to 1 condition + for _, slot := range allSlots { + if eq, ok := equip[slot]; ok { + eq.Condition = 1 + } } + // Post room announcement + nextWindow := now.Add(168 * time.Hour) + gr := gamesRoom() + if gr != "" { + p.SendMessage(gr, renderArenaDeathReprieve(char.DisplayName, loc.Name, nextWindow)) + } + } else { + char.Kill() + char.GrudgeLocation = loc.Name } - // Post room announcement - nextWindow := now.Add(168 * time.Hour) - gr := gamesRoom() - if gr != "" { - p.SendMessage(gr, renderArenaDeathReprieve(char.DisplayName, loc.Name, nextWindow)) - } - } else { - char.Kill() - char.GrudgeLocation = loc.Name } } else if hasGrudge && (result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional) { // Clear grudge on successful return @@ -881,6 +899,11 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha slog.Error("adventure: failed to send resolution DM", "user", ctx.Sender, "err", err) } + // Quiet DM for death pardon + if pardonFired { + p.SendDM(ctx.Sender, "The healers got there in time. Barely. Don't make this a habit.") + } + // Send hospital ad on death (delayed, arrives after resolution DM) if result.Outcome == AdvOutcomeDeath && !deathReprieved { p.sendHospitalAd(ctx.Sender, char) @@ -960,7 +983,7 @@ func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharact // ── Treasure Drop Check ───────────────────────────────────────────────────── func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCharacter, loc *AdvLocation) { - drop := rollAdvTreasureDrop(loc.Tier, userID) + drop := rollAdvTreasureDrop(loc.Tier, userID, p.chatLevel(userID)) if drop == nil { return } diff --git a/internal/plugin/adventure_arena.go b/internal/plugin/adventure_arena.go index 99ac7ba..9560b4e 100644 --- a/internal/plugin/adventure_arena.go +++ b/internal/plugin/adventure_arena.go @@ -361,12 +361,15 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun run.RoundsSurvived++ run.LastMonster = monster.Name - // Award battle XP (Ironclad set: Battle-Hardened — +5% XP) + // Award battle XP (Ironclad set: Battle-Hardened — +5% XP, chat level bonus) battleXP := tier.BattleXP equip, _ := loadAdvEquipment(ctx.Sender) if advEquippedArenaSets(equip)["ironclad"] { battleXP = int(float64(battleXP) * 1.05) } + if bonus := chatLevelXPBonus(p.chatLevel(ctx.Sender)); bonus > 0 { + battleXP = int(float64(battleXP) * (1.0 + bonus)) + } char.CombatXP += battleXP leveled, newLevel := checkAdvLevelUp(char, "combat") if leveled { diff --git a/internal/plugin/adventure_character.go b/internal/plugin/adventure_character.go index b368a87..a827812 100644 --- a/internal/plugin/adventure_character.go +++ b/internal/plugin/adventure_character.go @@ -61,6 +61,7 @@ type AdventureCharacter struct { HospitalVisits int RobbieVisitCount int LastDeathDate string + LastPardonUsed *time.Time } type AdvEquipment struct { @@ -205,6 +206,15 @@ func (c *AdventureCharacter) DeathReprieveAvailable() bool { return time.Since(*c.DeathReprieveLast) >= 168*time.Hour } +// PardonAvailable returns true if the chat level death pardon cooldown +// has expired (or was never triggered). 7-day rolling cooldown. +func (c *AdventureCharacter) PardonAvailable() bool { + if c.LastPardonUsed == nil { + return true + } + return time.Since(*c.LastPardonUsed) >= 168*time.Hour +} + // Kill marks the character as dead with a 6-hour respawn timer. func (c *AdventureCharacter) Kill() { c.Alive = false @@ -346,7 +356,7 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) { d := db.Get() c := &AdventureCharacter{} var alive, actionTaken, holidayTaken, rivalUnlocked, babysitAct int - var deadUntil, reprieveLast, babysitExp sql.NullTime + var deadUntil, reprieveLast, babysitExp, pardonUsed sql.NullTime err := d.QueryRow(` SELECT user_id, display_name, @@ -360,7 +370,8 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) { rival_pool, rival_unlocked_notified, babysit_active, babysit_expires_at, babysit_skill_focus, hospital_visits, robbie_visit_count, last_death_date, - combat_actions_used, harvest_actions_used + combat_actions_used, harvest_actions_used, + last_pardon_used FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan( &c.UserID, &c.DisplayName, &c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill, @@ -374,6 +385,7 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) { &babysitAct, &babysitExp, &c.BabysitSkillFocus, &c.HospitalVisits, &c.RobbieVisitCount, &c.LastDeathDate, &c.CombatActionsUsed, &c.HarvestActionsUsed, + &pardonUsed, ) if err != nil { return nil, err @@ -392,6 +404,9 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) { if babysitExp.Valid { c.BabysitExpiresAt = &babysitExp.Time } + if pardonUsed.Valid { + c.LastPardonUsed = &pardonUsed.Time + } return c, nil } @@ -459,7 +474,8 @@ func saveAdvCharacter(char *AdventureCharacter) error { rival_pool = ?, rival_unlocked_notified = ?, babysit_active = ?, babysit_expires_at = ?, babysit_skill_focus = ?, hospital_visits = ?, robbie_visit_count = ?, last_death_date = ?, - combat_actions_used = ?, harvest_actions_used = ? + combat_actions_used = ?, harvest_actions_used = ?, + last_pardon_used = ? WHERE user_id = ?`, char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill, char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP, @@ -471,6 +487,7 @@ func saveAdvCharacter(char *AdventureCharacter) error { babysitAct, char.BabysitExpiresAt, char.BabysitSkillFocus, char.HospitalVisits, char.RobbieVisitCount, char.LastDeathDate, char.CombatActionsUsed, char.HarvestActionsUsed, + char.LastPardonUsed, string(char.UserID), ) return err diff --git a/internal/plugin/adventure_chat_perks.go b/internal/plugin/adventure_chat_perks.go new file mode 100644 index 0000000..3aaa551 --- /dev/null +++ b/internal/plugin/adventure_chat_perks.go @@ -0,0 +1,38 @@ +package plugin + +// ── Chat Level Perks ──────────────────────────────────────────────────────── +// Passive bonuses based on community chat level. All perks are automatic. + +// chatLevelXPBonus returns the fractional XP bonus for the given chat level. +// +5% per 10 levels, capped at +25% at level 50+. +func chatLevelXPBonus(chatLevel int) float64 { + tier := chatLevel / 10 + if tier > 5 { + tier = 5 + } + return float64(tier) * 0.05 +} + +// chatLevelRareBonus returns the additive rare drop rate bonus. +// +0.5% per 10 levels, capped at +2.5% at level 50+. +func chatLevelRareBonus(chatLevel int) float64 { + tier := chatLevel / 10 + if tier > 5 { + tier = 5 + } + return float64(tier) * 0.005 +} + +// shopGreeting returns the shopkeeper's greeting based on chat level. +func shopGreeting(chatLevel int) string { + switch { + case chatLevel >= 50: + return "I saw you coming from down the street. Your usual is already on the counter. The shop is open." + case chatLevel >= 30: + return "Back again. I've started keeping your usual in the back. The shop is open." + case chatLevel >= 10: + return "Ah. You again. The shop is open." + default: + return "The shop is open. What do you need?" + } +} diff --git a/internal/plugin/adventure_masterwork.go b/internal/plugin/adventure_masterwork.go index 37ad8d0..1d842a6 100644 --- a/internal/plugin/adventure_masterwork.go +++ b/internal/plugin/adventure_masterwork.go @@ -169,8 +169,9 @@ func (p *AdventurePlugin) checkMasterworkDrop(userID id.UserID, char *AdventureC return // no masterwork available for this activity+tier (e.g. dungeon) } - // Roll for drop - if rand.Float64() >= def.DropRate { + // Roll for drop (chat level rare bonus applied additively) + dropRate := def.DropRate + chatLevelRareBonus(p.chatLevel(userID)) + if rand.Float64() >= dropRate { return } diff --git a/internal/plugin/adventure_shop.go b/internal/plugin/adventure_shop.go index 0bd722e..e9d9cd3 100644 --- a/internal/plugin/adventure_shop.go +++ b/internal/plugin/adventure_shop.go @@ -122,7 +122,7 @@ func (p *AdventurePlugin) shopScheduleBrowseNudge(userID id.UserID) { // ── Display: Luigi Greeting + Category Menu ───────────────────────────────── -func luigiShopGreeting(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, balance float64, showAll bool) string { +func luigiShopGreeting(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, balance float64, showAll bool, chatLevel int) string { var sb strings.Builder // Check if fully maxed out. @@ -141,7 +141,12 @@ func luigiShopGreeting(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, return sb.String() } - greet, _ := advPickFlavor(luigiGreetings, userID, "luigi_greet") + var greet string + if chatLevel >= 10 { + greet = shopGreeting(chatLevel) + } else { + greet, _ = advPickFlavor(luigiGreetings, userID, "luigi_greet") + } sb.WriteString(fmt.Sprintf("🛒 **Luigi's**\n💰 Balance: €%.0f\n\n", balance)) sb.WriteString(fmt.Sprintf("*%s*\n\n", greet)) @@ -359,7 +364,7 @@ func (p *AdventurePlugin) resolveShopItemChoice(ctx MessageContext, interaction } balance := p.euro.GetBalance(ctx.Sender) - text := luigiShopGreeting(ctx.Sender, equip, balance, data.ShowAll) + text := luigiShopGreeting(ctx.Sender, equip, balance, data.ShowAll, p.chatLevel(ctx.Sender)) p.pending.Store(string(ctx.Sender), &advPendingInteraction{ Type: "shop_category", Data: &advPendingShopCategory{ShowAll: data.ShowAll}, diff --git a/internal/plugin/adventure_shop_test.go b/internal/plugin/adventure_shop_test.go index 7d09cce..79f327a 100644 --- a/internal/plugin/adventure_shop_test.go +++ b/internal/plugin/adventure_shop_test.go @@ -11,7 +11,7 @@ func TestLuigiShopGreeting_Basic(t *testing.T) { equip := map[EquipmentSlot]*AdvEquipment{ SlotWeapon: {Tier: 2, Condition: 100}, } - text := luigiShopGreeting("@user:test", equip, 5000, false) + text := luigiShopGreeting("@user:test", equip, 5000, false, 0) if !strings.Contains(text, "Luigi's") { t.Error("greeting should contain Luigi's") @@ -35,7 +35,7 @@ func TestLuigiShopGreeting_MaxedOut(t *testing.T) { SlotBoots: {Tier: 5, Condition: 100}, SlotTool: {Tier: 5, Condition: 100}, } - text := luigiShopGreeting("@user:test", equip, 99999, false) + text := luigiShopGreeting("@user:test", equip, 99999, false, 0) // Should NOT show category grid. if strings.Contains(text, "Reply with a category") { @@ -49,7 +49,7 @@ func TestLuigiShopGreeting_MaxedOut(t *testing.T) { func TestLuigiShopGreeting_ShowAll(t *testing.T) { equip := map[EquipmentSlot]*AdvEquipment{} - text := luigiShopGreeting("@user:test", equip, 1000, true) + text := luigiShopGreeting("@user:test", equip, 1000, true, 0) // Should contain a show-all comment. if !strings.Contains(text, "judgment") && !strings.Contains(text, "thoroughness") { diff --git a/internal/plugin/adventure_treasure.go b/internal/plugin/adventure_treasure.go index 18506ef..885187b 100644 --- a/internal/plugin/adventure_treasure.go +++ b/internal/plugin/adventure_treasure.go @@ -241,11 +241,12 @@ var advAllTreasures = map[int][]AdvTreasureDef{ // ── Treasure Drop Logic ────────────────────────────────────────────────────── -func rollAdvTreasureDrop(tier int, userID id.UserID) *AdvTreasureDrop { +func rollAdvTreasureDrop(tier int, userID id.UserID, chatLevel int) *AdvTreasureDrop { rate, ok := advTreasureDropRates[tier] if !ok { return nil } + rate += chatLevelRareBonus(chatLevel) if rand.Float64() >= rate { return nil