mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Add rival duels, babysitting service, multilingual !define, economy tuning
Rival System: - RPS-based duels between combat level 5+ players (best of 3, ties re-prompt) - Stakes scale with level, split 50/50 between winner and community pot - 3-4 day randomized challenge interval, 7-day same-pair cooldown - 24h response window with auto-forfeit - Full flavor text pools, character sheet integration, !adventure rivals Babysitting Service: - Weekly/monthly auto-play service targeting weakest skill - Babysitter rerolls death, claims items, credits gold and XP - Rivals automatically declined during service - End-of-service summary with diaper report Revive fixes: - On-demand revive in handleMenu, parseAndResolveChoice, handleStatus - Morning DM respawn check now runs before babysit interception - Players no longer stuck dead after DeadUntil expires mid-day Other changes: - Multilingual !define searches all DreamDict languages by default - TwinBee empty haul now shows "jackshit" instead of blank - Wordle solve payouts increased (e.g. 1-guess: €100 -> €500) - Per-message euro payouts doubled across all tiers - Audit fixes: user locks on RPS/babysit handlers, TOCTOU on gold transfers, dead code removal, deprecated strings.Title replaced, error logging on silent failures Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -611,7 +611,7 @@ A multi-tier combat gauntlet independent of the daily adventure action. Fight th
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `!wiki <topic>` | Wikipedia summary |
|
||||
| `!define <word>` | Dictionary definition (includes antonyms from DreamDict when available) |
|
||||
| `!define <word> [lang]` | Dictionary definition — searches all languages via DreamDict by default, or specify one (en/fr/pt-PT/zh). Includes antonyms when available. Falls back to free dictionary API for English if DreamDict is unavailable. |
|
||||
| `!urban <term>` | Urban Dictionary |
|
||||
| `!translate <word> [lang]` | Cross-language word lookup (en/fr/pt-PT, auto-detects source) |
|
||||
| `!antonym <word> [lang]` | Antonyms for a word (en/fr/pt-PT/zh) |
|
||||
|
||||
@@ -74,6 +74,11 @@ func runMigrations(d *sql.DB) error {
|
||||
`ALTER TABLE adventure_inventory ADD COLUMN slot TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE adventure_inventory ADD COLUMN skill_source TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE user_stats ADD COLUMN fancy_words INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN rival_pool INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN rival_unlocked_notified INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN babysit_active INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN babysit_expires_at DATETIME`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN babysit_skill_focus TEXT NOT NULL DEFAULT ''`,
|
||||
}
|
||||
for _, stmt := range columnMigrations {
|
||||
if _, err := d.Exec(stmt); err != nil {
|
||||
@@ -1040,6 +1045,50 @@ CREATE TABLE IF NOT EXISTS arena_stats (
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Rival System
|
||||
CREATE TABLE IF NOT EXISTS adventure_rival_records (
|
||||
user_id TEXT NOT NULL,
|
||||
rival_id TEXT NOT NULL,
|
||||
wins INTEGER NOT NULL DEFAULT 0,
|
||||
losses INTEGER NOT NULL DEFAULT 0,
|
||||
last_duel_at DATETIME,
|
||||
PRIMARY KEY (user_id, rival_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS adventure_rival_challenges (
|
||||
challenge_id TEXT PRIMARY KEY,
|
||||
challenger_id TEXT NOT NULL,
|
||||
challenged_id TEXT NOT NULL,
|
||||
stake INTEGER NOT NULL,
|
||||
round INTEGER NOT NULL DEFAULT 1,
|
||||
player_score INTEGER NOT NULL DEFAULT 0,
|
||||
rival_score INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rival_challenges_user ON adventure_rival_challenges(challenged_id, expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS community_pot (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
balance INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Babysitting Service
|
||||
CREATE TABLE IF NOT EXISTS adventure_babysit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
log_date DATE NOT NULL,
|
||||
activity TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
gold_earned INTEGER NOT NULL DEFAULT 0,
|
||||
xp_gained INTEGER NOT NULL DEFAULT 0,
|
||||
items_dropped TEXT DEFAULT NULL,
|
||||
rival_refused TEXT DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_babysit_log_user ON adventure_babysit_log(user_id);
|
||||
|
||||
-- Forex
|
||||
CREATE TABLE IF NOT EXISTS forex_rates (
|
||||
currency TEXT NOT NULL,
|
||||
|
||||
@@ -117,6 +117,7 @@ func (p *AdventurePlugin) Init() error {
|
||||
go p.midnightTicker()
|
||||
go p.eventTicker()
|
||||
go p.arenaAutoCashoutTicker()
|
||||
go p.rivalChallengeTicker()
|
||||
|
||||
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
||||
p.arenaCleanupStaleRuns()
|
||||
@@ -199,6 +200,10 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
|
||||
return p.handleEventRespond(ctx)
|
||||
case lower == "help":
|
||||
return p.SendDM(ctx.Sender, advHelpText)
|
||||
case lower == "rivals":
|
||||
return p.handleRivalsCmd(ctx)
|
||||
case lower == "babysit" || strings.HasPrefix(lower, "babysit "):
|
||||
return p.handleBabysitCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "babysit")))
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.")
|
||||
@@ -216,6 +221,8 @@ const advHelpText = `**Adventure Commands**
|
||||
` + "`!adventure inventory`" + ` — View your inventory
|
||||
` + "`!adventure leaderboard`" + ` — View the leaderboard
|
||||
` + "`!adventure respond`" + ` — Respond to a mid-day event
|
||||
` + "`!adventure rivals`" + ` — View rival duel records
|
||||
` + "`!adventure babysit`" + ` — Adventurer Babysitting Service
|
||||
` + "`!adventure help`" + ` — This message
|
||||
|
||||
**Arena:**
|
||||
@@ -238,8 +245,22 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
if !char.Alive {
|
||||
text := renderAdvDeathStatusDM(char)
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
// On-demand revive if death timer has expired
|
||||
if char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) {
|
||||
char.Alive = true
|
||||
char.DeadUntil = nil
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("adventure: on-demand revive failed", "user", char.UserID, "err", err)
|
||||
} else {
|
||||
text := renderAdvRespawnDM(char)
|
||||
p.SendDM(ctx.Sender, text)
|
||||
// Fall through to show menu
|
||||
}
|
||||
}
|
||||
if !char.Alive {
|
||||
text := renderAdvDeathStatusDM(char)
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
}
|
||||
|
||||
if char.ActionTakenToday {
|
||||
@@ -284,9 +305,29 @@ func (p *AdventurePlugin) handleStatus(ctx MessageContext) error {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No adventurer found. Type `!adventure` to create one.")
|
||||
}
|
||||
|
||||
equip, _ := loadAdvEquipment(ctx.Sender)
|
||||
items, _ := loadAdvInventory(ctx.Sender)
|
||||
treasures, _ := loadAdvTreasureBonuses(ctx.Sender)
|
||||
// On-demand revive if death timer has expired
|
||||
if !char.Alive && char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) {
|
||||
char.Alive = true
|
||||
char.DeadUntil = nil
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("adventure: on-demand revive failed", "user", char.UserID, "err", err)
|
||||
} else {
|
||||
p.SendDM(ctx.Sender, renderAdvRespawnDM(char))
|
||||
}
|
||||
}
|
||||
|
||||
equip, err := loadAdvEquipment(ctx.Sender)
|
||||
if err != nil {
|
||||
slog.Error("adventure: failed to load equipment for status", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
items, err := loadAdvInventory(ctx.Sender)
|
||||
if err != nil {
|
||||
slog.Error("adventure: failed to load inventory for status", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
treasures, err := loadAdvTreasureBonuses(ctx.Sender)
|
||||
if err != nil {
|
||||
slog.Error("adventure: failed to load treasures for status", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
|
||||
text := renderAdvCharacterSheet(char, equip, items, treasures, balance)
|
||||
@@ -453,6 +494,8 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
|
||||
return p.handleMasterworkEquipReply(ctx, interaction)
|
||||
case "masterwork_equip_confirm":
|
||||
return p.handleMasterworkEquipConfirm(ctx, interaction)
|
||||
case "rival_rps":
|
||||
return p.resolveRivalRPSRound(ctx, interaction)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -499,7 +542,19 @@ func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string)
|
||||
}
|
||||
|
||||
if !char.Alive {
|
||||
return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char))
|
||||
// On-demand revive if death timer has expired
|
||||
if char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) {
|
||||
char.Alive = true
|
||||
char.DeadUntil = nil
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("adventure: on-demand revive failed", "user", char.UserID, "err", err)
|
||||
} else {
|
||||
p.SendDM(ctx.Sender, renderAdvRespawnDM(char))
|
||||
}
|
||||
}
|
||||
if !char.Alive {
|
||||
return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char))
|
||||
}
|
||||
}
|
||||
|
||||
if char.ActionTakenToday {
|
||||
@@ -665,6 +720,9 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
|
||||
|
||||
// Check level up
|
||||
result.LeveledUp, result.NewLevel = checkAdvLevelUp(char, result.XPSkill)
|
||||
if result.LeveledUp && result.XPSkill == "combat" {
|
||||
p.checkRivalPoolUnlock(char)
|
||||
}
|
||||
|
||||
// Handle death
|
||||
deathReprieved := false
|
||||
|
||||
@@ -369,6 +369,9 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
|
||||
}
|
||||
char.CombatXP += battleXP
|
||||
leveled, newLevel := checkAdvLevelUp(char, "combat")
|
||||
if leveled {
|
||||
p.checkRivalPoolUnlock(char)
|
||||
}
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("arena: failed to save character after survival", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
@@ -538,7 +541,9 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c
|
||||
now := time.Now().UTC()
|
||||
char.ArenaLosses++
|
||||
char.CombatXP += arenaParticipationXP // +60 flat participation XP
|
||||
checkAdvLevelUp(char, "combat")
|
||||
if leveled, _ := checkAdvLevelUp(char, "combat"); leveled {
|
||||
p.checkRivalPoolUnlock(char)
|
||||
}
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("arena: failed to save character after death", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
|
||||
455
internal/plugin/adventure_babysit.go
Normal file
455
internal/plugin/adventure_babysit.go
Normal file
@@ -0,0 +1,455 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// ── Pricing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func babysitDailyCost(combatLevel int) int {
|
||||
return 100 + (combatLevel * 20)
|
||||
}
|
||||
|
||||
// ── Weakest Skill ───────────────────────────────────────────────────────────
|
||||
|
||||
func babysitWeakestSkill(char *AdventureCharacter) string {
|
||||
skills := []struct {
|
||||
name string
|
||||
level int
|
||||
}{
|
||||
{"mining", char.MiningSkill},
|
||||
{"fishing", char.FishingSkill},
|
||||
{"foraging", char.ForagingSkill},
|
||||
}
|
||||
minLevel := skills[0].level
|
||||
for _, s := range skills[1:] {
|
||||
if s.level < minLevel {
|
||||
minLevel = s.level
|
||||
}
|
||||
}
|
||||
// Collect ties
|
||||
var tied []string
|
||||
for _, s := range skills {
|
||||
if s.level == minLevel {
|
||||
tied = append(tied, s.name)
|
||||
}
|
||||
}
|
||||
return tied[rand.IntN(len(tied))]
|
||||
}
|
||||
|
||||
// skillToActivity maps a skill name to its activity type.
|
||||
func skillToActivity(skill string) AdvActivityType {
|
||||
switch skill {
|
||||
case "mining":
|
||||
return AdvActivityMining
|
||||
case "fishing":
|
||||
return AdvActivityFishing
|
||||
case "foraging":
|
||||
return AdvActivityForaging
|
||||
}
|
||||
return AdvActivityMining
|
||||
}
|
||||
|
||||
// ── Command Handlers ────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleBabysitCmd(ctx MessageContext, args string) error {
|
||||
lower := strings.ToLower(strings.TrimSpace(args))
|
||||
|
||||
switch {
|
||||
case lower == "status":
|
||||
return p.handleBabysitStatus(ctx)
|
||||
case lower == "cancel":
|
||||
return p.handleBabysitCancel(ctx)
|
||||
case lower == "week":
|
||||
return p.handleBabysitPurchase(ctx, 7)
|
||||
case lower == "month":
|
||||
return p.handleBabysitPurchase(ctx, 30)
|
||||
default:
|
||||
return p.SendDM(ctx.Sender, "🍼 **Adventurer Babysitting Service**\n\n"+
|
||||
"`!adventure babysit week` — 7 days of service\n"+
|
||||
"`!adventure babysit month` — 30 days of service\n"+
|
||||
"`!adventure babysit status` — check service status\n"+
|
||||
"`!adventure babysit cancel` — cancel early (no refund)")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
char, err := loadAdvCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "No adventurer found. Type `!adventure` to create one.")
|
||||
}
|
||||
|
||||
if char.BabysitActive {
|
||||
return p.SendDM(ctx.Sender, "🍼 The babysitter is already here. They're not leaving until the job is done.")
|
||||
}
|
||||
|
||||
if !char.Alive {
|
||||
return p.SendDM(ctx.Sender, "Your adventurer is dead. The babysitter does not work with corpses.")
|
||||
}
|
||||
|
||||
daily := babysitDailyCost(char.CombatLevel)
|
||||
totalCost := daily * days
|
||||
balance := p.euro.GetBalance(char.UserID)
|
||||
if balance < float64(totalCost) {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 The babysitting service costs €%d for %d days. You have €%.0f. The service has standards. Not many, but some.", totalCost, days, balance))
|
||||
}
|
||||
|
||||
// Debit gold
|
||||
if !p.euro.Debit(char.UserID, float64(totalCost), "babysit_purchase") {
|
||||
return p.SendDM(ctx.Sender, "Payment failed. The babysitter looked at your wallet and walked away.")
|
||||
}
|
||||
|
||||
// Set babysit fields
|
||||
skill := babysitWeakestSkill(char)
|
||||
expires := time.Now().UTC().Add(time.Duration(days) * 24 * time.Hour)
|
||||
char.BabysitActive = true
|
||||
char.BabysitExpiresAt = &expires
|
||||
char.BabysitSkillFocus = skill
|
||||
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("babysit: failed to save character", "user", char.UserID, "err", err)
|
||||
// Refund
|
||||
p.euro.Credit(char.UserID, float64(totalCost), "babysit_refund")
|
||||
return p.SendDM(ctx.Sender, "Something went wrong activating the service. Your gold has been refunded.")
|
||||
}
|
||||
|
||||
confirm := pickBabysitFlavor(babysitConfirmLines)
|
||||
durLabel := "1 week"
|
||||
if days == 30 {
|
||||
durLabel = "1 month"
|
||||
}
|
||||
|
||||
text := fmt.Sprintf("🍼 **Adventurer Babysitting Service — Activated**\n\n"+
|
||||
"Duration: %s (%d days)\n"+
|
||||
"Cost: €%d\n"+
|
||||
"Focus: %s (currently level %d)\n"+
|
||||
"Rival duels: declined on your behalf\n\n"+
|
||||
"Daily DMs are suspended until the service ends.\n\n"+
|
||||
"_%s_", durLabel, days, totalCost, titleCase(skill), babysitSkillLevel(char, skill), confirm)
|
||||
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleBabysitStatus(ctx MessageContext) error {
|
||||
char, err := loadAdvCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "No adventurer found.")
|
||||
}
|
||||
|
||||
if !char.BabysitActive {
|
||||
return p.SendDM(ctx.Sender, "🍼 No active babysitting service. Use `!adventure babysit week` or `!adventure babysit month` to start.")
|
||||
}
|
||||
|
||||
remaining := "unknown"
|
||||
if char.BabysitExpiresAt != nil {
|
||||
days := int(time.Until(*char.BabysitExpiresAt).Hours() / 24)
|
||||
if days < 1 {
|
||||
remaining = "less than a day"
|
||||
} else {
|
||||
remaining = fmt.Sprintf("%d days", days)
|
||||
}
|
||||
}
|
||||
|
||||
// Load log stats
|
||||
logs, err := loadBabysitLogs(char.UserID)
|
||||
if err != nil {
|
||||
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
|
||||
}
|
||||
totalGold, totalXP, itemsClaimed, rivalsRefused := babysitLogStats(logs)
|
||||
|
||||
text := fmt.Sprintf("🍼 **Babysitting Service — Status**\n\n"+
|
||||
"Time remaining: %s\n"+
|
||||
"Skill focus: %s\n"+
|
||||
"Days completed: %d\n"+
|
||||
"Gold earned: €%d\n"+
|
||||
"XP gained: %d\n"+
|
||||
"Items claimed by babysitter: %d\n"+
|
||||
"Rivals declined: %d",
|
||||
remaining, titleCase(char.BabysitSkillFocus), len(logs), totalGold, totalXP, itemsClaimed, rivalsRefused)
|
||||
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
char, err := loadAdvCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "No adventurer found.")
|
||||
}
|
||||
|
||||
if !char.BabysitActive {
|
||||
return p.SendDM(ctx.Sender, "🍼 There's nothing to cancel. The babysitter isn't here.")
|
||||
}
|
||||
|
||||
// Compile partial summary
|
||||
logs, err := loadBabysitLogs(char.UserID)
|
||||
if err != nil {
|
||||
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
|
||||
}
|
||||
summary := renderBabysitSummary(char, logs)
|
||||
|
||||
// Clear babysit state
|
||||
char.BabysitActive = false
|
||||
char.BabysitExpiresAt = nil
|
||||
char.BabysitSkillFocus = ""
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("babysit: failed to save character on cancel", "user", char.UserID, "err", err)
|
||||
}
|
||||
|
||||
// Clear logs
|
||||
clearBabysitLogs(char.UserID)
|
||||
|
||||
return p.SendDM(ctx.Sender, "🍼 Service cancelled. No refund. The babysitter was already there.\n\n"+summary)
|
||||
}
|
||||
|
||||
// ── Daily Auto-Resolution ───────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) runBabysitDaily(char *AdventureCharacter) {
|
||||
activity := skillToActivity(char.BabysitSkillFocus)
|
||||
|
||||
equip, err := loadAdvEquipment(char.UserID)
|
||||
if err != nil {
|
||||
slog.Error("babysit: failed to load equipment", "user", char.UserID, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Pick highest-tier eligible location for the focus skill
|
||||
bonuses := &AdvBonusSummary{}
|
||||
eligible := advEligibleLocations(char, equip, activity, bonuses)
|
||||
if len(eligible) == 0 {
|
||||
slog.Warn("babysit: no eligible locations", "user", char.UserID, "skill", char.BabysitSkillFocus)
|
||||
return
|
||||
}
|
||||
// Pick the last one (highest tier since they're returned in order)
|
||||
loc := eligible[len(eligible)-1].Location
|
||||
inPenalty := eligible[len(eligible)-1].InPenaltyZone
|
||||
|
||||
// Resolve action
|
||||
result := resolveAdvAction(char, equip, loc, bonuses, inPenalty)
|
||||
|
||||
// Babysitter never lets the adventurer die — reroll death to empty
|
||||
if result.Outcome == AdvOutcomeDeath {
|
||||
result.Outcome = AdvOutcomeEmpty
|
||||
result.LootItems = nil
|
||||
result.TotalLootValue = 0
|
||||
result.EquipDamage = nil
|
||||
result.EquipBroken = nil
|
||||
}
|
||||
|
||||
// Apply XP
|
||||
switch result.XPSkill {
|
||||
case "mining":
|
||||
char.MiningXP += result.XPGained
|
||||
case "foraging":
|
||||
char.ForagingXP += result.XPGained
|
||||
case "fishing":
|
||||
char.FishingXP += result.XPGained
|
||||
}
|
||||
checkAdvLevelUp(char, result.XPSkill)
|
||||
|
||||
// Credit gold to player
|
||||
if result.TotalLootValue > 0 {
|
||||
p.euro.Credit(char.UserID, float64(result.TotalLootValue), "babysit_haul")
|
||||
}
|
||||
|
||||
// Items are claimed by the babysitter (not added to player inventory)
|
||||
var itemNames []string
|
||||
for _, item := range result.LootItems {
|
||||
itemNames = append(itemNames, item.Name)
|
||||
}
|
||||
|
||||
// No treasure drops during babysitting
|
||||
result.TreasureFound = nil
|
||||
|
||||
// Mark action taken
|
||||
char.ActionTakenToday = true
|
||||
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("babysit: failed to save character after daily", "user", char.UserID, "err", err)
|
||||
}
|
||||
|
||||
// Log to babysit table
|
||||
itemsJSON := ""
|
||||
if len(itemNames) > 0 {
|
||||
itemsJSON = strings.Join(itemNames, ", ")
|
||||
}
|
||||
logBabysitActivity(char.UserID, string(activity), string(result.Outcome),
|
||||
int(result.TotalLootValue), result.XPGained, itemsJSON)
|
||||
}
|
||||
|
||||
// ── Expiry Check ────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) checkBabysitExpiry(chars []AdventureCharacter) {
|
||||
now := time.Now().UTC()
|
||||
for _, char := range chars {
|
||||
if !char.BabysitActive {
|
||||
continue
|
||||
}
|
||||
if char.BabysitExpiresAt == nil || now.Before(*char.BabysitExpiresAt) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Service expired — compile summary and send DM
|
||||
logs, err := loadBabysitLogs(char.UserID)
|
||||
if err != nil {
|
||||
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
|
||||
}
|
||||
summary := renderBabysitSummary(&char, logs)
|
||||
|
||||
char.BabysitActive = false
|
||||
char.BabysitExpiresAt = nil
|
||||
char.BabysitSkillFocus = ""
|
||||
if err := saveAdvCharacter(&char); err != nil {
|
||||
slog.Error("babysit: failed to save character on expiry", "user", char.UserID, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
clearBabysitLogs(char.UserID)
|
||||
|
||||
if err := p.SendDM(char.UserID, summary); err != nil {
|
||||
slog.Error("babysit: failed to send expiry summary DM", "user", char.UserID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary Rendering ───────────────────────────────────────────────────────
|
||||
|
||||
func renderBabysitSummary(char *AdventureCharacter, logs []babysitLogEntry) string {
|
||||
totalGold, totalXP, itemsClaimed, rivalsRefused := babysitLogStats(logs)
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🍼 **BABYSITTING SERVICE — END OF REPORT**\n\n")
|
||||
sb.WriteString(fmt.Sprintf("Duration: %d days\n", len(logs)))
|
||||
sb.WriteString(fmt.Sprintf("Tasks completed: %d\n", len(logs)))
|
||||
sb.WriteString(fmt.Sprintf("Skill focused: %s\n", titleCase(char.BabysitSkillFocus)))
|
||||
sb.WriteString(fmt.Sprintf("Gold earned from hauls: €%d\n", totalGold))
|
||||
sb.WriteString(fmt.Sprintf("XP gained: %d\n", totalXP))
|
||||
sb.WriteString(fmt.Sprintf("Items dropped: %d items. Claimed by the babysitter as per the terms.\n", itemsClaimed))
|
||||
|
||||
if rivalsRefused > 0 {
|
||||
sb.WriteString(fmt.Sprintf("\nRival challenges: %d declined\n", rivalsRefused))
|
||||
// Pick a rival refusal flavor (generic — no specific rival name available)
|
||||
for _, log := range logs {
|
||||
if log.RivalRefused != "" {
|
||||
line := pickBabysitFlavor(babysitRivalRefusalLines)
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", fmt.Sprintf(line, log.RivalRefused, log.LogDate)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Diaper line
|
||||
sb.WriteString("\n" + pickBabysitFlavor(babysitDiaperLines))
|
||||
|
||||
// Closing
|
||||
sb.WriteString(fmt.Sprintf("\n\nYour adventurer is fed, rested, and slightly better at %s.", char.BabysitSkillFocus))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ── Babysit Log CRUD ────────────────────────────────────────────────────────
|
||||
|
||||
type babysitLogEntry struct {
|
||||
ID int64
|
||||
UserID id.UserID
|
||||
LogDate string
|
||||
Activity string
|
||||
Outcome string
|
||||
GoldEarned int
|
||||
XPGained int
|
||||
ItemsDropped string
|
||||
RivalRefused string
|
||||
}
|
||||
|
||||
func logBabysitActivity(userID id.UserID, activity, outcome string, gold, xp int, items string) {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(`INSERT INTO adventure_babysit_log (user_id, log_date, activity, outcome, gold_earned, xp_gained, items_dropped)
|
||||
VALUES (?, DATE('now'), ?, ?, ?, ?, ?)`,
|
||||
string(userID), activity, outcome, gold, xp, items)
|
||||
if err != nil {
|
||||
slog.Error("babysit: failed to log activity", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func logBabysitRivalRefusal(userID id.UserID, rivalName string) {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(`INSERT INTO adventure_babysit_log (user_id, log_date, activity, outcome, rival_refused)
|
||||
VALUES (?, DATE('now'), 'rival_refused', 'declined', ?)`,
|
||||
string(userID), rivalName)
|
||||
if err != nil {
|
||||
slog.Error("babysit: failed to log rival refusal", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func loadBabysitLogs(userID id.UserID) ([]babysitLogEntry, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT id, user_id, log_date, activity, outcome, gold_earned, xp_gained,
|
||||
COALESCE(items_dropped,''), COALESCE(rival_refused,'')
|
||||
FROM adventure_babysit_log WHERE user_id = ? ORDER BY log_date`, string(userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []babysitLogEntry
|
||||
for rows.Next() {
|
||||
var l babysitLogEntry
|
||||
if err := rows.Scan(&l.ID, &l.UserID, &l.LogDate, &l.Activity, &l.Outcome,
|
||||
&l.GoldEarned, &l.XPGained, &l.ItemsDropped, &l.RivalRefused); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logs = append(logs, l)
|
||||
}
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
func clearBabysitLogs(userID id.UserID) {
|
||||
d := db.Get()
|
||||
if _, err := d.Exec(`DELETE FROM adventure_babysit_log WHERE user_id = ?`, string(userID)); err != nil {
|
||||
slog.Error("babysit: failed to clear logs", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func babysitSkillLevel(char *AdventureCharacter, skill string) int {
|
||||
switch skill {
|
||||
case "mining":
|
||||
return char.MiningSkill
|
||||
case "fishing":
|
||||
return char.FishingSkill
|
||||
case "foraging":
|
||||
return char.ForagingSkill
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func babysitLogStats(logs []babysitLogEntry) (totalGold, totalXP, itemsClaimed, rivalsRefused int) {
|
||||
for _, l := range logs {
|
||||
totalGold += l.GoldEarned
|
||||
totalXP += l.XPGained
|
||||
if l.ItemsDropped != "" {
|
||||
// Count comma-separated items
|
||||
itemsClaimed += len(strings.Split(l.ItemsDropped, ", "))
|
||||
}
|
||||
if l.RivalRefused != "" {
|
||||
rivalsRefused++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -49,8 +49,13 @@ type AdventureCharacter struct {
|
||||
GrudgeLocation string
|
||||
CreatedAt time.Time
|
||||
LastActiveAt time.Time
|
||||
DeathReprieveLast *time.Time
|
||||
DeathReprieveLast *time.Time
|
||||
MasterworkDropsReceived int
|
||||
RivalPool int
|
||||
RivalUnlockedNotified bool
|
||||
BabysitActive bool
|
||||
BabysitExpiresAt *time.Time
|
||||
BabysitSkillFocus string
|
||||
}
|
||||
|
||||
type AdvEquipment struct {
|
||||
@@ -289,8 +294,8 @@ func checkAdvLevelUp(char *AdventureCharacter, skill string) (bool, int) {
|
||||
func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
|
||||
d := db.Get()
|
||||
c := &AdventureCharacter{}
|
||||
var alive, actionTaken, holidayTaken int
|
||||
var deadUntil, reprieveLast sql.NullTime
|
||||
var alive, actionTaken, holidayTaken, rivalUnlocked, babysitAct int
|
||||
var deadUntil, reprieveLast, babysitExp sql.NullTime
|
||||
|
||||
err := d.QueryRow(`
|
||||
SELECT user_id, display_name,
|
||||
@@ -300,7 +305,9 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
|
||||
arena_wins, arena_losses, invasion_score, title,
|
||||
current_streak, best_streak, last_action_date, grudge_location,
|
||||
created_at, last_active_at, death_reprieve_last,
|
||||
masterwork_drops_received
|
||||
masterwork_drops_received,
|
||||
rival_pool, rival_unlocked_notified,
|
||||
babysit_active, babysit_expires_at, babysit_skill_focus
|
||||
FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan(
|
||||
&c.UserID, &c.DisplayName,
|
||||
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
|
||||
@@ -310,6 +317,8 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
|
||||
&c.CurrentStreak, &c.BestStreak, &c.LastActionDate, &c.GrudgeLocation,
|
||||
&c.CreatedAt, &c.LastActiveAt, &reprieveLast,
|
||||
&c.MasterworkDropsReceived,
|
||||
&c.RivalPool, &rivalUnlocked,
|
||||
&babysitAct, &babysitExp, &c.BabysitSkillFocus,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -317,12 +326,17 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
|
||||
c.Alive = alive == 1
|
||||
c.ActionTakenToday = actionTaken == 1
|
||||
c.HolidayActionTaken = holidayTaken == 1
|
||||
c.RivalUnlockedNotified = rivalUnlocked == 1
|
||||
c.BabysitActive = babysitAct == 1
|
||||
if deadUntil.Valid {
|
||||
c.DeadUntil = &deadUntil.Time
|
||||
}
|
||||
if reprieveLast.Valid {
|
||||
c.DeathReprieveLast = &reprieveLast.Time
|
||||
}
|
||||
if babysitExp.Valid {
|
||||
c.BabysitExpiresAt = &babysitExp.Time
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@@ -369,6 +383,14 @@ func saveAdvCharacter(char *AdventureCharacter) error {
|
||||
if char.HolidayActionTaken {
|
||||
holidayTaken = 1
|
||||
}
|
||||
rivalUnlocked := 0
|
||||
if char.RivalUnlockedNotified {
|
||||
rivalUnlocked = 1
|
||||
}
|
||||
babysitAct := 0
|
||||
if char.BabysitActive {
|
||||
babysitAct = 1
|
||||
}
|
||||
|
||||
_, err := d.Exec(`
|
||||
UPDATE adventure_characters SET
|
||||
@@ -378,14 +400,19 @@ func saveAdvCharacter(char *AdventureCharacter) error {
|
||||
arena_wins = ?, arena_losses = ?, invasion_score = ?, title = ?,
|
||||
current_streak = ?, best_streak = ?, last_action_date = ?, grudge_location = ?,
|
||||
last_active_at = CURRENT_TIMESTAMP, death_reprieve_last = ?,
|
||||
masterwork_drops_received = ?
|
||||
masterwork_drops_received = ?,
|
||||
rival_pool = ?, rival_unlocked_notified = ?,
|
||||
babysit_active = ?, babysit_expires_at = ?, babysit_skill_focus = ?
|
||||
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,
|
||||
char.DeathReprieveLast, char.MasterworkDropsReceived, string(char.UserID),
|
||||
char.DeathReprieveLast, char.MasterworkDropsReceived,
|
||||
char.RivalPool, rivalUnlocked,
|
||||
babysitAct, char.BabysitExpiresAt, char.BabysitSkillFocus,
|
||||
string(char.UserID),
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -499,7 +526,9 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
|
||||
arena_wins, arena_losses, invasion_score, title,
|
||||
current_streak, best_streak, last_action_date, grudge_location,
|
||||
created_at, last_active_at, death_reprieve_last,
|
||||
masterwork_drops_received
|
||||
masterwork_drops_received,
|
||||
rival_pool, rival_unlocked_notified,
|
||||
babysit_active, babysit_expires_at, babysit_skill_focus
|
||||
FROM adventure_characters`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -509,8 +538,8 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
|
||||
var chars []AdventureCharacter
|
||||
for rows.Next() {
|
||||
c := AdventureCharacter{}
|
||||
var alive, actionTaken, holidayTaken int
|
||||
var deadUntil, reprieveLast sql.NullTime
|
||||
var alive, actionTaken, holidayTaken, rivalUnlocked, babysitAct int
|
||||
var deadUntil, reprieveLast, babysitExp sql.NullTime
|
||||
if err := rows.Scan(
|
||||
&c.UserID, &c.DisplayName,
|
||||
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
|
||||
@@ -520,18 +549,25 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
|
||||
&c.CurrentStreak, &c.BestStreak, &c.LastActionDate, &c.GrudgeLocation,
|
||||
&c.CreatedAt, &c.LastActiveAt, &reprieveLast,
|
||||
&c.MasterworkDropsReceived,
|
||||
&c.RivalPool, &rivalUnlocked,
|
||||
&babysitAct, &babysitExp, &c.BabysitSkillFocus,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Alive = alive == 1
|
||||
c.ActionTakenToday = actionTaken == 1
|
||||
c.HolidayActionTaken = holidayTaken == 1
|
||||
c.RivalUnlockedNotified = rivalUnlocked == 1
|
||||
c.BabysitActive = babysitAct == 1
|
||||
if deadUntil.Valid {
|
||||
c.DeadUntil = &deadUntil.Time
|
||||
}
|
||||
if reprieveLast.Valid {
|
||||
c.DeathReprieveLast = &reprieveLast.Time
|
||||
}
|
||||
if babysitExp.Valid {
|
||||
c.BabysitExpiresAt = &babysitExp.Time
|
||||
}
|
||||
chars = append(chars, c)
|
||||
}
|
||||
return chars, rows.Err()
|
||||
|
||||
37
internal/plugin/adventure_flavor_babysit.go
Normal file
37
internal/plugin/adventure_flavor_babysit.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package plugin
|
||||
|
||||
import "math/rand/v2"
|
||||
|
||||
// ── Babysitting Service Flavor Pools ────────────────────────────────────────
|
||||
|
||||
// babysitConfirmLines are shown on purchase confirmation.
|
||||
var babysitConfirmLines = []string{
|
||||
"Your adventurer is now in capable hands. Relatively speaking. We've done this before.",
|
||||
"Service begins immediately. Go do whatever it is you do when you're not doing this.",
|
||||
"Payment received. Your adventurer will be fine. Probably.",
|
||||
"We've looked at your skill levels. We know what needs work. We'll handle it. You handle whatever you're handling.",
|
||||
}
|
||||
|
||||
// babysitRivalRefusalLines are used when a rival showed up during babysitting.
|
||||
// All lines use exactly two %s: (rival display name, date string).
|
||||
var babysitRivalRefusalLines = []string{
|
||||
"%s came by on %s with what appeared to be prepared remarks. They were turned away at the door. Their remarks went undelivered. This is probably for the best.",
|
||||
"%s attempted to initiate a duel on %s. The babysitter informed them this was not possible. They stood there for a moment. Then left.",
|
||||
"%s showed up on %s. The babysitter made them regret it in ways you could only dream of doing.",
|
||||
}
|
||||
|
||||
// babysitDiaperLines appear once per summary, rotated.
|
||||
var babysitDiaperLines = []string{
|
||||
"The diapers have been handled. We don't discuss the diapers.",
|
||||
"Diaper situation: resolved. No further details will be provided.",
|
||||
"Standard diaper protocols were followed. The adventurer was cooperative. Mostly.",
|
||||
"The diapers. They happened. They were handled. Moving on.",
|
||||
}
|
||||
|
||||
// pickBabysitFlavor returns a random entry from the given pool.
|
||||
func pickBabysitFlavor(pool []string) string {
|
||||
if len(pool) == 0 {
|
||||
return ""
|
||||
}
|
||||
return pool[rand.IntN(len(pool))]
|
||||
}
|
||||
105
internal/plugin/adventure_flavor_rival.go
Normal file
105
internal/plugin/adventure_flavor_rival.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package plugin
|
||||
|
||||
import "math/rand/v2"
|
||||
|
||||
// ── Rival Trash Talk Pools ──────────────────────────────────────────────────
|
||||
|
||||
// rivalOpeningTaunts are delivered with the challenge DM (Round 1).
|
||||
var rivalOpeningTaunts = []string{
|
||||
"Your strategy is filled with more holes than your boots. This will be like taking candy from a baby. Actually I shouldn't joke about that -- it looks like that's how you sustain yourself.",
|
||||
"I've seen better odds on a three-legged horse. The horse at least had a plan.",
|
||||
"I want you to know I considered not crossing the street. I crossed anyway. That tells you everything about how this is going to go.",
|
||||
"You look nervous. You should look nervous. That's the first sensible thing I've seen from you.",
|
||||
"I've done this before. You can tell by how little I'm sweating. Look at you. Look at me. See the difference?",
|
||||
"I'm not saying this will be easy. I'm saying it will be quick. There's a distinction.",
|
||||
"Take your time. Think it through. It won't help but I want you to feel like you had a fair shot.",
|
||||
}
|
||||
|
||||
// rivalRoundWon are said by the rival after they win a round.
|
||||
var rivalRoundWon = []string{
|
||||
"Looks like you won't be buying any tacos today. You don't look like you're used to eating real food anyway.",
|
||||
"One down. The math is unkind to you right now. As is most things, I suspect.",
|
||||
"Did that hurt? The losing, I mean. Or is this familiar enough to be comfortable?",
|
||||
"I knew what you were going to throw before you did. I know what you're going to throw next. I won't tell you.",
|
||||
"You should have thrown something else. You know that now. I knew it before you threw.",
|
||||
"*takes a moment to write something down* Sorry. Just keeping notes. Continue.",
|
||||
"The expression on your face right now is doing a lot of work. Most of it sad.",
|
||||
}
|
||||
|
||||
// rivalRoundLost are said by the rival after they lose a round.
|
||||
var rivalRoundLost = []string{
|
||||
"Even a battered, dirty, *sniffs* smelly, and broken beyond any hope of repair clock is right twice a day.",
|
||||
"Fine. You got one. Don't read into it. Actually -- you won't know what to read into. Never mind.",
|
||||
"I want you to enjoy this moment. Really sit in it. It may be all you have to take home today.",
|
||||
"I respect the throw. I don't respect the thrower. These are two separate things.",
|
||||
"Lucky. I've seen luck before and that's what that was. Make peace with it.",
|
||||
"*pauses* Okay. *pauses again* I wasn't expecting that. That changes nothing. Next round.",
|
||||
"You threw the right thing at the right time. The stopped clock principle applies. Ask someone to explain it to you later.",
|
||||
}
|
||||
|
||||
// rivalTied are said by the rival after a tied round (before re-throw).
|
||||
var rivalTied = []string{
|
||||
"Same throw. Neither of us embarrassed ourselves. One of us is about to rectify that.",
|
||||
"We're going again. That's fine. I have nowhere to be. Do you have somewhere to be? It doesn't matter.",
|
||||
"You matched me. I want to be clear that this is the ceiling of your performance and we both know it.",
|
||||
"*narrows eyes* Interesting. Going again. Don't get comfortable.",
|
||||
}
|
||||
|
||||
// rivalClosingWin are said by the rival when they win the match.
|
||||
var rivalClosingWin = []string{
|
||||
"I told you. I always tell people. People don't listen. Then it's over and they know I was right. Every time.",
|
||||
"I hope the walk home gives you some time to think. Not about what you could have done differently. Just in general. Thinking is good for people.",
|
||||
"Take care of yourself out there. Eat something. You look like you haven't in a while. *folds hands* Actually that's your business. Good day.",
|
||||
"The gold is mine. The record is updated. You may go.",
|
||||
"I've done my good deed. The world is slightly more balanced now. *nods slowly and walks away*",
|
||||
"You gave it everything. *looks you over* Everything wasn't enough. But still. Points for showing up.",
|
||||
}
|
||||
|
||||
// rivalClosingLoss are said by the rival when they lose the match.
|
||||
var rivalClosingLoss = []string{
|
||||
"You look like you've won the damn lottery. The sheer excitement on your face from winning a handful of gold is genuinely depressing. *sighs* I suppose I've done my good deed for today. *checks off an item on a list titled 'Feed a waif today'*",
|
||||
"Fine. Take it. You've clearly never had this much at one time and I'm not going to be the one to take that from you. Today.",
|
||||
"I'll be back. Enjoy the gold. Buy yourself something warm to eat. You look cold.",
|
||||
"*stares at you for a long moment* No. *turns and walks away*",
|
||||
"You won. I lost. The sun is back out. These things are unrelated. *looks up at the sky anyway*",
|
||||
"I'm not angry. I'm not even surprised. I'm something else entirely and I don't have the energy to name it right now. Congratulations.",
|
||||
"Keep it. Consider it a loan. *has not indicated when or if repayment is expected* You chuckle to yourself, convinced this was said in jest. You notice them scribbling a collection date.",
|
||||
}
|
||||
|
||||
// rivalRoundOutcomeWin are the player-wins-round outcome lines.
|
||||
var rivalRoundOutcomeWin = []string{
|
||||
"%s loses to %s. This is known.",
|
||||
"You read them perfectly. Or you guessed. Either way.",
|
||||
"%s takes it. Round to you.",
|
||||
}
|
||||
|
||||
// rivalRoundOutcomeLoss are the player-loses-round outcome lines.
|
||||
var rivalRoundOutcomeLoss = []string{
|
||||
"%s beats %s. The math is what it is.",
|
||||
"Wrong call. It happens.",
|
||||
"%s takes it. Round to them.",
|
||||
}
|
||||
|
||||
// rivalUnlockDM is sent once when a player reaches Combat Level 5.
|
||||
const rivalUnlockDM = `You have reached Combat Level 5.
|
||||
|
||||
This means something now. Not in the way you think. Somewhere out there, someone else hit level 5 too. They're going about their day. So are you. At some point, one of you will receive a DM.
|
||||
|
||||
That DM will not be friendly.
|
||||
|
||||
Good luck.`
|
||||
|
||||
// rivalForfeitLines are used when a challenge expires without response.
|
||||
var rivalForfeitLines = []string{
|
||||
"You didn't respond. The gold has been collected. The rival walked away satisfied. You weren't even there to see it.",
|
||||
"Time's up. The rival waited. You didn't show. The gold is gone.",
|
||||
"The 24 hours have passed. The rival has claimed their winnings by default. They seemed disappointed. Not about the gold.",
|
||||
}
|
||||
|
||||
// pickRivalFlavor returns a random entry from the given pool.
|
||||
func pickRivalFlavor(pool []string) string {
|
||||
if len(pool) == 0 {
|
||||
return ""
|
||||
}
|
||||
return pool[rand.IntN(len(pool))]
|
||||
}
|
||||
@@ -172,6 +172,35 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
|
||||
sb.WriteString(fmt.Sprintf("\n🎒 Inventory: %d items (total value ~€%d)\n", len(items), invValue))
|
||||
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n", balance))
|
||||
|
||||
// Babysit status
|
||||
if char.BabysitActive {
|
||||
remaining := "active"
|
||||
if char.BabysitExpiresAt != nil {
|
||||
days := int(time.Until(*char.BabysitExpiresAt).Hours() / 24)
|
||||
if days < 1 {
|
||||
remaining = "less than a day left"
|
||||
} else {
|
||||
remaining = fmt.Sprintf("%d days left", days)
|
||||
}
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\n🍼 Babysitting: %s (focus: %s)\n", remaining, char.BabysitSkillFocus))
|
||||
}
|
||||
|
||||
// Rival status
|
||||
if char.RivalPool == 1 {
|
||||
records, _ := loadAllRivalRecords(char.UserID)
|
||||
sb.WriteString("\n⚔️ Rivals: Unlocked")
|
||||
if len(records) > 0 {
|
||||
totalW, totalL := 0, 0
|
||||
for _, r := range records {
|
||||
totalW += r.Wins
|
||||
totalL += r.Losses
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" (%dW / %dL)", totalW, totalL))
|
||||
}
|
||||
sb.WriteString(" — `!adventure rivals` for details\n")
|
||||
}
|
||||
|
||||
// Today's action
|
||||
if char.ActionTakenToday {
|
||||
sb.WriteString("\n📅 Today: Action taken")
|
||||
@@ -547,12 +576,17 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
|
||||
|
||||
sb.WriteString("\n")
|
||||
if tbRewards.Eligible > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Rewards distributed to %d participating adventurers (scaled by level):\n", tbRewards.Eligible))
|
||||
if tbRewards.GoldShare > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" 💰 ~€%d avg\n", tbRewards.GoldShare))
|
||||
}
|
||||
if tbRewards.GiftCount > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" ⭐ %d players received a gift item\n", tbRewards.GiftCount))
|
||||
hasRewards := tbRewards.GoldShare > 0 || tbRewards.GiftCount > 0
|
||||
if hasRewards {
|
||||
sb.WriteString(fmt.Sprintf("Rewards distributed to %d participating adventurers (scaled by level):\n", tbRewards.Eligible))
|
||||
if tbRewards.GoldShare > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" 💰 ~€%d avg\n", tbRewards.GoldShare))
|
||||
}
|
||||
if tbRewards.GiftCount > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" ⭐ %d players received a gift item\n", tbRewards.GiftCount))
|
||||
}
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("Rewards distributed to %d participating adventurers: jackshit.\n", tbRewards.Eligible))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n(Players who rested today received nothing. Fallen adventurers still earn their share. TwinBee noticed.)\n\n")
|
||||
|
||||
789
internal/plugin/adventure_rival.go
Normal file
789
internal/plugin/adventure_rival.go
Normal file
@@ -0,0 +1,789 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
rivalMinCombatLevel = 5
|
||||
rivalChallengeWindow = 24 * time.Hour
|
||||
rivalSamePairCooldown = 7 * 24 * time.Hour
|
||||
rivalMinIntervalHours = 3 * 24 // 3 days in hours
|
||||
rivalMaxIntervalHours = 4 * 24 // 4 days in hours
|
||||
)
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type advRivalChallenge struct {
|
||||
ChallengeID string
|
||||
ChallengerID id.UserID
|
||||
ChallengedID id.UserID
|
||||
Stake int
|
||||
Round int
|
||||
PlayerScore int
|
||||
RivalScore int
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type advRivalRecord struct {
|
||||
RivalID id.UserID
|
||||
Wins int
|
||||
Losses int
|
||||
LastDuelAt *time.Time
|
||||
}
|
||||
|
||||
type advPendingRivalRPS struct {
|
||||
ChallengeID string
|
||||
}
|
||||
|
||||
// ── Stake Calculation ────────────────────────────────────────────────────────
|
||||
|
||||
func rivalStake(combatLevel int) int {
|
||||
return (combatLevel / 5) * 1000
|
||||
}
|
||||
|
||||
// ── Rival Pool Unlock ────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) checkRivalPoolUnlock(char *AdventureCharacter) {
|
||||
if char.CombatLevel >= rivalMinCombatLevel && char.RivalPool == 0 {
|
||||
char.RivalPool = 1
|
||||
if !char.RivalUnlockedNotified {
|
||||
char.RivalUnlockedNotified = true
|
||||
p.SendDM(char.UserID, rivalUnlockDM)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── DB CRUD ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func loadRivalChallengeByID(challengeID string) (*advRivalChallenge, error) {
|
||||
d := db.Get()
|
||||
c := &advRivalChallenge{}
|
||||
err := d.QueryRow(`
|
||||
SELECT challenge_id, challenger_id, challenged_id, stake,
|
||||
round, player_score, rival_score, expires_at, created_at
|
||||
FROM adventure_rival_challenges WHERE challenge_id = ?`, challengeID).Scan(
|
||||
&c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake,
|
||||
&c.Round, &c.PlayerScore, &c.RivalScore, &c.ExpiresAt, &c.CreatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func insertRivalChallenge(c *advRivalChallenge) error {
|
||||
db.Exec("rival: insert challenge",
|
||||
`INSERT INTO adventure_rival_challenges
|
||||
(challenge_id, challenger_id, challenged_id, stake, round, player_score, rival_score, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.ChallengeID, string(c.ChallengerID), string(c.ChallengedID),
|
||||
c.Stake, c.Round, c.PlayerScore, c.RivalScore, c.ExpiresAt,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveRivalChallengeRound(c *advRivalChallenge) {
|
||||
db.Exec("rival: update round",
|
||||
`UPDATE adventure_rival_challenges
|
||||
SET round = ?, player_score = ?, rival_score = ?
|
||||
WHERE challenge_id = ?`,
|
||||
c.Round, c.PlayerScore, c.RivalScore, c.ChallengeID,
|
||||
)
|
||||
}
|
||||
|
||||
func deleteRivalChallenge(challengeID string) {
|
||||
db.Exec("rival: delete challenge",
|
||||
`DELETE FROM adventure_rival_challenges WHERE challenge_id = ?`,
|
||||
challengeID,
|
||||
)
|
||||
}
|
||||
|
||||
func upsertRivalRecord(userID, rivalID id.UserID, won bool) {
|
||||
if won {
|
||||
db.Exec("rival: upsert record win",
|
||||
`INSERT INTO adventure_rival_records (user_id, rival_id, wins, losses, last_duel_at)
|
||||
VALUES (?, ?, 1, 0, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id, rival_id) DO UPDATE SET wins = wins + 1, last_duel_at = CURRENT_TIMESTAMP`,
|
||||
string(userID), string(rivalID),
|
||||
)
|
||||
} else {
|
||||
db.Exec("rival: upsert record loss",
|
||||
`INSERT INTO adventure_rival_records (user_id, rival_id, wins, losses, last_duel_at)
|
||||
VALUES (?, ?, 0, 1, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id, rival_id) DO UPDATE SET losses = losses + 1, last_duel_at = CURRENT_TIMESTAMP`,
|
||||
string(userID), string(rivalID),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func loadAllRivalRecords(userID id.UserID) ([]advRivalRecord, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`
|
||||
SELECT rival_id, wins, losses, last_duel_at
|
||||
FROM adventure_rival_records
|
||||
WHERE user_id = ?
|
||||
ORDER BY last_duel_at DESC`, string(userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []advRivalRecord
|
||||
for rows.Next() {
|
||||
r := advRivalRecord{}
|
||||
var lastDuel sql.NullTime
|
||||
if err := rows.Scan(&r.RivalID, &r.Wins, &r.Losses, &lastDuel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lastDuel.Valid {
|
||||
r.LastDuelAt = &lastDuel.Time
|
||||
}
|
||||
records = append(records, r)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func communityPotAdd(amount int) {
|
||||
db.Exec("rival: community pot add",
|
||||
`INSERT INTO community_pot (id, balance, updated_at)
|
||||
VALUES (1, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(id) DO UPDATE SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP`,
|
||||
amount, amount,
|
||||
)
|
||||
}
|
||||
|
||||
func loadExpiredRivalChallenges() ([]advRivalChallenge, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`
|
||||
SELECT challenge_id, challenger_id, challenged_id, stake,
|
||||
round, player_score, rival_score, expires_at, created_at
|
||||
FROM adventure_rival_challenges
|
||||
WHERE expires_at <= CURRENT_TIMESTAMP`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var challenges []advRivalChallenge
|
||||
for rows.Next() {
|
||||
c := advRivalChallenge{}
|
||||
if err := rows.Scan(
|
||||
&c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake,
|
||||
&c.Round, &c.PlayerScore, &c.RivalScore, &c.ExpiresAt, &c.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
challenges = append(challenges, c)
|
||||
}
|
||||
return challenges, rows.Err()
|
||||
}
|
||||
|
||||
func lastRivalChallengeTime() time.Time {
|
||||
d := db.Get()
|
||||
var t sql.NullTime
|
||||
_ = d.QueryRow(`SELECT MAX(created_at) FROM adventure_rival_challenges`).Scan(&t)
|
||||
if t.Valid {
|
||||
return t.Time
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func hasActiveChallenge(userID id.UserID) bool {
|
||||
d := db.Get()
|
||||
var count int
|
||||
_ = d.QueryRow(`
|
||||
SELECT COUNT(*) FROM adventure_rival_challenges
|
||||
WHERE (challenger_id = ? OR challenged_id = ?) AND expires_at > CURRENT_TIMESTAMP`,
|
||||
string(userID), string(userID)).Scan(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func lastDuelBetween(a, b id.UserID) time.Time {
|
||||
d := db.Get()
|
||||
var t sql.NullTime
|
||||
_ = d.QueryRow(`
|
||||
SELECT last_duel_at FROM adventure_rival_records
|
||||
WHERE user_id = ? AND rival_id = ?`, string(a), string(b)).Scan(&t)
|
||||
if t.Valid {
|
||||
return t.Time
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// ── Pair Selection ───────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) selectRivalPair() (*AdventureCharacter, *AdventureCharacter) {
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
slog.Error("rival: load characters", "err", err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Filter to eligible pool members.
|
||||
var pool []AdventureCharacter
|
||||
for _, c := range chars {
|
||||
if c.RivalPool == 0 || !c.Alive || c.BabysitActive {
|
||||
continue
|
||||
}
|
||||
if hasActiveChallenge(c.UserID) {
|
||||
continue
|
||||
}
|
||||
stake := rivalStake(c.CombatLevel)
|
||||
if stake <= 0 {
|
||||
continue
|
||||
}
|
||||
bal := p.euro.GetBalance(c.UserID)
|
||||
if bal < float64(stake) {
|
||||
continue
|
||||
}
|
||||
pool = append(pool, c)
|
||||
}
|
||||
|
||||
if len(pool) < 2 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Shuffle and find first valid pair.
|
||||
rand.Shuffle(len(pool), func(i, j int) { pool[i], pool[j] = pool[j], pool[i] })
|
||||
|
||||
now := time.Now().UTC()
|
||||
for i := 0; i < len(pool); i++ {
|
||||
for j := i + 1; j < len(pool); j++ {
|
||||
a, b := &pool[i], &pool[j]
|
||||
// Check same-pair cooldown (7 days).
|
||||
last := lastDuelBetween(a.UserID, b.UserID)
|
||||
if !last.IsZero() && now.Sub(last) < rivalSamePairCooldown {
|
||||
continue
|
||||
}
|
||||
// Randomly assign challenger/challenged.
|
||||
if rand.IntN(2) == 0 {
|
||||
return a, b
|
||||
}
|
||||
return b, a
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ── Challenge Issuance ───────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) issueRivalChallenge(challenger, challenged *AdventureCharacter) {
|
||||
stake := rivalStake(challenged.CombatLevel)
|
||||
challenge := &advRivalChallenge{
|
||||
ChallengeID: uuid.New().String()[:12],
|
||||
ChallengerID: challenger.UserID,
|
||||
ChallengedID: challenged.UserID,
|
||||
Stake: stake,
|
||||
Round: 1,
|
||||
PlayerScore: 0,
|
||||
RivalScore: 0,
|
||||
ExpiresAt: time.Now().UTC().Add(rivalChallengeWindow),
|
||||
}
|
||||
insertRivalChallenge(challenge)
|
||||
|
||||
// Notify the rival (challenger) that a challenge was issued in their name.
|
||||
p.SendDM(challenger.UserID, fmt.Sprintf(
|
||||
"⚔️ You've been matched as a rival against **%s**. A challenge has been issued in your name. "+
|
||||
"Your throws will be generated automatically. Sit back and wait for the result.",
|
||||
challenged.DisplayName))
|
||||
|
||||
// Send the dramatic challenge DM to the challenged player.
|
||||
openingTaunt := pickRivalFlavor(rivalOpeningTaunts)
|
||||
dm := fmt.Sprintf(`⚔️ RIVAL CHALLENGE
|
||||
|
||||
You're walking along enjoying a nice sunny day when you look across the road and notice someone staring at you intensely -- the look of someone who caught a faint but unmistakable smell at a restaurant and has traced it back to its source.
|
||||
|
||||
The person crosses the street. The sun disappears behind dark, angry clouds as if on cue.
|
||||
|
||||
*"You're looking at me like you got a problem."*
|
||||
|
||||
They look you over slowly. The scowl becomes a smug smile.
|
||||
|
||||
*"Your face looks like someone who's fought countless battles and lost just as many. Ah well. I already crossed the street so I guess I'll make this quick."*
|
||||
|
||||
They reach to their side. You ready yourself.
|
||||
|
||||
The stakes are €%d. Best of 3. Rock Paper Scissors.
|
||||
|
||||
*%s*
|
||||
|
||||
Reply with **rock**, **paper**, or **scissors** for Round 1.
|
||||
You have 24 hours.`, stake, openingTaunt)
|
||||
|
||||
p.SendDM(challenged.UserID, dm)
|
||||
|
||||
// Store pending interaction for the challenged player.
|
||||
p.pending.Store(string(challenged.UserID), &advPendingInteraction{
|
||||
Type: "rival_rps",
|
||||
Data: &advPendingRivalRPS{ChallengeID: challenge.ChallengeID},
|
||||
ExpiresAt: challenge.ExpiresAt,
|
||||
})
|
||||
|
||||
slog.Info("rival: challenge issued",
|
||||
"challenger", challenger.DisplayName,
|
||||
"challenged", challenged.DisplayName,
|
||||
"stake", stake)
|
||||
}
|
||||
|
||||
// ── RPS Resolution ───────────────────────────────────────────────────────────
|
||||
|
||||
type rpsThrow int
|
||||
|
||||
const (
|
||||
rpsRock rpsThrow = iota
|
||||
rpsPaper
|
||||
rpsScissors
|
||||
)
|
||||
|
||||
var rpsNames = map[rpsThrow]string{
|
||||
rpsRock: "rock",
|
||||
rpsPaper: "paper",
|
||||
rpsScissors: "scissors",
|
||||
}
|
||||
|
||||
func parseRPS(s string) (rpsThrow, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "rock", "r":
|
||||
return rpsRock, true
|
||||
case "paper", "p":
|
||||
return rpsPaper, true
|
||||
case "scissors", "s", "scissor":
|
||||
return rpsScissors, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func randomRPS() rpsThrow {
|
||||
return rpsThrow(rand.IntN(3))
|
||||
}
|
||||
|
||||
// rpsResult: 1 = player wins, -1 = rival wins, 0 = tie.
|
||||
func rpsResult(player, rival rpsThrow) int {
|
||||
if player == rival {
|
||||
return 0
|
||||
}
|
||||
if (player == rpsRock && rival == rpsScissors) ||
|
||||
(player == rpsPaper && rival == rpsRock) ||
|
||||
(player == rpsScissors && rival == rpsPaper) {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) resolveRivalRPSRound(ctx MessageContext, interaction *advPendingInteraction) error {
|
||||
// Acquire per-user lock to prevent concurrent resolution from rapid messages.
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
data := interaction.Data.(*advPendingRivalRPS)
|
||||
|
||||
playerThrow, ok := parseRPS(ctx.Body)
|
||||
if !ok {
|
||||
// Re-store the pending interaction so the player can try again.
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, "Rock, paper, or scissors. That's it. Those are the options.")
|
||||
}
|
||||
|
||||
challenge, err := loadRivalChallengeByID(data.ChallengeID)
|
||||
if err != nil || challenge == nil {
|
||||
// Challenge was expired/deleted by the ticker — don't re-store pending.
|
||||
return p.SendDM(ctx.Sender, "That challenge is no longer active.")
|
||||
}
|
||||
|
||||
// Resolve throw.
|
||||
rivalThrow := randomRPS()
|
||||
result := rpsResult(playerThrow, rivalThrow)
|
||||
|
||||
// Tie — both re-throw. Re-prompt the player.
|
||||
if result == 0 {
|
||||
tieLine := pickRivalFlavor(rivalTied)
|
||||
text := fmt.Sprintf("You threw %s. They threw %s. Tie.\n\n*%s*\n\n"+
|
||||
"Reply with **rock**, **paper**, or **scissors** to re-throw.",
|
||||
rpsNames[playerThrow], rpsNames[rivalThrow], tieLine)
|
||||
|
||||
// Re-store the pending interaction for the same round.
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "rival_rps",
|
||||
Data: &advPendingRivalRPS{ChallengeID: challenge.ChallengeID},
|
||||
ExpiresAt: challenge.ExpiresAt,
|
||||
})
|
||||
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
|
||||
// Build the round DM.
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("⚔️ ROUND %d RESULT\n\n", challenge.Round))
|
||||
sb.WriteString(fmt.Sprintf("You threw %s. They threw %s.\n\n", rpsNames[playerThrow], rpsNames[rivalThrow]))
|
||||
|
||||
if result == 1 {
|
||||
// Player won the round.
|
||||
challenge.PlayerScore++
|
||||
// Outcome line.
|
||||
outcomePool := rivalRoundOutcomeWin
|
||||
outcome := pickRivalFlavor(outcomePool)
|
||||
if strings.Contains(outcome, "%s") {
|
||||
outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow], rpsNames[playerThrow])
|
||||
}
|
||||
sb.WriteString(outcome + "\n\n")
|
||||
sb.WriteString(fmt.Sprintf("Score: You %d -- Rival %d\n\n", challenge.PlayerScore, challenge.RivalScore))
|
||||
// Rival reaction (they lost the round).
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n", pickRivalFlavor(rivalRoundLost)))
|
||||
} else {
|
||||
// Rival won the round.
|
||||
challenge.RivalScore++
|
||||
outcomePool := rivalRoundOutcomeLoss
|
||||
outcome := pickRivalFlavor(outcomePool)
|
||||
if strings.Contains(outcome, "%s") {
|
||||
outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow], rpsNames[playerThrow])
|
||||
}
|
||||
sb.WriteString(outcome + "\n\n")
|
||||
sb.WriteString(fmt.Sprintf("Score: You %d -- Rival %d\n\n", challenge.PlayerScore, challenge.RivalScore))
|
||||
// Rival reaction (they won the round).
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n", pickRivalFlavor(rivalRoundWon)))
|
||||
}
|
||||
|
||||
// Check for match end (best of 3 = first to 2).
|
||||
if challenge.PlayerScore >= 2 || challenge.RivalScore >= 2 {
|
||||
playerWon := challenge.PlayerScore >= 2
|
||||
saveRivalChallengeRound(challenge)
|
||||
p.pending.Delete(string(ctx.Sender))
|
||||
p.finalizeRivalMatch(challenge, playerWon, &sb)
|
||||
return p.SendDM(ctx.Sender, sb.String())
|
||||
}
|
||||
|
||||
// Match continues — next round.
|
||||
challenge.Round++
|
||||
saveRivalChallengeRound(challenge)
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\nReply with **rock**, **paper**, or **scissors** for Round %d.", challenge.Round))
|
||||
|
||||
// Update pending interaction for next round.
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "rival_rps",
|
||||
Data: &advPendingRivalRPS{ChallengeID: challenge.ChallengeID},
|
||||
ExpiresAt: challenge.ExpiresAt,
|
||||
})
|
||||
|
||||
return p.SendDM(ctx.Sender, sb.String())
|
||||
}
|
||||
|
||||
// ── Match Finalization ───────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) finalizeRivalMatch(challenge *advRivalChallenge, playerWon bool, sb *strings.Builder) {
|
||||
challengerChar, _ := loadAdvCharacter(challenge.ChallengerID)
|
||||
challengedChar, _ := loadAdvCharacter(challenge.ChallengedID)
|
||||
|
||||
var winnerID, loserID id.UserID
|
||||
var winnerName, loserName string
|
||||
|
||||
if playerWon {
|
||||
winnerID = challenge.ChallengedID
|
||||
loserID = challenge.ChallengerID
|
||||
winnerName = ""
|
||||
loserName = ""
|
||||
if challengedChar != nil {
|
||||
winnerName = challengedChar.DisplayName
|
||||
}
|
||||
if challengerChar != nil {
|
||||
loserName = challengerChar.DisplayName
|
||||
}
|
||||
} else {
|
||||
winnerID = challenge.ChallengerID
|
||||
loserID = challenge.ChallengedID
|
||||
if challengerChar != nil {
|
||||
winnerName = challengerChar.DisplayName
|
||||
}
|
||||
if challengedChar != nil {
|
||||
loserName = challengedChar.DisplayName
|
||||
}
|
||||
}
|
||||
|
||||
// Gold transfer — try full stake, fall back to available balance.
|
||||
stake := challenge.Stake
|
||||
if !p.euro.Debit(loserID, float64(stake), "rival_duel_loss") {
|
||||
// Loser can't cover full stake — debit whatever they have.
|
||||
bal := p.euro.GetBalance(loserID)
|
||||
stake = int(bal)
|
||||
if stake > 0 {
|
||||
// Best-effort debit of remaining balance. If it fails (concurrent drain), stake becomes 0.
|
||||
if !p.euro.Debit(loserID, float64(stake), "rival_duel_loss_partial") {
|
||||
stake = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
winnerShare := stake / 2
|
||||
potShare := stake - winnerShare
|
||||
if winnerShare > 0 {
|
||||
p.euro.Credit(winnerID, float64(winnerShare), "rival_duel_win")
|
||||
}
|
||||
if potShare > 0 {
|
||||
communityPotAdd(potShare)
|
||||
}
|
||||
|
||||
// Update rival records (both directions).
|
||||
upsertRivalRecord(challenge.ChallengedID, challenge.ChallengerID, playerWon)
|
||||
upsertRivalRecord(challenge.ChallengerID, challenge.ChallengedID, !playerWon)
|
||||
|
||||
// Append closing line to the challenged player's DM.
|
||||
sb.WriteString("\n⚔️ DUEL RESOLVED\n\n")
|
||||
sb.WriteString(fmt.Sprintf("Final score: You %d -- Rival %d\n", challenge.PlayerScore, challenge.RivalScore))
|
||||
|
||||
if playerWon {
|
||||
sb.WriteString(fmt.Sprintf("€%d added to your balance. €%d added to the community pot.\n\n", winnerShare, potShare))
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n", pickRivalFlavor(rivalClosingLoss)))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("€%d removed from your balance. €%d added to the community pot.\n\n", stake, potShare))
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n", pickRivalFlavor(rivalClosingWin)))
|
||||
}
|
||||
|
||||
// Load W/L record for display.
|
||||
records, _ := loadAllRivalRecords(challenge.ChallengedID)
|
||||
for _, r := range records {
|
||||
if r.RivalID == challenge.ChallengerID {
|
||||
rivalName := string(challenge.ChallengerID)
|
||||
if challengerChar != nil {
|
||||
rivalName = challengerChar.DisplayName
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Record vs %s: %dW-%dL\n", rivalName, r.Wins, r.Losses))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Send closing DM to the rival (challenger).
|
||||
var rivalDM strings.Builder
|
||||
rivalDM.WriteString("⚔️ DUEL RESOLVED\n\n")
|
||||
rivalDM.WriteString(fmt.Sprintf("Your challenge against **%s** has concluded.\n",
|
||||
func() string {
|
||||
if challengedChar != nil {
|
||||
return challengedChar.DisplayName
|
||||
}
|
||||
return string(challenge.ChallengedID)
|
||||
}()))
|
||||
rivalDM.WriteString(fmt.Sprintf("Final score: %s %d -- %s %d\n",
|
||||
func() string {
|
||||
if challengedChar != nil {
|
||||
return challengedChar.DisplayName
|
||||
}
|
||||
return "Them"
|
||||
}(), challenge.PlayerScore,
|
||||
func() string {
|
||||
if challengerChar != nil {
|
||||
return challengerChar.DisplayName
|
||||
}
|
||||
return "You"
|
||||
}(), challenge.RivalScore))
|
||||
|
||||
if !playerWon {
|
||||
rivalDM.WriteString(fmt.Sprintf("You won! +€%d to your balance. €%d to the community pot.\n", winnerShare, potShare))
|
||||
} else {
|
||||
rivalDM.WriteString(fmt.Sprintf("You lost. €%d removed from your balance.\n", stake))
|
||||
}
|
||||
p.SendDM(challenge.ChallengerID, rivalDM.String())
|
||||
|
||||
// Room announcement.
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
var announce string
|
||||
if challenge.Round >= 3 {
|
||||
announce = fmt.Sprintf("⚔️ DUEL OUTCOME DECIDED! **%s** has snatched €%d from **%s** in a nail-biting best of 3!",
|
||||
winnerName, stake, loserName)
|
||||
} else {
|
||||
announce = fmt.Sprintf("⚔️ DUEL OUTCOME DECIDED! **%s** has snatched €%d from **%s**!",
|
||||
winnerName, stake, loserName)
|
||||
}
|
||||
p.SendMessage(gr, announce)
|
||||
}
|
||||
|
||||
// Delete the challenge row.
|
||||
deleteRivalChallenge(challenge.ChallengeID)
|
||||
|
||||
slog.Info("rival: match resolved",
|
||||
"winner", winnerName, "loser", loserName,
|
||||
"stake", stake, "rounds", challenge.Round)
|
||||
}
|
||||
|
||||
// ── Expiry Handling ──────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) expireRivalChallenges() {
|
||||
expired, err := loadExpiredRivalChallenges()
|
||||
if err != nil {
|
||||
slog.Error("rival: load expired challenges", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, challenge := range expired {
|
||||
// Challenged player forfeits — rival wins.
|
||||
challengerChar, _ := loadAdvCharacter(challenge.ChallengerID)
|
||||
challengedChar, _ := loadAdvCharacter(challenge.ChallengedID)
|
||||
|
||||
stake := challenge.Stake
|
||||
if !p.euro.Debit(challenge.ChallengedID, float64(stake), "rival_forfeit") {
|
||||
bal := p.euro.GetBalance(challenge.ChallengedID)
|
||||
stake = int(bal)
|
||||
if stake > 0 {
|
||||
if !p.euro.Debit(challenge.ChallengedID, float64(stake), "rival_forfeit_partial") {
|
||||
stake = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
winnerShare := stake / 2
|
||||
potShare := stake - winnerShare
|
||||
if winnerShare > 0 {
|
||||
p.euro.Credit(challenge.ChallengerID, float64(winnerShare), "rival_forfeit_win")
|
||||
}
|
||||
if potShare > 0 {
|
||||
communityPotAdd(potShare)
|
||||
}
|
||||
|
||||
upsertRivalRecord(challenge.ChallengedID, challenge.ChallengerID, false)
|
||||
upsertRivalRecord(challenge.ChallengerID, challenge.ChallengedID, true)
|
||||
|
||||
// DM the challenged player (forfeit notice).
|
||||
forfeitLine := pickRivalFlavor(rivalForfeitLines)
|
||||
p.SendDM(challenge.ChallengedID, fmt.Sprintf("⚔️ DUEL FORFEITED\n\n%s\n\n€%d removed from your balance.",
|
||||
forfeitLine, stake))
|
||||
|
||||
// DM the challenger.
|
||||
challengedName := string(challenge.ChallengedID)
|
||||
if challengedChar != nil {
|
||||
challengedName = challengedChar.DisplayName
|
||||
}
|
||||
p.SendDM(challenge.ChallengerID, fmt.Sprintf(
|
||||
"⚔️ Your rival **%s** didn't respond in time. You win by default. +€%d to your balance.",
|
||||
challengedName, winnerShare))
|
||||
|
||||
// Room announcement.
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
winnerName := string(challenge.ChallengerID)
|
||||
if challengerChar != nil {
|
||||
winnerName = challengerChar.DisplayName
|
||||
}
|
||||
loserName := challengedName
|
||||
p.SendMessage(gr, fmt.Sprintf(
|
||||
"⚔️ DUEL OUTCOME DECIDED! **%s** has snatched €%d from **%s**, who couldn't even be bothered to show up!",
|
||||
winnerName, stake, loserName))
|
||||
}
|
||||
|
||||
deleteRivalChallenge(challenge.ChallengeID)
|
||||
p.pending.Delete(string(challenge.ChallengedID))
|
||||
|
||||
slog.Info("rival: challenge expired",
|
||||
"challenger", challenge.ChallengerID,
|
||||
"challenged", challenge.ChallengedID,
|
||||
"stake", stake)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Challenge Scheduler ──────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) rivalChallengeTicker() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Roll the next challenge interval once. Re-roll after each issued challenge.
|
||||
nextIntervalHours := rivalMinIntervalHours + rand.IntN(rivalMaxIntervalHours-rivalMinIntervalHours+1)
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Only fire on the hour.
|
||||
if now.Minute() != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only issue challenges between 08:00 and 22:00 UTC.
|
||||
if now.Hour() < 8 || now.Hour() >= 22 {
|
||||
// Still check for expiry outside challenge hours.
|
||||
p.expireRivalChallenges()
|
||||
continue
|
||||
}
|
||||
|
||||
// Expire old challenges first.
|
||||
p.expireRivalChallenges()
|
||||
|
||||
// Check if enough time has passed since last challenge.
|
||||
last := lastRivalChallengeTime()
|
||||
if !last.IsZero() && now.Sub(last) < time.Duration(nextIntervalHours)*time.Hour {
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to issue a challenge.
|
||||
challenger, challenged := p.selectRivalPair()
|
||||
if challenger == nil || challenged == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
p.issueRivalChallenge(challenger, challenged)
|
||||
|
||||
// Roll a fresh interval for the next challenge.
|
||||
nextIntervalHours = rivalMinIntervalHours + rand.IntN(rivalMaxIntervalHours-rivalMinIntervalHours+1)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rivals Command ───────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleRivalsCmd(ctx MessageContext) error {
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if char.RivalPool == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("You need Combat Level %d to enter the rival pool. Currently: %d.",
|
||||
rivalMinCombatLevel, char.CombatLevel))
|
||||
}
|
||||
|
||||
records, err := loadAllRivalRecords(char.UserID)
|
||||
if err != nil || len(records) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"⚔️ You're in the rival pool but haven't been challenged yet. Your time will come.")
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("⚔️ **Rival Record**\n\n")
|
||||
for _, r := range records {
|
||||
rivalChar, _ := loadAdvCharacter(r.RivalID)
|
||||
name := string(r.RivalID)
|
||||
if rivalChar != nil {
|
||||
name = rivalChar.DisplayName
|
||||
}
|
||||
daysAgo := ""
|
||||
if r.LastDuelAt != nil {
|
||||
days := int(time.Since(*r.LastDuelAt).Hours() / 24)
|
||||
if days == 0 {
|
||||
daysAgo = "today"
|
||||
} else if days == 1 {
|
||||
daysAgo = "1 day ago"
|
||||
} else {
|
||||
daysAgo = fmt.Sprintf("%d days ago", days)
|
||||
}
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %-16s %dW - %dL last duel: %s\n", name, r.Wins, r.Losses, daysAgo))
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
@@ -60,9 +60,9 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
||||
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
|
||||
}
|
||||
|
||||
// Check if dead and ready to respawn
|
||||
// Check if dead and ready to respawn (before babysit check so
|
||||
// dead+babysitting characters don't stay stuck dead forever).
|
||||
if !char.Alive && char.DeadUntil != nil && now.After(*char.DeadUntil) {
|
||||
// Revive
|
||||
char.Alive = true
|
||||
char.DeadUntil = nil
|
||||
if err := saveAdvCharacter(&char); err != nil {
|
||||
@@ -77,6 +77,16 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
||||
}
|
||||
}
|
||||
|
||||
// Babysitting: auto-resolve daily action, skip DM
|
||||
if char.BabysitActive {
|
||||
if !char.Alive {
|
||||
// Dead and not yet ready to respawn — skip babysit action
|
||||
continue
|
||||
}
|
||||
p.runBabysitDaily(&char)
|
||||
continue
|
||||
}
|
||||
|
||||
// If still dead, send death status
|
||||
if !char.Alive {
|
||||
text := renderAdvDeathStatusDM(&char)
|
||||
@@ -365,6 +375,12 @@ func (p *AdventurePlugin) midnightReset() error {
|
||||
return true
|
||||
})
|
||||
|
||||
// Expire any rival challenges that went unanswered
|
||||
p.expireRivalChallenges()
|
||||
|
||||
// Check babysitting service expirations
|
||||
p.checkBabysitExpiry(chars)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -121,15 +121,15 @@ func (p *EuroPlugin) awardPassiveEuros(ctx MessageContext) {
|
||||
var amount float64
|
||||
switch {
|
||||
case words >= 51:
|
||||
amount = 10.00
|
||||
amount = 20.00
|
||||
case words >= 26:
|
||||
amount = 5.00
|
||||
amount = 10.00
|
||||
case words >= 11:
|
||||
amount = 2.50
|
||||
amount = 5.00
|
||||
case words >= 4:
|
||||
amount = 1.25
|
||||
amount = 2.50
|
||||
default:
|
||||
amount = 0.50
|
||||
amount = 1.00
|
||||
}
|
||||
|
||||
p.ensureBalance(ctx.Sender)
|
||||
|
||||
@@ -39,7 +39,7 @@ func (p *LookupPlugin) Name() string { return "lookup" }
|
||||
func (p *LookupPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "wiki", Description: "Look up a Wikipedia summary", Usage: "!wiki <topic>", Category: "Lookup & Reference"},
|
||||
{Name: "define", Description: "Look up a word definition", Usage: "!define <word>", Category: "Lookup & Reference"},
|
||||
{Name: "define", Description: "Look up a word definition", Usage: "!define <word> [lang]", Category: "Lookup & Reference"},
|
||||
{Name: "urban", Description: "Look up Urban Dictionary definition", Usage: "!urban <term>", Category: "Lookup & Reference"},
|
||||
{Name: "translate", Description: "Look up word translations (en/fr/pt-PT)", Usage: "!translate <word> [lang]", Category: "Lookup & Reference"},
|
||||
}
|
||||
@@ -129,99 +129,172 @@ func (p *LookupPlugin) handleWiki(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
func (p *LookupPlugin) handleDefine(ctx MessageContext) error {
|
||||
word := strings.TrimSpace(p.GetArgs(ctx.Body, "define"))
|
||||
if word == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !define <word>")
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "define"))
|
||||
if args == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !define <word> [lang]")
|
||||
}
|
||||
|
||||
// Check 24h cache
|
||||
cacheKey := "define:" + strings.ToLower(word)
|
||||
parts := strings.Fields(args)
|
||||
word := parts[0]
|
||||
var filterLang string
|
||||
if len(parts) >= 2 {
|
||||
filterLang = normaliseLangExt(parts[len(parts)-1])
|
||||
if filterLang != "" && len(parts) > 2 {
|
||||
word = strings.Join(parts[:len(parts)-1], " ")
|
||||
} else if filterLang == "" {
|
||||
// Last token isn't a lang — treat the whole thing as the word.
|
||||
word = args
|
||||
}
|
||||
}
|
||||
|
||||
wordLower := strings.ToLower(word)
|
||||
|
||||
// Check 24h cache.
|
||||
cacheKey := "define:" + wordLower
|
||||
if filterLang != "" {
|
||||
cacheKey += ":" + filterLang
|
||||
}
|
||||
if cached := db.CacheGet(cacheKey, 86400); cached != "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, cached)
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://api.dictionaryapi.dev/api/v2/entries/en/%s", url.PathEscape(word))
|
||||
|
||||
resp, err := httpClient.Get(apiURL)
|
||||
if err != nil {
|
||||
slog.Error("lookup: define request", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to reach dictionary API.")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 404 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No definition found for \"%s\".", word))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to read dictionary response.")
|
||||
}
|
||||
|
||||
var entries []struct {
|
||||
Word string `json:"word"`
|
||||
Meanings []struct {
|
||||
PartOfSpeech string `json:"partOfSpeech"`
|
||||
Definitions []struct {
|
||||
Definition string `json:"definition"`
|
||||
Example string `json:"example"`
|
||||
} `json:"definitions"`
|
||||
} `json:"meanings"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &entries); err != nil || len(entries) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No definition found for \"%s\".", word))
|
||||
}
|
||||
|
||||
entry := entries[0]
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Definition of \"%s\":\n\n", entry.Word))
|
||||
foundDreamDict := false
|
||||
|
||||
for i, meaning := range entry.Meanings {
|
||||
if i >= 3 { // Limit to 3 meanings
|
||||
break
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("(%s)\n", meaning.PartOfSpeech))
|
||||
for j, def := range meaning.Definitions {
|
||||
if j >= 2 { // Limit to 2 definitions per meaning
|
||||
break
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %d. %s\n", j+1, def.Definition))
|
||||
if def.Example != "" {
|
||||
sb.WriteString(fmt.Sprintf(" Example: \"%s\"\n", def.Example))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Concurrently fetch antonyms from DreamDict (500ms timeout).
|
||||
// Try DreamDict across languages.
|
||||
if p.dict != nil {
|
||||
langs := []string{"en", "fr", "pt-PT", "zh"}
|
||||
if filterLang != "" {
|
||||
langs = []string{filterLang}
|
||||
}
|
||||
|
||||
// Fire antonym fetch concurrently (for whichever lang the word is found in).
|
||||
type antResult struct {
|
||||
ants []string
|
||||
}
|
||||
ch := make(chan antResult, 1)
|
||||
go func() {
|
||||
ants, err := p.dict.Antonyms(strings.ToLower(word), "en")
|
||||
if err != nil {
|
||||
ch <- antResult{}
|
||||
return
|
||||
}
|
||||
ch <- antResult{ants: ants}
|
||||
}()
|
||||
antCh := make(chan antResult, 1)
|
||||
antStarted := false
|
||||
|
||||
timer := time.NewTimer(500 * time.Millisecond)
|
||||
select {
|
||||
case r := <-ch:
|
||||
timer.Stop()
|
||||
if len(r.ants) > 0 {
|
||||
display := r.ants
|
||||
if len(display) > 3 {
|
||||
display = display[:3]
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\nAntonyms: %s\n", strings.Join(display, ", ")))
|
||||
for _, lang := range langs {
|
||||
defs, err := p.dict.Define(wordLower, lang)
|
||||
if err != nil {
|
||||
slog.Error("lookup: dreamdict define", "lang", lang, "err", err)
|
||||
continue
|
||||
}
|
||||
if len(defs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if !foundDreamDict {
|
||||
sb.WriteString(fmt.Sprintf("Definition of \"%s\":\n", word))
|
||||
foundDreamDict = true
|
||||
}
|
||||
|
||||
langName := langDisplayName(lang)
|
||||
sb.WriteString(fmt.Sprintf("\n🏷️ %s\n", langName))
|
||||
|
||||
count := 0
|
||||
for _, def := range defs {
|
||||
if count >= 4 {
|
||||
break
|
||||
}
|
||||
if def.POS != "" {
|
||||
sb.WriteString(fmt.Sprintf(" (%s) %s\n", def.POS, def.Gloss))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", def.Gloss))
|
||||
}
|
||||
count++
|
||||
}
|
||||
|
||||
// Start antonym fetch for the first language we find a match in.
|
||||
if !antStarted {
|
||||
antStarted = true
|
||||
antLang := lang
|
||||
go func() {
|
||||
ants, err := p.dict.Antonyms(wordLower, antLang)
|
||||
if err != nil {
|
||||
antCh <- antResult{}
|
||||
return
|
||||
}
|
||||
antCh <- antResult{ants: ants}
|
||||
}()
|
||||
}
|
||||
case <-timer.C:
|
||||
// Timeout — omit antonyms silently.
|
||||
}
|
||||
|
||||
// Collect antonyms (500ms timeout).
|
||||
if antStarted {
|
||||
timer := time.NewTimer(500 * time.Millisecond)
|
||||
select {
|
||||
case r := <-antCh:
|
||||
timer.Stop()
|
||||
if len(r.ants) > 0 {
|
||||
display := r.ants
|
||||
if len(display) > 3 {
|
||||
display = display[:3]
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\nAntonyms: %s\n", strings.Join(display, ", ")))
|
||||
}
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to free dictionary API for English if DreamDict had no results.
|
||||
if !foundDreamDict && (filterLang == "" || filterLang == "en") {
|
||||
apiURL := fmt.Sprintf("https://api.dictionaryapi.dev/api/v2/entries/en/%s", url.PathEscape(word))
|
||||
|
||||
resp, err := httpClient.Get(apiURL)
|
||||
if err != nil {
|
||||
slog.Error("lookup: define request", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to reach dictionary API.")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 404 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No definition found for \"%s\".", word))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to read dictionary response.")
|
||||
}
|
||||
|
||||
var entries []struct {
|
||||
Word string `json:"word"`
|
||||
Meanings []struct {
|
||||
PartOfSpeech string `json:"partOfSpeech"`
|
||||
Definitions []struct {
|
||||
Definition string `json:"definition"`
|
||||
Example string `json:"example"`
|
||||
} `json:"definitions"`
|
||||
} `json:"meanings"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &entries); err != nil || len(entries) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No definition found for \"%s\".", word))
|
||||
}
|
||||
|
||||
entry := entries[0]
|
||||
sb.WriteString(fmt.Sprintf("Definition of \"%s\":\n\n", entry.Word))
|
||||
|
||||
for i, meaning := range entry.Meanings {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("(%s)\n", meaning.PartOfSpeech))
|
||||
for j, def := range meaning.Definitions {
|
||||
if j >= 2 {
|
||||
break
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %d. %s\n", j+1, def.Definition))
|
||||
if def.Example != "" {
|
||||
sb.WriteString(fmt.Sprintf(" Example: \"%s\"\n", def.Example))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if !foundDreamDict {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("No definition found for \"%s\" in %s.", word, langDisplayName(filterLang)))
|
||||
}
|
||||
|
||||
msg := sb.String()
|
||||
|
||||
@@ -637,7 +637,7 @@ type WordlePayout struct {
|
||||
}
|
||||
|
||||
// wordleBasePots maps guess count to euro prize pot. Fewer guesses = bigger reward.
|
||||
var wordleBasePots = [7]int{0, 100, 80, 60, 45, 35, 25}
|
||||
var wordleBasePots = [7]int{0, 500, 350, 250, 150, 100, 75}
|
||||
|
||||
// awardPrize credits euros to all contributors when a puzzle is solved.
|
||||
// The solver who got the final guess gets a 50% bonus on their share.
|
||||
@@ -671,8 +671,8 @@ func (p *WordlePlugin) awardPrize(puzzle *WordlePuzzle) []WordlePayout {
|
||||
|
||||
numPlayers := len(contributors)
|
||||
share := pot / numPlayers
|
||||
if share < 5 {
|
||||
share = 5
|
||||
if share < 10 {
|
||||
share = 10
|
||||
}
|
||||
solverBonus := share / 2
|
||||
|
||||
|
||||
Reference in New Issue
Block a user