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 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-04-08 15:50:28 -07:00
parent a44a9d9234
commit 68b2f8b7a5
9 changed files with 120 additions and 31 deletions

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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?"
}
}

View File

@@ -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
}

View File

@@ -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},

View File

@@ -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") {

View File

@@ -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