Add masterwork rework, arena combat log, Death's Reprieve fix, and wordle improvements

Adventure — Masterwork Rework:
- Expand from 3 generic items to 15 tiered masterwork items across
  mining (weapon), fishing (armor), and foraging (boots) with per-tier
  drop rates (5%/4%/3%/2%/1.5%) and location gating
- Skill-specific cross-skill bonuses replace flat masterwork check
- Auto-equip if better, silent discard for duplicates, inventory for rest
- Add !adventure equip command for manual masterwork equipping
- Tiered room announcements: T1-2 DM only, T3 quiet, T4-5 full
- First-drop detection with special message
- Character sheet shows /⚔️ markers for masterwork/arena gear
- Sell protection for special gear in shop

Adventure — Arena Combat Log:
- Turn-based Dragon Quest style narrative for arena fights
- Outcome-first design: roll determines win/loss, log assembled backward
- No-repeat flavor text via actionPicker with per-pool tracking
- 60 participation XP on arena loss

Adventure — Death's Reprieve Fix:
- All equipment set to 1 condition instead of death-level degradation

Wordle:
- Refactored word sourcing and expanded fallback word list
- Simplified Wordnik integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-03-29 16:24:59 -07:00
parent 31a1d00236
commit 0a1359de46
19 changed files with 2427 additions and 289 deletions

View File

