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 |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `!wiki <topic>` | Wikipedia summary |
|
| `!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 |
|
| `!urban <term>` | Urban Dictionary |
|
||||||
| `!translate <word> [lang]` | Cross-language word lookup (en/fr/pt-PT, auto-detects source) |
|
| `!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) |
|
| `!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 slot TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE adventure_inventory ADD COLUMN skill_source 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 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 {
|
for _, stmt := range columnMigrations {
|
||||||
if _, err := d.Exec(stmt); err != nil {
|
if _, err := d.Exec(stmt); err != nil {
|
||||||
@@ -1040,6 +1045,50 @@ CREATE TABLE IF NOT EXISTS arena_stats (
|
|||||||
updated_at INTEGER NOT NULL
|
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
|
-- Forex
|
||||||
CREATE TABLE IF NOT EXISTS forex_rates (
|
CREATE TABLE IF NOT EXISTS forex_rates (
|
||||||
currency TEXT NOT NULL,
|
currency TEXT NOT NULL,
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ func (p *AdventurePlugin) Init() error {
|
|||||||
go p.midnightTicker()
|
go p.midnightTicker()
|
||||||
go p.eventTicker()
|
go p.eventTicker()
|
||||||
go p.arenaAutoCashoutTicker()
|
go p.arenaAutoCashoutTicker()
|
||||||
|
go p.rivalChallengeTicker()
|
||||||
|
|
||||||
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
||||||
p.arenaCleanupStaleRuns()
|
p.arenaCleanupStaleRuns()
|
||||||
@@ -199,6 +200,10 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
|
|||||||
return p.handleEventRespond(ctx)
|
return p.handleEventRespond(ctx)
|
||||||
case lower == "help":
|
case lower == "help":
|
||||||
return p.SendDM(ctx.Sender, advHelpText)
|
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.")
|
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 inventory`" + ` — View your inventory
|
||||||
` + "`!adventure leaderboard`" + ` — View the leaderboard
|
` + "`!adventure leaderboard`" + ` — View the leaderboard
|
||||||
` + "`!adventure respond`" + ` — Respond to a mid-day event
|
` + "`!adventure respond`" + ` — Respond to a mid-day event
|
||||||
|
` + "`!adventure rivals`" + ` — View rival duel records
|
||||||
|
` + "`!adventure babysit`" + ` — Adventurer Babysitting Service
|
||||||
` + "`!adventure help`" + ` — This message
|
` + "`!adventure help`" + ` — This message
|
||||||
|
|
||||||
**Arena:**
|
**Arena:**
|
||||||
@@ -237,10 +244,24 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
|
|||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !char.Alive {
|
||||||
|
// 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 {
|
if !char.Alive {
|
||||||
text := renderAdvDeathStatusDM(char)
|
text := renderAdvDeathStatusDM(char)
|
||||||
return p.SendDM(ctx.Sender, text)
|
return p.SendDM(ctx.Sender, text)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if char.ActionTakenToday {
|
if char.ActionTakenToday {
|
||||||
// On holidays, allow second action if not yet taken
|
// On holidays, allow second action if not yet taken
|
||||||
@@ -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.")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No adventurer found. Type `!adventure` to create one.")
|
||||||
}
|
}
|
||||||
|
|
||||||
equip, _ := loadAdvEquipment(ctx.Sender)
|
// On-demand revive if death timer has expired
|
||||||
items, _ := loadAdvInventory(ctx.Sender)
|
if !char.Alive && char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) {
|
||||||
treasures, _ := loadAdvTreasureBonuses(ctx.Sender)
|
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)
|
balance := p.euro.GetBalance(ctx.Sender)
|
||||||
|
|
||||||
text := renderAdvCharacterSheet(char, equip, items, treasures, balance)
|
text := renderAdvCharacterSheet(char, equip, items, treasures, balance)
|
||||||
@@ -453,6 +494,8 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
|
|||||||
return p.handleMasterworkEquipReply(ctx, interaction)
|
return p.handleMasterworkEquipReply(ctx, interaction)
|
||||||
case "masterwork_equip_confirm":
|
case "masterwork_equip_confirm":
|
||||||
return p.handleMasterworkEquipConfirm(ctx, interaction)
|
return p.handleMasterworkEquipConfirm(ctx, interaction)
|
||||||
|
case "rival_rps":
|
||||||
|
return p.resolveRivalRPSRound(ctx, interaction)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -498,9 +541,21 @@ func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string)
|
|||||||
return nil // not a registered player
|
return nil // not a registered player
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !char.Alive {
|
||||||
|
// 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 {
|
if !char.Alive {
|
||||||
return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char))
|
return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if char.ActionTakenToday {
|
if char.ActionTakenToday {
|
||||||
// On holidays, allow second action if not yet taken
|
// On holidays, allow second action if not yet taken
|
||||||
@@ -665,6 +720,9 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
|
|||||||
|
|
||||||
// Check level up
|
// Check level up
|
||||||
result.LeveledUp, result.NewLevel = checkAdvLevelUp(char, result.XPSkill)
|
result.LeveledUp, result.NewLevel = checkAdvLevelUp(char, result.XPSkill)
|
||||||
|
if result.LeveledUp && result.XPSkill == "combat" {
|
||||||
|
p.checkRivalPoolUnlock(char)
|
||||||
|
}
|
||||||
|
|
||||||
// Handle death
|
// Handle death
|
||||||
deathReprieved := false
|
deathReprieved := false
|
||||||
|
|||||||
@@ -369,6 +369,9 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
|
|||||||
}
|
}
|
||||||
char.CombatXP += battleXP
|
char.CombatXP += battleXP
|
||||||
leveled, newLevel := checkAdvLevelUp(char, "combat")
|
leveled, newLevel := checkAdvLevelUp(char, "combat")
|
||||||
|
if leveled {
|
||||||
|
p.checkRivalPoolUnlock(char)
|
||||||
|
}
|
||||||
if err := saveAdvCharacter(char); err != nil {
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
slog.Error("arena: failed to save character after survival", "user", ctx.Sender, "err", err)
|
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()
|
now := time.Now().UTC()
|
||||||
char.ArenaLosses++
|
char.ArenaLosses++
|
||||||
char.CombatXP += arenaParticipationXP // +60 flat participation XP
|
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 {
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
slog.Error("arena: failed to save character after death", "user", ctx.Sender, "err", err)
|
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
|
||||||
|
}
|
||||||
@@ -51,6 +51,11 @@ type AdventureCharacter struct {
|
|||||||
LastActiveAt time.Time
|
LastActiveAt time.Time
|
||||||
DeathReprieveLast *time.Time
|
DeathReprieveLast *time.Time
|
||||||
MasterworkDropsReceived int
|
MasterworkDropsReceived int
|
||||||
|
RivalPool int
|
||||||
|
RivalUnlockedNotified bool
|
||||||
|
BabysitActive bool
|
||||||
|
BabysitExpiresAt *time.Time
|
||||||
|
BabysitSkillFocus string
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdvEquipment struct {
|
type AdvEquipment struct {
|
||||||
@@ -289,8 +294,8 @@ func checkAdvLevelUp(char *AdventureCharacter, skill string) (bool, int) {
|
|||||||
func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
|
func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
|
||||||
d := db.Get()
|
d := db.Get()
|
||||||
c := &AdventureCharacter{}
|
c := &AdventureCharacter{}
|
||||||
var alive, actionTaken, holidayTaken int
|
var alive, actionTaken, holidayTaken, rivalUnlocked, babysitAct int
|
||||||
var deadUntil, reprieveLast sql.NullTime
|
var deadUntil, reprieveLast, babysitExp sql.NullTime
|
||||||
|
|
||||||
err := d.QueryRow(`
|
err := d.QueryRow(`
|
||||||
SELECT user_id, display_name,
|
SELECT user_id, display_name,
|
||||||
@@ -300,7 +305,9 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
|
|||||||
arena_wins, arena_losses, invasion_score, title,
|
arena_wins, arena_losses, invasion_score, title,
|
||||||
current_streak, best_streak, last_action_date, grudge_location,
|
current_streak, best_streak, last_action_date, grudge_location,
|
||||||
created_at, last_active_at, death_reprieve_last,
|
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(
|
FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan(
|
||||||
&c.UserID, &c.DisplayName,
|
&c.UserID, &c.DisplayName,
|
||||||
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
|
&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.CurrentStreak, &c.BestStreak, &c.LastActionDate, &c.GrudgeLocation,
|
||||||
&c.CreatedAt, &c.LastActiveAt, &reprieveLast,
|
&c.CreatedAt, &c.LastActiveAt, &reprieveLast,
|
||||||
&c.MasterworkDropsReceived,
|
&c.MasterworkDropsReceived,
|
||||||
|
&c.RivalPool, &rivalUnlocked,
|
||||||
|
&babysitAct, &babysitExp, &c.BabysitSkillFocus,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -317,12 +326,17 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
|
|||||||
c.Alive = alive == 1
|
c.Alive = alive == 1
|
||||||
c.ActionTakenToday = actionTaken == 1
|
c.ActionTakenToday = actionTaken == 1
|
||||||
c.HolidayActionTaken = holidayTaken == 1
|
c.HolidayActionTaken = holidayTaken == 1
|
||||||
|
c.RivalUnlockedNotified = rivalUnlocked == 1
|
||||||
|
c.BabysitActive = babysitAct == 1
|
||||||
if deadUntil.Valid {
|
if deadUntil.Valid {
|
||||||
c.DeadUntil = &deadUntil.Time
|
c.DeadUntil = &deadUntil.Time
|
||||||
}
|
}
|
||||||
if reprieveLast.Valid {
|
if reprieveLast.Valid {
|
||||||
c.DeathReprieveLast = &reprieveLast.Time
|
c.DeathReprieveLast = &reprieveLast.Time
|
||||||
}
|
}
|
||||||
|
if babysitExp.Valid {
|
||||||
|
c.BabysitExpiresAt = &babysitExp.Time
|
||||||
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,6 +383,14 @@ func saveAdvCharacter(char *AdventureCharacter) error {
|
|||||||
if char.HolidayActionTaken {
|
if char.HolidayActionTaken {
|
||||||
holidayTaken = 1
|
holidayTaken = 1
|
||||||
}
|
}
|
||||||
|
rivalUnlocked := 0
|
||||||
|
if char.RivalUnlockedNotified {
|
||||||
|
rivalUnlocked = 1
|
||||||
|
}
|
||||||
|
babysitAct := 0
|
||||||
|
if char.BabysitActive {
|
||||||
|
babysitAct = 1
|
||||||
|
}
|
||||||
|
|
||||||
_, err := d.Exec(`
|
_, err := d.Exec(`
|
||||||
UPDATE adventure_characters SET
|
UPDATE adventure_characters SET
|
||||||
@@ -378,14 +400,19 @@ func saveAdvCharacter(char *AdventureCharacter) error {
|
|||||||
arena_wins = ?, arena_losses = ?, invasion_score = ?, title = ?,
|
arena_wins = ?, arena_losses = ?, invasion_score = ?, title = ?,
|
||||||
current_streak = ?, best_streak = ?, last_action_date = ?, grudge_location = ?,
|
current_streak = ?, best_streak = ?, last_action_date = ?, grudge_location = ?,
|
||||||
last_active_at = CURRENT_TIMESTAMP, death_reprieve_last = ?,
|
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 = ?`,
|
WHERE user_id = ?`,
|
||||||
char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill,
|
char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill,
|
||||||
char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP,
|
char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP,
|
||||||
alive, char.DeadUntil, actionTaken, holidayTaken,
|
alive, char.DeadUntil, actionTaken, holidayTaken,
|
||||||
char.ArenaWins, char.ArenaLosses, char.InvasionScore, char.Title,
|
char.ArenaWins, char.ArenaLosses, char.InvasionScore, char.Title,
|
||||||
char.CurrentStreak, char.BestStreak, char.LastActionDate, char.GrudgeLocation,
|
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
|
return err
|
||||||
}
|
}
|
||||||
@@ -499,7 +526,9 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
|
|||||||
arena_wins, arena_losses, invasion_score, title,
|
arena_wins, arena_losses, invasion_score, title,
|
||||||
current_streak, best_streak, last_action_date, grudge_location,
|
current_streak, best_streak, last_action_date, grudge_location,
|
||||||
created_at, last_active_at, death_reprieve_last,
|
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`)
|
FROM adventure_characters`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -509,8 +538,8 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
|
|||||||
var chars []AdventureCharacter
|
var chars []AdventureCharacter
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
c := AdventureCharacter{}
|
c := AdventureCharacter{}
|
||||||
var alive, actionTaken, holidayTaken int
|
var alive, actionTaken, holidayTaken, rivalUnlocked, babysitAct int
|
||||||
var deadUntil, reprieveLast sql.NullTime
|
var deadUntil, reprieveLast, babysitExp sql.NullTime
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&c.UserID, &c.DisplayName,
|
&c.UserID, &c.DisplayName,
|
||||||
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
|
&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.CurrentStreak, &c.BestStreak, &c.LastActionDate, &c.GrudgeLocation,
|
||||||
&c.CreatedAt, &c.LastActiveAt, &reprieveLast,
|
&c.CreatedAt, &c.LastActiveAt, &reprieveLast,
|
||||||
&c.MasterworkDropsReceived,
|
&c.MasterworkDropsReceived,
|
||||||
|
&c.RivalPool, &rivalUnlocked,
|
||||||
|
&babysitAct, &babysitExp, &c.BabysitSkillFocus,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c.Alive = alive == 1
|
c.Alive = alive == 1
|
||||||
c.ActionTakenToday = actionTaken == 1
|
c.ActionTakenToday = actionTaken == 1
|
||||||
c.HolidayActionTaken = holidayTaken == 1
|
c.HolidayActionTaken = holidayTaken == 1
|
||||||
|
c.RivalUnlockedNotified = rivalUnlocked == 1
|
||||||
|
c.BabysitActive = babysitAct == 1
|
||||||
if deadUntil.Valid {
|
if deadUntil.Valid {
|
||||||
c.DeadUntil = &deadUntil.Time
|
c.DeadUntil = &deadUntil.Time
|
||||||
}
|
}
|
||||||
if reprieveLast.Valid {
|
if reprieveLast.Valid {
|
||||||
c.DeathReprieveLast = &reprieveLast.Time
|
c.DeathReprieveLast = &reprieveLast.Time
|
||||||
}
|
}
|
||||||
|
if babysitExp.Valid {
|
||||||
|
c.BabysitExpiresAt = &babysitExp.Time
|
||||||
|
}
|
||||||
chars = append(chars, c)
|
chars = append(chars, c)
|
||||||
}
|
}
|
||||||
return chars, rows.Err()
|
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("\n🎒 Inventory: %d items (total value ~€%d)\n", len(items), invValue))
|
||||||
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n", balance))
|
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
|
// Today's action
|
||||||
if char.ActionTakenToday {
|
if char.ActionTakenToday {
|
||||||
sb.WriteString("\n📅 Today: Action taken")
|
sb.WriteString("\n📅 Today: Action taken")
|
||||||
@@ -547,6 +576,8 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
|
|||||||
|
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
if tbRewards.Eligible > 0 {
|
if tbRewards.Eligible > 0 {
|
||||||
|
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))
|
sb.WriteString(fmt.Sprintf("Rewards distributed to %d participating adventurers (scaled by level):\n", tbRewards.Eligible))
|
||||||
if tbRewards.GoldShare > 0 {
|
if tbRewards.GoldShare > 0 {
|
||||||
sb.WriteString(fmt.Sprintf(" 💰 ~€%d avg\n", tbRewards.GoldShare))
|
sb.WriteString(fmt.Sprintf(" 💰 ~€%d avg\n", tbRewards.GoldShare))
|
||||||
@@ -554,6 +585,9 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
|
|||||||
if tbRewards.GiftCount > 0 {
|
if tbRewards.GiftCount > 0 {
|
||||||
sb.WriteString(fmt.Sprintf(" ⭐ %d players received a gift item\n", tbRewards.GiftCount))
|
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")
|
sb.WriteString("\n(Players who rested today received nothing. Fallen adventurers still earn their share. TwinBee noticed.)\n\n")
|
||||||
sb.WriteString("───────────────────\n\n")
|
sb.WriteString("───────────────────\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)
|
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) {
|
if !char.Alive && char.DeadUntil != nil && now.After(*char.DeadUntil) {
|
||||||
// Revive
|
|
||||||
char.Alive = true
|
char.Alive = true
|
||||||
char.DeadUntil = nil
|
char.DeadUntil = nil
|
||||||
if err := saveAdvCharacter(&char); err != 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 still dead, send death status
|
||||||
if !char.Alive {
|
if !char.Alive {
|
||||||
text := renderAdvDeathStatusDM(&char)
|
text := renderAdvDeathStatusDM(&char)
|
||||||
@@ -365,6 +375,12 @@ func (p *AdventurePlugin) midnightReset() error {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Expire any rival challenges that went unanswered
|
||||||
|
p.expireRivalChallenges()
|
||||||
|
|
||||||
|
// Check babysitting service expirations
|
||||||
|
p.checkBabysitExpiry(chars)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,15 +121,15 @@ func (p *EuroPlugin) awardPassiveEuros(ctx MessageContext) {
|
|||||||
var amount float64
|
var amount float64
|
||||||
switch {
|
switch {
|
||||||
case words >= 51:
|
case words >= 51:
|
||||||
amount = 10.00
|
amount = 20.00
|
||||||
case words >= 26:
|
case words >= 26:
|
||||||
amount = 5.00
|
amount = 10.00
|
||||||
case words >= 11:
|
case words >= 11:
|
||||||
amount = 2.50
|
amount = 5.00
|
||||||
case words >= 4:
|
case words >= 4:
|
||||||
amount = 1.25
|
amount = 2.50
|
||||||
default:
|
default:
|
||||||
amount = 0.50
|
amount = 1.00
|
||||||
}
|
}
|
||||||
|
|
||||||
p.ensureBalance(ctx.Sender)
|
p.ensureBalance(ctx.Sender)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func (p *LookupPlugin) Name() string { return "lookup" }
|
|||||||
func (p *LookupPlugin) Commands() []CommandDef {
|
func (p *LookupPlugin) Commands() []CommandDef {
|
||||||
return []CommandDef{
|
return []CommandDef{
|
||||||
{Name: "wiki", Description: "Look up a Wikipedia summary", Usage: "!wiki <topic>", Category: "Lookup & Reference"},
|
{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: "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"},
|
{Name: "translate", Description: "Look up word translations (en/fr/pt-PT)", Usage: "!translate <word> [lang]", Category: "Lookup & Reference"},
|
||||||
}
|
}
|
||||||
@@ -129,17 +129,118 @@ func (p *LookupPlugin) handleWiki(ctx MessageContext) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *LookupPlugin) handleDefine(ctx MessageContext) error {
|
func (p *LookupPlugin) handleDefine(ctx MessageContext) error {
|
||||||
word := strings.TrimSpace(p.GetArgs(ctx.Body, "define"))
|
args := strings.TrimSpace(p.GetArgs(ctx.Body, "define"))
|
||||||
if word == "" {
|
if args == "" {
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !define <word>")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !define <word> [lang]")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check 24h cache
|
parts := strings.Fields(args)
|
||||||
cacheKey := "define:" + strings.ToLower(word)
|
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 != "" {
|
if cached := db.CacheGet(cacheKey, 86400); cached != "" {
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, cached)
|
return p.SendReply(ctx.RoomID, ctx.EventID, cached)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
foundDreamDict := false
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
antCh := make(chan antResult, 1)
|
||||||
|
antStarted := false
|
||||||
|
|
||||||
|
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}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
apiURL := fmt.Sprintf("https://api.dictionaryapi.dev/api/v2/entries/en/%s", url.PathEscape(word))
|
||||||
|
|
||||||
resp, err := httpClient.Get(apiURL)
|
resp, err := httpClient.Get(apiURL)
|
||||||
@@ -174,16 +275,15 @@ func (p *LookupPlugin) handleDefine(ctx MessageContext) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
entry := entries[0]
|
entry := entries[0]
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString(fmt.Sprintf("Definition of \"%s\":\n\n", entry.Word))
|
sb.WriteString(fmt.Sprintf("Definition of \"%s\":\n\n", entry.Word))
|
||||||
|
|
||||||
for i, meaning := range entry.Meanings {
|
for i, meaning := range entry.Meanings {
|
||||||
if i >= 3 { // Limit to 3 meanings
|
if i >= 3 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
sb.WriteString(fmt.Sprintf("(%s)\n", meaning.PartOfSpeech))
|
sb.WriteString(fmt.Sprintf("(%s)\n", meaning.PartOfSpeech))
|
||||||
for j, def := range meaning.Definitions {
|
for j, def := range meaning.Definitions {
|
||||||
if j >= 2 { // Limit to 2 definitions per meaning
|
if j >= 2 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
sb.WriteString(fmt.Sprintf(" %d. %s\n", j+1, def.Definition))
|
sb.WriteString(fmt.Sprintf(" %d. %s\n", j+1, def.Definition))
|
||||||
@@ -192,36 +292,9 @@ func (p *LookupPlugin) handleDefine(ctx MessageContext) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if !foundDreamDict {
|
||||||
// Concurrently fetch antonyms from DreamDict (500ms timeout).
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
if p.dict != nil {
|
fmt.Sprintf("No definition found for \"%s\" in %s.", word, langDisplayName(filterLang)))
|
||||||
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}
|
|
||||||
}()
|
|
||||||
|
|
||||||
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, ", ")))
|
|
||||||
}
|
|
||||||
case <-timer.C:
|
|
||||||
// Timeout — omit antonyms silently.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := sb.String()
|
msg := sb.String()
|
||||||
|
|||||||
@@ -637,7 +637,7 @@ type WordlePayout struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wordleBasePots maps guess count to euro prize pot. Fewer guesses = bigger reward.
|
// 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.
|
// 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.
|
// 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)
|
numPlayers := len(contributors)
|
||||||
share := pot / numPlayers
|
share := pot / numPlayers
|
||||||
if share < 5 {
|
if share < 10 {
|
||||||
share = 5
|
share = 10
|
||||||
}
|
}
|
||||||
solverBonus := share / 2
|
solverBonus := share / 2
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user