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

@@ -16,7 +16,7 @@ LOG_LEVEL=info
# ---- External API Keys (all optional) ----
RAWG_API_KEY=
WORDNIK_API_KEY=
DREAMDICT_URL=http://127.0.0.1:7777
CALENDARIFIC_API_KEY=
HOLIDAY_COUNTRIES=US,GB,CA,AU,PT,IN,JP,DE,FR,BR,MX,IT,ES,KR,NL,SE,NO,IE,NZ,ZA,PH
OPENWEATHER_API_KEY=

View File

@@ -64,6 +64,15 @@ func runMigrations(d *sql.DB) error {
// exists in SQLite (we just swallow "duplicate column name" errors).
columnMigrations := []string{
`ALTER TABLE adventure_characters ADD COLUMN holiday_action_taken INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE wordle_puzzles ADD COLUMN category TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE adventure_equipment ADD COLUMN arena_tier INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_equipment ADD COLUMN arena_set TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE adventure_equipment ADD COLUMN masterwork INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN death_reprieve_last DATETIME`,
`ALTER TABLE adventure_equipment ADD COLUMN skill_source TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE adventure_characters ADD COLUMN masterwork_drops_received INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_inventory ADD COLUMN slot TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE adventure_inventory ADD COLUMN skill_source TEXT NOT NULL DEFAULT ''`,
}
for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil {
@@ -803,6 +812,7 @@ CREATE TABLE IF NOT EXISTS wordle_puzzles (
room_id TEXT NOT NULL,
answer TEXT NOT NULL,
word_length INTEGER NOT NULL,
category TEXT NOT NULL DEFAULT '',
solved INTEGER NOT NULL DEFAULT 0,
guess_count INTEGER NOT NULL DEFAULT 0,
started_at DATETIME NOT NULL,
@@ -843,16 +853,22 @@ CREATE TABLE IF NOT EXISTS adventure_characters (
last_action_date TEXT NOT NULL DEFAULT '',
grudge_location TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_active_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
last_active_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
death_reprieve_last DATETIME,
masterwork_drops_received INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS adventure_equipment (
user_id TEXT NOT NULL,
slot TEXT NOT NULL,
tier INTEGER NOT NULL DEFAULT 0,
condition INTEGER NOT NULL DEFAULT 100,
name TEXT NOT NULL,
user_id TEXT NOT NULL,
slot TEXT NOT NULL,
tier INTEGER NOT NULL DEFAULT 0,
condition INTEGER NOT NULL DEFAULT 100,
name TEXT NOT NULL,
actions_used INTEGER NOT NULL DEFAULT 0,
arena_tier INTEGER NOT NULL DEFAULT 0,
arena_set TEXT NOT NULL DEFAULT '',
masterwork INTEGER NOT NULL DEFAULT 0,
skill_source TEXT NOT NULL DEFAULT '',
PRIMARY KEY (user_id, slot)
);
@@ -862,7 +878,9 @@ CREATE TABLE IF NOT EXISTS adventure_inventory (
name TEXT NOT NULL,
item_type TEXT NOT NULL,
tier INTEGER NOT NULL,
value INTEGER NOT NULL
value INTEGER NOT NULL,
slot TEXT NOT NULL DEFAULT '',
skill_source TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_adv_inv_user ON adventure_inventory(user_id);

View File

@@ -185,6 +185,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
return p.handleShopCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "shop")))
case strings.HasPrefix(lower, "buy "):
return p.handleBuyCmd(ctx, strings.TrimSpace(args[4:]))
case lower == "equip":
return p.handleEquipCmd(ctx)
case lower == "inventory" || lower == "inv":
return p.handleInventoryCmd(ctx)
case lower == "leaderboard" || lower == "lb":
@@ -209,6 +211,7 @@ const advHelpText = `**Adventure Commands**
` + "`!adventure shop`" + ` — Browse equipment categories
` + "`!adventure shop <category>`" + ` — View a category (weapon, armor, helmet, boots, tool)
` + "`!adventure buy <item>`" + ` — Buy equipment (e.g. ` + "`buy Enchanted Blade`" + ` or ` + "`buy 4 sword`" + `)
` + "`!adventure equip`" + ` — Equip Masterwork gear from inventory
` + "`!adventure sell <item>`" + ` — Sell an inventory item (or ` + "`sell all`" + `)
` + "`!adventure inventory`" + ` — View your inventory
` + "`!adventure leaderboard`" + ` — View the leaderboard
@@ -446,6 +449,10 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
switch interaction.Type {
case "treasure_discard":
return p.handleTreasureDiscard(ctx, interaction)
case "masterwork_equip":
return p.handleMasterworkEquipReply(ctx, interaction)
case "masterwork_equip_confirm":
return p.handleMasterworkEquipConfirm(ctx, interaction)
}
return nil
}
@@ -523,13 +530,13 @@ func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string)
lower := strings.ToLower(body)
// Parse "5" or "rest"
if lower == "5" || lower == "rest" {
// Parse "6" or "rest"
if lower == "6" || lower == "rest" {
return p.resolveRest(ctx, char)
}
// Parse "4" or "shop"
if lower == "4" || lower == "shop" {
// Parse "5" or "shop"
if lower == "5" || lower == "shop" {
equip, _ := loadAdvEquipment(ctx.Sender)
balance := p.euro.GetBalance(ctx.Sender)
return p.SendDM(ctx.Sender, advShopOverview(equip, balance))
@@ -566,9 +573,11 @@ func (p *AdventurePlugin) parseActivityLocation(input string, char *AdventureCha
activity = AdvActivityMining
case "3", "forage", "f", "forest":
activity = AdvActivityForaging
case "4", "fish", "fishing":
activity = AdvActivityFishing
default:
// Try matching location name directly
for _, act := range []AdvActivityType{AdvActivityDungeon, AdvActivityMining, AdvActivityForaging} {
for _, act := range []AdvActivityType{AdvActivityDungeon, AdvActivityMining, AdvActivityForaging, AdvActivityFishing} {
if loc := findAdvLocation(act, input); loc != nil {
return act, loc
}
@@ -637,18 +646,41 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
char.MiningXP += result.XPGained
case "foraging":
char.ForagingXP += result.XPGained
case "fishing":
char.FishingXP += result.XPGained
}
// Check level up
result.LeveledUp, result.NewLevel = checkAdvLevelUp(char, result.XPSkill)
// Handle death
deathReprieved := false
if result.Outcome == AdvOutcomeDeath {
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.GrudgeLocation = loc.Name
// 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.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.GrudgeLocation = loc.Name
}
} else if hasGrudge && (result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional) {
// Clear grudge on successful return
char.GrudgeLocation = ""
@@ -672,7 +704,7 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
// Holiday flags: mark second action done, or mark it done on death during action 1
if isAction2 {
char.HolidayActionTaken = true
} else if isHol && result.Outcome == AdvOutcomeDeath {
} else if isHol && result.Outcome == AdvOutcomeDeath && !deathReprieved {
char.HolidayActionTaken = true // died on action 1 — no second action
}
@@ -713,6 +745,12 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
// Send resolution DM with closing block
text := renderAdvResolutionDM(result, char)
if deathReprieved {
nextWindow := char.DeathReprieveLast.Add(168 * time.Hour)
text += fmt.Sprintf("\n\n⚔ **Death's Reprieve activated.** Your Sovereign gear absorbed the killing blow. "+
"You survived — barely. All equipment took heavy damage.\n"+
"Next reprieve window: %s", nextWindow.Format("2006-01-02 15:04 UTC"))
}
closing := advClosingBlock(result.Outcome, char.UserID, loc.Name, p.morningHour, p.summaryHour)
if closing != "" {
text += "\n" + closing
@@ -724,12 +762,13 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
// Check for treasure drop
if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
p.checkTreasureDrop(ctx.Sender, char, loc)
p.checkMasterworkDrop(ctx.Sender, char, equip, loc, result.Outcome)
}
// TODO: holiday achievement hooks
// Holiday: offer second action if this was action 1 and player survived
if !isAction2 && isHol && result.Outcome != AdvOutcomeDeath {
if !isAction2 && isHol && (result.Outcome != AdvOutcomeDeath || deathReprieved) {
equip2, _ := loadAdvEquipment(char.UserID)
treasures2, _ := loadAdvTreasureBonuses(char.UserID)
buffs2, _ := loadAdvActiveBuffs(char.UserID)
@@ -967,6 +1006,26 @@ func (p *AdventurePlugin) selectFlavorText(char *AdventureCharacter, result *Adv
pool = tierPool
}
}
case AdvActivityFishing:
switch result.Outcome {
case AdvOutcomeDeath:
if tierPool, ok := FishingDeath[loc.Tier]; ok {
pool = tierPool
}
case AdvOutcomeEmpty:
if tierPool, ok := FishingEmpty[loc.Tier]; ok {
pool = tierPool
}
case AdvOutcomeSuccess:
if tierPool, ok := FishingSuccess[loc.Tier]; ok {
pool = tierPool
}
case AdvOutcomeExceptional:
if tierPool, ok := FishingExceptional[loc.Tier]; ok {
pool = tierPool
}
}
}
if len(pool) == 0 {

View File

@@ -15,6 +15,7 @@ const (
AdvActivityDungeon AdvActivityType = "dungeon"
AdvActivityMining AdvActivityType = "mining"
AdvActivityForaging AdvActivityType = "foraging"
AdvActivityFishing AdvActivityType = "fishing"
AdvActivityRest AdvActivityType = "rest"
AdvActivityShop AdvActivityType = "shop"
)
@@ -76,6 +77,14 @@ var advForests = []AdvLocation{
{"Primal Wilds", AdvActivityForaging, 5, "Primordial Bark, Spirit Herbs, Starfruit", 20, 10, 40, 4},
}
var advFishingSpots = []AdvLocation{
{"Muddy Pond", AdvActivityFishing, 1, "Sad Fish, Boots, Tin Cans", 5, 20, 1, 0},
{"Iron Creek", AdvActivityFishing, 2, "Creek Fish, Cheep Cheeps, Wet Rocks", 12, 18, 8, 1},
{"Silver Lake", AdvActivityFishing, 3, "Lake Fish, Bloopers, Legends", 22, 16, 18, 2},
{"The Deep Current", AdvActivityFishing, 4, "River Monsters, Sea Serpents, Whirlpools", 35, 14, 30, 3},
{"Abyssal Trench", AdvActivityFishing, 5, "Ancient Things, The Unnamed, The Deep", 50, 12, 44, 4},
}
// allAdvLocations returns all locations for a given activity type.
func allAdvLocations(activity AdvActivityType) []AdvLocation {
switch activity {
@@ -85,6 +94,8 @@ func allAdvLocations(activity AdvActivityType) []AdvLocation {
return advMines
case AdvActivityForaging:
return advForests
case AdvActivityFishing:
return advFishingSpots
}
return nil
}
@@ -137,6 +148,14 @@ var advForagingLoot = map[int][]AdvLootDef{
5: {{"Primordial Bark", "wood", 600, 1500}, {"Spirit Herbs", "fruit", 800, 2000}, {"Starfruit", "fruit", 1000, 3000}},
}
var advFishingLoot = map[int][]AdvLootDef{
1: {{"Sad Fish", "fish", 1, 4}, {"Old Boot", "junk", 2, 5}, {"Tin Can", "junk", 1, 3}},
2: {{"Creek Trout", "fish", 12, 22}, {"Iron Scale", "fish", 15, 28}, {"River Pearl", "gem", 20, 40}},
3: {{"Silver Bass", "fish", 50, 90}, {"Lake Sturgeon", "fish", 60, 110}, {"Blooper Ink", "treasure", 80, 150}},
4: {{"Deep Eel", "fish", 180, 350}, {"River Serpent Scale", "treasure", 250, 500}, {"Abyssal Pearl", "gem", 300, 600}},
5: {{"Ancient Leviathan Tooth", "treasure", 800, 2000}, {"Trench Horror", "fish", 1200, 3000}, {"Void Pearl", "gem", 1500, 4000}},
}
func advLootTable(activity AdvActivityType) map[int][]AdvLootDef {
switch activity {
case AdvActivityDungeon:
@@ -145,6 +164,8 @@ func advLootTable(activity AdvActivityType) map[int][]AdvLootDef {
return advMiningLoot
case AdvActivityForaging:
return advForagingLoot
case AdvActivityFishing:
return advFishingLoot
}
return nil
}
@@ -180,6 +201,13 @@ var advXPTable = map[AdvActivityType]map[int]advXPEntry{
4: {165, 48, 18, 248},
5: {230, 62, 22, 345},
},
AdvActivityFishing: {
1: {45, 16, 8, 68},
2: {78, 25, 12, 117},
3: {120, 38, 16, 180},
4: {180, 52, 20, 270},
5: {250, 68, 25, 375},
},
}
func advXPForOutcome(activity AdvActivityType, tier int, outcome AdvOutcomeType) int {
@@ -213,6 +241,8 @@ func advXPSkill(activity AdvActivityType) string {
return "mining"
case AdvActivityForaging:
return "foraging"
case AdvActivityFishing:
return "fishing"
}
return ""
}
@@ -223,6 +253,7 @@ type AdvBonusSummary struct {
CombatBonus int
MiningBonus int
ForagingBonus int
FishingBonus int
DeathModifier float64 // negative = less death
LootQuality float64 // percentage modifier
XPMultiplier float64 // percentage modifier
@@ -242,10 +273,13 @@ func computeAdvBonuses(treasures []AdvTreasureBonus, buffs []AdvBuff, streak int
b.MiningBonus += int(t.BonusValue)
case "foraging_skill":
b.ForagingBonus += int(t.BonusValue)
case "fishing_skill":
b.FishingBonus += int(t.BonusValue)
case "all_skills":
b.CombatBonus += int(t.BonusValue)
b.MiningBonus += int(t.BonusValue)
b.ForagingBonus += int(t.BonusValue)
b.FishingBonus += int(t.BonusValue)
case "death_chance":
b.DeathModifier += t.BonusValue
case "loot_quality":
@@ -344,6 +378,8 @@ func advEffectiveSkill(char *AdventureCharacter, activity AdvActivityType, bonus
return char.MiningSkill + bonuses.MiningBonus
case AdvActivityForaging:
return char.ForagingSkill + bonuses.ForagingBonus
case AdvActivityFishing:
return char.FishingSkill + bonuses.FishingBonus
}
return 1
}
@@ -374,6 +410,17 @@ func calculateAdvProbabilities(char *AdventureCharacter, equip map[EquipmentSlot
// Success modifiers
baseSuccess := 100 - deathPct - emptyPct
successMod := (eqScore * 1.2) + (skillLevel * 0.8) + bonuses.SuccessBonus
// Masterwork gear: +5% skill-specific success bonus (mining sword → mining, etc.)
if advMasterworkSkillBonus(equip, loc.Activity) {
successMod += 5
}
// Bloodied set: Survivor's Instinct — +3% to all activity success rates
arenaSets := advEquippedArenaSets(equip)
if arenaSets["bloodied"] {
successMod += 3
}
if inPenaltyZone {
successMod -= 15
}
@@ -483,12 +530,18 @@ func applyAdvEquipDegradation(equip map[EquipmentSlot]*AdvEquipment, outcome Adv
// No equipment damage — they don't care about your sword
}
// Tempered set: Seasoned — condition degrades 25% slower (applied once per set)
tempered := advEquippedArenaSets(equip)["tempered"]
// Apply damage and check for breaks
for slot, dmg := range damage {
eq, ok := equip[slot]
if !ok {
continue
}
if tempered {
dmg = int(float64(dmg) * 0.75)
}
// Equipment mastery: well-used gear degrades slower
if eq.ActionsUsed >= 20 {
dmg = int(float64(dmg) * 0.8)
@@ -516,6 +569,10 @@ func advCheckBrokenEquipment(equip map[EquipmentSlot]*AdvEquipment) []EquipmentS
eq.Condition = 100
eq.Name = def.Name
eq.ActionsUsed = 0
eq.ArenaTier = 0
eq.ArenaSet = ""
eq.Masterwork = false
eq.SkillSource = ""
broken = append(broken, slot)
}
return broken
@@ -595,6 +652,10 @@ func resolveAdvAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEqui
if bonuses.XPMultiplier != 0 {
xp = int(float64(xp) * (1 + bonuses.XPMultiplier/100))
}
// Ironclad set: Battle-Hardened — +5% XP gain
if advEquippedArenaSets(equip)["ironclad"] {
xp = int(float64(xp) * 1.05)
}
result.XPGained = xp
// Equipment degradation on bad outcomes
@@ -637,6 +698,10 @@ func resolveAdvEmptyOutcome(loc *AdvLocation, _ float64) AdvOutcomeType {
return AdvOutcomeEmpty
}
case AdvActivityFishing:
// Fishing empty is just empty — no sub-outcomes
return AdvOutcomeEmpty
default:
return AdvOutcomeEmpty
}

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

View File

@@ -0,0 +1,478 @@
package plugin
import (
"fmt"
"math/rand/v2"
"strings"
)
// ── Combat Log Types ───────────────────────────────────────────────────────
type ArenaCombatLog struct {
Rounds []ArenaCombatRound
PlayerHP int
EnemyHP int
PlayerWon bool
}
type ArenaCombatRound struct {
Number int
Text string // action description with damage filled in
Type string // "player_hit", "enemy_hit", "block", "environmental"
DamageToPlayer int
DamageToEnemy int
PlayerHP int // HP after this round
EnemyHP int // HP after this round
}
// ── Combat Log Generation ──────────────────────────────────────────────────
// generateArenaCombatLog assembles a turn-by-turn narrative for a fight whose
// outcome is already determined. The log is cosmetic — the roll already happened.
// closeness is 0.0 (decisive) to 1.0 (razor-thin margin).
func generateArenaCombatLog(playerWon bool, closeness float64) *ArenaCombatLog {
// Pick HP pools
playerHP := 60 + rand.IntN(41) // 60-100
enemyHP := 60 + rand.IntN(41) // 60-100
// Determine round count: decisive=3-4, close=5-6
numRounds := 3
if closeness > 0.7 {
numRounds = 5 + rand.IntN(2) // 5-6
} else if closeness > 0.4 {
numRounds = 4 + rand.IntN(2) // 4-5
} else {
numRounds = 3 + rand.IntN(2) // 3-4
}
// Assign round types
types := assignRoundTypes(numRounds, playerWon)
// Calculate damage distribution
picker := newActionPicker()
rounds := distributeDamage(types, playerHP, enemyHP, playerWon, picker)
return &ArenaCombatLog{
Rounds: rounds,
PlayerHP: playerHP,
EnemyHP: enemyHP,
PlayerWon: playerWon,
}
}
// assignRoundTypes determines what happens each round.
// Final round is always winner hitting — this is enforced.
// Guarantees at least 1 hit round per side.
func assignRoundTypes(numRounds int, playerWon bool) []string {
types := make([]string, numRounds)
// Final round: winner lands the killing blow
if playerWon {
types[numRounds-1] = "player_hit"
} else {
types[numRounds-1] = "enemy_hit"
}
// Fill remaining rounds
for i := 0; i < numRounds-1; i++ {
roll := rand.Float64()
switch {
case roll < 0.15:
types[i] = "environmental"
case roll < 0.35:
types[i] = "block"
case roll < 0.65:
if i%2 == 0 {
types[i] = "enemy_hit"
} else {
types[i] = "player_hit"
}
default:
if i%2 == 0 {
types[i] = "player_hit"
} else {
types[i] = "enemy_hit"
}
}
}
// Guarantee at least 1 hit round per side (besides the final round).
hasPlayerHit := false
hasEnemyHit := false
for _, t := range types {
if t == "player_hit" {
hasPlayerHit = true
}
if t == "enemy_hit" || t == "environmental" {
hasEnemyHit = true
}
}
// If missing a side, convert the first block round (or first non-final round).
if !hasPlayerHit {
for i := 0; i < numRounds-1; i++ {
if types[i] == "block" || types[i] == "enemy_hit" || types[i] == "environmental" {
types[i] = "player_hit"
break
}
}
}
if !hasEnemyHit {
for i := 0; i < numRounds-1; i++ {
if types[i] == "block" || types[i] == "player_hit" {
types[i] = "enemy_hit"
break
}
}
}
return types
}
// distributeDamage creates rounds with damage values that sum correctly.
func distributeDamage(types []string, playerHP, enemyHP int, playerWon bool, picker *actionPicker) []ArenaCombatRound {
numRounds := len(types)
// Total damage dealt: winner kills the loser (deals their full HP).
// Loser deals some but not all of winner's HP.
var totalDmgToEnemy, totalDmgToPlayer int
if playerWon {
totalDmgToEnemy = enemyHP
totalDmgToPlayer = int(float64(playerHP) * (0.3 + rand.Float64()*0.5)) // 30-80% of player HP
} else {
totalDmgToPlayer = playerHP
totalDmgToEnemy = int(float64(enemyHP) * (0.3 + rand.Float64()*0.5))
}
// Count damage rounds for each side
var playerHitRounds, enemyHitRounds []int
for i, t := range types {
switch t {
case "player_hit":
playerHitRounds = append(playerHitRounds, i)
case "enemy_hit":
enemyHitRounds = append(enemyHitRounds, i)
case "environmental":
enemyHitRounds = append(enemyHitRounds, i) // environmental damages player
}
}
// Distribute damage to enemy across player_hit rounds
enemyDmgPerRound := splitDamage(totalDmgToEnemy, len(playerHitRounds))
// Distribute damage to player across enemy_hit + environmental rounds
playerDmgPerRound := splitDamage(totalDmgToPlayer, len(enemyHitRounds))
// Build rounds
rounds := make([]ArenaCombatRound, numRounds)
currentPlayerHP := playerHP
currentEnemyHP := enemyHP
playerDmgIdx := 0
enemyDmgIdx := 0
for i, t := range types {
r := ArenaCombatRound{
Number: i + 1,
Type: t,
}
switch t {
case "player_hit":
dmg := 0
if enemyDmgIdx < len(enemyDmgPerRound) {
dmg = enemyDmgPerRound[enemyDmgIdx]
enemyDmgIdx++
}
r.DamageToEnemy = dmg
currentEnemyHP -= dmg
if currentEnemyHP < 0 {
currentEnemyHP = 0
}
r.Text = pickFrom(arenaPlayerHitActions, picker.player, dmg)
case "enemy_hit":
dmg := 0
if playerDmgIdx < len(playerDmgPerRound) {
dmg = playerDmgPerRound[playerDmgIdx]
playerDmgIdx++
}
r.DamageToPlayer = dmg
currentPlayerHP -= dmg
if currentPlayerHP < 0 {
currentPlayerHP = 0
}
r.Text = pickFrom(arenaEnemyActions, picker.enemy, dmg)
case "block":
r.Text = pickFromNoFmt(arenaBlockActions, picker.block)
case "environmental":
dmg := 0
if playerDmgIdx < len(playerDmgPerRound) {
dmg = playerDmgPerRound[playerDmgIdx]
playerDmgIdx++
}
r.DamageToPlayer = dmg
currentPlayerHP -= dmg
if currentPlayerHP < 0 {
currentPlayerHP = 0
}
r.Text = pickFrom(arenaEnvironmentalActions, picker.environment, dmg)
}
r.PlayerHP = currentPlayerHP
r.EnemyHP = currentEnemyHP
rounds[i] = r
}
// Ensure final round ends at exactly 0 for the loser
last := &rounds[numRounds-1]
if playerWon {
last.EnemyHP = 0
} else {
last.PlayerHP = 0
}
return rounds
}
// splitDamage distributes total damage across n rounds with some variance.
// Each round gets at least 1 damage. If total < n, excess rounds get 0.
func splitDamage(total, n int) []int {
if n <= 0 {
return nil
}
if n == 1 {
return []int{total}
}
result := make([]int, n)
// If total < n, give 1 to the first `total` rounds, 0 to the rest.
if total <= n {
for i := 0; i < total && i < n; i++ {
result[i] = 1
}
return result
}
remaining := total
for i := 0; i < n-1; i++ {
avg := remaining / (n - i)
if avg <= 0 {
avg = 1
}
// Variance: 50%-150% of average
lo := avg / 2
if lo < 1 {
lo = 1
}
hi := avg + avg/2
if hi < lo {
hi = lo
}
dmg := lo + rand.IntN(hi-lo+1)
// Reserve at least 1 per remaining round
maxThisRound := remaining - (n - 1 - i)
if maxThisRound < 1 {
maxThisRound = 1
}
if dmg > maxThisRound {
dmg = maxThisRound
}
if dmg < 1 {
dmg = 1
}
result[i] = dmg
remaining -= dmg
}
result[n-1] = remaining
if result[n-1] < 1 {
result[n-1] = 1
}
return result
}
// actionPicker tracks used indices per pool to avoid repeats within a fight.
type actionPicker struct {
enemy map[int]bool
player map[int]bool
block map[int]bool
environment map[int]bool
}
func newActionPicker() *actionPicker {
return &actionPicker{
enemy: make(map[int]bool),
player: make(map[int]bool),
block: make(map[int]bool),
environment: make(map[int]bool),
}
}
// pickFrom selects a random unused entry from pool, formats it with damage, and marks it used.
// Resets if pool is exhausted.
func pickFrom(pool []string, used map[int]bool, damage int) string {
if len(used) >= len(pool) {
for k := range used {
delete(used, k)
}
}
idx := rand.IntN(len(pool))
for used[idx] {
idx = (idx + 1) % len(pool)
}
used[idx] = true
return fmt.Sprintf(pool[idx], damage)
}
func pickFromNoFmt(pool []string, used map[int]bool) string {
if len(used) >= len(pool) {
for k := range used {
delete(used, k)
}
}
idx := rand.IntN(len(pool))
for used[idx] {
idx = (idx + 1) % len(pool)
}
used[idx] = true
return pool[idx]
}
// ── Render ─────────────────────────────────────────────────────────────────
func renderArenaCombatLog(log *ArenaCombatLog, monster *ArenaMonster, won bool, reward int64, xp int, closerLine string) string {
var sb strings.Builder
for _, r := range log.Rounds {
sb.WriteString(r.Text + "\n")
// Compact HP status line — damage is already in the action text via %d
switch r.Type {
case "player_hit":
sb.WriteString(fmt.Sprintf(" [You: %d/%d | Enemy: %d/%d]\n", r.PlayerHP, log.PlayerHP, r.EnemyHP, log.EnemyHP))
case "enemy_hit", "environmental":
sb.WriteString(fmt.Sprintf(" [You: %d/%d | Enemy: %d/%d]\n", r.PlayerHP, log.PlayerHP, r.EnemyHP, log.EnemyHP))
}
// Blocks: no HP line (no damage happened)
}
sb.WriteString("\n")
if won {
sb.WriteString(fmt.Sprintf("💀 %s has been defeated.\n", monster.Name))
sb.WriteString(closerLine + "\n")
sb.WriteString(fmt.Sprintf("🏆 +%d XP | €%d earned\n", xp, reward))
} else {
sb.WriteString("The healers are already moving.\n")
sb.WriteString("💀 Defeated.\n")
sb.WriteString(closerLine + "\n")
sb.WriteString(fmt.Sprintf("+%d XP (participation) | Back tomorrow.\n", arenaParticipationXP))
}
return sb.String()
}
const arenaParticipationXP = 60
// ── Closer Lines ───────────────────────────────────────────────────────────
func arenaWinCloser(loserName string, lastRound int) string {
closers := []string{
"%s fought. It counts.",
"%s will be back. The arena keeps score.",
fmt.Sprintf("%%s has until tomorrow to think about round %d.", lastRound),
"%s gave you more trouble than you'd like to admit. They don't need to know that.",
"%s loses this one. The next one is an open question.",
"%s came here to fight and did. The result is a separate matter.",
"%s is already planning the rematch. You can feel it.",
}
return fmt.Sprintf(closers[rand.IntN(len(closers))], loserName)
}
func arenaLoseCloser(winnerName string, lastRound int) string {
closers := []string{
"You fought. It counts.",
"You'll be back. The arena keeps score.",
fmt.Sprintf("You have until tomorrow to think about round %d.", lastRound),
fmt.Sprintf("You gave %s more trouble than they'd like to admit. Small comfort. Still comfort.", winnerName),
"You lose this one. The next one is an open question.",
"You came here to fight and did. The result is a separate matter.",
fmt.Sprintf("%s won this one. You're already planning the rematch.", winnerName),
}
return closers[rand.IntN(len(closers))]
}
// ── Action Pools ───────────────────────────────────────────────────────────
// Enemy actions — hit the player. %d is damage.
var arenaEnemyActions = []string{
"The enemy insults your clothing choices. Spot-on. Hits you for %d emotional damage. They weren't wrong about the boots.",
"The enemy puts their weapon away, walks up to you, and Will Smiths you across the face. The audacity hurts more than the hit. %d damage.",
"The enemy questions your life choices. You pause to genuinely reflect. They hit you during the pause. %d damage.",
"The enemy delivers a full monologue. You listen to the whole thing. It was actually pretty good. %d damage from the time lost.",
"The enemy compliments you unexpectedly. You thank them. They snicker. %d damage.",
"The enemy points at something behind you. You don't fall for it. They throw a projectile that bounces off the wall and hits you in the back of the head. %d damage.",
"The enemy pulls out their phone and starts filming. You perform for the camera. This was a mistake. %d damage.",
"The enemy sneezes directly in your face. You lose your turn being disgusted. %d damage while you process this.",
"The enemy whispers something. You lean in to hear it. %d damage. There was nothing worth hearing.",
"The enemy trips. Recovers. Hits you anyway. %d damage. You were rooting for them for a second there.",
"The enemy takes a phone call, hits you one-handed, and continues the call. %d damage. You were barely a distraction.",
"The enemy critiques your fighting stance in detail. You correct it instinctively. Your corrected stance is worse. %d damage.",
"The enemy yawns mid-fight. Not performatively. Genuinely. %d damage while you process the disrespect.",
"The enemy pauses to stretch before attacking. You wait. You don't know why you waited. %d damage when they finish.",
"The enemy hits you with the flat of their blade. A choice. A message. %d damage. The message is received.",
"The enemy stares at you for an uncomfortably long time before attacking. You break eye contact first. %d damage.",
"The enemy sighs before hitting you. Like they had somewhere better to be. %d damage.",
"The enemy recounts a mildly interesting story mid-fight. You get drawn in. %d damage before the ending, which was not worth it.",
"The enemy raises one eyebrow at you and then attacks. The eyebrow did more damage than the hit. %d damage total.",
"The enemy adjusts their grip, rolls their shoulders, and hits you with the bare minimum of effort. %d damage.",
}
// Player actions — hit the enemy. %d is damage.
var arenaPlayerHitActions = []string{
"You make a joke using a painfully dated reference. While the enemy ponders what on earth you could mean, you strike. %d damage.",
"You attempt a battle cry. It comes out as a question. The enemy is briefly confused. You hit them for %d damage before they recover.",
"You wind up for a big hit and connect for %d damage. You pulled something. The enemy doesn't know this yet.",
"You hit the enemy for %d damage. They seem fine. You are less fine about this than they are.",
"You connect cleanly for %d damage and immediately look at your hand like you're surprised it worked. You were.",
"You score a clean hit for %d damage and immediately start explaining to no one how you did that. Nobody asked.",
"You land a hit for %d damage and follow up with a second strike that connects with nothing. You style it out. Nobody is convinced.",
}
// Block/dodge actions — no damage.
var arenaBlockActions = []string{
"You swing with conviction. The enemy sidesteps it with the energy of someone who has somewhere else to be. Nothing happens. You both reset.",
"The enemy lunges. You step aside. They continue past you for several feet and have to walk back. The pause is awkward for everyone.",
"You block the incoming strike so cleanly that the enemy looks at their weapon like it betrayed them personally.",
"The enemy's attack grazes you but doesn't connect. They seem more annoyed by this than you are relieved.",
"You duck. The enemy's strike passes exactly where your head was. You both take a moment to appreciate how close that was.",
"The enemy deflects your attack with a move that was frankly unnecessary for the situation. It worked.",
"You parry. The enemy's weapon skids off yours and they stumble slightly. They recover before you can do anything about it.",
"The enemy blocks your poor-timed strike with their forearm. This speaks more about your striking abilities than their forearm.",
"You dodge sideways into a pillar. It hurts but it doesn't count as a hit. The pillar gets no credit.",
"The enemy telegraphs the attack so clearly that you block it before they've finished committing. They look briefly embarrassed.",
"You attempt a dodge and accidentally do something that looks extremely skilled. It was not intentional.",
"The enemy's strike is deflected off your shoulder guard and disappears somewhere into the arena.",
"You and the enemy swing at exactly the same moment. Both weapons meet in the middle. You stare at each other.",
"The enemy's attack comes in low. You jump. Not gracefully. But adequately. Nothing connects.",
"You sidestep a strike that wasn't aimed at you. The enemy had already redirected. Both end up slightly confused.",
}
// Environmental actions — damage to player. %d is damage.
var arenaEnvironmentalActions = []string{
"Your mother calls in the middle of battle asking about grandchildren. The enemy hits you for %d damage while you answer on speakerphone.",
"A bird lands between you and the enemy. Both stop. The bird leaves. The enemy recovers first. %d damage.",
"A spectator is eating something that smells incredible. Both fighters lose focus. The enemy had less going on mentally. %d damage.",
"The arena announcer mispronounces your name. You correct them mid-fight. The enemy hits you for %d damage.",
"An old acquaintance you've been avoiding is in the crowd. You make brief eye contact. The enemy hits you for %d damage during this.",
"Someone in the crowd drops their drink. The sound is startling. You both flinch. The enemy flinches smaller. %d damage.",
"A cloud passes in front of the sun at the wrong moment. %d damage. The cloud did not mean anything by it.",
"The arena's background music cuts out unexpectedly. The silence is louder than the fight. %d damage in the disorientation.",
"The arena PA crackles and announces something completely unrelated. You both look up. The enemy looks back down first. %d damage.",
"Something falls from the spectator area. Nobody claims it. You both look at it. The enemy decides faster. %d damage.",
"A dog wanders into the arena perimeter briefly. Both fighters stop. The dog is removed. The enemy uses the reset better. %d damage.",
"The arena's scoreboard updates mid-fight and briefly shows wrong numbers. You spend a round working out if that changes anything. %d damage.",
"The arena sells a limited merch item at exactly this moment. The announcement is enthusiastic. You are briefly curious. %d damage.",
"The crowd goes quiet at an inopportune moment. You can hear everything. Including things you did not want to hear. %d damage.",
}

View File

@@ -3,6 +3,7 @@ package plugin
import (
"fmt"
"strings"
"time"
)
// ── Arena Tier Menu ─────────────────────────────────────────────────────────
@@ -30,7 +31,10 @@ func renderArenaTierMenu(char *AdventureCharacter, stats *ArenaPersonalStats) st
stats.TotalRuns, stats.TotalDeaths, stats.TotalEarnings))
}
b.WriteString("\n`!arena tier <1-5>` — Enter a tier\n")
b.WriteString("\n⛑️ _Today's helmets are provided by Brim & Battle — celebrated makers of baseball hats — and proud sponsors of the Arena. ")
b.WriteString("Their new VeriFort line of field-evaluated combat headgear represents an exciting expansion beyond their area of expertise. ")
b.WriteString("Winners may receive a complimentary sample. Brim & Battle thanks you for your participation in their ongoing verification process._\n\n")
b.WriteString("`!arena tier <1-5>` — Enter a tier\n")
b.WriteString("`!arena stats` — Your arena stats\n")
b.WriteString("`!arena leaderboard` — Top arena players\n")
return b.String()
@@ -262,6 +266,39 @@ func renderArenaLevelGate(tier *ArenaTier, playerLevel int) string {
tier.Number, tier.Name, tier.MinLevel, playerLevel)
}
// ── Helmet Drop ────────────────────────────────────────────────────────────
func renderArenaHelmetDrop(gear *ArenaGearSet) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("⛑️ **Equipment Drop: %s**\n\n", gear.HelmetName))
b.WriteString(fmt.Sprintf("_%s_\n\n", gear.Description))
b.WriteString(fmt.Sprintf("Tier %d Arena gear — 1.5x effectiveness. ", gear.Tier))
switch gear.SetKey {
case "bloodied":
b.WriteString("Set bonus: **Survivor's Instinct** — +3% to all activity success rates.")
case "ironclad":
b.WriteString("Set bonus: **Battle-Hardened** — +5% XP gain from all activities.")
case "tempered":
b.WriteString("Set bonus: **Seasoned** — Equipment condition degrades 25% slower across all slots.")
case "champions":
b.WriteString("Set bonus: **Commanding Presence** — +10% to equipment score in all probability calculations.")
case "sovereign":
b.WriteString("Set bonus: **Death's Reprieve** — Once per 7 days, survive a lethal outcome instead of dying.")
}
b.WriteString("\n\nEquipped automatically.")
return b.String()
}
// ── Death's Reprieve Announcement ──────────────────────────────────────────
func renderArenaDeathReprieve(playerName, location string, nextWindow time.Time) string {
return fmt.Sprintf("⚔️ **%s**'s Sovereign gear activated **Death's Reprieve**. "+
"They should be dead. They are not. %s will have to try harder. Next window: %s.",
playerName, location, nextWindow.Format("2006-01-02 15:04 UTC"))
}
// ── Already In Run Message ──────────────────────────────────────────────────
func renderArenaAlreadyInRun(run *ArenaRun) string {

View File

@@ -49,22 +49,30 @@ type AdventureCharacter struct {
GrudgeLocation string
CreatedAt time.Time
LastActiveAt time.Time
DeathReprieveLast *time.Time
MasterworkDropsReceived int
}
type AdvEquipment struct {
Slot EquipmentSlot
Tier int
Condition int
Name string
Slot EquipmentSlot
Tier int
Condition int
Name string
ActionsUsed int
ArenaTier int
ArenaSet string
Masterwork bool
SkillSource string
}
type AdvItem struct {
ID int64
Name string
Type string // ore, wood, fruit, treasure, gem
Tier int
Value int64
ID int64
Name string
Type string // ore, wood, fruit, treasure, gem, MasterworkGear
Tier int
Value int64
Slot EquipmentSlot // non-empty for MasterworkGear
SkillSource string // non-empty for MasterworkGear
}
type AdvBuff struct {
@@ -142,16 +150,69 @@ func equipmentDefByTier(slot EquipmentSlot, tier int) EquipmentDef {
return defs[tier]
}
// ── Arena Gear Helpers ──────────────────────────────────────────────────────
// advEquippedArenaSets returns the unique arena set names currently equipped.
func advEquippedArenaSets(equip map[EquipmentSlot]*AdvEquipment) map[string]bool {
sets := make(map[string]bool)
for _, eq := range equip {
if eq.ArenaSet != "" {
sets[eq.ArenaSet] = true
}
}
return sets
}
// advMasterworkSkillBonus returns true if any equipped masterwork piece's
// SkillSource matches the given activity type.
func advMasterworkSkillBonus(equip map[EquipmentSlot]*AdvEquipment, activity AdvActivityType) bool {
source := ""
switch activity {
case AdvActivityMining:
source = "mining"
case AdvActivityFishing:
source = "fishing"
case AdvActivityForaging:
source = "foraging"
}
if source == "" {
return false
}
for _, eq := range equip {
if eq.Masterwork && eq.SkillSource == source {
return true
}
}
return false
}
// DeathReprieveAvailable returns true if the Sovereign Death's Reprieve
// cooldown has expired (or was never triggered).
func (c *AdventureCharacter) DeathReprieveAvailable() bool {
if c.DeathReprieveLast == nil {
return true
}
return time.Since(*c.DeathReprieveLast) >= 168*time.Hour
}
// ── Equipment Score ──────────────────────────────────────────────────────────
func advEquipmentScore(equip map[EquipmentSlot]*AdvEquipment) int {
score := 0
arenaSets := advEquippedArenaSets(equip)
for _, slot := range allSlots {
eq, ok := equip[slot]
if !ok {
continue
}
tierContrib := eq.Tier
// Arena gear: 1.5x effectiveness
if eq.ArenaTier > 0 {
tierContrib = int(float64(tierContrib) * 1.5)
} else if eq.Masterwork {
// Masterwork: 1.25x effectiveness
tierContrib = int(float64(tierContrib) * 1.25)
}
if slot == SlotWeapon {
tierContrib *= 2
}
@@ -161,6 +222,10 @@ func advEquipmentScore(equip map[EquipmentSlot]*AdvEquipment) int {
}
score += tierContrib
}
// Champion's set: Commanding Presence — +10% equipment score
if arenaSets["champions"] {
score = int(float64(score) * 1.10)
}
return score
}
@@ -188,6 +253,9 @@ func checkAdvLevelUp(char *AdventureCharacter, skill string) (bool, int) {
case "foraging":
xp = &char.ForagingXP
level = &char.ForagingSkill
case "fishing":
xp = &char.FishingXP
level = &char.FishingSkill
default:
return false, 0
}
@@ -215,7 +283,7 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
d := db.Get()
c := &AdventureCharacter{}
var alive, actionTaken, holidayTaken int
var deadUntil sql.NullTime
var deadUntil, reprieveLast sql.NullTime
err := d.QueryRow(`
SELECT user_id, display_name,
@@ -224,7 +292,8 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
alive, dead_until, action_taken_today, holiday_action_taken,
arena_wins, arena_losses, invasion_score, title,
current_streak, best_streak, last_action_date, grudge_location,
created_at, last_active_at
created_at, last_active_at, death_reprieve_last,
masterwork_drops_received
FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan(
&c.UserID, &c.DisplayName,
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
@@ -232,7 +301,8 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
&alive, &deadUntil, &actionTaken, &holidayTaken,
&c.ArenaWins, &c.ArenaLosses, &c.InvasionScore, &c.Title,
&c.CurrentStreak, &c.BestStreak, &c.LastActionDate, &c.GrudgeLocation,
&c.CreatedAt, &c.LastActiveAt,
&c.CreatedAt, &c.LastActiveAt, &reprieveLast,
&c.MasterworkDropsReceived,
)
if err != nil {
return nil, err
@@ -243,6 +313,9 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
if deadUntil.Valid {
c.DeadUntil = &deadUntil.Time
}
if reprieveLast.Valid {
c.DeathReprieveLast = &reprieveLast.Time
}
return c, nil
}
@@ -265,8 +338,8 @@ func createAdvCharacter(userID id.UserID, displayName string) error {
for _, slot := range allSlots {
def := equipmentTiers[slot][0]
_, err = tx.Exec(`
INSERT INTO adventure_equipment (user_id, slot, tier, condition, name, actions_used)
VALUES (?, ?, 0, 100, ?, 0)`, string(userID), string(slot), def.Name)
INSERT INTO adventure_equipment (user_id, slot, tier, condition, name, actions_used, arena_tier, arena_set, masterwork, skill_source)
VALUES (?, ?, 0, 100, ?, 0, 0, '', 0, '')`, string(userID), string(slot), def.Name)
if err != nil {
return err
}
@@ -297,14 +370,15 @@ func saveAdvCharacter(char *AdventureCharacter) error {
alive = ?, dead_until = ?, action_taken_today = ?, holiday_action_taken = ?,
arena_wins = ?, arena_losses = ?, invasion_score = ?, title = ?,
current_streak = ?, best_streak = ?, last_action_date = ?, grudge_location = ?,
last_active_at = CURRENT_TIMESTAMP
last_active_at = CURRENT_TIMESTAMP, death_reprieve_last = ?,
masterwork_drops_received = ?
WHERE user_id = ?`,
char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill,
char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP,
alive, char.DeadUntil, actionTaken, holidayTaken,
char.ArenaWins, char.ArenaLosses, char.InvasionScore, char.Title,
char.CurrentStreak, char.BestStreak, char.LastActionDate, char.GrudgeLocation,
string(char.UserID),
char.DeathReprieveLast, char.MasterworkDropsReceived, string(char.UserID),
)
return err
}
@@ -312,7 +386,7 @@ func saveAdvCharacter(char *AdventureCharacter) error {
func loadAdvEquipment(userID id.UserID) (map[EquipmentSlot]*AdvEquipment, error) {
d := db.Get()
rows, err := d.Query(`
SELECT slot, tier, condition, name, actions_used
SELECT slot, tier, condition, name, actions_used, arena_tier, arena_set, masterwork, skill_source
FROM adventure_equipment WHERE user_id = ?`, string(userID))
if err != nil {
return nil, err
@@ -323,10 +397,12 @@ func loadAdvEquipment(userID id.UserID) (map[EquipmentSlot]*AdvEquipment, error)
for rows.Next() {
e := &AdvEquipment{}
var slot string
if err := rows.Scan(&slot, &e.Tier, &e.Condition, &e.Name, &e.ActionsUsed); err != nil {
var mw int
if err := rows.Scan(&slot, &e.Tier, &e.Condition, &e.Name, &e.ActionsUsed, &e.ArenaTier, &e.ArenaSet, &mw, &e.SkillSource); err != nil {
return nil, err
}
e.Slot = EquipmentSlot(slot)
e.Masterwork = mw == 1
equip[e.Slot] = e
}
return equip, rows.Err()
@@ -334,11 +410,15 @@ func loadAdvEquipment(userID id.UserID) (map[EquipmentSlot]*AdvEquipment, error)
func saveAdvEquipment(userID id.UserID, eq *AdvEquipment) error {
d := db.Get()
mw := 0
if eq.Masterwork {
mw = 1
}
_, err := d.Exec(`
UPDATE adventure_equipment
SET tier = ?, condition = ?, name = ?, actions_used = ?
SET tier = ?, condition = ?, name = ?, actions_used = ?, arena_tier = ?, arena_set = ?, masterwork = ?, skill_source = ?
WHERE user_id = ? AND slot = ?`,
eq.Tier, eq.Condition, eq.Name, eq.ActionsUsed,
eq.Tier, eq.Condition, eq.Name, eq.ActionsUsed, eq.ArenaTier, eq.ArenaSet, mw, eq.SkillSource,
string(userID), string(eq.Slot))
return err
}
@@ -346,7 +426,7 @@ func saveAdvEquipment(userID id.UserID, eq *AdvEquipment) error {
func loadAdvInventory(userID id.UserID) ([]AdvItem, error) {
d := db.Get()
rows, err := d.Query(`
SELECT id, name, item_type, tier, value
SELECT id, name, item_type, tier, value, slot, skill_source
FROM adventure_inventory WHERE user_id = ?
ORDER BY tier DESC, value DESC`, string(userID))
if err != nil {
@@ -357,9 +437,11 @@ func loadAdvInventory(userID id.UserID) ([]AdvItem, error) {
var items []AdvItem
for rows.Next() {
var it AdvItem
if err := rows.Scan(&it.ID, &it.Name, &it.Type, &it.Tier, &it.Value); err != nil {
var slot string
if err := rows.Scan(&it.ID, &it.Name, &it.Type, &it.Tier, &it.Value, &slot, &it.SkillSource); err != nil {
return nil, err
}
it.Slot = EquipmentSlot(slot)
items = append(items, it)
}
return items, rows.Err()
@@ -368,9 +450,9 @@ func loadAdvInventory(userID id.UserID) ([]AdvItem, error) {
func addAdvInventoryItem(userID id.UserID, item AdvItem) error {
d := db.Get()
_, err := d.Exec(`
INSERT INTO adventure_inventory (user_id, name, item_type, tier, value)
VALUES (?, ?, ?, ?, ?)`,
string(userID), item.Name, item.Type, item.Tier, item.Value)
INSERT INTO adventure_inventory (user_id, name, item_type, tier, value, slot, skill_source)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
string(userID), item.Name, item.Type, item.Tier, item.Value, string(item.Slot), item.SkillSource)
return err
}
@@ -409,7 +491,8 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
alive, dead_until, action_taken_today, holiday_action_taken,
arena_wins, arena_losses, invasion_score, title,
current_streak, best_streak, last_action_date, grudge_location,
created_at, last_active_at
created_at, last_active_at, death_reprieve_last,
masterwork_drops_received
FROM adventure_characters`)
if err != nil {
return nil, err
@@ -420,7 +503,7 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
for rows.Next() {
c := AdventureCharacter{}
var alive, actionTaken, holidayTaken int
var deadUntil sql.NullTime
var deadUntil, reprieveLast sql.NullTime
if err := rows.Scan(
&c.UserID, &c.DisplayName,
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
@@ -428,7 +511,8 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
&alive, &deadUntil, &actionTaken, &holidayTaken,
&c.ArenaWins, &c.ArenaLosses, &c.InvasionScore, &c.Title,
&c.CurrentStreak, &c.BestStreak, &c.LastActionDate, &c.GrudgeLocation,
&c.CreatedAt, &c.LastActiveAt,
&c.CreatedAt, &c.LastActiveAt, &reprieveLast,
&c.MasterworkDropsReceived,
); err != nil {
return nil, err
}
@@ -438,6 +522,9 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
if deadUntil.Valid {
c.DeadUntil = &deadUntil.Time
}
if reprieveLast.Valid {
c.DeathReprieveLast = &reprieveLast.Time
}
chars = append(chars, c)
}
return chars, rows.Err()

View File

@@ -0,0 +1,491 @@
package plugin
// ── FISHING FLAVOR TEXT ───────────────────────────────────────────────────────
//
// Tier 1: Muddy Pond — garbage, sad fish, puns, Stardew energy
// Tier 2: Iron Creek — slightly less sad fish, mild danger
// Tier 3: Silver Lake — actually fishing now, occasional menace
// Tier 4: The Deep Current — serious water, serious things in it
// Tier 5: Abyssal Trench — Hemingway. Something old. Terse.
//
// Deaths: their own category. Drowning. Hooks. Sea monsters. Things that aren't fish.
var FishingDeath = map[int][]string{
// ── TIER 1: MUDDY POND ────────────────────────────────────────────────────
1: {
"You fell in.\n\nThe Muddy Pond is four feet deep.\n\nYou drowned anyway.\n\n" +
"The healthcare system has seen this before. They have a form for it. " +
"The form says 'Muddy Pond' at the top and has checkboxes.",
"Your stick with string caught on something. You pulled. It pulled back. " +
"You went in face-first.\n\nThe something was a boot.\n\n" +
"A boot pulled you into a pond.\n\nYou have 24 hours to think about this.",
"You leaned over too far.\n\nThat's it.\n\nThat's the whole story.\n\n" +
"Healthcare is not judging you. Healthcare is absolutely judging you.",
"The frog startled you. One frog. Sitting on a lily pad, doing nothing, being a frog, " +
"and you went backwards into the Muddy Pond like a plank falling over.\n\n" +
"The frog watched you go.\n\nThe frog is still there.",
"Your fishing line got tangled in your own boots somehow. " +
"You investigated this while standing at the edge of the pond.\n\n" +
"Investigating complete.",
"You caught a fish. You were so surprised that you caught a fish that you forgot " +
"where you were standing.\n\nThe fish got away.\n\nYou did not get away.",
"A duck bit you. You recoiled. Pond.\n\nThe duck is fine.",
},
// ── TIER 2: IRON CREEK ───────────────────────────────────────────────────
2: {
"The current took you. Iron Creek has a current that the name does not adequately convey. " +
"You found out what the name was trying to say. Healthcare found out where you were.",
"Something bit your line, your rod, and then you, in that order, with increasing commitment. " +
"It let go eventually. You were already in the water.",
"You slipped on a wet rock. Every single wet rock in every fishing location has done this " +
"to someone. The wet rock does not care. The wet rock has always been wet.",
"A Cheep Cheep came from nowhere.\n\nYou know what a Cheep Cheep is. " +
"Everyone knows what a Cheep Cheep is. The knowledge did not help you.\n\nIt never helps.",
"The fishing line snapped back and took your footing with it. " +
"Quick, efficient, no malice. Just physics and a creek that was never on your side.",
},
// ── TIER 3: SILVER LAKE ──────────────────────────────────────────────────
3: {
"Something large surfaced directly under your boat.\n\n" +
"You don't have a boat.\n\nYou had a boat.",
"The Silver Lake has a legend. A local thing — old fishermen, hushed tones, the usual. " +
"You heard the legend and went anyway.\n\nThe legend is accurate.",
"A Blooper. In a lake. You're not going to ask how. " +
"You're going to spend 24 hours in healthcare not asking how.",
"The thing you hooked was bigger than you. " +
"This is not always a problem. This was a problem.",
"You were doing fine until the water level started rising for no reason.\n\n" +
"Water levels rising for no reason is always a reason.\n\n" +
"You know this.\n\nYou stayed anyway.",
},
// ── TIER 4: THE DEEP CURRENT ─────────────────────────────────────────────
4: {
"Something came up from the Deep Current and looked at you.\n\n" +
"Not at your bait.\n\nAt you.\n\n" +
"You were in the water shortly after that.",
"The thing on your line was not a fish. You knew it wasn't a fish by the second pull. " +
"You kept pulling anyway.\n\nIt was not a fish.",
"The Deep Current has a whirlpool. The whirlpool is not on any map. " +
"The whirlpool introduced itself personally.",
"A sea serpent. Not a large eel. A sea serpent, in a river, because the world doesn't " +
"have rules anymore.\n\nHealthcare has a new checkbox now. Just for you.",
"You went too deep. Wading, which you were explicitly not supposed to be doing, " +
"and the current and the depth and the something that lives at that depth " +
"all agreed on the same outcome.",
},
// ── TIER 5: ABYSSAL TRENCH ───────────────────────────────────────────────
// Terse. Whatever is down there is old.
5: {
"It took the line, the rod, and most of the pier.\n\n" +
"You were attached to the rod.\n\nYou are alive.\n\nIt let you go.",
"You saw it before it saw you. This did not help.",
"The Abyssal Trench is not a fishing location. The Abyssal Trench is a place where " +
"things live that do not acknowledge the concept of fishing. You went anyway.\n\n" +
"You're back.\n\nSomething down there made a decision about you.",
"The thing on your line pulled once.\n\nOnce was enough.",
"You heard it before you saw it. You saw it for approximately one second. " +
"You were in the water for the rest of the encounter.\n\n" +
"You are alive.\n\nThink about that.",
},
}
var FishingEmpty = map[int][]string{
1: {
"Three hours at the Muddy Pond.\n\nNothing.\n\nNot even a boot.\n\n" +
"Just you, a stick with string, and the specific silence of a pond " +
"that has decided you're not worth the effort.",
"You caught something. You reeled it in with great excitement. " +
"It was pond water in the shape of disappointment.\n\nAlso a leaf.",
"The fish saw you coming.\n\nAll of them.\n\nThey made a group decision.",
"The Muddy Pond is four feet deep and approximately the size of a large puddle " +
"and somehow contains nothing you can sell. You checked every inch. " +
"The pond is empty. The pond has always been empty. The pond is laughing at you in pond.",
"You sat there for four hours.\n\nA duck swam past.\n\n" +
"The duck had somewhere to be.\n\nYou did not.",
"Nothing bit. Not the bait, not the hook, not even the increasingly desperate " +
"energy you were projecting at the water. The Muddy Pond was unmoved.",
"Your stick with string snapped on the third cast. You fashioned a replacement " +
"stick with string. The replacement performed identically to the original, " +
"which is to say: nothing happened.",
},
2: {
"Iron Creek fished clean today. Clean meaning: nothing.\n\n" +
"Not the clean of a well-managed fishery.\n\n" +
"The clean of a creek that has been thoroughly fished by someone faster than you.",
"You had bites. Genuine bites — the line moved, something was interested, " +
"the interest peaked and then vanished like it remembered who you were.",
"The fish in Iron Creek are not stupid. You are fishing with a " +
"Dull Copper Pickaxe energy-tier fishing rod in a creek that has been " +
"here longer than you. They've seen this before.",
},
3: {
"Silver Lake: famously full of fish. The fish were elsewhere today. " +
"Where fish go when they're elsewhere is not known. They were not here.",
"Four hours. The lake. A good rod. The right bait. Perfect conditions.\n\n" +
"Absolutely nothing.\n\nSilver Lake shrugs in lake.",
"You caught a reflection of yourself in the water.\n\n" +
"You look tired.\n\nYou came home with nothing.",
},
4: {
"The Deep Current gave you nothing today and the nothing felt intentional.\n\n" +
"Something down there made a decision about your haul.\n\nThe decision was no.",
"You were this close. Whatever 'this close' means at the Deep Current, " +
"which is a river where things live that have never been catalogued. " +
"You felt the line move. Then it stopped moving. Then nothing.",
},
5: {
"The Abyssal Trench does not give up its catch easily.\n\n" +
"Today it didn't give up anything.\n\n" +
"You fished for hours in the dark above water that has no bottom anyone has measured.\n\n" +
"You came home.\n\nThe Trench noticed.",
"Nothing rose to meet you today.\n\n" +
"This is either very good news or the other kind.\n\n" +
"You are choosing to believe the former.",
},
}
var FishingSuccess = map[int][]string{
1: {
"You caught a fish from the Muddy Pond.\n\nA small fish.\n\n" +
"It looked at you with an expression that can only be described as resigned.\n\n" +
"You felt bad.\n\nYou sold it anyway.\n\n{item} — €{value}.",
"Something on the line. Reeled it in.\n\nA boot.\n\n" +
"Inside the boot: €{value} and a note that says 'GOOD LUCK.'\n\n" +
"Someone has been here before you.\n\nThe boot is yours now.",
"A tin can. Inside the tin can: a smaller tin can. Inside the smaller tin can: " +
"{item} worth €{value}.\n\nThe Muddy Pond has layers.\n\n" +
"You did not expect the Muddy Pond to have layers.",
"The fish you caught was technically too small to sell.\n\n" +
"You sold it anyway.\n\nThe buyer didn't ask.\n\nYou didn't offer.\n\n" +
"€{value}. Everyone moved on.",
"You caught {item} worth €{value} and {xp} XP and the quiet dignity of a person " +
"who caught something from the Muddy Pond and is choosing to feel good about it.\n\n" +
"The dignity is thin.\n\nIt is still dignity.",
"Three small fish, two of which got away, one of which didn't.\n\n" +
"The one that didn't is worth €{value}.\n\nIt tried very hard.\n\n" +
"You respect that.\n\nYou sold it.",
"The pond gave up {item} today.\n\nNo drama. No fight. It just came up.\n\n" +
"The Muddy Pond felt sorry for you.\n\n" +
"You choose not to think about that.\n\n€{value}.",
"You pulled up an old helmet from the pond floor.\n\n" +
"Inside the helmet: a fish.\n\nThe fish had been living in the helmet.\n\n" +
"You have displaced a fish from its home.\n\nYou sold both.\n\n€{value}.",
"A rusted sword from the pond floor.\n\n" +
"On it: an inscription you can't read.\n\n" +
"In a better story this would be significant.\n\n" +
"It sold for €{value}.\n\nThis is not a better story.",
},
2: {
"Iron Creek delivered {item} today. Not enthusiastically — more like it was " +
"clearing out storage. But delivered.\n\n€{value}. {xp} XP.",
"The fish fought. Good fight actually. You won, which surprised the fish " +
"and mildly surprised you.\n\n{item}, €{value}.",
"You caught something in Iron Creek that you don't have a name for.\n\n" +
"The fishmonger had a name for it.\n\nThe fishmonger paid €{value} without blinking.\n\n" +
"You're choosing not to ask what it was.",
"Two fish. Both annoyed about it. €{value} total.\n\n{xp} XP.\n\n" +
"Iron Creek fished well today if you don't count the water level.",
},
3: {
"Silver Lake, finally cooperating.\n\n{item} worth €{value}.\n\n" +
"The lake didn't make it easy.\n\nThe lake never makes it easy.\n\nBut here you are.",
"You caught {item} from Silver Lake and gained {xp} XP and the specific satisfaction " +
"of a lake that tried to defeat you and failed today.\n\n" +
"Just today.\n\nIt'll try again tomorrow.\n\n€{value}.",
"The catch practically jumped into the boat.\n\nYou don't have a boat.\n\n" +
"It jumped onto the bank.\n\nThis has never happened before and you are " +
"not questioning it.\n\n{item}, €{value}.",
},
4: {
"The Deep Current gave up {item} worth €{value} and only tried to kill you once, " +
"which at this tier is practically a warm welcome.\n\n{xp} XP.",
"You landed {item} from the Deep Current.\n\n" +
"The fight took forty minutes.\n\nYour arms are not okay.\n\n" +
"The fish is €{value}.\n\nYour arms will recover.",
"Something worth €{value} from the depths.\n\n" +
"You don't know exactly what it is.\n\nThe buyer knew exactly what it was.\n\n" +
"The buyer's face when you walked in told you everything about " +
"whether you charged enough.\n\n{xp} XP.",
},
5: {
"The Abyssal Trench released {item}.\n\nNot gave. Released.\n\n" +
"Like it had been holding it and made a decision.\n\n" +
"€{value}. {xp} XP.\n\nYou came home.\n\nThat's the whole win.",
"You fished the Abyssal Trench and came back with {item} worth €{value}.\n\n" +
"Something down there let you.\n\nRemember that.",
"An hour at the Trench.\n\n{item} worth €{value}.\n\n" +
"The thing that lives down there watched you the entire time.\n\n" +
"You could feel it watching.\n\nIt let you keep the fish.\n\n{xp} XP.",
},
}
var FishingExceptional = map[int][]string{
1: {
"THE MUDDY POND HAS PRODUCED SOMETHING REMARKABLE.\n\n" +
"Not a fish. Not a boot. Not a tin can inside a tin can.\n\n" +
"{item} worth €{value}.\n\nIN THE MUDDY POND.\n\n" +
"YOU FOUND THIS IN THE MUDDY POND.\n\n" +
"The pond has been keeping secrets.\n\n" +
"The pond has been keeping €{value} worth of secrets.",
"Critical catch from the Muddy Pond.\n\nThe Muddy Pond.\n\n" +
"Where the sad fish live.\n\nWhere boots go to die.\n\n" +
"{item} — €{value} — {xp} XP.\n\nSomeone go check on the Muddy Pond.",
"You caught the legendary fish of the Muddy Pond.\n\n" +
"There is no legendary fish of the Muddy Pond.\n\nThere is now.\n\n" +
"You caught it.\n\n{item}, €{value}, {xp} XP.\n\nThe frog witnessed this.",
},
2: {
"Iron Creek: exceptional haul.\n\n{item} worth €{value}.\n\n" +
"The creek is annoyed about this.\n\nYou can tell.\n\n" +
"The creek is definitely annoyed.\n\n{xp} XP.",
"The fish that got away last time came back.\n\nWith friends.\n\n" +
"The friends were a mistake on the fish's part.\n\n{item}, €{value}.",
},
3: {
"Silver Lake, fully cooperating, no asterisks.\n\n{item} worth €{value}.\n\n" +
"{xp} XP.\n\nA genuinely good day at a lake that usually has something to say about it.\n\n" +
"Today: nothing to say.\n\nJust the fish.",
"You caught something Silver Lake clearly didn't mean to let go.\n\n" +
"The water seemed surprised.\n\nWater doesn't get surprised.\n\n" +
"Silver Lake did.\n\n{item}, €{value}, {xp} XP.",
},
4: {
"The Deep Current tried everything.\n\nThe current. The cold. The things that live in it.\n\n" +
"You caught {item} worth €{value} anyway.\n\n{xp} XP.\n\n" +
"Something down there is going to be thinking about this for a while.",
"An exceptional haul from the Deep Current and you're only slightly broken.\n\n" +
"{item}, €{value}.\n\nThe fish put up a fight that will honestly be hard to " +
"explain to people.\n\nYou're going to try anyway.",
},
5: {
"The Abyssal Trench, against all precedent and possibly against its will, " +
"gave up {item} worth €{value}.\n\n{xp} XP.\n\n" +
"Something old and enormous watched you take it.\n\n" +
"It didn't stop you.\n\nThat's the part to think about.",
"You caught something from the deepest part of the Abyssal Trench that has no name " +
"because the people who named things didn't come back from down there.\n\n" +
"You came back.\n\n{item}. €{value}. {xp} XP.\n\n" +
"The Trench is reconsidering its position on you.",
"LEGENDARY CATCH from the Abyssal Trench.\n\n{item} worth €{value}.\n\n" +
"The thing that lives down there surfaced briefly.\n\n" +
"Looked at what you caught.\n\nSubmerged again.\n\n" +
"No comment from the thing.\n\n{xp} XP.",
},
}
// ── FISH PUNS ─────────────────────────────────────────────────────────────────
// Used as optional one-line appends to success outcomes.
// Rotate through pool, skip last 5 used per player.
// These should cause physical pain.
var FishPuns = []string{
"You're on a reel roll today.",
"Quite the net result.",
"That's a catch worth carping about.",
"You really flounder-ed your way into that one. Wait, no. The opposite.",
"Looks like today was your lucky day. No trout about it.",
"You're krilling it.",
"That went swimmingly.",
"Fin-tastic work.",
"You really showed that fish who's bass.",
"Cod you believe that?",
"This is a moray than you expected.",
"For eel real though.",
"You've really got sole.",
"Quite the dab hand at this.",
"That's just plain e-fish-ent.",
"You really reeled that one in. Because fishing. That's the pun.",
"Water day.",
"Scale of one to ten: excellent.",
"You de-fin-itely earned that.",
"Pike-fect execution.",
"Something smells fishy. It's the fish. You caught fish.",
"Another one bites the lure.",
"Cheep cheep. Oh wait, wrong water enemy.",
"You avoided every Cheep Cheep, every Blooper, and every inexplicable underwater obstacle " +
"and came home with fish. Mario would be proud. Mario drowned in world 3-3.",
"The Bloopers were not invited. They came anyway. They left.",
"The water level did not rise today. This is exceptional and deserves acknowledgement.",
"You navigated the underwater section without losing a single life. The underwater section " +
"of what? Of life. This is the underwater section of your life.",
}
// ── LOCATION-SPECIFIC FLAVOR ──────────────────────────────────────────────────
// MuddyPondTrash — catches a trash item (not a fish)
var MuddyPondTrash = []string{
"A boot. Just the one.",
"An old helmet. A fish was living in it. The fish did not appreciate the eviction.",
"A tin can containing a smaller tin can containing nothing.",
"Someone's shield. Tier 0. Worse condition than yours.",
"A fishing rod. Better than yours. Belonging to whoever is at the bottom of the Muddy Pond.",
"A locked box. You cannot open it. The lock is interesting. The box is staying.",
"Seventeen copper coins and a note that says 'DO NOT SPEND THESE.' You spend them.",
"A boot. A different boot. Not a pair. Just another different single boot.",
"A sword. Inscribed. The inscription says 'RETURN TO GERALD.' You don't know Gerald. " +
"You are confident that the shop to which you sold his sword for €{value} will return it to him.",
"A duck's nest. Occupied. The duck's position on this is immediate and physical.",
}
// MuddyPondSadFish — catches a sad fish
var MuddyPondSadFish = []string{
"A fish. Small. It looked at you and somehow it managed to scoff at the sight of your visage. " +
"You sold it immediately to salvage what little remains of your self-respect.",
"The saddest fish you have ever seen.\n\nIt didn't fight.\n\nIt just came up.\n\n" +
"When it sees you, its disposition dramatically improved as if somehow it felt immensely " +
"better after witnessing someone with a life far worse than its own. " +
"It is delighted to be sold for €{value} now.",
"A fish that was clearly having a bad day before you showed up.\n\n" +
"When it saw you, it stopped struggling entirely.\n\nNot out of defeat.\n\nOut of pity.\n\n" +
"It sold for €{value}.",
"The fish sighed when you pulled it out.\n\nNot from exhaustion.\n\n" +
"Because it was ready to have its life ended by a superior lifeform, " +
"but instead it was you that caught it.\n\nSold for €{value}.",
"A very small fish.\n\nIt took one look at you and immediately stopped trying to get back " +
"in the water.\n\nYou made the mistake of believing this was due to the fish being " +
"frozen by your dominating aura.\n\nCloser inspection reveals the fish to be laughing " +
"hysterically. So much so that it was unable to move.\n\n" +
"It was still doing so when you sold it for €{value}.",
"The fish turned to get a look at you and once it did, it immediately turned around " +
"to poop in your general direction.\n\nThis wasn't a coincidence as it proceeded " +
"to do so each time it saw you until you eventually sold it for €{value}.",
}
// AbyssalCatch — things that aren't fish
var AbyssalCatch = []string{
"Not a fish. Something with too many eyes and a completely calm energy about being caught. " +
"The fishmonger paid €{value}. You both looked at it, only the fishmonger's gaze never " +
"left the creature.. with eyes filled with lust and biting their lip slightly. " +
"You immediately felt uncomfortable and vacated the premises.",
"Ancient. Whatever it is, it has been down there since before the Trench was called " +
"the Trench. Worth €{value}. The buyer's hands shook.",
"A fish, technically. In the same way the Trench is technically a body of water. " +
"€{value}. {xp} XP. The thing looked at you on the way in like it recognized you.",
"Something that makes a sound when it opens its mouth. Not thrashing. A sound. Deliberate. " +
"You finally recognize it.. the sound it makes every time it looks at you and opens its " +
"mouth.. it's a bleep censor. €{value}.",
}
// ── FISHING SKILL LEVEL UP MESSAGES ──────────────────────────────────────────
var FishingLevelUp = []string{
"🎣 Fishing Lv.{n}. You are slightly better at sitting near water and waiting.",
"🎣 Fishing Lv.{n}. The fish have gained some respect for you now. " +
"I would tell you how much, if the amount were large enough to measure.",
"🎣 Fishing Lv.{n}. You've graduated from 'stick with string' energy, technically.",
"🎣 Fishing Lv.{n}. A milestone. Definitely not one to brag about but you will anyway. " +
"I shall pray for your friends.",
"🎣 Fishing Lv.{n}. You understand fish now in the way that a predator understands prey. " +
"Which is to say: commercially.",
"🎣 Fishing Lv.{n}. Something at the bottom of the Abyssal Trench stirred briefly. " +
"You get the feeling that something was looking at you the same way you look at a " +
"steak dinner.. rather the way you WOULD look at a steak dinner if you could ever afford one.",
"🎣 Fishing Lv.{n}. Whatever lives at the bottom is planning to invite you to a feast.. " +
"no wait, I read that wrong, nevermind.",
}
// ── SUMMARY ONE-LINERS ───────────────────────────────────────────────────────
var SummaryFishingSuccess = []string{
"Fished {location}. Caught {item}. €{value}.",
"{location} cooperated. {item}, €{value}. A good day on the water.",
"Caught something in {location}. Sold for €{value}. The fish disagreed with this outcome.",
}
var SummaryFishingEmpty = []string{
"Fished {location}. Nothing. The water won.",
"{location}: hours of sitting. Zero fish. Many thoughts.",
"Nothing from {location}. The fish made a collective decision.",
}
var SummaryFishingDeath = []string{
"Drowned at {location}. The water was involved. {hours}h.",
"Fishing death. {location}. Something in the water had opinions. {hours}h.",
"Dead at {location}. Fishing-related. Healthcare has the form. {hours}h.",
}

View File

@@ -0,0 +1,517 @@
package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"strings"
"time"
"maunium.net/go/mautrix/id"
)
// ── Masterwork Skill Drop Definitions ──────────────────────────────────────
type MasterworkDef struct {
Slot EquipmentSlot
Activity AdvActivityType
SkillSource string // "mining", "fishing", "foraging"
Tier int // 1-5, matches location tier
Name string
Description string // character sheet / trade listing
DropRate float64 // per-tier: 0.05, 0.04, 0.03, 0.02, 0.015
}
var masterworkDefs = []MasterworkDef{
// ── Mining → Weapon (sword) ────────────────────────────────────────────
{
Slot: SlotWeapon, Activity: AdvActivityMining, SkillSource: "mining", Tier: 1,
Name: "The Ore You Know", DropRate: 0.05,
Description: "Found wedged in a copper seam. Pre-sharpened by geological pressure over approximately four million years. Somehow better than what the shop sells for twice the effort.",
},
{
Slot: SlotWeapon, Activity: AdvActivityMining, SkillSource: "mining", Tier: 2,
Name: "Shaft Splitter", DropRate: 0.04,
Description: "This came out of an iron vein looking exactly like this. You didn't forge it. You found it. The iron vein did all the work. You're going to take full credit.",
},
{
Slot: SlotWeapon, Activity: AdvActivityMining, SkillSource: "mining", Tier: 3,
Name: "The Vein Attempt", DropRate: 0.03,
Description: "Named after the desperate prayer you said when your pickaxe hit something that wasn't ore. It wasn't a god that answered. It was a sword. Close enough.",
},
{
Slot: SlotWeapon, Activity: AdvActivityMining, SkillSource: "mining", Tier: 4,
Name: "Lode Bearer", DropRate: 0.02,
Description: "A serious blade pulled from a serious depth. The edge was formed under pressure that would have killed you. Something down there was planning ahead.",
},
{
Slot: SlotWeapon, Activity: AdvActivityMining, SkillSource: "mining", Tier: 5,
Name: "The Motherload", DropRate: 0.015,
Description: "There is no good explanation for why this was in the rock. The rock isn't talking. You're not asking. You've decided to stop thinking about it and just use it.",
},
// ── Fishing → Armor (chest) ────────────────────────────────────────────
{
Slot: SlotArmor, Activity: AdvActivityFishing, SkillSource: "fishing", Tier: 1,
Name: "Mudscale Plate", DropRate: 0.05,
Description: "Assembled from scales shed by something that lives in the Muddy Pond and apparently grew very large doing it. Smells like the pond. Always will.",
},
{
Slot: SlotArmor, Activity: AdvActivityFishing, SkillSource: "fishing", Tier: 2,
Name: "Riverglass Mail", DropRate: 0.04,
Description: "The links are formed from some kind of calcified river growth — not metal, exactly, but harder than it has any right to be. It came up on your line. You're choosing not to investigate further.",
},
{
Slot: SlotArmor, Activity: AdvActivityFishing, SkillSource: "fishing", Tier: 3,
Name: "Tidal Cuirass", DropRate: 0.03,
Description: "Whatever this came from, it lived in tidal zones and learned to take a beating. The surface is worn smooth from years of wave impact. It fits like it was meant for something your size. It wasn't.",
},
{
Slot: SlotArmor, Activity: AdvActivityFishing, SkillSource: "fishing", Tier: 4,
Name: "Pelagic Plate", DropRate: 0.02,
Description: "Deep sea armor that ended up in Sable Cove via a process you would rather not reconstruct. The plating is layered like fish scales but heavier. Cold to the touch even in direct sun. You've stopped noticing.",
},
{
Slot: SlotArmor, Activity: AdvActivityFishing, SkillSource: "fishing", Tier: 5,
Name: "The Deepforged Carapace", DropRate: 0.015,
Description: "This came up on the line from the Abyssal Trench and the line almost didn't hold. It is not from a fish. It is not from anything you have seen or want to see. It fits perfectly. You find this more unsettling than the alternative.",
},
// ── Foraging <20><> Boots ───────────────────────────────────────────────────
{
Slot: SlotBoots, Activity: AdvActivityForaging, SkillSource: "foraging", Tier: 1,
Name: "Bark Treads", DropRate: 0.05,
Description: "Woven from bark that was evidently done with being a tree. Lightweight, grippy, and somehow waterproof. Found under a log in the Sunlit Meadow where they had no business being. Better than the shop's offering, which tells you something about the shop.",
},
{
Slot: SlotBoots, Activity: AdvActivityForaging, SkillSource: "foraging", Tier: 2,
Name: "Thornweave Boots", DropRate: 0.04,
Description: "Made entirely from briars that have been worked into something wearable, which should not be possible. They don't scratch. They don't catch. They move like they know where you're going before you do.",
},
{
Slot: SlotBoots, Activity: AdvActivityForaging, SkillSource: "foraging", Tier: 3,
Name: "The Wandering Sole", DropRate: 0.03,
Description: "These have been places. The sole is worn in the pattern of a specific path you've never walked. Whoever wore them before you covered serious ground. They handed off the work to you without being asked.",
},
{
Slot: SlotBoots, Activity: AdvActivityForaging, SkillSource: "foraging", Tier: 4,
Name: "Deepwood Striders", DropRate: 0.02,
Description: "The forest didn't make these. The forest let these happen. There's a difference and you feel it when you put them on — like being tolerated by something very old that has decided you're not worth stopping.",
},
{
Slot: SlotBoots, Activity: AdvActivityForaging, SkillSource: "foraging", Tier: 5,
Name: "The Last Step", DropRate: 0.015,
Description: "Found at the edge of the Fungal Dark where the light stops. You almost didn't go that far. Something about wearing them suggests you will always go that far, from now on. This is either good news or the other kind.",
},
}
// masterworkDefFor returns the masterwork definition for a given activity and tier.
func masterworkDefFor(activity AdvActivityType, tier int) *MasterworkDef {
for i := range masterworkDefs {
if masterworkDefs[i].Activity == activity && masterworkDefs[i].Tier == tier {
return &masterworkDefs[i]
}
}
return nil
}
// ── Drop Flavor Text (DM) ─────────────────────────────────────────────────
func masterworkDropFlavorText(activity AdvActivityType, tier int) string {
switch activity {
case AdvActivityMining:
switch {
case tier <= 2:
return "Your pickaxe hits something that rings differently. Metallic but not ore. You dig around it. It is a sword. It was in the rock. You decide this is fine and put it in your bag."
case tier == 3:
return "The vein opens up and there it is. A blade, fully formed, embedded in silver ore like it grew there. Your mining handbook does not cover this. You take it anyway."
case tier == 4:
return "Deep in the forge level, your pickaxe sparks against something that sparks back. You spend twenty minutes clearing the rock around it. When you pull it free it catches the lamplight in a way that makes the other miners stop working. You pretend not to notice."
default:
return "The deepest seam in the Abyssal Mine gives up something it was keeping. No drama. No earthquake. The rock just opens and there it is. A blade that looks like it was waiting. You take it. You don't ask how long it's been there. You don't want to know."
}
case AdvActivityFishing:
switch {
case tier <= 2:
return "You reel in something heavy that turns out not to be a fish. It is armor, or something shaped like armor, and it is in better condition than anything at the shop. You rinse it off in the water and put it on. The pond smells follow it everywhere."
case tier == 3:
return "The line goes dead-weight and you pull up a cuirass that was clearly not in the catch plan. It's heavier than it looks and colder than the water should account for. You add it to your haul. The fish you didn't catch would have been worth less anyway."
case tier == 4:
return "Something takes your bait and doesn't run. It just sinks. You reel against it for ten minutes before the line comes up with armor attached and nothing else. No creature. No explanation. The armor is exceptional. You decide this is the important part."
default:
return "The Abyssal Trench gives up the carapace without a fight, which is somehow worse than if it had fought. You didn't hook it. It was just there on the line when you pulled up. It fits. You start fishing again immediately because thinking about it isn't an option you're entertaining."
}
case AdvActivityForaging:
switch {
case tier <= 2:
return "Under a root cluster in the foraging area, half-buried in moss: a pair of boots. They fit. You don't know what to do with that information so you put them on and keep foraging. They are noticeably better than what you were wearing."
case tier == 3:
return "You push through a dense section of the Ancient Forest and find a clearing that wasn't on any route you've taken before. In the center, on a flat rock, a pair of boots. No tracks around the rock. No sign anyone left them. They're your size. You take them and don't mention the clearing to anyone."
case tier == 4:
return "The Verdant Depths foraging run turns up the usual haul plus one thing that isn't usual: boots, hanging from a branch at eye level, laced and ready. The branch is forty feet up. The boots are at eye level. You decide to focus on the boots and not the branch."
default:
return "The Fungal Dark produces your haul and then, at the very last moment before you turn back, a pair of boots you didn't put there. They are better than anything in the shop. The fungi around them are growing in an outward pattern, like they were avoiding something. You put the boots on. The fungi don't react. You don't find this reassuring."
}
}
return ""
}
// ── Drop Logic ─────────────────────────────────────────────────────────────
func (p *AdventurePlugin) checkMasterworkDrop(userID id.UserID, char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, outcome AdvOutcomeType) {
// Only full-success outcomes drop masterwork (success or exceptional)
if outcome != AdvOutcomeSuccess && outcome != AdvOutcomeExceptional {
return
}
def := masterworkDefFor(loc.Activity, loc.Tier)
if def == nil {
return // no masterwork available for this activity+tier (e.g. dungeon)
}
// Roll for drop
if rand.Float64() >= def.DropRate {
return
}
// Check current equipment in target slot
existing := equip[def.Slot]
// Determine: auto-equip, inventory, or silent discard
autoEquip := false
if existing == nil {
autoEquip = true
} else if existing.Masterwork {
if def.Tier > existing.Tier {
autoEquip = true // upgrade over lower-tier masterwork
} else {
return // silent discard: same or higher tier masterwork already equipped
}
} else {
autoEquip = true // any masterwork > shop gear
}
// First-drop detection
isFirstDrop := char.MasterworkDropsReceived == 0
char.MasterworkDropsReceived++
if err := saveAdvCharacter(char); err != nil {
slog.Error("adventure: failed to save masterwork counter", "user", userID, "err", err)
}
if autoEquip {
// Equip directly
eq := equip[def.Slot]
if eq == nil {
eq = &AdvEquipment{Slot: def.Slot}
equip[def.Slot] = eq
}
eq.Tier = def.Tier
eq.Condition = 100
eq.Name = def.Name
eq.ActionsUsed = 0
eq.ArenaTier = 0
eq.ArenaSet = ""
eq.Masterwork = true
eq.SkillSource = def.SkillSource
if err := saveAdvEquipment(userID, eq); err != nil {
slog.Error("adventure: failed to save masterwork drop", "user", userID, "slot", def.Slot, "err", err)
return
}
} else {
// Send to inventory
item := AdvItem{
Name: def.Name,
Type: "MasterworkGear",
Tier: def.Tier,
Value: 0,
Slot: def.Slot,
SkillSource: def.SkillSource,
}
if err := addAdvInventoryItem(userID, item); err != nil {
slog.Error("adventure: failed to add masterwork to inventory", "user", userID, "err", err)
return
}
}
// Build DM text
var sb strings.Builder
if isFirstDrop {
sb.WriteString("_This doesn't come from the shop._\n\n")
}
// Flavor text
flavor := masterworkDropFlavorText(def.Activity, def.Tier)
if flavor != "" {
sb.WriteString(fmt.Sprintf("_%s_\n\n", flavor))
}
sb.WriteString(fmt.Sprintf("⭐ **Masterwork Drop: %s** (T%d)\n\n", def.Name, def.Tier))
sb.WriteString(fmt.Sprintf("_%s_\n\n", def.Description))
sb.WriteString(fmt.Sprintf("Masterwork %s — 1.25x effectiveness, +5%% %s success.\n", slotTitle(def.Slot), def.SkillSource))
if autoEquip {
sb.WriteString("Equipped automatically.")
} else {
sb.WriteString("Added to inventory. Use `!adventure equip` to equip it.")
}
p.SendDM(userID, sb.String())
// Room announcement (tiered)
p.postMasterworkAnnouncement(char.DisplayName, def, loc.Name)
}
// ── Room Announcements ─────────────────────────────────────────────────────
func (p *AdventurePlugin) postMasterworkAnnouncement(playerName string, def *MasterworkDef, locName string) {
if def.Tier <= 2 {
return // DM only, no room post
}
gr := gamesRoom()
if gr == "" {
return
}
verb := "working"
switch def.Activity {
case AdvActivityMining:
verb = "mining"
case AdvActivityFishing:
verb = "fishing"
case AdvActivityForaging:
verb = "foraging"
}
var msg string
switch {
case def.Tier == 3:
msg = fmt.Sprintf("%s found something unexpected while %s. The %s suggests they've been at this longer than most.",
playerName, verb, def.Name)
case def.Tier == 4:
domain := "mine"
switch def.Activity {
case AdvActivityFishing:
domain = "sea"
case AdvActivityForaging:
domain = "forest"
}
msg = fmt.Sprintf("%s pulled %s out of %s. ⭐ Masterwork %s. The shop doesn't sell this. The %s apparently does.",
playerName, def.Name, locName, slotTitle(def.Slot), domain)
case def.Tier == 5:
msg = fmt.Sprintf("%s has %s. ⭐⭐ Tier 5 Masterwork %s, found in %s. Nobody else has this. The circumstances of its discovery have been described as \"unclear\" and left at that.",
playerName, def.Name, slotTitle(def.Slot), locName)
}
if msg != "" {
p.SendMessage(gr, msg)
}
}
// ── Equip Command ──────────────────────────────────────────────────────────
type advPendingMasterworkEquip struct {
Items []AdvItem
}
type advPendingMasterworkConfirm struct {
Item AdvItem
}
func (p *AdventurePlugin) handleEquipCmd(ctx MessageContext) error {
_, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found. Use `!adventure` to start.")
}
items, err := loadAdvInventory(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load inventory.")
}
// Filter to masterwork items only
var mwItems []AdvItem
for _, it := range items {
if it.Type == "MasterworkGear" {
mwItems = append(mwItems, it)
}
}
if len(mwItems) == 0 {
return p.SendDM(ctx.Sender, "You have no Masterwork gear waiting to be equipped. Go find some.")
}
equip, err := loadAdvEquipment(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load equipment.")
}
// Build listing
var sb strings.Builder
sb.WriteString("⭐ **Your unequipped Masterwork gear:**\n\n")
for i, it := range mwItems {
current := equip[it.Slot]
currentDesc := "None"
if current != nil {
tag := fmt.Sprintf("T%d shop", current.Tier)
if current.Masterwork {
tag = fmt.Sprintf("T%d ⭐", current.Tier)
} else if current.ArenaTier > 0 {
tag = fmt.Sprintf("T%d ⚔️ arena", current.Tier)
}
currentDesc = fmt.Sprintf("%s (%s)", current.Name, tag)
}
sb.WriteString(fmt.Sprintf("%d. %s (T%d %s) — currently: %s\n",
i+1, it.Name, it.Tier, slotTitle(it.Slot), currentDesc))
}
sb.WriteString("\nReply with a number to equip, or \"cancel\".")
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "masterwork_equip",
Data: &advPendingMasterworkEquip{Items: mwItems},
ExpiresAt: time.Now().Add(5 * time.Minute),
})
return p.SendDM(ctx.Sender, sb.String())
}
func (p *AdventurePlugin) handleMasterworkEquipReply(ctx MessageContext, interaction *advPendingInteraction) error {
data := interaction.Data.(*advPendingMasterworkEquip)
reply := strings.TrimSpace(ctx.Body)
if strings.EqualFold(reply, "cancel") {
return p.SendDM(ctx.Sender, "Equip cancelled.")
}
// Parse number
idx := 0
for _, c := range reply {
if c >= '0' && c <= '9' {
idx = idx*10 + int(c-'0')
} else {
break
}
}
idx-- // 1-indexed to 0-indexed
if idx < 0 || idx >= len(data.Items) {
return p.SendDM(ctx.Sender, "Invalid selection. Reply with a number from the list, or \"cancel\".")
}
selected := data.Items[idx]
equip, err := loadAdvEquipment(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load equipment.")
}
current := equip[selected.Slot]
// Warn if downgrading
warning := ""
if current != nil && current.ArenaTier > 0 {
warning = fmt.Sprintf("\n⚠ This will replace your **%s** (Arena gear). Are you sure?\n", current.Name)
} else if current != nil && current.Masterwork && current.Tier > selected.Tier {
warning = fmt.Sprintf("\n⚠ %s is Tier %d. Your current %s is Tier %d. You'll be downgrading tier.\n",
selected.Name, selected.Tier, current.Name, current.Tier)
}
currentName := "nothing"
if current != nil {
currentName = current.Name
}
// Store confirmation pending
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "masterwork_equip_confirm",
Data: &advPendingMasterworkConfirm{Item: selected},
ExpiresAt: time.Now().Add(5 * time.Minute),
})
def := masterworkDefFor(slotToActivity(selected.Slot), selected.Tier)
bonusDesc := ""
if def != nil {
bonusDesc = fmt.Sprintf("\n%s gives 1.25x %s effectiveness and +5%% %s success.\n", selected.Name, slotTitle(selected.Slot), def.SkillSource)
}
return p.SendDM(ctx.Sender, fmt.Sprintf("Equip **%s** (T%d ⭐ %s)?%s\nThis replaces your %s. The old item goes to inventory.%s\nReply \"yes\" to confirm.",
selected.Name, selected.Tier, slotTitle(selected.Slot), warning, currentName, bonusDesc))
}
func (p *AdventurePlugin) handleMasterworkEquipConfirm(ctx MessageContext, interaction *advPendingInteraction) error {
data := interaction.Data.(*advPendingMasterworkConfirm)
reply := strings.TrimSpace(strings.ToLower(ctx.Body))
if reply != "yes" {
return p.SendDM(ctx.Sender, "Equip cancelled.")
}
selected := data.Item
equip, err := loadAdvEquipment(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load equipment.")
}
current := equip[selected.Slot]
// If current equipment is special, move it to inventory
if current != nil && (current.Masterwork || current.ArenaTier > 0) {
oldItem := AdvItem{
Name: current.Name,
Type: "MasterworkGear",
Tier: current.Tier,
Value: 0,
Slot: current.Slot,
SkillSource: current.SkillSource,
}
if current.ArenaTier > 0 {
oldItem.Type = "ArenaGear"
}
if err := addAdvInventoryItem(ctx.Sender, oldItem); err != nil {
slog.Error("adventure: failed to move old equip to inventory", "user", ctx.Sender, "err", err)
}
}
// Equip the selected item
eq := equip[selected.Slot]
if eq == nil {
eq = &AdvEquipment{Slot: selected.Slot}
equip[selected.Slot] = eq
}
eq.Tier = selected.Tier
eq.Condition = 100
eq.Name = selected.Name
eq.ActionsUsed = 0
eq.ArenaTier = 0
eq.ArenaSet = ""
eq.Masterwork = true
eq.SkillSource = selected.SkillSource
if err := saveAdvEquipment(ctx.Sender, eq); err != nil {
return p.SendDM(ctx.Sender, "Failed to save equipment. Try again.")
}
// Remove from inventory
if err := removeAdvInventoryItem(selected.ID); err != nil {
slog.Error("adventure: failed to remove equipped item from inventory", "user", ctx.Sender, "err", err)
}
return p.SendDM(ctx.Sender, fmt.Sprintf("**%s** equipped. ⭐ Masterwork %s, Tier %d.", selected.Name, slotTitle(selected.Slot), selected.Tier))
}
// slotToActivity converts an EquipmentSlot to the activity that drops masterwork for it.
func slotToActivity(s EquipmentSlot) AdvActivityType {
switch s {
case SlotWeapon:
return AdvActivityMining
case SlotArmor:
return AdvActivityFishing
case SlotBoots:
return AdvActivityForaging
}
return ""
}

View File

@@ -98,6 +98,7 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
sb.WriteString(fmt.Sprintf(" Combat: Lv.%d (%d/%d XP)\n", char.CombatLevel, char.CombatXP, xpToNextLevel(char.CombatLevel)))
sb.WriteString(fmt.Sprintf(" Mining: Lv.%d (%d/%d XP)\n", char.MiningSkill, char.MiningXP, xpToNextLevel(char.MiningSkill)))
sb.WriteString(fmt.Sprintf(" Forage: Lv.%d (%d/%d XP)\n", char.ForagingSkill, char.ForagingXP, xpToNextLevel(char.ForagingSkill)))
sb.WriteString(fmt.Sprintf(" Fishing: Lv.%d (%d/%d XP)\n", char.FishingSkill, char.FishingXP, xpToNextLevel(char.FishingSkill)))
// Status
if char.Alive {
@@ -129,8 +130,14 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
mastery = " ✦"
}
if eq != nil {
sb.WriteString(fmt.Sprintf(" %s %s: %s (Tier %d | %d%% condition%s)\n",
slotEmoji(slot), slotTitle(slot), eq.Name, eq.Tier, eq.Condition, mastery))
marker := ""
if eq.Masterwork {
marker = " ⭐"
} else if eq.ArenaTier > 0 {
marker = " ⚔️"
}
sb.WriteString(fmt.Sprintf(" %s %s: %s%s (Tier %d | %d%% condition%s)\n",
slotEmoji(slot), slotTitle(slot), eq.Name, marker, eq.Tier, eq.Condition, mastery))
}
}
sb.WriteString(fmt.Sprintf(" Equipment Score: %d\n", eqScore))
@@ -190,8 +197,8 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
vars := map[string]string{
"{name}": char.DisplayName,
"{character_sheet}": fmt.Sprintf(
" ⚔️ Combat Lv.%d ⛏️ Mining Lv.%d 🌿 Foraging Lv.%d\n 💰 €%.0f",
char.CombatLevel, char.MiningSkill, char.ForagingSkill, balance),
" ⚔️ Combat Lv.%d ⛏️ Mining Lv.%d 🌿 Foraging Lv.%d 🎣 Fishing Lv.%d\n 💰 €%.0f",
char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill, balance),
}
sb.WriteString(advSubstituteFlavor(greeting, vars))
sb.WriteString("\n\n")
@@ -251,8 +258,17 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString("**4Shop** — buy/sell gear and loot\n")
sb.WriteString("**5⃣ Rest** — skip today, bank your luck\n\n")
sb.WriteString("**4Fish:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityFishing, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString("**5⃣ Shop** — buy/sell gear and loot\n")
sb.WriteString("**6⃣ Rest** — skip today, bank your luck\n\n")
sb.WriteString("Reply with the number and location, e.g: `1 Soggy Cellar`\n")
sb.WriteString("You have until midnight UTC to choose.")
@@ -294,8 +310,17 @@ func renderAdvHolidaySecondPrompt(char *AdventureCharacter, equip map[EquipmentS
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString("**4Shop** — buy/sell gear and loot\n")
sb.WriteString("**5⃣ Rest** — skip the second action\n\n")
sb.WriteString("**4Fish:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityFishing, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString("**5⃣ Shop** — buy/sell gear and loot\n")
sb.WriteString("**6⃣ Rest** — skip the second action\n\n")
sb.WriteString("Reply with the number and location, e.g: `1 Soggy Cellar`")
return sb.String()
@@ -468,6 +493,7 @@ type AdvPlayerDaySummary struct {
CombatLevel int
MiningSkill int
ForagingSkill int
FishingSkill int
Activity string
Location string
Outcome string
@@ -529,7 +555,7 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
sb.WriteString(fmt.Sprintf(" ⭐ %d players received a gift item\n", tbRewards.GiftCount))
}
}
sb.WriteString("\n(Players who rested today received nothing. TwinBee noticed.)\n\n")
sb.WriteString("\n(Players who rested today received nothing. Fallen adventurers still earn their share. TwinBee noticed.)\n\n")
sb.WriteString("───────────────────\n\n")
}
@@ -543,8 +569,15 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
p := &players[i]
if p.IsDead {
dead = append(dead, *p)
if worstPlayer == nil {
worstPlayer = p
// Dead players who acted today still show in the main section
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))
sb.WriteString(fmt.Sprintf(" Went to: %s\n", p.Location))
sb.WriteString(fmt.Sprintf(" Outcome: %s\n\n", p.SummaryLine))
if worstPlayer == nil {
worstPlayer = p
}
}
continue
}
@@ -553,8 +586,8 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
continue
}
sb.WriteString(fmt.Sprintf("⚔️ **%s** — Combat Lv.%d | Mining Lv.%d | Forage Lv.%d\n",
p.DisplayName, p.CombatLevel, p.MiningSkill, p.ForagingSkill))
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))
sb.WriteString(fmt.Sprintf(" Went to: %s\n", p.Location))
sb.WriteString(fmt.Sprintf(" Outcome: %s\n\n", p.SummaryLine))
@@ -563,10 +596,16 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
}
}
// Dead list
if len(dead) > 0 {
// Dead list (players who didn't act today but are still dead from before)
var deadNoAction []AdvPlayerDaySummary
for _, d := range dead {
if d.Location == "" {
deadNoAction = append(deadNoAction, d)
}
}
if len(deadNoAction) > 0 {
sb.WriteString("💀 **Currently Dead:**\n")
for _, d := range dead {
for _, d := range deadNoAction {
sb.WriteString(fmt.Sprintf(" %s — returns %s\n", d.DisplayName, d.DeadUntil))
}
sb.WriteString("\n")
@@ -778,6 +817,15 @@ func advSummaryOneLiner(userID id.UserID, activity AdvActivityType, outcome AdvO
case AdvOutcomeSuccess, AdvOutcomeExceptional:
pool = SummaryForagingSuccess
}
case AdvActivityFishing:
switch outcome {
case AdvOutcomeDeath:
pool = SummaryFishingDeath
case AdvOutcomeEmpty:
pool = SummaryFishingEmpty
case AdvOutcomeSuccess, AdvOutcomeExceptional:
pool = SummaryFishingSuccess
}
}
if len(pool) == 0 {

View File

@@ -190,6 +190,7 @@ func (p *AdventurePlugin) postDailySummary() {
CombatLevel: c.CombatLevel,
MiningSkill: c.MiningSkill,
ForagingSkill: c.ForagingSkill,
FishingSkill: c.FishingSkill,
}
// Holiday action count from log entries
@@ -237,9 +238,9 @@ func (p *AdventurePlugin) postDailySummary() {
// Check party bonuses and add to summary
for i := range players {
if players[i].Location != "" && !players[i].IsDead && !players[i].IsResting {
if players[i].Location != "" && !players[i].IsResting {
for j := i + 1; j < len(players); j++ {
if players[j].Location == players[i].Location && !players[j].IsDead && !players[j].IsResting {
if players[j].Location == players[i].Location && !players[j].IsResting {
players[i].SummaryLine += fmt.Sprintf(" (Party bonus with %s!)", players[j].DisplayName)
players[j].SummaryLine += fmt.Sprintf(" (Party bonus with %s!)", players[i].DisplayName)
}

View File

@@ -221,6 +221,16 @@ func (p *AdventurePlugin) advBuyEquipment(userID id.UserID, slot EquipmentSlot,
return fmt.Sprintf("You already have %s (Tier %d). %s is Tier %d. That's not an upgrade, that's a lateral move at best.",
current.Name, current.Tier, def.Name, def.Tier)
}
// Block shop purchases that would overwrite arena or masterwork gear
if current != nil && (current.ArenaTier > 0 || current.Masterwork) {
label := "Masterwork"
if current.ArenaTier > 0 {
label = "Arena"
}
return fmt.Sprintf("You already have **%s** (%s gear). A shop item cannot replace this. "+
"You earned that. Don't throw it away.",
current.Name, label)
}
balance := p.euro.GetBalance(userID)
if balance < def.Price {
@@ -258,7 +268,7 @@ func (p *AdventurePlugin) advBuyEquipment(userID id.UserID, slot EquipmentSlot,
// ── Sell Items ────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) advSellAll(userID id.UserID) string {
items, err := clearAdvInventory(userID)
items, err := loadAdvInventory(userID)
if err != nil {
return "Failed to access your inventory. Try again."
}
@@ -267,15 +277,36 @@ func (p *AdventurePlugin) advSellAll(userID id.UserID) string {
}
var total float64
var sold int
var keptSpecial int
for _, item := range items {
if item.Type == "MasterworkGear" || item.Type == "ArenaGear" {
keptSpecial++
continue
}
if err := removeAdvInventoryItem(item.ID); err != nil {
continue
}
total += float64(item.Value)
sold++
}
if sold == 0 {
if keptSpecial > 0 {
return "Your inventory only contains Masterwork and Arena gear. The merchant doesn't deal in that. Use `!adventure equip` instead."
}
return "Your inventory is empty. There is nothing to sell. This is a metaphor for something but now is not the time."
}
p.euro.Credit(userID, total, "adventure_sell_all")
return fmt.Sprintf("Sold %d items for **€%.0f**.\n\nThe merchant took everything without comment. "+
msg := fmt.Sprintf("Sold %d items for **€%.0f**.\n\nThe merchant took everything without comment. "+
"This is the most respect anyone has shown your loot collection. Take the money.",
len(items), total)
sold, total)
if keptSpecial > 0 {
msg += fmt.Sprintf("\n\n(%d special gear items kept — the merchant knows better than to touch those.)", keptSpecial)
}
return msg
}
func (p *AdventurePlugin) advSellItem(userID id.UserID, itemName string) string {
@@ -287,6 +318,9 @@ func (p *AdventurePlugin) advSellItem(userID id.UserID, itemName string) string
// Fuzzy match
for _, item := range items {
if containsFold(item.Name, itemName) {
if item.Type == "MasterworkGear" || item.Type == "ArenaGear" {
return fmt.Sprintf("**%s** is special gear. The merchant won't touch it. Use `!adventure equip` to equip it.", item.Name)
}
if err := removeAdvInventoryItem(item.ID); err != nil {
return "Failed to sell that item."
}
@@ -314,10 +348,17 @@ func advInventoryDisplay(userID id.UserID) string {
var total int64
for _, item := range items {
sb.WriteString(fmt.Sprintf(" %s (T%d %s) — €%d\n", item.Name, item.Tier, item.Type, item.Value))
total += item.Value
if item.Type == "MasterworkGear" {
sb.WriteString(fmt.Sprintf(" ⭐ %s (T%d Masterwork %s)\n", item.Name, item.Tier, slotTitle(item.Slot)))
} else if item.Type == "ArenaGear" {
sb.WriteString(fmt.Sprintf(" ⚔️ %s (T%d Arena %s)\n", item.Name, item.Tier, slotTitle(item.Slot)))
} else {
sb.WriteString(fmt.Sprintf(" %s (T%d %s) — €%d\n", item.Name, item.Tier, item.Type, item.Value))
total += item.Value
}
}
sb.WriteString(fmt.Sprintf("\n%d items — total value ~€%d", len(items), total))
sb.WriteString(fmt.Sprintf("\n%d items — sellable value ~€%d", len(items), total))
sb.WriteString("\n\nTo sell: `!adventure sell all` or `!adventure sell <item>`")
sb.WriteString("\nTo equip Masterwork gear: `!adventure equip`")
return sb.String()
}

View File

@@ -227,7 +227,7 @@ func (p *AdventurePlugin) distributeTwinBeeRewards(result *TwinBeeResult) TwinBe
return summary
}
// Find eligible players: alive + took action today
// Find eligible players: anyone who took action today (including dead)
chars, err := loadAllAdvCharacters()
if err != nil {
slog.Error("adventure: twinbee rewards failed to load chars", "err", err)
@@ -236,7 +236,7 @@ func (p *AdventurePlugin) distributeTwinBeeRewards(result *TwinBeeResult) TwinBe
var eligible []id.UserID
for _, c := range chars {
if c.ActionTakenToday && c.Alive {
if c.ActionTakenToday {
eligible = append(eligible, c.UserID)
}
}

View File

@@ -4,7 +4,6 @@ import (
"database/sql"
"fmt"
"log/slog"
"net/http"
"os"
"strconv"
"strings"
@@ -12,6 +11,7 @@ import (
"time"
"gogobee/internal/db"
"gogobee/internal/dreamclient"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
@@ -21,8 +21,7 @@ import (
type WordlePlugin struct {
Base
euro *EuroPlugin
apiKey string
httpClient *http.Client
dict *dreamclient.Client
defaultLength int
mu sync.Mutex
@@ -33,7 +32,7 @@ type WordlePlugin struct {
}
// NewWordlePlugin creates a new WordlePlugin.
func NewWordlePlugin(client *mautrix.Client, euro *EuroPlugin) *WordlePlugin {
func NewWordlePlugin(client *mautrix.Client, euro *EuroPlugin, dict *dreamclient.Client) *WordlePlugin {
length := 5
if v := os.Getenv("WORDLE_DEFAULT_LENGTH"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 5 && n <= 7 {
@@ -44,8 +43,7 @@ func NewWordlePlugin(client *mautrix.Client, euro *EuroPlugin) *WordlePlugin {
return &WordlePlugin{
Base: NewBase(client),
euro: euro,
apiKey: os.Getenv("WORDNIK_API_KEY"),
httpClient: &http.Client{Timeout: 10 * time.Second},
dict: dict,
defaultLength: length,
puzzles: make(map[id.RoomID]*WordlePuzzle),
validCache: make(map[string]map[string]bool),
@@ -113,6 +111,8 @@ func (p *WordlePlugin) handleHelp(ctx MessageContext) error {
"`!wordle stats` — All-time leaderboard\n"+
"`!wordle new` — Start a new puzzle (admin)\n"+
"`!wordle new <5|6|7>` — New puzzle with specific length (admin)\n"+
"`!wordle new pt` — Portuguese puzzle (admin)\n"+
"`!wordle new fr` — French puzzle (admin)\n"+
"`!wordle skip` — Reveal answer and end puzzle (admin)")
}
@@ -152,8 +152,8 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
}
}
// Validate word via Wordnik (with caching).
valid, apiErr := p.isValidWord(puzzle.PuzzleID, guess)
// Validate word via DreamDict (with caching).
valid, apiErr := p.isValidWord(puzzle.PuzzleID, guess, puzzle.Category)
if apiErr {
return p.SendReply(ctx.RoomID, ctx.EventID,
"Word validation is temporarily unavailable. Try again in a moment.")
@@ -218,10 +218,7 @@ func (p *WordlePlugin) handleGrid(ctx MessageContext) error {
}
if len(puzzle.Guesses) == 0 {
hint := ""
if isCustomAllowedWord(puzzle.Answer) {
hint = "Today's word is video game related"
}
hint := wordleCategoryHint(puzzle.Category)
return p.SendReply(ctx.RoomID, ctx.EventID,
renderWordleStartAnnouncement(puzzle.PuzzleNumber, puzzle.WordLength, hint))
}
@@ -235,12 +232,21 @@ func (p *WordlePlugin) handleNew(ctx MessageContext, args string) error {
}
wordLength := p.defaultLength
category := WordleCategoryEN
parts := strings.Fields(args)
if len(parts) > 1 {
if n, err := strconv.Atoi(parts[1]); err == nil && n >= 5 && n <= 7 {
wordLength = n
} else {
return p.SendReply(ctx.RoomID, ctx.EventID, "Word length must be 5, 6, or 7.")
// Parse optional arguments: language (pt/fr) and/or length (5/6/7)
for _, part := range parts[1:] {
lower := strings.ToLower(part)
switch lower {
case "pt", "portuguese":
category = WordleCategoryPT
case "fr", "french":
category = WordleCategoryFR
default:
if n, err := strconv.Atoi(part); err == nil && n >= 5 && n <= 7 {
wordLength = n
}
}
}
@@ -253,7 +259,7 @@ func (p *WordlePlugin) handleNew(ctx MessageContext, args string) error {
}
p.mu.Unlock()
return p.createAndPostPuzzle(ctx.RoomID, wordLength)
return p.createAndPostPuzzle(ctx.RoomID, wordLength, category)
}
func (p *WordlePlugin) handleSkip(ctx MessageContext) error {
@@ -311,12 +317,22 @@ func (p *WordlePlugin) handleStats(ctx MessageContext) error {
}
// createAndPostPuzzle creates a new puzzle, persists it, and posts the announcement.
func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int) error {
// Use local word list for puzzle selection. Wordnik's randomWord endpoint
// requires a paid API tier; we keep Wordnik for guess validation and definitions only.
word := pickFallbackWord(wordLength)
func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int, category WordleCategory) error {
// Pick word from the appropriate pool.
var word string
switch category {
case WordleCategoryPT, WordleCategoryFR:
word = pickLanguageWord(category, wordLength)
default:
word = pickFallbackWord(wordLength)
}
if word == "" {
return p.SendMessage(roomID, "Failed to select a puzzle word — no words available for that length.")
return p.SendMessage(roomID, "Failed to select a puzzle word — no words available for that length/language.")
}
// Detect game words within the English pool.
if category == WordleCategoryEN && isCustomAllowedWord(word) {
category = WordleCategoryGames
}
puzzleNumber := p.nextPuzzleNumber()
@@ -330,17 +346,18 @@ func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int) err
Answer: word,
WordLength: wordLength,
MaxGuesses: 6,
Category: category,
StartedAt: now,
LetterStates: make(map[rune]LetterResult),
}
// Persist to DB.
db.Exec("wordle: persist puzzle",
`INSERT INTO wordle_puzzles (puzzle_id, room_id, answer, word_length, solved, guess_count, started_at)
VALUES (?, ?, ?, ?, 0, 0, ?)
ON CONFLICT(puzzle_id, room_id) DO UPDATE SET answer = ?, word_length = ?, solved = 0, guess_count = 0, started_at = ?`,
today, string(roomID), word, wordLength, now,
word, wordLength, now,
`INSERT INTO wordle_puzzles (puzzle_id, room_id, answer, word_length, category, solved, guess_count, started_at)
VALUES (?, ?, ?, ?, ?, 0, 0, ?)
ON CONFLICT(puzzle_id, room_id) DO UPDATE SET answer = ?, word_length = ?, category = ?, solved = 0, guess_count = 0, started_at = ?`,
today, string(roomID), word, wordLength, string(category), now,
word, wordLength, string(category), now,
)
p.mu.Lock()
@@ -354,13 +371,23 @@ func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int) err
p.validCache[today] = make(map[string]bool)
p.mu.Unlock()
hint := ""
if isCustomAllowedWord(word) {
hint = "Today's word is video game related"
}
hint := wordleCategoryHint(category)
return p.SendMessage(roomID, renderWordleStartAnnouncement(puzzleNumber, wordLength, hint))
}
// wordleCategoryHint returns the hint string for a puzzle category.
func wordleCategoryHint(category WordleCategory) string {
switch category {
case WordleCategoryPT:
return "Today's word is in European Portuguese 🇵🇹"
case WordleCategoryFR:
return "Today's word is in French 🇫🇷"
case WordleCategoryGames:
return "Today's word is video game related"
}
return ""
}
// PostDailyPuzzle is called by the scheduler to post today's puzzle.
func (p *WordlePlugin) PostDailyPuzzle(roomID id.RoomID) error {
today := time.Now().UTC().Format("2006-01-02")
@@ -374,13 +401,13 @@ func (p *WordlePlugin) PostDailyPuzzle(roomID id.RoomID) error {
}
p.mu.Unlock()
return p.createAndPostPuzzle(roomID, p.defaultLength)
return p.createAndPostPuzzle(roomID, p.defaultLength, WordleCategoryEN)
}
// isValidWord checks if a word is valid, using the in-memory cache first,
// then the custom allow-list, then Wordnik.
// Returns (valid, apiError). apiError is true when the API is unreachable.
func (p *WordlePlugin) isValidWord(puzzleID, word string) (bool, bool) {
// then the custom allow-list, then DreamDict.
// Returns (valid, apiError). apiError is true when the service is unreachable.
func (p *WordlePlugin) isValidWord(puzzleID, word string, category WordleCategory) (bool, bool) {
cache := p.validCache[puzzleID]
if cache == nil {
cache = make(map[string]bool)
@@ -391,21 +418,49 @@ func (p *WordlePlugin) isValidWord(puzzleID, word string) (bool, bool) {
return valid, false
}
// Check custom allow-list (game titles, etc.) before hitting the API.
// Check custom allow-list (game titles, etc.) before hitting the service.
if isCustomAllowedWord(word) {
cache[word] = true
return true, false
}
valid, apiErr := wordnikValidateWord(p.apiKey, p.httpClient, word)
if !apiErr {
cache[word] = valid // only cache definitive results
// Check the puzzle's language first.
lang := categoryLang(category)
valid, apiErr := dictValidateWord(p.dict, word, lang)
if apiErr {
return false, true
}
return valid, apiErr
if valid {
cache[word] = true
return true, false
}
// For non-English puzzles, also accept English words as guesses.
if lang != "en" {
valid, apiErr = dictValidateWord(p.dict, word, "en")
if apiErr {
return false, true
}
if valid {
cache[word] = true
return true, false
}
}
cache[word] = false
return false, false
}
func (p *WordlePlugin) fetchDefinition(word string) string {
return wordnikFetchDefinitionText(p.apiKey, p.httpClient, word)
func (p *WordlePlugin) fetchDefinition(answer string) string {
// Determine language from active puzzle if possible.
lang := "en"
for _, puzzle := range p.puzzles {
if puzzle.Answer == answer {
lang = categoryLang(puzzle.Category)
break
}
}
return dictFetchDefinitionText(p.dict, answer, lang)
}
@@ -589,11 +644,12 @@ func (p *WordlePlugin) rehydratePuzzles() {
roomStr string
answer string
wordLength int
category string
startedAt time.Time
}
rows, err := d.Query(
`SELECT puzzle_id, room_id, answer, word_length, solved, guess_count, started_at
`SELECT puzzle_id, room_id, answer, word_length, COALESCE(category, ''), solved, guess_count, started_at
FROM wordle_puzzles WHERE puzzle_id = ?`, today)
if err != nil {
slog.Warn("wordle: rehydrate query failed", "err", err)
@@ -602,10 +658,10 @@ func (p *WordlePlugin) rehydratePuzzles() {
var pending []puzzleRow
for rows.Next() {
var pid, roomStr, answer string
var pid, roomStr, answer, category string
var wordLength, solved, guessCount int
var startedAt time.Time
if err := rows.Scan(&pid, &roomStr, &answer, &wordLength, &solved, &guessCount, &startedAt); err != nil {
if err := rows.Scan(&pid, &roomStr, &answer, &wordLength, &category, &solved, &guessCount, &startedAt); err != nil {
continue
}
@@ -613,7 +669,7 @@ func (p *WordlePlugin) rehydratePuzzles() {
continue // already done
}
pending = append(pending, puzzleRow{pid, roomStr, answer, wordLength, startedAt})
pending = append(pending, puzzleRow{pid, roomStr, answer, wordLength, category, startedAt})
}
rows.Close()
@@ -634,6 +690,7 @@ func (p *WordlePlugin) rehydratePuzzles() {
Answer: pr.answer,
WordLength: pr.wordLength,
MaxGuesses: 6,
Category: WordleCategory(pr.category),
StartedAt: pr.startedAt,
LetterStates: make(map[rune]LetterResult),
}
@@ -689,7 +746,7 @@ func (p *WordlePlugin) checkAndPostDaily() {
}
slog.Info("wordle: posting daily puzzle", "room", gr)
if err := p.createAndPostPuzzle(gr, p.defaultLength); err != nil {
if err := p.createAndPostPuzzle(gr, p.defaultLength, WordleCategoryEN); err != nil {
slog.Error("wordle: daily puzzle failed", "err", err)
}
}

View File

@@ -14,6 +14,10 @@ import (
var (
fallbackWords map[int][]string
customAllowSet map[string]bool // words valid as guesses but not in dictionary
ptWords map[int][]string
frWords map[int][]string
ptWordSet map[string]bool
frWordSet map[string]bool
fallbackWordsOnce sync.Once
)
@@ -51,6 +55,10 @@ func loadFallbackWords() {
// puzzle pool AND accepted as valid guesses even if not in the dictionary.
customAllowSet = make(map[string]bool)
loadCustomWordFile("data/wordle_games.txt")
// Load Portuguese and French word lists.
ptWords, ptWordSet = loadLanguageWordFile("data/wordle_pt.txt", "pt")
frWords, frWordSet = loadLanguageWordFile("data/wordle_fr.txt", "fr")
}
func loadCustomWordFile(path string) {
@@ -79,6 +87,41 @@ func loadCustomWordFile(path string) {
slog.Info("wordle: loaded custom words", "path", path, "count", count)
}
// loadLanguageWordFile loads a language-specific word list and returns both
// the length-grouped map and a flat set for guess validation.
func loadLanguageWordFile(path, lang string) (map[int][]string, map[string]bool) {
words := make(map[int][]string)
wordSet := make(map[string]bool)
f, err := os.Open(path)
if err != nil {
slog.Warn("wordle: language word list not found", "lang", lang, "path", path)
return words, wordSet
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
word := strings.TrimSpace(scanner.Text())
if word == "" || strings.HasPrefix(word, "#") {
continue
}
word = strings.ToUpper(word)
n := len([]rune(word))
if n >= 5 && n <= 7 {
words[n] = append(words[n], word)
wordSet[word] = true
}
}
total := 0
for _, w := range words {
total += len(w)
}
slog.Info("wordle: loaded language words", "lang", lang, "count", total)
return words, wordSet
}
// pickFallbackWord picks a random word of the given length from the fallback list,
// excluding words used in the last 500 puzzles.
func pickFallbackWord(length int) string {
@@ -105,6 +148,52 @@ func pickFallbackWord(length int) string {
return candidates[rand.IntN(len(candidates))]
}
// pickLanguageWord picks a random word from a language-specific list.
func pickLanguageWord(category WordleCategory, length int) string {
fallbackWordsOnce.Do(loadFallbackWords)
var pool map[int][]string
switch category {
case WordleCategoryPT:
pool = ptWords
case WordleCategoryFR:
pool = frWords
default:
return pickFallbackWord(length)
}
words := pool[length]
if len(words) == 0 {
return ""
}
recent := loadRecentWordleAnswers(500)
var candidates []string
for _, w := range words {
if !recent[w] {
candidates = append(candidates, w)
}
}
if len(candidates) == 0 {
candidates = words
}
return candidates[rand.IntN(len(candidates))]
}
// isLanguageWord checks if a word exists in a language's word set (for guess validation).
func isLanguageWord(category WordleCategory, word string) bool {
fallbackWordsOnce.Do(loadFallbackWords)
word = strings.ToUpper(word)
switch category {
case WordleCategoryPT:
return ptWordSet[word]
case WordleCategoryFR:
return frWordSet[word]
}
return false
}
// isCustomAllowedWord checks if a word is in the custom allow-list (game titles, etc.).
func isCustomAllowedWord(word string) bool {
fallbackWordsOnce.Do(loadFallbackWords)

View File

@@ -24,6 +24,16 @@ type WordleGuess struct {
Timestamp time.Time
}
// WordleCategory identifies the language/category of a puzzle.
type WordleCategory string
const (
WordleCategoryEN WordleCategory = "" // English (default)
WordleCategoryPT WordleCategory = "pt" // European Portuguese
WordleCategoryFR WordleCategory = "fr" // French
WordleCategoryGames WordleCategory = "games" // Video game words (English)
)
// WordlePuzzle holds all state for one day's puzzle.
type WordlePuzzle struct {
PuzzleID string // YYYY-MM-DD
@@ -32,6 +42,7 @@ type WordlePuzzle struct {
Answer string // uppercased
WordLength int
MaxGuesses int // always 6
Category WordleCategory
Guesses []WordleGuess
Solved bool
Failed bool

View File

@@ -78,7 +78,7 @@ func renderWordleStartAnnouncement(puzzleNumber, wordLength int, hint string) st
puzzleNumber, wordLength,
)
if hint != "" {
base += fmt.Sprintf("\n🎮 **Hint:** %s", hint)
base += fmt.Sprintf("\n💡 **Hint:** %s", hint)
}
base += "\n\nGuess with: `!wordle <word>`"
return base

View File

@@ -1,175 +1,76 @@
package plugin
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"unicode"
"gogobee/internal/dreamclient"
)
// wordnikRandomWordResponse is the Wordnik randomWord response.
type wordnikRandomWordResponse struct {
Word string `json:"word"`
}
// wordnikDefinitionResponse is one definition entry.
type wordnikDefinitionResponse struct {
Text string `json:"text"`
PartOfSpeech string `json:"partOfSpeech"`
}
// wordnikFetchRandomWord fetches a random word of the given length from Wordnik.
// Returns the uppercased word. Retries up to 5 times to avoid bad words.
func wordnikFetchRandomWord(apiKey string, client *http.Client, wordLength int) (string, error) {
for attempt := 0; attempt < 5; attempt++ {
url := fmt.Sprintf(
"https://api.wordnik.com/v4/words.json/randomWord?hasDictionaryDef=true&minLength=%d&maxLength=%d&minCorpusCount=5000&minDictionaryCount=3&api_key=%s",
wordLength, wordLength, apiKey,
)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("wordle: create request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("wordle: fetch random word: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("wordle: API returned status %d", resp.StatusCode)
}
var result wordnikRandomWordResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("wordle: decode response: %w", err)
}
word := strings.ToUpper(strings.TrimSpace(result.Word))
// Reject words with hyphens, spaces, apostrophes.
if strings.ContainsAny(word, "-' ") {
slog.Debug("wordle: rejecting word with special chars", "word", word, "attempt", attempt+1)
continue
}
// Reject if not all alphabetic.
allAlpha := true
for _, r := range word {
if !unicode.IsLetter(r) {
allAlpha = false
break
}
}
if !allAlpha {
slog.Debug("wordle: rejecting non-alpha word", "word", word, "attempt", attempt+1)
continue
}
// Check definition to reject proper nouns.
if isProperNoun(apiKey, client, word) {
slog.Debug("wordle: rejecting proper noun", "word", word, "attempt", attempt+1)
continue
}
slog.Info("wordle: selected word", "word", word, "length", wordLength, "attempt", attempt+1)
return word, nil
// dictValidateWord checks if a word exists in DreamDict.
// Returns (valid, apiErr). apiErr is true when the service is unreachable.
func dictValidateWord(dict *dreamclient.Client, word, lang string) (valid bool, apiErr bool) {
if dict == nil {
return true, false // no client configured = skip validation
}
return "", fmt.Errorf("wordle: failed to find suitable word after 5 attempts")
}
// isProperNoun checks if a word's first definition starts with a capital letter.
func isProperNoun(apiKey string, client *http.Client, word string) bool {
defs, err := wordnikFetchDefinitions(apiKey, client, strings.ToLower(word), 1)
if err != nil || len(defs) == 0 {
return false
}
text := strings.TrimSpace(defs[0].Text)
if text == "" {
return false
}
return unicode.IsUpper([]rune(text)[0])
}
// wordnikValidateWord checks if a word exists by looking up its definitions.
// Returns valid bool and an error flag. On API errors, returns false with apiErr=true
// so the caller can show a different message than "not a valid word".
func wordnikValidateWord(apiKey string, client *http.Client, word string) (valid bool, apiErr bool) {
if apiKey == "" {
return true, false // no API key = skip validation
}
defs, err := wordnikFetchDefinitions(apiKey, client, strings.ToLower(word), 1)
v, err := dict.IsValidWord(strings.ToLower(word), lang)
if err != nil {
slog.Warn("wordle: validation API error", "word", word, "err", err)
slog.Warn("wordle: dictionary validation error", "word", word, "lang", lang, "err", err)
return false, true
}
return len(defs) > 0, false
return v, false
}
// wordnikFetchDefinitionText fetches a clean definition string for display.
func wordnikFetchDefinitionText(apiKey string, client *http.Client, word string) string {
defs, err := wordnikFetchDefinitions(apiKey, client, strings.ToLower(word), 3)
if err != nil || len(defs) == 0 {
// dictFetchDefinitionText fetches a clean definition string for display.
// Tries the given language first, falls back to English.
func dictFetchDefinitionText(dict *dreamclient.Client, word, lang string) string {
if dict == nil {
return ""
}
// Find the first clean definition.
for _, d := range defs {
text := strings.TrimSpace(d.Text)
if text == "" {
continue
}
// Strip HTML tags if any.
text = stripHTMLTags(text)
pos := d.PartOfSpeech
if pos != "" {
return fmt.Sprintf("%s (%s): %s", strings.ToLower(word), pos, text)
}
return fmt.Sprintf("%s: %s", strings.ToLower(word), text)
defs, err := dict.Define(strings.ToLower(word), lang)
if err != nil {
slog.Warn("wordle: definition fetch error", "word", word, "lang", lang, "err", err)
return ""
}
return ""
// If no definitions in the puzzle language, try English.
if len(defs) == 0 && lang != "en" {
defs, err = dict.Define(strings.ToLower(word), "en")
if err != nil || len(defs) == 0 {
return ""
}
}
if len(defs) == 0 {
return ""
}
d := defs[0]
text := strings.TrimSpace(d.Gloss)
if text == "" {
return ""
}
// Strip HTML tags if any.
text = stripHTMLTags(text)
pos := d.POS
if pos != "" {
return fmt.Sprintf("%s (%s): %s", strings.ToLower(word), pos, text)
}
return fmt.Sprintf("%s: %s", strings.ToLower(word), text)
}
// wordnikFetchDefinitions fetches definitions from Wordnik.
func wordnikFetchDefinitions(apiKey string, client *http.Client, word string, limit int) ([]wordnikDefinitionResponse, error) {
url := fmt.Sprintf(
"https://api.wordnik.com/v4/word.json/%s/definitions?limit=%d&sourceDictionaries=all&api_key=%s",
word, limit, apiKey,
)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
// categoryLang returns the DreamDict language code for a Wordle category.
func categoryLang(category WordleCategory) string {
switch category {
case WordleCategoryPT:
return "pt-PT"
case WordleCategoryFR:
return "fr"
default:
return "en"
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil // word not found
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
var defs []wordnikDefinitionResponse
if err := json.NewDecoder(resp.Body).Decode(&defs); err != nil {
return nil, err
}
return defs, nil
}
// stripHTMLTags removes HTML tags from a string.