@@ -182,11 +182,15 @@ func (p *AdventurePlugin) handleArenaFight(ctx MessageContext) error {
roll := rand.Float64()
died := roll < deathChance
// Generate combat log (cosmetic — outcome already determined)
closeness := 1.0 - math.Abs(roll-deathChance)/math.Max(deathChance, 1-deathChance)
combatLog := generateArenaCombatLog(!died, closeness)
if died {
return p.resolveArenaDeath(ctx, run, char, tier, monster)
return p.resolveArenaDeath(ctx, run, char, tier, monster, combatLog)
}
return p.resolveArenaSurvival(ctx, run, char, tier, monster)
return p.resolveArenaSurvival(ctx, run, char, tier, monster, combatLog)
}
func (p *AdventurePlugin) confirmAndStartArenaRun(ctx MessageContext, tierNum int) error {
@@ -235,11 +239,14 @@ func (p *AdventurePlugin) confirmAndStartArenaRun(ctx MessageContext, tierNum in
roll := rand.Float64()
died := roll < deathChance
closeness := 1.0 - math.Abs(roll-deathChance)/math.Max(deathChance, 1-deathChance)
combatLog := generateArenaCombatLog(!died, closeness)
if died {
return p.resolveArenaDeath(ctx, run, char, tier, monster)
return p.resolveArenaDeath(ctx, run, char, tier, monster, combatLog)
}
return p.resolveArenaSurvival(ctx, run, char, tier, monster)
return p.resolveArenaSurvival(ctx, run, char, tier, monster, combatLog)
}
func (p *AdventurePlugin) handleArenaCancel(ctx MessageContext) error {
@@ -347,22 +354,29 @@ func (p *AdventurePlugin) handleArenaLeaderboard(ctx MessageContext) error {
// ── Combat Resolution ───────────────────────────────────────────────────────
func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster) error {
func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster, combatLog *ArenaCombatLog) error {
// Calculate reward
reward := arenaRoundReward(tier, run.Round, char.CombatLevel)
run.Earnings += reward
run.RoundsSurvived++
run.LastMonster = monster.Name
// Award battle XP
char.CombatXP += tier.BattleXP
// Award battle XP (Ironclad set: Battle-Hardened — +5% XP)
battleXP := tier.BattleXP
equip, _ := loadAdvEquipment(ctx.Sender)
if advEquippedArenaSets(equip)["ironclad"] {
battleXP = int(float64(battleXP) * 1.05)
}
char.CombatXP += battleXP
leveled, newLevel := checkAdvLevelUp(char, "combat")
if err := saveAdvCharacter(char); err != nil {
slog.Error("arena: failed to save character after survival", "user", ctx.Sender, "err", err)
}
// Build survival message
text := renderArenaSurvival(tier, run.Round, monster, reward, tier.BattleXP, run.Earnings)
// Build survival message with combat log
closer := arenaWinCloser(monster.Name, len(combatLog.Rounds))
text := renderArenaCombatLog(combatLog, monster, true, reward, tier.BattleXP, closer)
text += fmt.Sprintf("\nRun total: €%d\n", run.Earnings)
if leveled {
text += fmt.Sprintf("\n🎉 **Combat Level %d!**", newLevel)
}
@@ -400,6 +414,12 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
text += "\n\n" + renderArenaTierComplete(tier, tier.CompletionBonus, run.Earnings)
// Check for arena helmet drop
if dropped := p.arenaRollHelmetDrop(ctx.Sender, run.Tier); dropped != nil {
text += "\n\n" + renderArenaHelmetDrop(dropped)
p.postArenaDropAnnouncement(char.DisplayName, dropped)
}
// Grant tier achievement
p.grantArenaTierAchievement(ctx.Sender, run.Tier)
@@ -424,16 +444,103 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster) error {
lostEarnings := run.Earnings
func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster, combatLog *ArenaCombatLog) error {
run.LastMonster = monster.Name
// Sovereign set: Death's Reprieve — survive lethal arena outcome
equip, _ := loadAdvEquipment(ctx.Sender)
if advEquippedArenaSets(equip)["sovereign"] && char.DeathReprieveAvailable() {
now := time.Now().UTC()
char.DeathReprieveLast = &now
if err := saveAdvCharacter(char); err != nil {
slog.Error("arena: failed to save character after reprieve", "user", ctx.Sender, "err", err)
}
// Gear absorbs the blow — all equipment set to 1 condition
for _, slot := range allSlots {
if eq, ok := equip[slot]; ok {
eq.Condition = 1
saveAdvEquipment(ctx.Sender, eq)
}
}
// Run continues — not dead, earnings preserved
nextWindow := now.Add(168 * time.Hour)
gr := gamesRoom()
if gr != "" {
p.SendMessage(gr, renderArenaDeathReprieve(char.DisplayName, monster.Name, nextWindow))
}
// Show combat log (player "lost" but was saved by reprieve)
closer := arenaLoseCloser(monster.Name, len(combatLog.Rounds))
text := renderArenaCombatLog(combatLog, monster, false, 0, 0, closer)
text += fmt.Sprintf("\n💀→⚔ **%s nearly killed you.**\n\n"+
"Your Sovereign gear activated **Death's Reprieve**. You survived — barely.\n"+
"All equipment set to 1 condition.\n\n"+
"Next reprieve window: %s\n",
monster.Name, nextWindow.Format("2006-01-02 15:04 UTC"))
run.RoundsSurvived++
// Check if this was round 4 — tier completion via reprieve
if run.Round >= 4 {
run.Earnings += tier.CompletionBonus
run.Round = 4
if run.Tier >= 5 {
// Tier 5 complete — hand off to the normal T5 completion path
return p.arenaCompleteTier5(ctx, run, char, tier, text)
}
// Tier complete, awaiting descend/cashout
run.Status = "awaiting"
if err := saveArenaRun(run); err != nil {
slog.Error("arena: failed to save run after reprieve tier complete", "user", ctx.Sender, "err", err)
}
deadline := time.Now().UTC().Add(10 * time.Minute)
p.arenaDeadlines.Store(string(ctx.Sender), deadline)
text += "\n" + renderArenaTierComplete(tier, tier.CompletionBonus, run.Earnings)
// Check for arena helmet drop
if dropped := p.arenaRollHelmetDrop(ctx.Sender, run.Tier); dropped != nil {
text += "\n\n" + renderArenaHelmetDrop(dropped)
p.postArenaDropAnnouncement(char.DisplayName, dropped)
}
p.grantArenaTierAchievement(ctx.Sender, run.Tier)
return p.SendDM(ctx.Sender, text)
}
// Not round 4 — advance to next round
run.Round++
if err := saveArenaRun(run); err != nil {
slog.Error("arena: failed to save run after reprieve", "user", ctx.Sender, "err", err)
}
text += fmt.Sprintf("\nRun earnings: €%d (still at risk)\n", run.Earnings)
nextMonster := arenaGetMonster(run.Tier, run.Round)
if nextMonster != nil {
text += fmt.Sprintf("\n─────────────────────────────\n\n")
text += fmt.Sprintf("**Round %d/4 — %s**\n", run.Round, nextMonster.Name)
text += fmt.Sprintf("_%s_\n\n", nextMonster.Flavor)
text += "`!arena fight` — Face this opponent"
}
return p.SendDM(ctx.Sender, text)
}
lostEarnings := run.Earnings
// Kill the character (locked out until next midnight UTC)
char.Alive = false
now := time.Now().UTC()
deadUntil := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
char.DeadUntil = &deadUntil
char.ArenaLosses++
char.CombatXP += arenaParticipationXP // +60 flat participation XP
if err := saveAdvCharacter(char); err != nil {
slog.Error("arena: failed to save character after death", "user", ctx.Sender, "err", err)
}
@@ -457,10 +564,11 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c
p.achievements.GrantAchievement(ctx.Sender, "arena_death_t5")
}
// Pick death message
deathMsg := arenaPickDeathMessage(monster, run.Tier, run.Round)
// Build death message with combat log
closer := arenaLoseCloser(monster.Name, len(combatLog.Rounds))
text := renderArenaCombatLog(combatLog, monster, false, 0, arenaParticipationXP, closer)
text += fmt.Sprintf("\nLost earnings: €%d\n", lostEarnings)
text := renderArenaDeath(tier, run.Round, monster, lostEarnings, deathMsg)
return p.SendDM(ctx.Sender, text)
}
@@ -499,6 +607,13 @@ func (p *AdventurePlugin) arenaCompleteTier5(ctx MessageContext, run *ArenaRun,
}
text := prefixText + "\n\n" + renderArenaTier5Complete(run.Earnings, run.StartTier)
// Check for arena helmet drop
if dropped := p.arenaRollHelmetDrop(ctx.Sender, 5); dropped != nil {
text += "\n\n" + renderArenaHelmetDrop(dropped)
p.postArenaDropAnnouncement(char.DisplayName, dropped)
}
return p.SendDM(ctx.Sender, text)
}
@@ -836,3 +951,126 @@ func loadArenaPersonalStats(userID id.UserID) *ArenaPersonalStats {
}
return stats
}
// ── Arena Gear ──────────────────────────────────────────────────────────────
type ArenaGearSet struct {
Tier int
SetKey string // DB key: "bloodied", "ironclad", etc.
SetName string // Display: "Bloodied", "Ironclad", etc.
HelmetName string
Description string
DropRate float64
}
var arenaGearSets = [5]ArenaGearSet{
{
Tier: 1, SetKey: "bloodied", SetName: "Bloodied", HelmetName: "Bloodied Helm",
Description: "A Brim & Battle VeriFort Series 1. The foam padding smells like a sporting goods store. " +
"The chin strap is the kind used on baseball helmets, which this technically isn't anymore. " +
"It held up. Brim & Battle would like you to know it held up.",
DropRate: 0.05,
},
{
Tier: 2, SetKey: "ironclad", SetName: "Ironclad", HelmetName: "Ironclad Helm",
Description: "VeriFort Series 2. Brim & Battle made some adjustments after Series 1 feedback, " +
"which they received exclusively from observing what happened to Series 1. " +
"The rivets are new. The rivets are good.",
DropRate: 0.04,
},
{
Tier: 3, SetKey: "tempered", SetName: "Tempered", HelmetName: "Tempered Helm",
Description: "VeriFort Series 3. At this point Brim & Battle has stopped calling these prototypes in public. " +
"The internal documentation still says prototype. " +
"The helmet does not know this and performs accordingly.",
DropRate: 0.03,
},
{
Tier: 4, SetKey: "champions", SetName: "Champion's", HelmetName: "Champion's Crown",
Description: "VeriFort Series 4. Brim & Battle's premium tier, priced for the \"serious enthusiast combatant market\" " +
"according to a pitch deck that was definitely never meant to be seen publicly. " +
"The branding is subtle. The performance is not. " +
"The QR code goes to a waitlist page for a product that does not yet exist.",
DropRate: 0.02,
},
{
Tier: 5, SetKey: "sovereign", SetName: "Sovereign", HelmetName: "Sovereign Crown",
Description: "VeriFort Series 5. Brim & Battle did not design this. " +
"They are not certain where it came from. It appeared in their warehouse inventory in Q3 " +
"with no purchase order attached. They have claimed it anyway. " +
"The feedback survey in the lining links to a page that returns a 404. " +
"Brim & Battle appreciates all feedback.",
DropRate: 0.005,
},
}
func arenaGearByTier(tier int) *ArenaGearSet {
if tier < 1 || tier > 5 {
return nil
}
return &arenaGearSets[tier-1]
}
// arenaRollHelmetDrop checks if a helmet should drop and equips it if the player
// doesn't already have an arena helmet at this tier or higher. If they do, the
// drop is silently discarded (no duplicate drops).
// Returns the gear set if a drop was equipped, nil otherwise.
func (p *AdventurePlugin) arenaRollHelmetDrop(userID id.UserID, tier int) *ArenaGearSet {
gear := arenaGearByTier(tier)
if gear == nil {
return nil
}
// Roll for drop
if rand.Float64() >= gear.DropRate {
return nil
}
// Check current helmet
equip, err := loadAdvEquipment(userID)
if err != nil {
slog.Error("arena: failed to load equipment for drop check", "user", userID, "err", err)
return nil
}
helmet, hasHelmet := equip[SlotHelmet]
if hasHelmet && helmet.ArenaTier >= tier {
// Already has same or better arena helmet — silent discard
return nil
}
// Equip the arena helmet
if !hasHelmet {
// Shouldn't happen (all slots created at character creation), but be safe
helmet = &AdvEquipment{Slot: SlotHelmet}
}
helmet.Tier = tier
helmet.Condition = 100
helmet.Name = gear.HelmetName
helmet.ActionsUsed = 0
helmet.ArenaTier = tier
helmet.ArenaSet = gear.SetKey
if err := saveAdvEquipment(userID, helmet); err != nil {
slog.Error("arena: failed to save arena helmet drop", "user", userID, "err", err)
return nil
}
return gear
}
func (p *AdventurePlugin) postArenaDropAnnouncement(playerName string, gear *ArenaGearSet) {
gr := gamesRoom()
if gr == "" {
return
}
var announce string
if gear.Tier == 5 {
announce = fmt.Sprintf("⚔️ **%s** has claimed **%s** from Tier 5 of the Arena. This is Sovereign gear. There are very few of these.",
playerName, gear.HelmetName)
} else {
announce = fmt.Sprintf("⚔️ **%s** cleared Tier %d of the Arena and walked away with **%s**. %s Helmet. The monsters were unavailable for comment.",
playerName, gear.Tier, gear.HelmetName, gear.SetName)
}
p.SendMessage(id.RoomID(gr), announce)
}