mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Add forex plugin, stability fixes, and async HTTP dispatch
- Add forex plugin (Frankfurter v2 API) with rate lookups, analysis, DM-based alerts, and daily cron poll. Backfills 1 year of history on startup for moving averages and buy signal scoring. - Fix bot hang caused by SQLite lock contention in reminder polling: rows cursor was held open while writing to the same DB. Collect results first, close cursor, then process. Same fix in milkcarton. - Add sync retry loop so the bot reconnects after network drops instead of silently exiting. StopSync() for clean Ctrl+C shutdown. - Add panic recovery to all dispatch, syncer, and cron paths. - Make all HTTP-calling plugin commands async (goroutines) so a slow or dead external API cannot block the message dispatch pipeline. Affects: lookup, stocks, forex, anime, movies, concerts, gaming, retro, wotd, urls, howami. - Extract DisplayName to Base, add db.Exec helper, convert silent error discards across the codebase. - Fix UNO mercy-kill bug (eliminated bot continues playing), adventure DM nag spam, stats column mismatch, per-call regex/replacer allocs. - Update README: forex commands, Finance section, 47 plugins. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -453,7 +453,22 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
|
||||
}
|
||||
|
||||
// statGTE checks if a user_stats column is >= threshold.
|
||||
var allowedStatColumns = map[string]bool{
|
||||
"total_messages": true,
|
||||
"night_messages": true,
|
||||
"morning_messages": true,
|
||||
"total_links": true,
|
||||
"total_images": true,
|
||||
"total_questions": true,
|
||||
"total_emojis": true,
|
||||
"total_exclamations": true,
|
||||
}
|
||||
|
||||
func statGTE(d *sql.DB, userID id.UserID, column string, threshold int) bool {
|
||||
if !allowedStatColumns[column] {
|
||||
slog.Error("statGTE: unknown column", "column", column)
|
||||
return false
|
||||
}
|
||||
var val int
|
||||
query := fmt.Sprintf(`SELECT COALESCE(%s, 0) FROM user_stats WHERE user_id = ?`, column)
|
||||
err := d.QueryRow(query, string(userID)).Scan(&val)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
@@ -20,9 +19,10 @@ type AdventurePlugin struct {
|
||||
Base
|
||||
euro *EuroPlugin
|
||||
mu sync.Mutex
|
||||
dmToPlayer map[id.RoomID]id.UserID
|
||||
pending sync.Map // userID string -> *advPendingInteraction
|
||||
userLocks sync.Map // userID string -> *sync.Mutex
|
||||
dmToPlayer map[id.RoomID]id.UserID
|
||||
pending sync.Map // userID string -> *advPendingInteraction
|
||||
userLocks sync.Map // userID string -> *sync.Mutex
|
||||
dmRemindedDate sync.Map // userID string -> "2006-01-02" date string
|
||||
morningHour int
|
||||
summaryHour int
|
||||
}
|
||||
@@ -78,6 +78,7 @@ func (p *AdventurePlugin) Init() error {
|
||||
go p.morningTicker()
|
||||
go p.summaryTicker()
|
||||
go p.midnightTicker()
|
||||
go p.eventTicker()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -101,6 +102,10 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
return p.dispatchCommand(ctx)
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "adventure"))
|
||||
lower := strings.ToLower(args)
|
||||
|
||||
@@ -111,8 +116,8 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
||||
return p.handleStatus(ctx)
|
||||
case strings.HasPrefix(lower, "sell "):
|
||||
return p.handleSellCmd(ctx, strings.TrimSpace(args[5:]))
|
||||
case lower == "shop":
|
||||
return p.handleShopCmd(ctx)
|
||||
case lower == "shop" || strings.HasPrefix(lower, "shop "):
|
||||
return p.handleShopCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "shop")))
|
||||
case strings.HasPrefix(lower, "buy "):
|
||||
return p.handleBuyCmd(ctx, strings.TrimSpace(args[4:]))
|
||||
case lower == "inventory" || lower == "inv":
|
||||
@@ -123,11 +128,30 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
||||
return p.handleAdminRevive(ctx, strings.TrimSpace(args[7:]))
|
||||
case lower == "summary":
|
||||
return p.handleAdminSummary(ctx)
|
||||
case lower == "respond":
|
||||
return p.handleEventRespond(ctx)
|
||||
case lower == "help":
|
||||
return p.SendDM(ctx.Sender, advHelpText)
|
||||
}
|
||||
|
||||
return nil
|
||||
return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.")
|
||||
}
|
||||
|
||||
const advHelpText = `**Adventure Commands**
|
||||
|
||||
` + "`!adventure`" + ` — Show today's activity menu
|
||||
` + "`!adventure status`" + ` — View your character sheet
|
||||
` + "`!adventure shop`" + ` — Browse equipment categories
|
||||
` + "`!adventure shop <category>`" + ` — View a category (weapon, armor, helmet, boots, tool)
|
||||
` + "`!adventure buy <item>`" + ` — Buy equipment (e.g. ` + "`buy Enchanted Blade`" + ` or ` + "`buy 4 sword`" + `)
|
||||
` + "`!adventure sell <item>`" + ` — Sell an inventory item (or ` + "`sell all`" + `)
|
||||
` + "`!adventure inventory`" + ` — View your inventory
|
||||
` + "`!adventure leaderboard`" + ` — View the leaderboard
|
||||
` + "`!adventure respond`" + ` — Respond to a mid-day event
|
||||
` + "`!adventure help`" + ` — This message
|
||||
|
||||
**In DM:** Reply with a number (e.g. ` + "`1`" + `) or location name to take your daily action.`
|
||||
|
||||
// ── Command Handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
|
||||
@@ -142,7 +166,16 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
if char.ActionTakenToday {
|
||||
return p.SendDM(ctx.Sender, "You've already taken your action today. Tomorrow awaits. Try to survive it.")
|
||||
now := time.Now().UTC()
|
||||
midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
|
||||
remaining := midnight.Sub(now)
|
||||
hours := int(remaining.Hours())
|
||||
minutes := int(remaining.Minutes()) % 60
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"You've already taken your action today. Tomorrow awaits. Try to survive it.\n\n"+
|
||||
"Next action: 00:00 UTC (%dh %dm from now)\n"+
|
||||
"Morning DM: %02d:00 UTC",
|
||||
hours, minutes, p.morningHour))
|
||||
}
|
||||
|
||||
treasures, _ := loadAdvTreasureBonuses(char.UserID)
|
||||
@@ -169,22 +202,34 @@ func (p *AdventurePlugin) handleStatus(ctx MessageContext) error {
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleShopCmd(ctx MessageContext) error {
|
||||
func (p *AdventurePlugin) handleShopCmd(ctx MessageContext, category string) error {
|
||||
_, equip, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
|
||||
}
|
||||
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
text := advShopListings(equip, balance)
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
|
||||
if category == "" {
|
||||
return p.SendDM(ctx.Sender, advShopOverview(equip, balance))
|
||||
}
|
||||
|
||||
slot := advParseShopCategory(category)
|
||||
if slot == "" {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Unknown category '%s'. Try: weapon, armor, helmet, boots, or tool.", category))
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, advShopCategory(slot, equip, balance))
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleBuyCmd(ctx MessageContext, itemName string) error {
|
||||
_, equip, err := p.ensureCharacter(ctx.Sender)
|
||||
char, equip, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
|
||||
}
|
||||
if !char.Alive {
|
||||
return p.SendDM(ctx.Sender, "You're dead. Shopping can wait until you've respawned.")
|
||||
}
|
||||
|
||||
slot, def, found := advFindShopItem(itemName)
|
||||
if !found {
|
||||
@@ -196,7 +241,13 @@ func (p *AdventurePlugin) handleBuyCmd(ctx MessageContext, itemName string) erro
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleSellCmd(ctx MessageContext, args string) error {
|
||||
p.ensureCharacter(ctx.Sender)
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
|
||||
}
|
||||
if !char.Alive {
|
||||
return p.SendDM(ctx.Sender, "You're dead. No haggling from beyond the grave.")
|
||||
}
|
||||
|
||||
var result string
|
||||
if strings.ToLower(args) == "all" {
|
||||
@@ -208,7 +259,9 @@ func (p *AdventurePlugin) handleSellCmd(ctx MessageContext, args string) error {
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleInventoryCmd(ctx MessageContext) error {
|
||||
p.ensureCharacter(ctx.Sender)
|
||||
if _, _, err := p.ensureCharacter(ctx.Sender); err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
|
||||
}
|
||||
text := advInventoryDisplay(ctx.Sender)
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
@@ -270,10 +323,9 @@ func (p *AdventurePlugin) handleDMReply(ctx MessageContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Strip !adventure prefix if present
|
||||
// Strip !adventure prefix if present — dispatch directly to avoid recursion
|
||||
if strings.HasPrefix(strings.ToLower(body), "!adventure") {
|
||||
// Re-dispatch as command
|
||||
return p.OnMessage(ctx)
|
||||
return p.dispatchCommand(ctx)
|
||||
}
|
||||
|
||||
// Check for pending interaction first
|
||||
@@ -346,7 +398,23 @@ func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string)
|
||||
}
|
||||
|
||||
if char.ActionTakenToday {
|
||||
return p.SendDM(ctx.Sender, "You've already taken your action today. Rest now. Try again tomorrow.")
|
||||
// Only send the reminder once per day — subsequent DM messages
|
||||
// are silently ignored so they can be handled by other plugins (e.g. UNO).
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
if prev, ok := p.dmRemindedDate.Load(string(ctx.Sender)); ok && prev.(string) == today {
|
||||
return nil
|
||||
}
|
||||
p.dmRemindedDate.Store(string(ctx.Sender), today)
|
||||
|
||||
now := time.Now().UTC()
|
||||
midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
|
||||
remaining := midnight.Sub(now)
|
||||
hours := int(remaining.Hours())
|
||||
minutes := int(remaining.Minutes()) % 60
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"You've already taken your action today. Rest now. Try again tomorrow.\n\n"+
|
||||
"Next action: 00:00 UTC (%dh %dm from now)",
|
||||
hours, minutes))
|
||||
}
|
||||
|
||||
lower := strings.ToLower(body)
|
||||
@@ -360,7 +428,7 @@ func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string)
|
||||
if lower == "4" || lower == "shop" {
|
||||
equip, _ := loadAdvEquipment(ctx.Sender)
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
return p.SendDM(ctx.Sender, advShopListings(equip, balance))
|
||||
return p.SendDM(ctx.Sender, advShopOverview(equip, balance))
|
||||
}
|
||||
|
||||
// Parse activity + location
|
||||
@@ -486,21 +554,9 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
|
||||
_ = addAdvInventoryItem(char.UserID, item)
|
||||
}
|
||||
|
||||
// Party bonus: check if someone else visited the same location today
|
||||
if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
|
||||
if advCheckPartyBonus(char.UserID, loc.Name) {
|
||||
// Apply party bonus: +10% loot value
|
||||
partyBonus := int64(float64(result.TotalLootValue) * 0.10)
|
||||
if partyBonus > 0 {
|
||||
result.TotalLootValue += partyBonus
|
||||
// Credit the bonus directly
|
||||
p.euro.Credit(char.UserID, float64(partyBonus), "adventure_party_bonus")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark action taken
|
||||
// Mark action taken and record the date for streak tracking
|
||||
char.ActionTakenToday = true
|
||||
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
// Update streak info
|
||||
result.StreakBonus = char.CurrentStreak
|
||||
@@ -520,12 +576,29 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
|
||||
}
|
||||
}
|
||||
|
||||
// Log activity
|
||||
// Log activity BEFORE party bonus check so both visitors can see each other
|
||||
logAdvActivity(char.UserID, string(activity), loc.Name, string(result.Outcome),
|
||||
result.TotalLootValue, result.XPGained, result.FlavorKey)
|
||||
|
||||
// Send resolution DM
|
||||
// Party bonus: check if someone else visited the same location today
|
||||
if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
|
||||
if advCheckPartyBonus(char.UserID, loc.Name) {
|
||||
// Apply party bonus: +10% loot value
|
||||
partyBonus := int64(float64(result.TotalLootValue) * 0.10)
|
||||
if partyBonus > 0 {
|
||||
result.TotalLootValue += partyBonus
|
||||
// Credit the bonus directly
|
||||
p.euro.Credit(char.UserID, float64(partyBonus), "adventure_party_bonus")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send resolution DM with closing block
|
||||
text := renderAdvResolutionDM(result, char)
|
||||
closing := advClosingBlock(result.Outcome, char.UserID, loc.Name, p.morningHour, p.summaryHour)
|
||||
if closing != "" {
|
||||
text += "\n" + closing
|
||||
}
|
||||
if err := p.SendDM(ctx.Sender, text); err != nil {
|
||||
slog.Error("adventure: failed to send resolution DM", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
@@ -540,17 +613,29 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
|
||||
|
||||
func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharacter) error {
|
||||
char.ActionTakenToday = true
|
||||
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to save. Even resting is broken.")
|
||||
}
|
||||
|
||||
logAdvActivity(char.UserID, string(AdvActivityRest), "", "rest", 0, 0, "")
|
||||
|
||||
// Compute reset countdown
|
||||
now := time.Now().UTC()
|
||||
midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
|
||||
remaining := midnight.Sub(now)
|
||||
hours := int(remaining.Hours())
|
||||
minutes := int(remaining.Minutes()) % 60
|
||||
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"%s, you chose rest. No loot. No XP. No death.\n\n"+
|
||||
"You sat in your hovel and stared at the wall and achieved absolutely nothing. "+
|
||||
"Tomorrow awaits. It will probably be the same.",
|
||||
char.DisplayName))
|
||||
"Tomorrow awaits. It will probably be the same.\n\n"+
|
||||
"─────────────────────────────\n"+
|
||||
"Next action: 00:00 UTC (%dh %dm from now)\n"+
|
||||
"Morning DM: %02d:00 UTC\n"+
|
||||
"Evening summary: %02d:00 UTC",
|
||||
char.DisplayName, hours, minutes, p.morningHour, p.summaryHour))
|
||||
}
|
||||
|
||||
// ── Treasure Drop Check ─────────────────────────────────────────────────────
|
||||
@@ -739,7 +824,7 @@ func (p *AdventurePlugin) ensureCharacter(userID id.UserID) (*AdventureCharacter
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil {
|
||||
// Auto-create
|
||||
displayName := p.displayName(userID)
|
||||
displayName := p.DisplayName(userID)
|
||||
if err := createAdvCharacter(userID, displayName); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -764,15 +849,3 @@ func (p *AdventurePlugin) ensureCharacter(userID id.UserID) (*AdventureCharacter
|
||||
return char, equip, nil
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) displayName(userID id.UserID) string {
|
||||
resp, err := p.Client.GetDisplayName(context.Background(), userID)
|
||||
if err != nil || resp.DisplayName == "" {
|
||||
// Fallback to localpart
|
||||
s := string(userID)
|
||||
if idx := strings.Index(s, ":"); idx > 0 {
|
||||
s = s[1:idx]
|
||||
}
|
||||
return s
|
||||
}
|
||||
return resp.DisplayName
|
||||
}
|
||||
|
||||
@@ -317,11 +317,14 @@ func advIsEligible(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipme
|
||||
return false, false
|
||||
}
|
||||
|
||||
// Check minimum equipment tier
|
||||
minTier := 99
|
||||
for _, eq := range equip {
|
||||
if eq.Tier < minTier {
|
||||
minTier = eq.Tier
|
||||
// Check minimum equipment tier — no equipment means tier 0
|
||||
minTier := 0
|
||||
if len(equip) > 0 {
|
||||
minTier = 99
|
||||
for _, eq := range equip {
|
||||
if eq.Tier < minTier {
|
||||
minTier = eq.Tier
|
||||
}
|
||||
}
|
||||
}
|
||||
if minTier < loc.MinEquipTier {
|
||||
|
||||
@@ -2,7 +2,6 @@ package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
@@ -251,12 +250,12 @@ func createAdvCharacter(userID id.UserID, displayName string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO adventure_characters (user_id, display_name)
|
||||
VALUES (?, ?)`, string(userID), displayName)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -267,7 +266,6 @@ func createAdvCharacter(userID id.UserID, displayName string) error {
|
||||
INSERT INTO adventure_equipment (user_id, slot, tier, condition, name, actions_used)
|
||||
VALUES (?, ?, 0, 100, ?, 0)`, string(userID), string(slot), def.Name)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -440,19 +438,18 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
|
||||
|
||||
func resetAllAdvDailyActions() error {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(`UPDATE adventure_characters SET action_taken_today = 0`)
|
||||
// Only reset actions taken before today — protects against race if a player
|
||||
// resolves their action at exactly midnight.
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
_, err := d.Exec(`UPDATE adventure_characters SET action_taken_today = 0 WHERE last_action_date < ? OR last_action_date IS NULL`, today)
|
||||
return err
|
||||
}
|
||||
|
||||
func logAdvActivity(userID id.UserID, activityType, location, outcome string, lootValue int64, xpGained int, flavorKey string) {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(`
|
||||
INSERT INTO adventure_activity_log (user_id, activity_type, location, outcome, loot_value, xp_gained, flavor_key)
|
||||
db.Exec("adventure: log activity",
|
||||
`INSERT INTO adventure_activity_log (user_id, activity_type, location, outcome, loot_value, xp_gained, flavor_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
string(userID), activityType, location, outcome, lootValue, xpGained, flavorKey)
|
||||
if err != nil {
|
||||
slog.Error("adventure: failed to log activity", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Buff CRUD ────────────────────────────────────────────────────────────────
|
||||
@@ -511,8 +508,8 @@ func loadAdvTodayLogs() ([]AdvDayLog, error) {
|
||||
rows, err := d.Query(`
|
||||
SELECT user_id, activity_type, COALESCE(location,''), outcome, loot_value, xp_gained
|
||||
FROM adventure_activity_log
|
||||
WHERE DATE(logged_at) = ?
|
||||
ORDER BY logged_at`, today)
|
||||
WHERE logged_at >= ? AND logged_at < DATE(?, '+1 day')
|
||||
ORDER BY logged_at`, today, today)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
355
internal/plugin/adventure_events.go
Normal file
355
internal/plugin/adventure_events.go
Normal file
@@ -0,0 +1,355 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type advActiveEvent struct {
|
||||
ID int64
|
||||
UserID id.UserID
|
||||
EventKey string
|
||||
TriggeredAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// ── In-memory schedule ───────────────────────────────────────────────────────
|
||||
// Each day, every eligible player is assigned a random minute between 10:00
|
||||
// and 16:00 UTC at which the 0.5% trigger roll happens.
|
||||
|
||||
var (
|
||||
advEventScheduleMu sync.Mutex
|
||||
advEventSchedule map[string]int // userID string -> minute-of-day (600..959)
|
||||
advEventScheduleDay string // "2006-01-02" the schedule was built for
|
||||
)
|
||||
|
||||
func advBuildEventSchedule(chars []AdventureCharacter) {
|
||||
advEventSchedule = make(map[string]int, len(chars))
|
||||
for _, c := range chars {
|
||||
if !c.Alive {
|
||||
continue
|
||||
}
|
||||
// Random minute between 600 (10:00) and 959 (15:59)
|
||||
advEventSchedule[string(c.UserID)] = 600 + rand.IntN(360)
|
||||
}
|
||||
advEventScheduleDay = time.Now().UTC().Format("2006-01-02")
|
||||
}
|
||||
|
||||
// ── Event Ticker ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) eventTicker() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
dateKey := now.Format("2006-01-02")
|
||||
|
||||
// Expire stale pending events every tick
|
||||
expireAdvPendingEvents()
|
||||
|
||||
// Outside the trigger window — nothing to do
|
||||
if now.Hour() < 10 || now.Hour() >= 16 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Rebuild schedule if it's a new day or uninitialised
|
||||
advEventScheduleMu.Lock()
|
||||
if advEventScheduleDay != dateKey {
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
slog.Error("adventure: events: failed to load chars for schedule", "err", err)
|
||||
advEventScheduleMu.Unlock()
|
||||
continue
|
||||
}
|
||||
advBuildEventSchedule(chars)
|
||||
slog.Info("adventure: event schedule built", "players", len(advEventSchedule))
|
||||
}
|
||||
|
||||
// Find players whose roll minute is now
|
||||
currentMinute := now.Hour()*60 + now.Minute()
|
||||
var toRoll []id.UserID
|
||||
for uid, minute := range advEventSchedule {
|
||||
if minute == currentMinute {
|
||||
toRoll = append(toRoll, id.UserID(uid))
|
||||
}
|
||||
}
|
||||
advEventScheduleMu.Unlock()
|
||||
|
||||
for _, uid := range toRoll {
|
||||
p.tryTriggerEvent(uid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
|
||||
// Load character — must be alive and have acted today
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil || char == nil || !char.Alive || !char.ActionTakenToday {
|
||||
return
|
||||
}
|
||||
|
||||
// Already has an active event?
|
||||
active, _ := loadAdvActiveEvent(userID)
|
||||
if active != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 0.5% chance
|
||||
if rand.Float64() >= 0.005 {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine today's activity for filtering
|
||||
activityType := advPlayerTodayActivity(userID)
|
||||
|
||||
// Pick an event
|
||||
event := advPickRandomEvent(userID, activityType)
|
||||
if event == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Insert into DB
|
||||
now := time.Now().UTC()
|
||||
expiresAt := now.Add(2 * time.Hour)
|
||||
eventID, err := insertAdvEvent(userID, event.Key, expiresAt)
|
||||
if err != nil {
|
||||
slog.Error("adventure: events: failed to insert event", "user", userID, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("adventure: mid-day event triggered", "user", userID, "event", event.Key, "id", eventID)
|
||||
|
||||
// DM the player
|
||||
triggerDM := advSubstituteFlavor(event.TriggerDM, map[string]string{
|
||||
"{name}": char.DisplayName,
|
||||
})
|
||||
if err := p.SendDM(userID, triggerDM); err != nil {
|
||||
slog.Error("adventure: events: failed to send trigger DM", "user", userID, "err", err)
|
||||
}
|
||||
|
||||
// Post to game room
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
roomLine := advSubstituteFlavor(advEventRoomTriggerWrapper, map[string]string{
|
||||
"{trigger_room_line}": advSubstituteFlavor(event.TriggerRoomLine, map[string]string{"{name}": char.DisplayName}),
|
||||
})
|
||||
_ = p.SendMessage(id.RoomID(gr), roomLine)
|
||||
}
|
||||
}
|
||||
|
||||
// handleEventRespond processes `!adventure respond`.
|
||||
func (p *AdventurePlugin) handleEventRespond(ctx MessageContext) error {
|
||||
mu := p.advUserLock(ctx.Sender)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
active, err := loadAdvActiveEvent(ctx.Sender)
|
||||
if err != nil {
|
||||
slog.Error("adventure: events: failed to load active event", "user", ctx.Sender, "err", err)
|
||||
return p.SendDM(ctx.Sender, "Something went wrong checking for events. Try again in a moment.")
|
||||
}
|
||||
if active == nil {
|
||||
return p.SendDM(ctx.Sender, "You don't have an active event to respond to.")
|
||||
}
|
||||
|
||||
// Look up event definition
|
||||
event := advFindEventByKey(active.EventKey)
|
||||
if event == nil {
|
||||
slog.Error("adventure: events: unknown event key in DB", "key", active.EventKey)
|
||||
return p.SendDM(ctx.Sender, "Something went wrong — the event couldn't be found. This shouldn't happen.")
|
||||
}
|
||||
|
||||
// Roll gold reward
|
||||
gold := event.GoldMin
|
||||
if event.GoldMax > event.GoldMin {
|
||||
gold += rand.Int64N(event.GoldMax - event.GoldMin + 1)
|
||||
}
|
||||
|
||||
// Credit gold
|
||||
p.euro.Credit(ctx.Sender, float64(gold), "adventure_event_"+event.Key)
|
||||
|
||||
// Apply XP if applicable
|
||||
xpSkill := event.XPSkill
|
||||
if xpSkill == "" && event.XP > 0 {
|
||||
// For "any" events, apply XP to whatever they did today
|
||||
activityType := advPlayerTodayActivity(ctx.Sender)
|
||||
xpSkill = advXPSkill(AdvActivityType(activityType))
|
||||
}
|
||||
|
||||
if event.XP > 0 && xpSkill != "" {
|
||||
char, err := loadAdvCharacter(ctx.Sender)
|
||||
if err == nil && char != nil {
|
||||
switch xpSkill {
|
||||
case "combat":
|
||||
char.CombatXP += event.XP
|
||||
case "mining":
|
||||
char.MiningXP += event.XP
|
||||
case "foraging":
|
||||
char.ForagingXP += event.XP
|
||||
}
|
||||
checkAdvLevelUp(char, xpSkill)
|
||||
_ = saveAdvCharacter(char)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark responded in DB
|
||||
markAdvEventResponded(active.ID, gold, event.XP)
|
||||
|
||||
// Load display name for substitutions
|
||||
displayName := p.DisplayName(ctx.Sender)
|
||||
|
||||
// Send outcome DM
|
||||
goldStr := fmt.Sprintf("%d", gold)
|
||||
xpStr := fmt.Sprintf("%d", event.XP)
|
||||
outcomeDM := advSubstituteFlavor(event.OutcomeDM, map[string]string{
|
||||
"{gold}": goldStr,
|
||||
"{xp}": xpStr,
|
||||
"{name}": displayName,
|
||||
})
|
||||
_ = p.SendDM(ctx.Sender, outcomeDM)
|
||||
|
||||
// Post outcome to game room
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
xpSuffix := ""
|
||||
if event.XP > 0 {
|
||||
xpSuffix = fmt.Sprintf(" · +%d XP", event.XP)
|
||||
}
|
||||
roomLine := advSubstituteFlavor(advEventRoomOutcomeWrapper, map[string]string{
|
||||
"{outcome_room_line}": advSubstituteFlavor(event.OutcomeRoomLine, map[string]string{
|
||||
"{name}": displayName,
|
||||
"{gold}": goldStr,
|
||||
}),
|
||||
"{gold}": goldStr,
|
||||
"{xp_suffix}": xpSuffix,
|
||||
})
|
||||
_ = p.SendMessage(id.RoomID(gr), roomLine)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Event Selection ──────────────────────────────────────────────────────────
|
||||
|
||||
func advPickRandomEvent(userID id.UserID, activityType string) *AdvRandomEvent {
|
||||
// Load recent event keys for dedup
|
||||
recent, _ := loadAdvRecentEventKeys(userID, 10)
|
||||
recentSet := make(map[string]bool, len(recent))
|
||||
for _, k := range recent {
|
||||
recentSet[k] = true
|
||||
}
|
||||
|
||||
// Filter eligible events
|
||||
var candidates []int
|
||||
for i, e := range advRandomEvents {
|
||||
if e.Activity != "any" && e.Activity != activityType {
|
||||
continue
|
||||
}
|
||||
if recentSet[e.Key] {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, i)
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &advRandomEvents[candidates[rand.IntN(len(candidates))]]
|
||||
}
|
||||
|
||||
func advFindEventByKey(key string) *AdvRandomEvent {
|
||||
for i := range advRandomEvents {
|
||||
if advRandomEvents[i].Key == key {
|
||||
return &advRandomEvents[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// advPlayerTodayActivity returns the activity type string for what the player did today.
|
||||
func advPlayerTodayActivity(userID id.UserID) string {
|
||||
d := db.Get()
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
var actType string
|
||||
err := d.QueryRow(`SELECT activity_type FROM adventure_activity_log
|
||||
WHERE user_id = ? AND logged_at >= ? AND logged_at < DATE(?, '+1 day') LIMIT 1`, string(userID), today, today).Scan(&actType)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return actType
|
||||
}
|
||||
|
||||
// ── DB Operations ────────────────────────────────────────────────────────────
|
||||
|
||||
func insertAdvEvent(userID id.UserID, eventKey string, expiresAt time.Time) (int64, error) {
|
||||
d := db.Get()
|
||||
res, err := d.Exec(`INSERT INTO adventure_events_log (user_id, event_key, expires_at)
|
||||
VALUES (?, ?, ?)`, string(userID), eventKey, expiresAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func loadAdvActiveEvent(userID id.UserID) (*advActiveEvent, error) {
|
||||
d := db.Get()
|
||||
var e advActiveEvent
|
||||
err := d.QueryRow(`SELECT id, user_id, event_key, triggered_at, expires_at
|
||||
FROM adventure_events_log
|
||||
WHERE user_id = ? AND outcome = 'pending' AND expires_at > CURRENT_TIMESTAMP
|
||||
ORDER BY triggered_at DESC LIMIT 1`, string(userID)).Scan(
|
||||
&e.ID, &e.UserID, &e.EventKey, &e.TriggeredAt, &e.ExpiresAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
func markAdvEventResponded(eventID int64, goldAwarded int64, xpAwarded int) {
|
||||
db.Exec("adventure: mark event responded",
|
||||
`UPDATE adventure_events_log
|
||||
SET outcome = 'responded', responded_at = CURRENT_TIMESTAMP,
|
||||
gold_awarded = ?, xp_awarded = ?
|
||||
WHERE id = ?`, goldAwarded, xpAwarded, eventID)
|
||||
}
|
||||
|
||||
func expireAdvPendingEvents() {
|
||||
db.Exec("adventure: expire pending events",
|
||||
`UPDATE adventure_events_log SET outcome = 'expired'
|
||||
WHERE outcome = 'pending' AND expires_at < CURRENT_TIMESTAMP`)
|
||||
}
|
||||
|
||||
func loadAdvRecentEventKeys(userID id.UserID, limit int) ([]string, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT event_key FROM adventure_events_log
|
||||
WHERE user_id = ? ORDER BY triggered_at DESC LIMIT ?`, string(userID), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var keys []string
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if err := rows.Scan(&k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
204
internal/plugin/adventure_flavor_closing.go
Normal file
204
internal/plugin/adventure_flavor_closing.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package plugin
|
||||
|
||||
// ── OUTCOME DM CLOSING BLOCKS ─────────────────────────────────────────────────
|
||||
//
|
||||
// These are appended after the stats block in every resolution DM.
|
||||
// They confirm the day is spent, show time to reset, and close
|
||||
// the interaction in the game's voice.
|
||||
//
|
||||
// Substitutions:
|
||||
// {location} — where the player went
|
||||
// {reset_time} — absolute UTC reset time e.g. "00:00 UTC"
|
||||
// {time_until} — relative countdown e.g. "9h 23m"
|
||||
// {morning_time} — morning DM time e.g. "08:00 UTC"
|
||||
// {summary_time} — evening summary time e.g. "20:00 UTC"
|
||||
//
|
||||
// Selection: pick randomly per outcome category.
|
||||
// Skip last 3 used per player to avoid immediate repetition.
|
||||
// Death closings are NOT used when death DM is sent — that DM
|
||||
// has its own closure. These are for survive/succeed/fail outcomes.
|
||||
|
||||
// ── SUCCESS ───────────────────────────────────────────────────────────────────
|
||||
// Tone: dry acknowledgement. A good day happened. Don't oversell it.
|
||||
// The player earned something. The game acknowledges this without enthusiasm.
|
||||
|
||||
var ClosingSuccess = []string{
|
||||
"─────────────────────────────\n" +
|
||||
"That's your day. {location} has been dealt with.\n\n" +
|
||||
"Next action available: {reset_time} ({time_until} from now)\n" +
|
||||
"Evening summary: {summary_time} UTC — see what everyone else managed. Or didn't.\n" +
|
||||
"Tomorrow's choices: {morning_time} UTC\n\n" +
|
||||
"Rest. You've earned it. Questionably, but earned.",
|
||||
|
||||
"─────────────────────────────\n" +
|
||||
"Done. That's the day accounted for.\n\n" +
|
||||
"The {location} is behind you. The loot is in your inventory.\n" +
|
||||
"The rest of today is yours to do nothing useful with.\n\n" +
|
||||
"Resets: {reset_time} ({time_until})\n" +
|
||||
"Morning DM: {morning_time} UTC\n" +
|
||||
"Evening summary: {summary_time} UTC\n\n" +
|
||||
"Go rest. You've been through a dungeon.",
|
||||
|
||||
"─────────────────────────────\n" +
|
||||
"You're done. The {location} has been dealt with,\n" +
|
||||
"or dealt with you, depending on how you're counting.\n\n" +
|
||||
"Tomorrow arrives at {morning_time} UTC ({time_until} from now).\n" +
|
||||
"The evening summary posts at {summary_time} UTC if you want\n" +
|
||||
"to see what everyone else managed. Or didn't.\n\n" +
|
||||
"Rest. You've earned it. Questionably.",
|
||||
|
||||
"─────────────────────────────\n" +
|
||||
"A day, completed. The {location} has had its say\n" +
|
||||
"and you've had yours and the ledger is updated.\n\n" +
|
||||
"Nothing more to do until {reset_time} ({time_until}).\n" +
|
||||
"Morning DM at {morning_time} UTC with tomorrow's options.\n" +
|
||||
"TwinBee's results post at {summary_time} UTC.\n\n" +
|
||||
"You did alright today. Don't let it go to your head.",
|
||||
|
||||
"─────────────────────────────\n" +
|
||||
"That's it. Day spent. Action taken. Outcome recorded.\n\n" +
|
||||
"The {location} will still be there tomorrow, worse for your visit.\n" +
|
||||
"Your choices will also be there, at {morning_time} UTC,\n" +
|
||||
"refreshed and waiting and not judging yesterday.\n\n" +
|
||||
"Reset: {reset_time} · {time_until} remaining\n" +
|
||||
"Evening summary: {summary_time} UTC\n\n" +
|
||||
"Go do something that isn't this. You've done this for today.",
|
||||
|
||||
"─────────────────────────────\n" +
|
||||
"Done for today. The rest of the day is just\n" +
|
||||
"you and your choices and none of those choices involve this.\n\n" +
|
||||
"Resets: {reset_time} UTC · {time_until} from now\n" +
|
||||
"Tomorrow: {morning_time} UTC\n" +
|
||||
"Summary tonight: {summary_time} UTC\n\n" +
|
||||
"Rest. The {location} will be there when you get back.\n" +
|
||||
"The {location} is always there when you get back.",
|
||||
|
||||
"─────────────────────────────\n" +
|
||||
"You're done. Good run. Not exceptional, not terrible —\n" +
|
||||
"a run. The kind of run that keeps an adventurer\n" +
|
||||
"in business and out of the deeper healthcare plans.\n\n" +
|
||||
"Next action: {reset_time} ({time_until})\n" +
|
||||
"Morning DM: {morning_time} UTC\n" +
|
||||
"Evening summary: {summary_time} UTC — TwinBee will be there.\n\n" +
|
||||
"So will everyone else who showed up today.",
|
||||
}
|
||||
|
||||
// ── EXCEPTIONAL ───────────────────────────────────────────────────────────────
|
||||
// Tone: lets the result breathe. Doesn't undercut the achievement.
|
||||
// The closing is shorter here — the exceptional outcome DM already did
|
||||
// the heavy lifting. The closing just marks the day as done.
|
||||
|
||||
var ClosingExceptional = []string{
|
||||
"─────────────────────────────\n" +
|
||||
"That's your day. Write it down.\n\n" +
|
||||
"Resets: {reset_time} · {time_until}\n" +
|
||||
"Morning DM: {morning_time} UTC\n" +
|
||||
"Evening summary: {summary_time} UTC — this one's getting mentioned.",
|
||||
|
||||
"─────────────────────────────\n" +
|
||||
"Done. The {location} will not forget this.\n" +
|
||||
"Neither should you.\n\n" +
|
||||
"Next action: {reset_time} ({time_until})\n" +
|
||||
"Summary tonight at {summary_time} UTC.\n" +
|
||||
"Tomorrow: {morning_time} UTC.",
|
||||
|
||||
"─────────────────────────────\n" +
|
||||
"That's today. Keep the receipt.\n\n" +
|
||||
"Resets: {reset_time} · {time_until} remaining\n" +
|
||||
"Evening summary: {summary_time} UTC\n" +
|
||||
"Tomorrow's choices: {morning_time} UTC\n\n" +
|
||||
"Rest. You've more than earned it.",
|
||||
|
||||
"─────────────────────────────\n" +
|
||||
"Day spent. Exceptionally.\n\n" +
|
||||
"Next action: {reset_time} ({time_until})\n" +
|
||||
"The {summary_time} UTC summary is going to be good tonight.\n" +
|
||||
"Morning DM at {morning_time} UTC with whatever comes next.\n\n" +
|
||||
"It won't be this. But it'll be something.",
|
||||
|
||||
"─────────────────────────────\n" +
|
||||
"Done. That's a day worth having had.\n\n" +
|
||||
"Resets: {reset_time} · {time_until}\n" +
|
||||
"Summary: {summary_time} UTC\n" +
|
||||
"Tomorrow: {morning_time} UTC\n\n" +
|
||||
"Go rest. Exceptional days are still days and days end.",
|
||||
}
|
||||
|
||||
// ── FAILURE / EMPTY ───────────────────────────────────────────────────────────
|
||||
// Tone: the game knows it went badly. It's not cruel about it.
|
||||
// Resigned. Accurate. Leaves a door open for tomorrow.
|
||||
|
||||
var ClosingFailure = []string{
|
||||
"─────────────────────────────\n" +
|
||||
"That's your day. Not the day you wanted, but the day you got.\n\n" +
|
||||
"Next action: {reset_time} · {time_until} from now\n" +
|
||||
"Tomorrow's choices: {morning_time} UTC\n" +
|
||||
"Evening summary: {summary_time} UTC — see how everyone else did.\n\n" +
|
||||
"Some days the dungeon wins. Today was that day.\n" +
|
||||
"Tomorrow is a different proposition.",
|
||||
|
||||
"─────────────────────────────\n" +
|
||||
"Done. The {location} was uncooperative today.\n" +
|
||||
"This is noted. This will not be the last time it's noted.\n\n" +
|
||||
"Resets: {reset_time} ({time_until})\n" +
|
||||
"Morning DM: {morning_time} UTC — new choices, clean slate.\n" +
|
||||
"Summary: {summary_time} UTC\n\n" +
|
||||
"Rest. Tomorrow the {location} gets another chance to disappoint you.\n" +
|
||||
"You also get another chance. That's how this works.",
|
||||
|
||||
"─────────────────────────────\n" +
|
||||
"You're done. The {location} gave nothing today.\n" +
|
||||
"The {location} does not apologise for this.\n" +
|
||||
"The {location} does not apologise for anything.\n\n" +
|
||||
"Next action: {reset_time} · {time_until}\n" +
|
||||
"Tomorrow: {morning_time} UTC\n" +
|
||||
"Summary tonight: {summary_time} UTC\n\n" +
|
||||
"Come back tomorrow. Bring the same bad equipment.\n" +
|
||||
"Maybe the mountain will be in a better mood.",
|
||||
|
||||
"─────────────────────────────\n" +
|
||||
"That's today. Nothing found. XP gained, technically.\n" +
|
||||
"The experience of finding nothing is still experience.\n" +
|
||||
"The game counts it. The game is generous with definitions.\n\n" +
|
||||
"Resets: {reset_time} · {time_until} remaining\n" +
|
||||
"Morning DM: {morning_time} UTC\n" +
|
||||
"Evening summary: {summary_time} UTC\n\n" +
|
||||
"Tomorrow is not today. That's the best thing that can be said about it.",
|
||||
|
||||
"─────────────────────────────\n" +
|
||||
"Done. Bad day. It happens.\n\n" +
|
||||
"The {location} is still there. It will still be there tomorrow.\n" +
|
||||
"So will you, which is the important part.\n\n" +
|
||||
"Next action: {reset_time} ({time_until})\n" +
|
||||
"Tomorrow's choices at {morning_time} UTC.\n" +
|
||||
"TwinBee had a better day. The summary at {summary_time} UTC will confirm this.\n\n" +
|
||||
"Rest. You've earned the rest, at least.",
|
||||
|
||||
"─────────────────────────────\n" +
|
||||
"That's your day. The {location} was not impressed with you today.\n" +
|
||||
"The feeling, presumably, is mutual.\n\n" +
|
||||
"Resets: {reset_time} · {time_until}\n" +
|
||||
"Morning DM: {morning_time} UTC — fresh options.\n" +
|
||||
"Evening summary: {summary_time} UTC\n\n" +
|
||||
"Go rest. The {location} will be here when you get back.\n" +
|
||||
"The {location} is always here. That's the problem with it.",
|
||||
}
|
||||
|
||||
// ── DEATH ─────────────────────────────────────────────────────────────────────
|
||||
// Terse — the death resolution DM already handles the emotional weight.
|
||||
// This closing is a brief bridge to the healthcare lockout.
|
||||
|
||||
var ClosingDeath = []string{
|
||||
"─────────────────────────────\n" +
|
||||
"That's your day. Healthcare has the rest.\n\n" +
|
||||
"A separate DM is inbound with the details.\n" +
|
||||
"The details are not good. You already know the details.\n\n" +
|
||||
"Next action: after recovery\n" +
|
||||
"Morning DM: {morning_time} UTC — subject to medical clearance.",
|
||||
|
||||
"─────────────────────────────\n" +
|
||||
"Day over. Healthcare is involved.\n" +
|
||||
"Expect a DM shortly with the full situation.\n\n" +
|
||||
"The full situation: {location} won today.\n" +
|
||||
"Tomorrow is pending insurance confirmation.",
|
||||
}
|
||||
854
internal/plugin/adventure_flavor_events.go
Normal file
854
internal/plugin/adventure_flavor_events.go
Normal file
@@ -0,0 +1,854 @@
|
||||
package plugin
|
||||
|
||||
// ── RANDOM MID-DAY EVENTS ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Trigger chance: 0.5% per player per day, checked at a random time
|
||||
// between 10:00 and 16:00 UTC.
|
||||
// Player has 2 hours to reply !adventure respond.
|
||||
// No response: event expires silently, no penalty, no reward.
|
||||
// Response: OutcomeDM sent, reward applied, one-liner posted to room.
|
||||
|
||||
// AdvRandomEvent defines a single mid-day event with trigger/outcome text and rewards.
|
||||
type AdvRandomEvent struct {
|
||||
Key string
|
||||
TriggerDM string
|
||||
TriggerRoomLine string
|
||||
OutcomeDM string
|
||||
OutcomeRoomLine string
|
||||
GoldMin int64
|
||||
GoldMax int64
|
||||
XP int
|
||||
XPSkill string // "combat", "mining", "foraging", or "" for activity-based
|
||||
Activity string // "any" | "dungeon" | "mining" | "foraging"
|
||||
}
|
||||
|
||||
var advRandomEvents = []AdvRandomEvent{
|
||||
|
||||
// ── FOOD & BODILY CONSEQUENCES ────────────────────────────────────────────
|
||||
|
||||
{
|
||||
Key: "roast_chicken_wall",
|
||||
TriggerDM: "While picking at a crumbling wall for no reason you can adequately explain, " +
|
||||
"you find a roast chicken.\n\n" +
|
||||
"It has been in there a while. How long is unclear. " +
|
||||
"The colour is wrong. The smell is wrong. " +
|
||||
"A part of your brain is screaming at you and another part is already eating it.\n\n" +
|
||||
"You eat it. All of it. You lick your fingers.\n\n" +
|
||||
"Twenty minutes later your stomach has filed a formal complaint " +
|
||||
"that is escalating rapidly through your entire body.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ {name} found something in a wall and ate it. This is developing.",
|
||||
OutcomeDM: "Instead of holding it like an idiot — which is exactly what you would have done — " +
|
||||
"you drop trow and handle your situation in the nearest available ditch.\n\n" +
|
||||
"There is a person in the ditch.\n\n" +
|
||||
"The person immediately stands, climbing out of the ditch toward you, " +
|
||||
"covered in the consequences of your recent dietary decision. " +
|
||||
"You notice the name on their uniform: S. Kelly.\n\n" +
|
||||
"S. Kelly does not confront you. S. Kelly makes a sound. " +
|
||||
"An excited sound. Reaches into a pocket and throws a damp wad of bills at your feet.\n\n" +
|
||||
"You pick them up. You make eye contact with S. Kelly one final time. " +
|
||||
"You leave at pace.\n\n" +
|
||||
"Some questions are better left unasked. You have €{gold}.\n\n" +
|
||||
"S. Kelly watches you go.",
|
||||
OutcomeRoomLine: "✅ {name} handled the situation. S. Kelly was involved. Nobody is elaborating.",
|
||||
GoldMin: 35,
|
||||
GoldMax: 80,
|
||||
XP: 0,
|
||||
Activity: "any",
|
||||
},
|
||||
|
||||
{
|
||||
Key: "burning_house",
|
||||
TriggerDM: "There is a house on fire.\n\n" +
|
||||
"You run in.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ {name} has run into a burning building.",
|
||||
OutcomeDM: "There is nobody inside.\n\n" +
|
||||
"There was never anyone inside. You are an idiot who ran into a burning building " +
|
||||
"for no reason that holds up to scrutiny.\n\n" +
|
||||
"What IS inside is a truly remarkable amount of unsecured valuables " +
|
||||
"belonging to someone who left in a hurry. " +
|
||||
"You fill your pockets. A beam falls nearby. " +
|
||||
"You fill your pockets faster. Another beam falls. " +
|
||||
"You make a mental note about the beams and immediately forget it " +
|
||||
"because your pockets are full.\n\n" +
|
||||
"You emerge from the building on fire, briefly, " +
|
||||
"which a neighbour extinguishes with a bucket they were apparently already holding.\n\n" +
|
||||
"They applaud. They assume you saved someone. " +
|
||||
"You accept this.\n\n" +
|
||||
"€{gold}. You are on fire slightly less than before.",
|
||||
OutcomeRoomLine: "✅ {name} ran into a burning building. Nobody was inside. {name} was briefly on fire.",
|
||||
GoldMin: 80,
|
||||
GoldMax: 200,
|
||||
XP: 10,
|
||||
Activity: "any",
|
||||
},
|
||||
|
||||
{
|
||||
Key: "bandit_carriage",
|
||||
TriggerDM: "You come across a band of bandits robbing a carriage.\n\n" +
|
||||
"There are four of them. They are large. " +
|
||||
"The carriage owner is inside with the curtains drawn.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ {name} has spotted a bandit ambush and is deciding what kind of person they are.",
|
||||
OutcomeDM: "You dash over and immediately confront the bandits. " +
|
||||
"You point at them and proclaim that if they resist, " +
|
||||
"you will be forced to inflict great pain upon them.\n\n" +
|
||||
"They stomp the ever-loving shit out of you.\n\n" +
|
||||
"All four. In sequence and then simultaneously. " +
|
||||
"Your armor absorbs the first two hits and then stops absorbing. " +
|
||||
"You are making sounds you have not made since childhood.\n\n" +
|
||||
"The carriage door opens. The owner steps out with an auto-crossbow " +
|
||||
"and dispatches all four in approximately eight seconds.\n\n" +
|
||||
"The owner walks over to you on the ground and says, calmly, " +
|
||||
"that they could have stepped out considerably earlier. " +
|
||||
"They say the sight of you getting the stuffing knocked out of you " +
|
||||
"was honestly the most entertainment they'd had in weeks. " +
|
||||
"They press €{gold} into your hand and return to the carriage, " +
|
||||
"already mimicking the high-pitched yelps you made as each blow landed.\n\n" +
|
||||
"You walk away. The owner is still laughing. " +
|
||||
"You can hear it for quite some time.",
|
||||
OutcomeRoomLine: "✅ {name} confronted four bandits. The carriage owner let it play out for a while first.",
|
||||
GoldMin: 120,
|
||||
GoldMax: 250,
|
||||
XP: 25,
|
||||
Activity: "any",
|
||||
},
|
||||
|
||||
{
|
||||
Key: "mystery_stew",
|
||||
TriggerDM: "A stranger shoves a bowl of stew into your hands and walks away.\n\n" +
|
||||
"The stew is hot. The stranger is gone. " +
|
||||
"You are standing in the street holding someone else's dinner.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ A stranger gave {name} a bowl of stew and immediately left.",
|
||||
OutcomeDM: "You eat the stew.\n\n" +
|
||||
"It is the best thing you have ever eaten. Not 'best thing today.' Best thing. Ever. " +
|
||||
"You stand holding the empty bowl for a full minute just thinking about it.\n\n" +
|
||||
"Then you feel incredible. Not good. Incredible. " +
|
||||
"Like someone has gone into your settings and fixed something you didn't know was broken.\n\n" +
|
||||
"You turn the bowl over. €{gold} taped to the bottom.\n\n" +
|
||||
"You will think about this stew for the rest of your life. " +
|
||||
"You will never find the stranger. " +
|
||||
"This is fine. Some things are better as open wounds.",
|
||||
OutcomeRoomLine: "✅ {name} ate the stew. {name} will not be discussing the stew.",
|
||||
GoldMin: 20,
|
||||
GoldMax: 50,
|
||||
XP: 15,
|
||||
Activity: "any",
|
||||
},
|
||||
|
||||
// ── CRIME & MORAL FLEXIBILITY ─────────────────────────────────────────────
|
||||
|
||||
{
|
||||
Key: "drunk_merchant",
|
||||
TriggerDM: "A merchant is asleep at their stall. Aggressively asleep. " +
|
||||
"The kind that follows a serious lunch.\n\n" +
|
||||
"Their coin purse is on the counter. Three feet away. Unattended.\n\n" +
|
||||
"The merchant snores.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ {name} has found an unattended coin purse. A moral crossroads.",
|
||||
OutcomeDM: "You take the coin purse.\n\n" +
|
||||
"You were never not going to take the coin purse.\n\n" +
|
||||
"Inside: €{gold}, a very small portrait of someone's dog, " +
|
||||
"and a note that says 'DO NOT LOSE THIS.' " +
|
||||
"You pocket the money. You leave the note on the counter as a courtesy. " +
|
||||
"You leave the portrait because you are not a monster.\n\n" +
|
||||
"The merchant snores.\n\n" +
|
||||
"You walk away. The merchant wakes up three hours later " +
|
||||
"with a note that says DO NOT LOSE THIS " +
|
||||
"and no memory of what it referred to.\n\n" +
|
||||
"This haunts them. Good.",
|
||||
OutcomeRoomLine: "✅ {name} made a financial decision at the market. The merchant will be confused later.",
|
||||
GoldMin: 40,
|
||||
GoldMax: 120,
|
||||
XP: 0,
|
||||
Activity: "any",
|
||||
},
|
||||
|
||||
{
|
||||
Key: "wrongful_arrest",
|
||||
TriggerDM: "A guard grabs you by the collar. " +
|
||||
"You match the description of someone who robbed a bakery this morning.\n\n" +
|
||||
"You did not rob a bakery this morning. " +
|
||||
"You were somewhere significantly worse, which is a different problem.\n\n" +
|
||||
"The guard is waiting.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ A guard has {name} by the collar. Bakery robbery. {name} has an alibi of sorts.",
|
||||
OutcomeDM: "You explain where you actually were this morning in considerable detail.\n\n" +
|
||||
"The guard listens with the expression of someone who cannot determine " +
|
||||
"whether a dungeon is a better alibi than a bakery robbery. " +
|
||||
"They're still deciding when a runner arrives: actual robber caught three streets over.\n\n" +
|
||||
"The guard releases you. You ask about compensation.\n\n" +
|
||||
"A long pause. The guard produces a voucher for a free loaf " +
|
||||
"from the robbed bakery, which they apparently issue to witnesses as goodwill. " +
|
||||
"You go to the bakery. You sell the loaf you bought before knowing about the voucher for €{gold}.\n\n" +
|
||||
"The justice system has, in its way, provided.",
|
||||
OutcomeRoomLine: "✅ {name} was detained and compensated with bread. The system works, loosely.",
|
||||
GoldMin: 25,
|
||||
GoldMax: 60,
|
||||
XP: 5,
|
||||
Activity: "any",
|
||||
},
|
||||
|
||||
{
|
||||
Key: "tax_refund",
|
||||
TriggerDM: "A tax collector approaches you with a ledger and an apologetic expression.\n\n" +
|
||||
"The city has been over-collecting from you for several years. " +
|
||||
"There is a refund.\n\n" +
|
||||
"You have never paid city taxes. Not once. Not ever.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ A tax collector has found {name} in a ledger. A refund is being discussed.",
|
||||
OutcomeDM: "You accept the refund.\n\n" +
|
||||
"You nod seriously while the amount is calculated. " +
|
||||
"You say 'that sounds about right' in the tone of someone " +
|
||||
"who has been thinking about this number for years. " +
|
||||
"You sign the form. The collector apologises on behalf of the city.\n\n" +
|
||||
"€{gold} is counted into your hand.\n\n" +
|
||||
"Somewhere in the city, someone who actually paid those taxes " +
|
||||
"is still waiting on their refund.\n\n" +
|
||||
"Not your problem.",
|
||||
OutcomeRoomLine: "✅ {name} accepted a refund for taxes they never paid. The city apologised.",
|
||||
GoldMin: 100,
|
||||
GoldMax: 250,
|
||||
XP: 0,
|
||||
Activity: "any",
|
||||
},
|
||||
|
||||
{
|
||||
Key: "pickpocket_reversed",
|
||||
TriggerDM: "Someone has just tried to pick your pocket.\n\n" +
|
||||
"You felt it. They're still standing right next to you, " +
|
||||
"hand inside your coat, making eye contact and smiling " +
|
||||
"with the frozen confidence of someone whose plan has not fully resolved.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ Someone is in {name}'s pocket. Both parties are aware of this.",
|
||||
OutcomeDM: "You look down at the hand. You look up at the person. " +
|
||||
"The person looks at you. A long moment.\n\n" +
|
||||
"You pick their pocket.\n\n" +
|
||||
"While they are processing what just happened, " +
|
||||
"while the hand is still technically inside your coat, " +
|
||||
"you reach into their jacket and remove their wallet " +
|
||||
"with the smooth efficiency of someone who has spent considerable time in dungeons " +
|
||||
"and has a working relationship with fast decisions.\n\n" +
|
||||
"The pickpocket looks at their own empty pocket. Looks at you. " +
|
||||
"Has the expression of someone revising a career.\n\n" +
|
||||
"You hand them back their own wallet, minus €{gold} for the inconvenience, " +
|
||||
"and walk away while they work through the sequence of events.\n\n" +
|
||||
"They do not follow you. They sit down on the nearest step " +
|
||||
"and think about their choices. Good.",
|
||||
OutcomeRoomLine: "✅ {name} was pickpocketed and immediately pickpocketed back. Net outcome: positive.",
|
||||
GoldMin: 30,
|
||||
GoldMax: 85,
|
||||
XP: 10,
|
||||
Activity: "any",
|
||||
},
|
||||
|
||||
// ── WILDLIFE ─────────────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
Key: "runaway_pig",
|
||||
TriggerDM: "A pig is running directly at you at a speed pigs should not be capable of.\n\n" +
|
||||
"It is large. It has committed to its direction. You are in its direction.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ {name} is in the path of a large fast pig.",
|
||||
OutcomeDM: "You sidestep the pig at the last possible moment.\n\n" +
|
||||
"The pig continues. You hear it round a corner. " +
|
||||
"Then shouting. Then nothing. You choose not to investigate.\n\n" +
|
||||
"A farmer arrives thirty seconds later, completely out of breath, " +
|
||||
"and asks which way. You point. The farmer presses coins into your hand " +
|
||||
"without breaking stride.\n\n" +
|
||||
"You stood in a road and moved slightly to the left. €{gold}.\n\n" +
|
||||
"The shouting was bad. " +
|
||||
"You do not ask what happened to the pig.",
|
||||
OutcomeRoomLine: "✅ {name} avoided the pig and was paid for it. The pig's status is unknown.",
|
||||
GoldMin: 15,
|
||||
GoldMax: 40,
|
||||
XP: 0,
|
||||
Activity: "any",
|
||||
},
|
||||
|
||||
{
|
||||
Key: "suspicious_crow",
|
||||
TriggerDM: "A crow has been following you for twenty minutes.\n\n" +
|
||||
"Not flying past. Following. Fence to fence, tree to tree, " +
|
||||
"making sustained eye contact every time you check.\n\n" +
|
||||
"You have checked six times.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ A crow is following {name}. Sustained eye contact has been established.",
|
||||
OutcomeDM: "You stop and face the crow.\n\n" +
|
||||
"It lands in front of you and drops a ring from its beak. " +
|
||||
"Silver. Engraved with initials you don't recognise. " +
|
||||
"Dropped with the deliberate energy of something being returned.\n\n" +
|
||||
"The crow watches you pick it up. Then it leaves. " +
|
||||
"Just flies away. Done. No further communication.\n\n" +
|
||||
"You stand in the road holding a ring a crow gave you.\n\n" +
|
||||
"A jeweller gives you €{gold} and asks no questions.\n\n" +
|
||||
"You do not ask questions either.\n\n" +
|
||||
"On the way home you see the crow drop a ring at someone else's feet.\n\n" +
|
||||
"The crow looks up at you. Brief eye contact.\n\n" +
|
||||
"The crow looks away. Business as usual.",
|
||||
OutcomeRoomLine: "✅ The crow gave {name} a ring and left. It gives everyone rings. Questions: none.",
|
||||
GoldMin: 50,
|
||||
GoldMax: 110,
|
||||
XP: 0,
|
||||
Activity: "any",
|
||||
},
|
||||
|
||||
{
|
||||
Key: "horse_standoff",
|
||||
TriggerDM: "A horse is standing in the middle of the road and will not move.\n\n" +
|
||||
"Not startled. Not lost. Just standing there with the settled energy " +
|
||||
"of something that has made a decision and is at peace with it. " +
|
||||
"A queue of people on both sides. Nobody doing anything.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ A horse is blocking the road. {name} is the nearest person with any credentials.",
|
||||
OutcomeDM: "You approach the horse.\n\n" +
|
||||
"The horse looks at you. You say something — more of a tone than words. " +
|
||||
"The horse considers this and then steps aside.\n\n" +
|
||||
"The crowd applauds. A merchant whose cargo has been stuck forty minutes " +
|
||||
"presses €{gold} into your hand.\n\n" +
|
||||
"You do not know what you said to the horse. " +
|
||||
"You walk away before anyone asks you to try.\n\n" +
|
||||
"Behind you, someone else approaches the horse with great confidence.\n\n" +
|
||||
"The horse bites them immediately.\n\n" +
|
||||
"You do not look back.",
|
||||
OutcomeRoomLine: "✅ {name} said something to a horse and it worked. The next person was bitten immediately. {name} cannot explain this.",
|
||||
GoldMin: 20,
|
||||
GoldMax: 55,
|
||||
XP: 0,
|
||||
Activity: "any",
|
||||
},
|
||||
|
||||
{
|
||||
Key: "cat_with_something",
|
||||
TriggerDM: "A cat has dropped something at your feet and is sitting back " +
|
||||
"looking at you with the expectant energy of a cat that has done its part " +
|
||||
"and is waiting for you to do yours.\n\n" +
|
||||
"The something is small and wrapped in cloth " +
|
||||
"and the cat is not going to explain itself.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ A cat has left something at {name}'s feet and is waiting.",
|
||||
OutcomeDM: "You pick up the cloth and unwrap it.\n\n" +
|
||||
"A key. Old. Specific. The kind that opens one thing " +
|
||||
"and that one thing is somewhere.\n\n" +
|
||||
"You spend an hour finding what the key opens. " +
|
||||
"The cat follows you the entire time, maintaining three feet of distance, " +
|
||||
"watching with total composure.\n\n" +
|
||||
"The key opens a small lockbox behind a loose brick " +
|
||||
"in a wall the cat led you to by sitting in front of it and waiting.\n\n" +
|
||||
"Inside: €{gold} and a note that says 'good cat.'\n\n" +
|
||||
"The cat has already left when you look up. " +
|
||||
"The note was for the cat. " +
|
||||
"The money was apparently for you. " +
|
||||
"You do not understand any part of this and you never will.",
|
||||
OutcomeRoomLine: "✅ A cat led {name} to a lockbox. The note inside was for the cat. The money was for {name}.",
|
||||
GoldMin: 45,
|
||||
GoldMax: 100,
|
||||
XP: 0,
|
||||
Activity: "any",
|
||||
},
|
||||
|
||||
// ── PEOPLE & SOCIAL DISASTERS ─────────────────────────────────────────────
|
||||
|
||||
{
|
||||
Key: "funeral_wrong_procession",
|
||||
TriggerDM: "You have accidentally joined a funeral procession.\n\n" +
|
||||
"You don't know how. One moment you were walking, " +
|
||||
"the next you were part of a slow column of mourners " +
|
||||
"and everyone assumed you belonged " +
|
||||
"and you didn't correct anyone " +
|
||||
"and now the church is very close.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ {name} has accidentally joined a funeral. The church is very close.",
|
||||
OutcomeDM: "You attend the entire funeral.\n\n" +
|
||||
"An hour and forty minutes. Third pew. You accept a memorial card " +
|
||||
"for one Roderick Hannaway, beloved, " +
|
||||
"and fold it carefully into a pocket.\n\n" +
|
||||
"At the reception a woman assumes you were a business colleague " +
|
||||
"and spends twenty minutes telling you what a genuinely difficult man Roderick was. " +
|
||||
"She cries twice. You hand her a napkin both times " +
|
||||
"because the napkins are right there and it would be weird not to.\n\n" +
|
||||
"She presses €{gold} into your hand and says Roderick would have wanted you to have it.\n\n" +
|
||||
"Based on everything you've heard today, " +
|
||||
"Roderick would have wanted no such thing. " +
|
||||
"Roderick sounds like he was awful. " +
|
||||
"You have his money now. " +
|
||||
"Karma is imprecise but directionally sound.",
|
||||
OutcomeRoomLine: "✅ {name} attended Roderick Hannaway's funeral uninvited. Roderick was difficult. {name} has the money.",
|
||||
GoldMin: 60,
|
||||
GoldMax: 130,
|
||||
XP: 10,
|
||||
Activity: "any",
|
||||
},
|
||||
|
||||
{
|
||||
Key: "bar_fight_aftermath",
|
||||
TriggerDM: "The bar fight is over.\n\n" +
|
||||
"You weren't in it. You were at a corner table minding your business " +
|
||||
"when it erupted and now it's done and there are people on the floor " +
|
||||
"in various states of consciousness and the barkeep is looking at everyone " +
|
||||
"like they're all someone else's problem.\n\n" +
|
||||
"You look at the people on the floor.\n\n" +
|
||||
"You look at your medical kit.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ A bar fight has concluded near {name}. {name} has a medical kit and is looking at it.",
|
||||
OutcomeDM: "You open the medical kit and get to work.\n\n" +
|
||||
"You also get out your pricing chart.\n\n" +
|
||||
"The first one is a large man with a broken nose. " +
|
||||
"Standard rate. You patch him up, he pays, he leaves without making eye contact.\n\n" +
|
||||
"The second is a woman who is, frankly, very attractive, " +
|
||||
"which means she has had it easy her entire life " +
|
||||
"and can afford to have it slightly less easy right now. " +
|
||||
"You charge her triple. She pays it. You feel nothing about this.\n\n" +
|
||||
"The third is a child.\n\n" +
|
||||
"A sweet child. A small child with enormous eyes " +
|
||||
"who had absolutely no business being in a bar during a bar fight " +
|
||||
"and has a gash on their forehead that needs cleaning.\n\n" +
|
||||
"You look at the child with sympathetic eyes.\n\n" +
|
||||
"You charge the child the most of all.\n\n" +
|
||||
"'This,' you say, cleaning the wound with practiced efficiency, " +
|
||||
"'is what happens when you meddle in grown folks' business.'\n\n" +
|
||||
"The parent shows up and pays you while glaring at you the entire time. " +
|
||||
"In fact the whole room is staring daggers at you " +
|
||||
"but you don't mind at all.\n\n" +
|
||||
"You smile and wave back at them as you count your money " +
|
||||
"while continuing on your way.\n\n" +
|
||||
"Friends made today: -20.\n" +
|
||||
"Money made today: €{gold}. A whole lot.",
|
||||
OutcomeRoomLine: "✅ {name} treated the bar fight wounded on a sliding scale. The child paid the most. Friends made: -20.",
|
||||
GoldMin: 85,
|
||||
GoldMax: 190,
|
||||
XP: 10,
|
||||
Activity: "any",
|
||||
},
|
||||
|
||||
{
|
||||
Key: "duel_wrong_person",
|
||||
TriggerDM: "Someone has challenged you to a duel.\n\n" +
|
||||
"Wrong person. Wrong name. Wrong grievance. " +
|
||||
"The passion with which they're delivering the challenge " +
|
||||
"suggests this has been building for a long time " +
|
||||
"in someone else's direction.\n\n" +
|
||||
"There is a crowd. They are waiting for your response.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ {name} has been challenged to a duel. Wrong person. {name} hasn't clarified this.",
|
||||
OutcomeDM: "You accept.\n\n" +
|
||||
"Correcting the situation now feels impossible. " +
|
||||
"There is a crowd. A duelling ground. A ritual. " +
|
||||
"You go through all of it.\n\n" +
|
||||
"Your opponent is very good. Specifically trained, " +
|
||||
"for years probably, for a fight against a person who is not you.\n\n" +
|
||||
"Halfway through the duel they stop, squint at you, " +
|
||||
"and say slowly that you are not who they thought.\n\n" +
|
||||
"A long silence.\n\n" +
|
||||
"They sheathe their weapon. Press €{gold} into your hand " +
|
||||
"as 'compensation for the inconvenience' and leave at speed.\n\n" +
|
||||
"The crowd disperses. You stand on the duelling ground alone " +
|
||||
"wondering who they were actually looking for " +
|
||||
"and whether that person knows what's coming.\n\n" +
|
||||
"They don't know. They will find out. " +
|
||||
"That is not your problem.",
|
||||
OutcomeRoomLine: "✅ {name} accepted a duel meant for someone else. Error discovered mid-duel. Compensation paid.",
|
||||
GoldMin: 80,
|
||||
GoldMax: 170,
|
||||
XP: 30,
|
||||
Activity: "any",
|
||||
},
|
||||
|
||||
{
|
||||
Key: "wrong_house_dinner",
|
||||
TriggerDM: "You have walked into the wrong house.\n\n" +
|
||||
"The door was open. You thought it was somewhere else. " +
|
||||
"It is very much not somewhere else.\n\n" +
|
||||
"There is a family eating dinner. " +
|
||||
"They are looking at you. " +
|
||||
"You are looking at them.\n\n" +
|
||||
"Nobody has said anything yet.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ {name} is standing in a stranger's home. Dinner is in progress. Nobody has spoken.",
|
||||
OutcomeDM: "You sit down.\n\n" +
|
||||
"There is an empty chair and your body uses it " +
|
||||
"before your brain can propose an alternative.\n\n" +
|
||||
"The family, after a moment, continues dinner.\n\n" +
|
||||
"They serve you a plate. The food is excellent. " +
|
||||
"Nobody explains the empty chair. You don't ask. " +
|
||||
"Conversation happens around you the way weather happens — " +
|
||||
"you're present without being the cause of it.\n\n" +
|
||||
"The eldest person at the table gives you €{gold} at the end " +
|
||||
"and says 'for the road' and shows you out.\n\n" +
|
||||
"On the way out you notice a painting on the wall.\n\n" +
|
||||
"It is a painting of you.\n\n" +
|
||||
"Not someone who looks like you. You.\n\n" +
|
||||
"The door closes before you can say anything.\n\n" +
|
||||
"You stand on the step for a moment.\n\n" +
|
||||
"You walk home.",
|
||||
OutcomeRoomLine: "✅ {name} sat at a stranger's dinner table. Was served. Was paid. The chair was waiting.",
|
||||
GoldMin: 30,
|
||||
GoldMax: 80,
|
||||
XP: 10,
|
||||
Activity: "any",
|
||||
},
|
||||
|
||||
{
|
||||
Key: "street_prophet",
|
||||
TriggerDM: "A street prophet has stopped their sermon to point at you.\n\n" +
|
||||
"The crowd is looking at you. The prophet is describing a prophecy " +
|
||||
"that is clearly about you — your description, your general situation, " +
|
||||
"several accurate details that are uncomfortably specific.\n\n" +
|
||||
"The prophet is waiting.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ A street prophet has identified {name} in a prophecy. The crowd is watching.",
|
||||
OutcomeDM: "You step forward.\n\n" +
|
||||
"The prophet delivers the rest of it directly to you, " +
|
||||
"in detail, for eleven minutes. A great task. A difficult road. " +
|
||||
"A door that 'will know you when you find it.'\n\n" +
|
||||
"You have no idea what door. You go through a lot of doors. " +
|
||||
"One of them is apparently significant " +
|
||||
"and you will not recognise the moment when it happens.\n\n" +
|
||||
"The crowd donates €{gold} to you afterward, " +
|
||||
"unprompted, as if this is simply what you do when someone is in a prophecy.\n\n" +
|
||||
"You walk away with money and a prophecy you don't understand " +
|
||||
"and the distinct feeling that something is now your problem " +
|
||||
"that wasn't your problem this morning.\n\n" +
|
||||
"Behind you the prophet points at someone else in the crowd " +
|
||||
"and begins describing a new prophecy with the same specific details. " +
|
||||
"Your description. Your equipment. Your general situation.\n\n" +
|
||||
"The prophet makes eye contact with you briefly.\n\n" +
|
||||
"The prophet looks away.",
|
||||
OutcomeRoomLine: "✅ {name} is in a prophecy. A door will know them. Nobody can say which door.",
|
||||
GoldMin: 50,
|
||||
GoldMax: 110,
|
||||
XP: 20,
|
||||
Activity: "any",
|
||||
},
|
||||
|
||||
// ── FOUND OBJECTS ─────────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
Key: "mysterious_clock",
|
||||
TriggerDM: "There is a package on your doorstep.\n\n" +
|
||||
"Your name on it, spelled correctly. " +
|
||||
"No return address. " +
|
||||
"A small but definite amount of ticking.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ {name} has received an anonymous package. It is ticking.",
|
||||
OutcomeDM: "You open it.\n\n" +
|
||||
"A clock. Small, ornate, running accurately. " +
|
||||
"A note: 'Sorry about the clock. Keep it. — T'\n\n" +
|
||||
"You don't know anyone whose name starts with T.\n\n" +
|
||||
"You sell it to a jeweller immediately " +
|
||||
"because a clock that arrives with a preemptive apology " +
|
||||
"is not a clock you want in your home.\n\n" +
|
||||
"€{gold}. The jeweller asks no questions.\n\n" +
|
||||
"You are half a street away when the explosion happens.\n\n" +
|
||||
"You turn around.\n\n" +
|
||||
"The jeweller is standing in the smoking doorway, " +
|
||||
"entirely unharmed, holding something aloft, " +
|
||||
"screaming at the top of their lungs.\n\n" +
|
||||
"'I'm rich! I'm rich! " +
|
||||
"Fuck all of you dirty bitches because I'm stinking wealthy!'\n\n" +
|
||||
"Congratulations. " +
|
||||
"That explosion would have probably had your ears ringing for a bit. " +
|
||||
"Whew.",
|
||||
OutcomeRoomLine: "✅ {name} received a clock and an apology from T. Both have been processed.",
|
||||
GoldMin: 55,
|
||||
GoldMax: 120,
|
||||
XP: 0,
|
||||
Activity: "any",
|
||||
},
|
||||
|
||||
{
|
||||
Key: "well_box",
|
||||
TriggerDM: "There is something shining at the bottom of a well.\n\n" +
|
||||
"The bucket is right there.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ {name} is looking at something shining at the bottom of a well.",
|
||||
OutcomeDM: "You go in.\n\n" +
|
||||
"Not in the bucket — the bucket wouldn't hold you — " +
|
||||
"but using the rope and the wall and a technique you invent on the way down " +
|
||||
"that works better than it has any right to.\n\n" +
|
||||
"At the bottom: an ornate box. Waterlogged. Locked. Heavy.\n\n" +
|
||||
"You get it in the bucket and climb back out, " +
|
||||
"which is harder than going down in the specific way " +
|
||||
"that makes you hate yourself for not planning this.\n\n" +
|
||||
"The box contains €{gold} in old currency and a letter.\n\n" +
|
||||
"You read the letter.\n\n" +
|
||||
"You read it again.\n\n" +
|
||||
"You drop it into a different well.\n\n" +
|
||||
"The money is yours. " +
|
||||
"The letter is the other well's problem. " +
|
||||
"You are not thinking about the letter.",
|
||||
OutcomeRoomLine: "✅ {name} went into a well. Came out. The letter has been re-disposed of in a different well.",
|
||||
GoldMin: 90,
|
||||
GoldMax: 210,
|
||||
XP: 10,
|
||||
Activity: "any",
|
||||
},
|
||||
|
||||
{
|
||||
Key: "treasure_map",
|
||||
TriggerDM: "Someone has pressed a piece of paper into your hand and walked away very fast.\n\n" +
|
||||
"It is old. There are markings. There is an X.\n\n" +
|
||||
"There is always an X.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ {name} has been given a piece of paper by someone who left immediately.",
|
||||
OutcomeDM: "You follow the map.\n\n" +
|
||||
"It leads, through three wrong turns and one moment of genuine doubt " +
|
||||
"about your entire direction in life, " +
|
||||
"to a loose flagstone in an unremarkable courtyard.\n\n" +
|
||||
"Under the flagstone: a tin. Inside the tin: €{gold} " +
|
||||
"and a note that says TELL NO ONE.\n\n" +
|
||||
"You replace the flagstone. You pocket the money.\n\n" +
|
||||
"You are telling everyone.",
|
||||
OutcomeRoomLine: "✅ {name} followed the X. It worked. {name} has been asked to tell no one.",
|
||||
GoldMin: 70,
|
||||
GoldMax: 160,
|
||||
XP: 5,
|
||||
Activity: "any",
|
||||
},
|
||||
|
||||
// ── ACTIVITY-SPECIFIC ─────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
Key: "dungeon_staff_entrance",
|
||||
TriggerDM: "You've found a back entrance to a dungeon.\n\n" +
|
||||
"A side door. Half-hidden. " +
|
||||
"The kind that exists because someone who worked here " +
|
||||
"wanted a way in that the front-door things didn't know about.\n\n" +
|
||||
"It's open.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ {name} has found an unmarked open door in a dungeon.",
|
||||
OutcomeDM: "You go in.\n\n" +
|
||||
"Staff area. A schedule in a language you don't read. " +
|
||||
"A considerable amount of food the monsters keep back here. " +
|
||||
"Three goblins on break who look up at you.\n\n" +
|
||||
"Under the table: a strongbox the goblins were clearly not supposed to be near.\n\n" +
|
||||
"You take the strongbox. You take some of the food. " +
|
||||
"The goblins watch and say nothing, " +
|
||||
"possibly because you've caught them somewhere they're not supposed to be " +
|
||||
"and the situation is mutually awkward for everyone.\n\n" +
|
||||
"You make eye contact with each goblin in turn. " +
|
||||
"A tacit agreement is reached. " +
|
||||
"You leave. The goblins file a complaint with management.\n\n" +
|
||||
"Management investigates and finds nothing useful.\n\n" +
|
||||
"Management is one of the goblins.\n\n" +
|
||||
"The goblins know exactly what happened.\n\n" +
|
||||
"The lock has already been changed.\n\n" +
|
||||
"€{gold}. The food was decent.",
|
||||
OutcomeRoomLine: "✅ {name} found the dungeon staff area. The goblins were on break. An agreement was reached.",
|
||||
GoldMin: 80,
|
||||
GoldMax: 180,
|
||||
XP: 20,
|
||||
XPSkill: "combat",
|
||||
Activity: "dungeon",
|
||||
},
|
||||
|
||||
{
|
||||
Key: "surface_ore",
|
||||
TriggerDM: "There is ore sitting on the ground.\n\n" +
|
||||
"Not in a mine. Not behind anything. " +
|
||||
"Just on the ground, in the open. " +
|
||||
"Several people have walked past it.\n\n" +
|
||||
"You don't know why nobody has taken it.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ {name} has found ore on the ground. Nobody has taken it. {name} is suspicious.",
|
||||
OutcomeDM: "You pick it up.\n\n" +
|
||||
"Nothing happens.\n\n" +
|
||||
"No one objects. No one appears. " +
|
||||
"The ground doesn't care. " +
|
||||
"The ore is just ore that was on the ground " +
|
||||
"and is now in your pocket.\n\n" +
|
||||
"You sell it for €{gold}.\n\n" +
|
||||
"The people who walked past it will think about this for the rest of the day. " +
|
||||
"Some of them will still be thinking about it next week.\n\n" +
|
||||
"You will not be thinking about it. " +
|
||||
"€{gold} has a clarifying effect on the mind.",
|
||||
OutcomeRoomLine: "✅ {name} picked up free ore off the ground. Nothing happened. €{gold}.",
|
||||
GoldMin: 20,
|
||||
GoldMax: 65,
|
||||
XP: 5,
|
||||
XPSkill: "mining",
|
||||
Activity: "mining",
|
||||
},
|
||||
|
||||
{
|
||||
Key: "unusual_plant",
|
||||
TriggerDM: "You've spotted a plant you've never seen before.\n\n" +
|
||||
"It's doing something. Not moving — present in a way regular plants aren't. " +
|
||||
"It's not on any foraging chart you've seen.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ {name} has found an unidentified plant that is present in an unusual way.",
|
||||
OutcomeDM: "You take a cutting.\n\n" +
|
||||
"A small piece. Just enough. " +
|
||||
"The plant doesn't react. " +
|
||||
"You're not certain what a reaction would look like " +
|
||||
"and you don't push your luck.\n\n" +
|
||||
"A herbalist in town goes very still when you show it to them. " +
|
||||
"The kind of still that means they know exactly what it is " +
|
||||
"and are working very hard not to show you that they know.\n\n" +
|
||||
"They buy it for €{gold} with the focused calm " +
|
||||
"of someone containing a significant emotion.\n\n" +
|
||||
"You don't ask what it is. " +
|
||||
"Whatever you just sold, the foraging skill has decided it counts.",
|
||||
OutcomeRoomLine: "✅ {name} sold an unidentified plant to a herbalist who was very controlled about wanting it.",
|
||||
GoldMin: 60,
|
||||
GoldMax: 140,
|
||||
XP: 25,
|
||||
XPSkill: "foraging",
|
||||
Activity: "foraging",
|
||||
},
|
||||
|
||||
{
|
||||
Key: "army_enlistment",
|
||||
TriggerDM: "A company is giving away free clothing.\n\n" +
|
||||
"You can see the table from here. Nice stuff too — " +
|
||||
"proper fabric, clean lines, nothing corroded or held together by optimism. " +
|
||||
"An actual upgrade over what you're currently wearing, " +
|
||||
"which the game has already described at length and does not need to describe again.\n\n" +
|
||||
"You get in line.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ {name} has spotted a free clothing giveaway and is getting in line.",
|
||||
OutcomeDM: "You wait your turn. You get to the front. " +
|
||||
"They hand you a very nice set of clothing that costs you absolutely nothing.\n\n" +
|
||||
"You are shuffled into a changing room.\n\n" +
|
||||
"You emerge from the changing room onto a battlefield.\n\n" +
|
||||
"This was not a clothing giveaway. " +
|
||||
"This was an army enlistment event. " +
|
||||
"Your reading ability, never your strongest attribute, " +
|
||||
"has failed you at a meaningful moment.\n\n" +
|
||||
"You are assigned to the Commanding General's meat shield group. " +
|
||||
"The arrangement is explained to you clearly: " +
|
||||
"stand in front of the General with your shield raised, " +
|
||||
"keep the General alive, receive 20,000 gold per day. " +
|
||||
"This is, mathematically, an excellent rate of pay.\n\n" +
|
||||
"You stand tall on the battlefield. Shield raised. General behind you. " +
|
||||
"The others in your group doing the same. " +
|
||||
"You are, for the first time in your life, part of something larger than yourself.\n\n" +
|
||||
"You notice a shiny button on the ground.\n\n" +
|
||||
"You bend over to pick it up.\n\n" +
|
||||
"The arrow that would have been stopped by your shield " +
|
||||
"strikes the Commanding General in the head, killing him instantly.\n\n" +
|
||||
"You are discharged from the army on the spot. " +
|
||||
"No gold. No commendation. No acknowledgement that you were " +
|
||||
"technically doing your job correctly right up until the button.\n\n" +
|
||||
"You do have a shiny button and €{gold} in back-pay for the twelve minutes you were technically enlisted.\n\n" +
|
||||
"Huzzah.",
|
||||
OutcomeRoomLine: "✅ {name} accidentally enlisted in an army and immediately got the General killed. Has a button and €{gold}.",
|
||||
GoldMin: 8,
|
||||
GoldMax: 22,
|
||||
XP: 5,
|
||||
Activity: "any",
|
||||
},
|
||||
|
||||
{
|
||||
Key: "tower_bird",
|
||||
TriggerDM: "There is a tall abandoned lookout tower nearby.\n\n" +
|
||||
"If you climbed it, you could survey the surrounding area. " +
|
||||
"Tactically useful. Good information. A sound decision.\n\n" +
|
||||
"It's time to do this like a professional.\n\n" +
|
||||
"Let's climb this ridiculously tall and abandoned lookout tower that is in extreme stages of disrepair. " +
|
||||
"The view from up there could be very enlightening!\n\n" +
|
||||
"The tower sways slightly in the wind from down here.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ {name} has decided to climb an abandoned tower that is visibly swaying.",
|
||||
OutcomeDM: "The tower sways violently back and forth as you climb. " +
|
||||
"Each gust is a conversation between the wind and the structural integrity " +
|
||||
"of something that has been abandoned for good reason.\n\n" +
|
||||
"After what seemed like ages — but thanks to the miracles of narrative " +
|
||||
"storytelling was just the next sentence for us — " +
|
||||
"you reach the top.\n\n" +
|
||||
"Before you can survey anything, something catches your eye. " +
|
||||
"Something sparkly. Inside a bird's nest.\n\n" +
|
||||
"A treasure, perhaps.\n\n" +
|
||||
"From the size of the nest, even a half-wit could tell " +
|
||||
"this is no bird to be trifled with. " +
|
||||
"Thankfully, you are not the average half-wit, " +
|
||||
"and you proceed to dig through the nest.\n\n" +
|
||||
"You find what you're seeking because it cuts you. " +
|
||||
"A worthless piece of broken glass. " +
|
||||
"You yank your hand back in pain, knocking something out of the nest " +
|
||||
"that makes a sound like an egg shattering.\n\n" +
|
||||
"It was an egg.\n\n" +
|
||||
"As luck would have it, mum is right about to arrive home.\n\n" +
|
||||
"A miracle occurs: you finally understand the situation you are in " +
|
||||
"and make haste down the ladder. " +
|
||||
"The bird lands on top of the tower. " +
|
||||
"For one brief moment you breathe a sigh of relief.\n\n" +
|
||||
"The bird looks down.\n\n" +
|
||||
"The bird begins descending.\n\n" +
|
||||
"You put up what few would consider an effective counterattack. " +
|
||||
"You are knocked off the ladder.\n\n" +
|
||||
"Down and down and down.\n\n" +
|
||||
"Splat.\n\n" +
|
||||
"You are not dead.\n\n" +
|
||||
"As luck would have it, you have landed on an enormous and moist pile of manure. " +
|
||||
"The bits on the very top are still warm. " +
|
||||
"You turn to see a farmer walking away with a shovel. " +
|
||||
"Must be fresh.\n\n" +
|
||||
"As you dig yourself out you notice something stuck in your hand. " +
|
||||
"A claw. A beautiful one, from that very large and very angry bird. " +
|
||||
"This ought to sell for a decent amount.\n\n" +
|
||||
"Mission accomplished.",
|
||||
OutcomeRoomLine: "✅ {name} climbed a tower, broke an egg, got knocked off the ladder, and landed in manure. Has a claw.",
|
||||
GoldMin: 45,
|
||||
GoldMax: 95,
|
||||
XP: 10,
|
||||
Activity: "any",
|
||||
},
|
||||
|
||||
{
|
||||
Key: "monkey_coin",
|
||||
TriggerDM: "You're walking home from the market.\n\n" +
|
||||
"You were so unsuccessful at haggling that you ended up " +
|
||||
"paying more than list price for your items, " +
|
||||
"which is an achievement in the wrong direction.\n\n" +
|
||||
"As you make your way home, you spot a monkey.\n\n" +
|
||||
"Ordinarily, most sensible folks would question what a monkey " +
|
||||
"is doing out here, whether it escaped from somewhere, " +
|
||||
"what the implications are.\n\n" +
|
||||
"You are fixated on what's in its hand.\n\n" +
|
||||
"A shiny gold coin.\n\n" +
|
||||
"Reply `!adventure respond` within 2 hours.",
|
||||
TriggerRoomLine: "⚡ {name} has spotted a monkey with a gold coin and is devising a plan.",
|
||||
OutcomeDM: "What's a monkey doing with a gold coin? " +
|
||||
"It doesn't need it. You'll use your superior wits to outsmart it " +
|
||||
"and claim that coin for yourself.\n\n" +
|
||||
"You devise an ingenious plan.\n\n" +
|
||||
"You'll toss an interesting-looking rock at it and it'll grab the rock instead, " +
|
||||
"leaving the gold coin behind. And why not? " +
|
||||
"This stupid monkey will only care that the rock is way bigger than the coin.\n\n" +
|
||||
"After a few moments you finally stop congratulating yourself " +
|
||||
"long enough to put the plan into action.\n\n" +
|
||||
"You gently toss the rock towards the monkey.\n\n" +
|
||||
"Eureka.\n\n" +
|
||||
"The monkey drops the coin exactly as planned and runs for the rock. " +
|
||||
"You hurry over and snatch up the coin. " +
|
||||
"You are about to perform your god awful victory dance " +
|
||||
"when something clocks you in the back of the head " +
|
||||
"causing you to drop your groceries.\n\n" +
|
||||
"Instead of turning around to confront your assailant " +
|
||||
"you focus on picking up the groceries, " +
|
||||
"as getting smacked in the head is a common occurrence for you " +
|
||||
"and the groceries are not.\n\n" +
|
||||
"You finally get yourself together and look up.\n\n" +
|
||||
"The monkey is holding the gold coin and your bananas.\n\n" +
|
||||
"It laughs.\n\n" +
|
||||
"It tosses you the coin out of pity and scurries away.\n\n" +
|
||||
"You did it.\n\n" +
|
||||
"Congratulations.",
|
||||
OutcomeRoomLine: "✅ {name} outsmarted a monkey. The monkey took the bananas and gave back the coin out of pity.",
|
||||
GoldMin: 1,
|
||||
GoldMax: 1,
|
||||
XP: 40,
|
||||
Activity: "any",
|
||||
},
|
||||
}
|
||||
|
||||
// ── FORMAT CONSTANTS ──────────────────────────────────────────────────────────
|
||||
|
||||
const advEventRoomTriggerWrapper = "{trigger_room_line}\n" +
|
||||
"They have 2 hours to respond with `!adventure respond`."
|
||||
|
||||
const advEventRoomOutcomeWrapper = "{outcome_room_line}\n" +
|
||||
"(+€{gold}{xp_suffix})"
|
||||
@@ -377,6 +377,7 @@ var SummaryMiningSuccess = []string{
|
||||
"Mining run at {location}: {ore} recovered, €{value}, no cave-ins.",
|
||||
"Came back from {location} with {ore} and €{value} and a worse {tool}.",
|
||||
"{ore} from {location}. €{value}. The back is filing a complaint.",
|
||||
"You went mining. You tripped. You fell into the wall. You're out cold. But a rock broke free. You may have lost 8 hours. But hell, you gained {ore}. €{value}.",
|
||||
}
|
||||
|
||||
var SummaryMiningDeath = []string{
|
||||
@@ -400,6 +401,14 @@ var SummaryForagingSuccess = []string{
|
||||
"Returned from {location} with {item} worth €{value} and zero injuries. Good day.",
|
||||
"Clean haul from {location}. {item}, €{value}. The forest cooperated.",
|
||||
"{item} from {location}. €{value}. One bear made eye contact. Moved on.",
|
||||
"You went foraging. You foraged. Yep. Stuff found. Good job. What was it? Who cares? It's foraging. Does it really matter? Okay. Fine. It was {item}. €{value}.",
|
||||
}
|
||||
|
||||
var SummaryForagingEmpty = []string{
|
||||
"Wandered through {location}. Found nothing. Not even hornets.",
|
||||
"{location}: searched every bush. Came home empty-handed.",
|
||||
"Nothing from {location}. The forest was uncooperative today.",
|
||||
"Went foraging in {location}. The forest kept its things.",
|
||||
}
|
||||
|
||||
var SummaryForagingDeath = []string{
|
||||
|
||||
@@ -454,7 +454,7 @@ var DeathDM = []string{
|
||||
"Neither will be comfortable. This is healthcare.\n\n" +
|
||||
"Your equipment has been assessed. The assessment was not kind.\n" +
|
||||
"The assessment was accurate. Return expected: {time} UTC.\n" +
|
||||
"The {location} has noted your visit in whatever record {location} keeps.\n" +
|
||||
"The {location} has noted your visit in whatever records it keeps.\n" +
|
||||
"You are in the record as 'visited once, briefly.'",
|
||||
|
||||
"📋 ADVENTURER STATUS: INDISPOSED\n\n" +
|
||||
|
||||
@@ -73,6 +73,14 @@ func advClearFlavorHistory() {
|
||||
func advSubstituteFlavor(template string, vars map[string]string) string {
|
||||
pairs := make([]string, 0, len(vars)*2)
|
||||
for k, v := range vars {
|
||||
// Prevent "The The Soggy Cellar" — if the value starts with "The "
|
||||
// and the template says "The {location}", replace that as a unit first.
|
||||
if strings.HasPrefix(v, "The ") {
|
||||
theKey := "The " + k
|
||||
if strings.Contains(template, theKey) {
|
||||
pairs = append(pairs, theKey, v)
|
||||
}
|
||||
}
|
||||
pairs = append(pairs, k, v)
|
||||
}
|
||||
return strings.NewReplacer(pairs...).Replace(template)
|
||||
@@ -114,9 +122,6 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
|
||||
// Equipment
|
||||
sb.WriteString("\n🛡️ Equipment:\n")
|
||||
eqScore := advEquipmentScore(equip)
|
||||
slotEmoji := map[EquipmentSlot]string{
|
||||
SlotWeapon: "⚔️", SlotArmor: "🛡️", SlotHelmet: "🪖", SlotBoots: "👢", SlotTool: "⛏️",
|
||||
}
|
||||
for _, slot := range allSlots {
|
||||
eq := equip[slot]
|
||||
mastery := ""
|
||||
@@ -125,7 +130,7 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
|
||||
}
|
||||
if eq != nil {
|
||||
sb.WriteString(fmt.Sprintf(" %s %s: %s (Tier %d | %d%% condition%s)\n",
|
||||
slotEmoji[slot], strings.Title(string(slot)), eq.Name, eq.Tier, eq.Condition, mastery))
|
||||
slotEmoji(slot), slotTitle(slot), eq.Name, eq.Tier, eq.Condition, mastery))
|
||||
}
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" Equipment Score: %d\n", eqScore))
|
||||
@@ -281,7 +286,7 @@ func renderAdvResolutionDM(result *AdvActionResult, char *AdventureCharacter) st
|
||||
|
||||
sb.WriteString(fmt.Sprintf("✨ +%d %s XP", result.XPGained, result.XPSkill))
|
||||
if result.LeveledUp {
|
||||
sb.WriteString(fmt.Sprintf(" — **LEVEL UP! %s Lv.%d!** 🎉", strings.Title(result.XPSkill), result.NewLevel))
|
||||
sb.WriteString(fmt.Sprintf(" — **LEVEL UP! %s Lv.%d!** 🎉", titleCase(result.XPSkill), result.NewLevel))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
@@ -290,7 +295,7 @@ func renderAdvResolutionDM(result *AdvActionResult, char *AdventureCharacter) st
|
||||
sb.WriteString("🔧 Equipment damage:\n")
|
||||
for slot, dmg := range result.EquipDamage {
|
||||
if dmg > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" • %s: -%d condition\n", strings.Title(string(slot)), dmg))
|
||||
sb.WriteString(fmt.Sprintf(" • %s: -%d condition\n", slotTitle(slot), dmg))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,6 +324,51 @@ func renderAdvResolutionDM(result *AdvActionResult, char *AdventureCharacter) st
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// advClosingBlock selects and formats a closing block based on outcome.
|
||||
func advClosingBlock(outcome AdvOutcomeType, userID id.UserID, location string, morningHour, summaryHour int) string {
|
||||
var pool []string
|
||||
var category string
|
||||
|
||||
switch outcome {
|
||||
case AdvOutcomeExceptional:
|
||||
pool = ClosingExceptional
|
||||
category = "closing_exceptional"
|
||||
case AdvOutcomeSuccess:
|
||||
pool = ClosingSuccess
|
||||
category = "closing_success"
|
||||
case AdvOutcomeDeath:
|
||||
pool = ClosingDeath
|
||||
category = "closing_death"
|
||||
default:
|
||||
// Empty, cave-in, hornets, bear, river — all failure closings
|
||||
pool = ClosingFailure
|
||||
category = "closing_failure"
|
||||
}
|
||||
|
||||
if len(pool) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
text, _ := advPickFlavor(pool, userID, category)
|
||||
|
||||
// Compute reset time (next midnight UTC)
|
||||
now := time.Now().UTC()
|
||||
midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
|
||||
remaining := midnight.Sub(now)
|
||||
hours := int(remaining.Hours())
|
||||
minutes := int(remaining.Minutes()) % 60
|
||||
|
||||
timeUntil := fmt.Sprintf("%dh %dm", hours, minutes)
|
||||
|
||||
return advSubstituteFlavor(text, map[string]string{
|
||||
"{location}": location,
|
||||
"{reset_time}": "00:00 UTC",
|
||||
"{time_until}": timeUntil,
|
||||
"{morning_time}": fmt.Sprintf("%02d:00", morningHour),
|
||||
"{summary_time}": fmt.Sprintf("%02d:00", summaryHour),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Death Status DM ──────────────────────────────────────────────────────────
|
||||
|
||||
func renderAdvDeathStatusDM(char *AdventureCharacter) string {
|
||||
@@ -327,9 +377,14 @@ func renderAdvDeathStatusDM(char *AdventureCharacter) string {
|
||||
if char.DeadUntil != nil {
|
||||
remaining = char.DeadUntil.Format("15:04")
|
||||
}
|
||||
location := char.GrudgeLocation
|
||||
if location == "" {
|
||||
location = "an unknown location"
|
||||
}
|
||||
return advSubstituteFlavor(text, map[string]string{
|
||||
"{name}": char.DisplayName,
|
||||
"{time}": remaining,
|
||||
"{name}": char.DisplayName,
|
||||
"{time}": remaining,
|
||||
"{location}": location,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -415,9 +470,9 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
|
||||
|
||||
sb.WriteString("\n")
|
||||
if tbRewards.Eligible > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Rewards distributed to %d participating adventurers:\n", tbRewards.Eligible))
|
||||
sb.WriteString(fmt.Sprintf("Rewards distributed to %d participating adventurers (scaled by level):\n", tbRewards.Eligible))
|
||||
if tbRewards.GoldShare > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" 💰 €%d each\n", tbRewards.GoldShare))
|
||||
sb.WriteString(fmt.Sprintf(" 💰 ~€%d avg\n", tbRewards.GoldShare))
|
||||
}
|
||||
if tbRewards.GiftCount > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" ⭐ %d players received a gift item\n", tbRewards.GiftCount))
|
||||
@@ -565,26 +620,28 @@ func renderAdvLeaderboard(chars []AdventureCharacter) string {
|
||||
// ── Treasure Discard Prompt ──────────────────────────────────────────────────
|
||||
|
||||
func renderAdvTreasureDiscardPrompt(newTreasure *AdvTreasureDef, existing []AdvTreasureDef) string {
|
||||
// Pick from treasure inventory cap pool
|
||||
var sb strings.Builder
|
||||
|
||||
if len(TreasureInventoryCap) > 0 {
|
||||
text := TreasureInventoryCap[rand.IntN(len(TreasureInventoryCap))]
|
||||
text = advSubstituteFlavor(text, map[string]string{
|
||||
"{treasure_name}": newTreasure.Name,
|
||||
})
|
||||
sb.WriteString(text)
|
||||
sb.WriteString("\n\n")
|
||||
if len(TreasureInventoryCap) == 0 {
|
||||
return "You found a treasure but your inventory is full. Reply 1, 2, or 3 to discard, or `keep`."
|
||||
}
|
||||
|
||||
sb.WriteString("Current treasures:\n")
|
||||
for i, t := range existing {
|
||||
sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, t.InventoryDesc))
|
||||
// Build substitution map with existing treasure info
|
||||
subs := map[string]string{
|
||||
"{treasure_name}": newTreasure.Name,
|
||||
"{location}": "",
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
key := fmt.Sprintf("%d", i+1)
|
||||
if i < len(existing) {
|
||||
subs["{treasure_"+key+"}"] = existing[i].Name
|
||||
subs["{bonus_"+key+"}"] = existing[i].InventoryDesc
|
||||
} else {
|
||||
subs["{treasure_"+key+"}"] = "(empty)"
|
||||
subs["{bonus_"+key+"}"] = ""
|
||||
}
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\nNew: %s\n", newTreasure.InventoryDesc))
|
||||
sb.WriteString("\nReply with 1, 2, or 3 to discard, or `keep` to leave the new treasure behind.")
|
||||
|
||||
return sb.String()
|
||||
text := TreasureInventoryCap[rand.IntN(len(TreasureInventoryCap))]
|
||||
return advSubstituteFlavor(text, subs)
|
||||
}
|
||||
|
||||
// ── Summary One-Liners ───────────────────────────────────────────────────────
|
||||
@@ -624,7 +681,7 @@ func advSummaryOneLiner(userID id.UserID, activity AdvActivityType, outcome AdvO
|
||||
case AdvOutcomeRiver:
|
||||
pool = SummaryForagingRiver
|
||||
case AdvOutcomeEmpty:
|
||||
pool = SummaryForagingSuccess // no specific empty pool for foraging
|
||||
pool = SummaryForagingEmpty
|
||||
case AdvOutcomeSuccess, AdvOutcomeExceptional:
|
||||
pool = SummaryForagingSuccess
|
||||
}
|
||||
@@ -640,5 +697,9 @@ func advSummaryOneLiner(userID id.UserID, activity AdvActivityType, outcome AdvO
|
||||
"{item}": "",
|
||||
"{value}": fmt.Sprintf("%d", lootValue),
|
||||
"{location}": location,
|
||||
"{hours}": "24",
|
||||
"{ore}": "",
|
||||
"{tool}": "",
|
||||
"{xp}": "",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -266,16 +266,17 @@ func (p *AdventurePlugin) midnightReset() {
|
||||
_ = saveAdvCharacter(&char)
|
||||
}
|
||||
} else {
|
||||
// Update streak
|
||||
if char.LastActionDate == time.Now().UTC().Add(-24*time.Hour).Format("2006-01-02") {
|
||||
// Update streak — LastActionDate was set at action time
|
||||
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
|
||||
if char.LastActionDate == yesterday || char.LastActionDate == today {
|
||||
char.CurrentStreak++
|
||||
} else if char.LastActionDate != today {
|
||||
} else {
|
||||
// Gap in activity — start fresh
|
||||
char.CurrentStreak = 1
|
||||
}
|
||||
if char.CurrentStreak > char.BestStreak {
|
||||
char.BestStreak = char.CurrentStreak
|
||||
}
|
||||
char.LastActionDate = today
|
||||
_ = saveAdvCharacter(&char)
|
||||
}
|
||||
}
|
||||
@@ -293,6 +294,12 @@ func (p *AdventurePlugin) midnightReset() {
|
||||
// Clear flavor history to prevent unbounded memory growth.
|
||||
// Entries are only used for dedup within a day, so clearing at midnight is fine.
|
||||
advClearFlavorHistory()
|
||||
|
||||
// Clear DM reminder dedup — entries are date-keyed so stale after midnight.
|
||||
p.dmRemindedDate.Range(func(key, _ any) bool {
|
||||
p.dmRemindedDate.Delete(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// ── Helper ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -9,67 +9,164 @@ import (
|
||||
|
||||
// ── Shop Listings ────────────────────────────────────────────────────────────
|
||||
|
||||
func advShopListings(equip map[EquipmentSlot]*AdvEquipment, balance float64) string {
|
||||
// slotEmoji returns a display emoji for a slot category.
|
||||
func slotEmoji(slot EquipmentSlot) string {
|
||||
switch slot {
|
||||
case SlotWeapon:
|
||||
return "⚔️"
|
||||
case SlotArmor:
|
||||
return "🛡️"
|
||||
case SlotHelmet:
|
||||
return "🪖"
|
||||
case SlotBoots:
|
||||
return "👢"
|
||||
case SlotTool:
|
||||
return "⛏️"
|
||||
default:
|
||||
return "📦"
|
||||
}
|
||||
}
|
||||
|
||||
// slotTitle returns a capitalized display name for a slot.
|
||||
func slotTitle(slot EquipmentSlot) string {
|
||||
s := string(slot)
|
||||
if len(s) == 0 {
|
||||
return s
|
||||
}
|
||||
return strings.ToUpper(s[:1]) + s[1:]
|
||||
}
|
||||
|
||||
// advShopOverview shows a compact category menu with current equipment and next upgrade.
|
||||
func advShopOverview(equip map[EquipmentSlot]*AdvEquipment, balance float64) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🛒 **Equipment Shop**\n")
|
||||
sb.WriteString(fmt.Sprintf("💰 Your balance: €%.0f\n\n", balance))
|
||||
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
|
||||
|
||||
for _, slot := range allSlots {
|
||||
current := equip[slot]
|
||||
currentTier := 0
|
||||
if current != nil {
|
||||
currentTier = current.Tier
|
||||
}
|
||||
|
||||
defs := equipmentTiers[slot]
|
||||
currentName := "None"
|
||||
if current != nil {
|
||||
currentTier = current.Tier
|
||||
currentName = current.Name
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("**%s** (current: %s, Tier %d)\n", strings.Title(string(slot)), currentName, currentTier))
|
||||
|
||||
hasUpgrades := false
|
||||
emoji := slotEmoji(slot)
|
||||
title := slotTitle(slot)
|
||||
|
||||
// Find next upgrade.
|
||||
defs := equipmentTiers[slot]
|
||||
nextUpgrade := ""
|
||||
for _, def := range defs {
|
||||
if def.Tier <= currentTier || def.Price == 0 {
|
||||
continue
|
||||
if def.Tier > currentTier && def.Price > 0 {
|
||||
nextUpgrade = fmt.Sprintf("Next: %s — €%.0f", def.Name, def.Price)
|
||||
break
|
||||
}
|
||||
hasUpgrades = true
|
||||
affordable := ""
|
||||
if balance < def.Price {
|
||||
affordable = " ❌"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" Tier %d: %s — €%.0f%s\n", def.Tier, def.Name, def.Price, affordable))
|
||||
sb.WriteString(fmt.Sprintf(" _%s_\n", advTruncate(def.Description, 120)))
|
||||
}
|
||||
if !hasUpgrades {
|
||||
sb.WriteString(" ✨ Max tier reached!\n")
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%s **%s** — %s (T%d)\n", emoji, title, currentName, currentTier))
|
||||
if nextUpgrade != "" {
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", nextUpgrade))
|
||||
} else {
|
||||
sb.WriteString(" ✨ Maxed out!\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("To buy: reply with `buy <item name>`\n")
|
||||
sb.WriteString("Example: `buy Sad Iron Sword`")
|
||||
sb.WriteString("Browse a category: `!adventure shop <category>`\n")
|
||||
sb.WriteString("Categories: `weapon` · `armor` · `helmet` · `boots` · `tool`")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func advTruncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
var shopCategoryAliases = map[string]EquipmentSlot{
|
||||
"weapon": SlotWeapon, "weapons": SlotWeapon, "sword": SlotWeapon, "swords": SlotWeapon,
|
||||
"armor": SlotArmor, "armour": SlotArmor,
|
||||
"helmet": SlotHelmet, "helm": SlotHelmet, "helmets": SlotHelmet,
|
||||
"boots": SlotBoots, "boot": SlotBoots,
|
||||
"tool": SlotTool, "tools": SlotTool, "pickaxe": SlotTool,
|
||||
}
|
||||
|
||||
// advParseShopCategory maps user input to an EquipmentSlot.
|
||||
func advParseShopCategory(input string) EquipmentSlot {
|
||||
return shopCategoryAliases[strings.ToLower(strings.TrimSpace(input))]
|
||||
}
|
||||
|
||||
// advShopCategory shows detailed listings for a single equipment slot.
|
||||
func advShopCategory(slot EquipmentSlot, equip map[EquipmentSlot]*AdvEquipment, balance float64) string {
|
||||
var sb strings.Builder
|
||||
|
||||
current := equip[slot]
|
||||
currentTier := 0
|
||||
currentName := "None"
|
||||
if current != nil {
|
||||
currentTier = current.Tier
|
||||
currentName = current.Name
|
||||
}
|
||||
return s[:n-3] + "..."
|
||||
|
||||
emoji := slotEmoji(slot)
|
||||
title := slotTitle(slot)
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%s **%s Shop**\n", emoji, title))
|
||||
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n", balance))
|
||||
sb.WriteString(fmt.Sprintf("Equipped: **%s** (Tier %d)\n\n", currentName, currentTier))
|
||||
|
||||
defs := equipmentTiers[slot]
|
||||
hasUpgrades := false
|
||||
for _, def := range defs {
|
||||
if def.Tier <= currentTier || def.Price == 0 {
|
||||
continue
|
||||
}
|
||||
hasUpgrades = true
|
||||
|
||||
priceTag := fmt.Sprintf("€%.0f", def.Price)
|
||||
if balance < def.Price {
|
||||
priceTag += " 💸 can't afford"
|
||||
} else {
|
||||
priceTag += " ✅ affordable"
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("**Tier %d: %s** — %s\n", def.Tier, def.Name, priceTag))
|
||||
sb.WriteString(fmt.Sprintf("%s\n\n", def.Description))
|
||||
}
|
||||
|
||||
if !hasUpgrades {
|
||||
sb.WriteString("✨ You've reached max tier! Nothing left to buy here.\n\n")
|
||||
}
|
||||
|
||||
sb.WriteString("To buy: `!adventure buy <item name>` or `!adventure buy <tier> <category>`\n")
|
||||
sb.WriteString("Example: `!adventure buy Enchanted Blade` or `!adventure buy 4 sword`\n")
|
||||
sb.WriteString("Back to overview: `!adventure shop`")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ── Find Shop Item ───────────────────────────────────────────────────────────
|
||||
|
||||
// normalizeQuotes replaces common Unicode quotes/apostrophes with ASCII equivalents.
|
||||
var quoteReplacer = strings.NewReplacer(
|
||||
"\u2018", "'", "\u2019", "'", // left/right single curly quotes
|
||||
"\u201C", "\"", "\u201D", "\"", // left/right double curly quotes
|
||||
"\u2032", "'", // prime
|
||||
)
|
||||
|
||||
func normalizeQuotes(s string) string {
|
||||
return quoteReplacer.Replace(s)
|
||||
}
|
||||
|
||||
func advFindShopItem(name string) (EquipmentSlot, *EquipmentDef, bool) {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
name = normalizeQuotes(strings.TrimSpace(name))
|
||||
|
||||
// Support tier+category shorthand: "3 sword", "tier 3 weapon", "t3 boots", etc.
|
||||
if slot, def, ok := advFindByTierShorthand(name); ok {
|
||||
return slot, def, true
|
||||
}
|
||||
|
||||
for _, slot := range allSlots {
|
||||
for i := range equipmentTiers[slot] {
|
||||
def := &equipmentTiers[slot][i]
|
||||
if def.Price == 0 {
|
||||
continue // can't buy tier 0
|
||||
}
|
||||
if strings.EqualFold(def.Name, name) || containsFold(def.Name, name) {
|
||||
if strings.EqualFold(def.Name, name) || containsFold(normalizeQuotes(def.Name), name) {
|
||||
return slot, def, true
|
||||
}
|
||||
}
|
||||
@@ -77,6 +174,45 @@ func advFindShopItem(name string) (EquipmentSlot, *EquipmentDef, bool) {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
// advFindByTierShorthand matches patterns like "3 sword", "tier 3 weapon", "t3 boots".
|
||||
func advFindByTierShorthand(input string) (EquipmentSlot, *EquipmentDef, bool) {
|
||||
lower := strings.ToLower(input)
|
||||
|
||||
// Strip optional "tier " or "t" prefix.
|
||||
lower = strings.TrimPrefix(lower, "tier ")
|
||||
lower = strings.TrimPrefix(lower, "t")
|
||||
|
||||
// Expect "<number> <category>" or "<number><category>".
|
||||
parts := strings.SplitN(strings.TrimSpace(lower), " ", 2)
|
||||
if len(parts) < 2 {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
tierStr := strings.TrimSpace(parts[0])
|
||||
category := strings.TrimSpace(parts[1])
|
||||
|
||||
tier := 0
|
||||
for _, c := range tierStr {
|
||||
if c < '0' || c > '9' {
|
||||
return "", nil, false
|
||||
}
|
||||
tier = tier*10 + int(c-'0')
|
||||
}
|
||||
|
||||
slot := advParseShopCategory(category)
|
||||
if slot == "" {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
defs := equipmentTiers[slot]
|
||||
for i := range defs {
|
||||
if defs[i].Tier == tier && defs[i].Price > 0 {
|
||||
return slot, &defs[i], true
|
||||
}
|
||||
}
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
// ── Buy Equipment ────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) advBuyEquipment(userID id.UserID, slot EquipmentSlot, def *EquipmentDef, equip map[EquipmentSlot]*AdvEquipment) string {
|
||||
|
||||
302
internal/plugin/adventure_test.go
Normal file
302
internal/plugin/adventure_test.go
Normal file
@@ -0,0 +1,302 @@
|
||||
package plugin
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAdvEquipmentScore_Empty(t *testing.T) {
|
||||
score := advEquipmentScore(map[EquipmentSlot]*AdvEquipment{})
|
||||
if score != 0 {
|
||||
t.Errorf("empty equipment should score 0, got %d", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvEquipmentScore_WeaponDoubled(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 3, Condition: 100},
|
||||
SlotArmor: {Tier: 3, Condition: 100},
|
||||
}
|
||||
score := advEquipmentScore(equip)
|
||||
// Weapon: 3*2=6, Armor: 3
|
||||
if score != 9 {
|
||||
t.Errorf("got %d, want 9 (weapon 6 + armor 3)", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvEquipmentScore_LowConditionHalved(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotArmor: {Tier: 4, Condition: 49}, // below 50%
|
||||
}
|
||||
score := advEquipmentScore(equip)
|
||||
// Tier 4, halved = 2
|
||||
if score != 2 {
|
||||
t.Errorf("got %d, want 2 (tier 4 halved for low condition)", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvEquipmentScore_FullLoadout(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 5, Condition: 100},
|
||||
SlotArmor: {Tier: 5, Condition: 100},
|
||||
SlotHelmet: {Tier: 5, Condition: 100},
|
||||
SlotBoots: {Tier: 5, Condition: 100},
|
||||
SlotTool: {Tier: 5, Condition: 100},
|
||||
}
|
||||
score := advEquipmentScore(equip)
|
||||
// Weapon: 5*2=10, others: 5*4=20, total=30
|
||||
if score != 30 {
|
||||
t.Errorf("got %d, want 30", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXpToNextLevel(t *testing.T) {
|
||||
// Level 1: 100 + 3 = 103
|
||||
if xp := xpToNextLevel(1); xp != 103 {
|
||||
t.Errorf("level 1: got %d, want 103", xp)
|
||||
}
|
||||
// Level 50: 100 + 150 = 250
|
||||
if xp := xpToNextLevel(50); xp != 250 {
|
||||
t.Errorf("level 50: got %d, want 250", xp)
|
||||
}
|
||||
// Higher levels should require more XP
|
||||
for i := 1; i < 50; i++ {
|
||||
if xpToNextLevel(i) >= xpToNextLevel(i+1) {
|
||||
t.Errorf("xp for level %d should be less than level %d", i, i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvIsEligible_TooLowLevel(t *testing.T) {
|
||||
char := &AdventureCharacter{CombatLevel: 5}
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 3, Condition: 100},
|
||||
}
|
||||
loc := &AdvLocation{Activity: AdvActivityDungeon, MinLevel: 20, MinEquipTier: 0}
|
||||
bonuses := &AdvBonusSummary{}
|
||||
|
||||
eligible, _ := advIsEligible(char, equip, loc, bonuses)
|
||||
if eligible {
|
||||
t.Error("should not be eligible with combat level 5 for min level 20")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvIsEligible_TooLowEquipTier(t *testing.T) {
|
||||
char := &AdventureCharacter{CombatLevel: 30}
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 1, Condition: 100},
|
||||
SlotArmor: {Tier: 0, Condition: 100}, // this is the bottleneck
|
||||
}
|
||||
loc := &AdvLocation{Activity: AdvActivityDungeon, MinLevel: 1, MinEquipTier: 1}
|
||||
bonuses := &AdvBonusSummary{}
|
||||
|
||||
eligible, _ := advIsEligible(char, equip, loc, bonuses)
|
||||
if eligible {
|
||||
t.Error("should not be eligible with min equip tier 0 for requirement 1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvIsEligible_PenaltyZone(t *testing.T) {
|
||||
char := &AdventureCharacter{CombatLevel: 21} // within 3 of min 20
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 2, Condition: 100},
|
||||
}
|
||||
loc := &AdvLocation{Activity: AdvActivityDungeon, MinLevel: 20, MinEquipTier: 0}
|
||||
bonuses := &AdvBonusSummary{}
|
||||
|
||||
eligible, penalty := advIsEligible(char, equip, loc, bonuses)
|
||||
if !eligible {
|
||||
t.Error("should be eligible at combat level 21 for min 20")
|
||||
}
|
||||
if !penalty {
|
||||
t.Error("should be in penalty zone (21-20=1, less than 3)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvIsEligible_NoPenalty(t *testing.T) {
|
||||
char := &AdventureCharacter{CombatLevel: 25}
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 2, Condition: 100},
|
||||
}
|
||||
loc := &AdvLocation{Activity: AdvActivityDungeon, MinLevel: 20, MinEquipTier: 0}
|
||||
bonuses := &AdvBonusSummary{}
|
||||
|
||||
eligible, penalty := advIsEligible(char, equip, loc, bonuses)
|
||||
if !eligible {
|
||||
t.Error("should be eligible")
|
||||
}
|
||||
if penalty {
|
||||
t.Error("should NOT be in penalty zone (25-20=5, >= 3)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateAdvProbabilities_SumsTo100(t *testing.T) {
|
||||
char := &AdventureCharacter{CombatLevel: 10}
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 2, Condition: 100},
|
||||
SlotArmor: {Tier: 2, Condition: 100},
|
||||
}
|
||||
loc := &AdvLocation{
|
||||
Activity: AdvActivityDungeon,
|
||||
BaseDeathPct: 18,
|
||||
EmptyPct: 15,
|
||||
MinLevel: 8,
|
||||
}
|
||||
bonuses := &AdvBonusSummary{}
|
||||
|
||||
prob := calculateAdvProbabilities(char, equip, loc, bonuses, false)
|
||||
total := prob.DeathPct + prob.EmptyPct + prob.SuccessPct + prob.ExceptionalPct
|
||||
|
||||
if total < 99.9 || total > 100.1 {
|
||||
t.Errorf("probabilities should sum to 100, got %.2f (death=%.1f empty=%.1f success=%.1f exceptional=%.1f)",
|
||||
total, prob.DeathPct, prob.EmptyPct, prob.SuccessPct, prob.ExceptionalPct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateAdvProbabilities_PenaltyIncreasesRisk(t *testing.T) {
|
||||
char := &AdventureCharacter{CombatLevel: 10}
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 2, Condition: 100},
|
||||
}
|
||||
loc := &AdvLocation{
|
||||
Activity: AdvActivityDungeon,
|
||||
BaseDeathPct: 18,
|
||||
EmptyPct: 15,
|
||||
MinLevel: 8,
|
||||
}
|
||||
bonuses := &AdvBonusSummary{}
|
||||
|
||||
noPenalty := calculateAdvProbabilities(char, equip, loc, bonuses, false)
|
||||
withPenalty := calculateAdvProbabilities(char, equip, loc, bonuses, true)
|
||||
|
||||
if withPenalty.DeathPct <= noPenalty.DeathPct {
|
||||
t.Errorf("penalty zone should increase death %% (%.1f vs %.1f)", withPenalty.DeathPct, noPenalty.DeathPct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateAdvProbabilities_BetterGearReducesDeath(t *testing.T) {
|
||||
char := &AdventureCharacter{CombatLevel: 10}
|
||||
loc := &AdvLocation{
|
||||
Activity: AdvActivityDungeon,
|
||||
BaseDeathPct: 30,
|
||||
EmptyPct: 15,
|
||||
MinLevel: 8,
|
||||
}
|
||||
bonuses := &AdvBonusSummary{}
|
||||
|
||||
weakGear := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 1, Condition: 100},
|
||||
}
|
||||
strongGear := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 5, Condition: 100},
|
||||
SlotArmor: {Tier: 5, Condition: 100},
|
||||
SlotHelmet: {Tier: 5, Condition: 100},
|
||||
SlotBoots: {Tier: 5, Condition: 100},
|
||||
SlotTool: {Tier: 5, Condition: 100},
|
||||
}
|
||||
|
||||
weakProb := calculateAdvProbabilities(char, weakGear, loc, bonuses, false)
|
||||
strongProb := calculateAdvProbabilities(char, strongGear, loc, bonuses, false)
|
||||
|
||||
if strongProb.DeathPct >= weakProb.DeathPct {
|
||||
t.Errorf("better gear should reduce death %% (strong=%.1f vs weak=%.1f)", strongProb.DeathPct, weakProb.DeathPct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvEffectiveSkill(t *testing.T) {
|
||||
char := &AdventureCharacter{
|
||||
CombatLevel: 10,
|
||||
MiningSkill: 15,
|
||||
ForagingSkill: 20,
|
||||
}
|
||||
bonuses := &AdvBonusSummary{
|
||||
CombatBonus: 5,
|
||||
MiningBonus: 3,
|
||||
ForagingBonus: 0,
|
||||
}
|
||||
|
||||
if s := advEffectiveSkill(char, AdvActivityDungeon, bonuses); s != 15 {
|
||||
t.Errorf("dungeon: got %d, want 15 (10+5)", s)
|
||||
}
|
||||
if s := advEffectiveSkill(char, AdvActivityMining, bonuses); s != 18 {
|
||||
t.Errorf("mining: got %d, want 18 (15+3)", s)
|
||||
}
|
||||
if s := advEffectiveSkill(char, AdvActivityForaging, bonuses); s != 20 {
|
||||
t.Errorf("foraging: got %d, want 20 (20+0)", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvParseShopCategory(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want EquipmentSlot
|
||||
}{
|
||||
{"weapon", SlotWeapon},
|
||||
{"sword", SlotWeapon},
|
||||
{"swords", SlotWeapon},
|
||||
{"armor", SlotArmor},
|
||||
{"armour", SlotArmor},
|
||||
{"helmet", SlotHelmet},
|
||||
{"helm", SlotHelmet},
|
||||
{"boots", SlotBoots},
|
||||
{"boot", SlotBoots},
|
||||
{"tool", SlotTool},
|
||||
{"pickaxe", SlotTool},
|
||||
{" WEAPON ", SlotWeapon}, // trimmed + lowered
|
||||
{"nonsense", ""},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := advParseShopCategory(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("advParseShopCategory(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlotEmoji(t *testing.T) {
|
||||
if e := slotEmoji(SlotWeapon); e != "⚔️" {
|
||||
t.Errorf("weapon emoji: got %q", e)
|
||||
}
|
||||
if e := slotEmoji(SlotArmor); e != "🛡️" {
|
||||
t.Errorf("armor emoji: got %q", e)
|
||||
}
|
||||
// Default case
|
||||
if e := slotEmoji("unknown"); e != "📦" {
|
||||
t.Errorf("unknown slot emoji: got %q, want 📦", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlotTitle(t *testing.T) {
|
||||
if s := slotTitle(SlotWeapon); s != "Weapon" {
|
||||
t.Errorf("got %q, want Weapon", s)
|
||||
}
|
||||
if s := slotTitle(SlotBoots); s != "Boots" {
|
||||
t.Errorf("got %q, want Boots", s)
|
||||
}
|
||||
if s := slotTitle(""); s != "" {
|
||||
t.Errorf("empty slot should return empty, got %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeAdvBonuses(t *testing.T) {
|
||||
treasures := []AdvTreasureBonus{
|
||||
{BonusType: "combat_level", BonusValue: 3},
|
||||
{BonusType: "mining_skill", BonusValue: 2},
|
||||
{BonusType: "all_skills", BonusValue: 1},
|
||||
{BonusType: "death_chance", BonusValue: -2.5},
|
||||
}
|
||||
|
||||
bonuses := computeAdvBonuses(treasures, nil, 0, false)
|
||||
|
||||
if bonuses.CombatBonus != 4 { // 3 + 1 from all_skills
|
||||
t.Errorf("CombatBonus: got %d, want 4", bonuses.CombatBonus)
|
||||
}
|
||||
if bonuses.MiningBonus != 3 { // 2 + 1 from all_skills
|
||||
t.Errorf("MiningBonus: got %d, want 3", bonuses.MiningBonus)
|
||||
}
|
||||
if bonuses.ForagingBonus != 1 { // 1 from all_skills
|
||||
t.Errorf("ForagingBonus: got %d, want 1", bonuses.ForagingBonus)
|
||||
}
|
||||
if bonuses.DeathModifier != -2.5 {
|
||||
t.Errorf("DeathModifier: got %f, want -2.5", bonuses.DeathModifier)
|
||||
}
|
||||
}
|
||||
@@ -281,6 +281,7 @@ func advSaveTreasure(userID id.UserID, def *AdvTreasureDef) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, bonus := range def.Bonuses {
|
||||
_, err := tx.Exec(`
|
||||
@@ -288,7 +289,6 @@ func advSaveTreasure(userID id.UserID, def *AdvTreasureDef) error {
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
string(userID), def.Key, def.Name, def.Tier, bonus.Type, bonus.Value)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,10 +44,57 @@ var twinBeeWeights = []twinBeeActionWeight{
|
||||
{AdvActivityForaging, 3, 0.15},
|
||||
}
|
||||
|
||||
// twinBeeMaxTier returns the highest tier TwinBee should visit,
|
||||
// based on the best player's combined adventure level.
|
||||
func twinBeeMaxTier() int {
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil || len(chars) == 0 {
|
||||
return 3
|
||||
}
|
||||
bestLevel := 0
|
||||
for _, c := range chars {
|
||||
if !c.Alive {
|
||||
continue
|
||||
}
|
||||
combined := c.CombatLevel + c.MiningSkill + c.ForagingSkill
|
||||
if combined > bestLevel {
|
||||
bestLevel = combined
|
||||
}
|
||||
}
|
||||
// Tier 3: anyone (combined 3+)
|
||||
// Tier 4: best player has combined 12+ (avg level 4 per skill)
|
||||
// Tier 5: best player has combined 21+ (avg level 7 per skill)
|
||||
switch {
|
||||
case bestLevel >= 21:
|
||||
return 5
|
||||
case bestLevel >= 12:
|
||||
return 4
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
}
|
||||
|
||||
func selectTwinBeeAction() (AdvActivityType, *AdvLocation) {
|
||||
roll := rand.Float64()
|
||||
cumulative := 0.0
|
||||
maxTier := twinBeeMaxTier()
|
||||
|
||||
// Filter weights to only include tiers within range.
|
||||
var filtered []twinBeeActionWeight
|
||||
var totalWeight float64
|
||||
for _, w := range twinBeeWeights {
|
||||
if w.Tier <= maxTier {
|
||||
filtered = append(filtered, w)
|
||||
totalWeight += w.Weight
|
||||
}
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
loc := findAdvLocationByTier(AdvActivityDungeon, 3)
|
||||
return AdvActivityDungeon, loc
|
||||
}
|
||||
|
||||
roll := rand.Float64() * totalWeight
|
||||
cumulative := 0.0
|
||||
for _, w := range filtered {
|
||||
cumulative += w.Weight
|
||||
if roll < cumulative {
|
||||
loc := findAdvLocationByTier(w.Activity, w.Tier)
|
||||
@@ -55,8 +102,8 @@ func selectTwinBeeAction() (AdvActivityType, *AdvLocation) {
|
||||
}
|
||||
}
|
||||
// Fallback
|
||||
loc := findAdvLocationByTier(AdvActivityDungeon, 3)
|
||||
return AdvActivityDungeon, loc
|
||||
loc := findAdvLocationByTier(filtered[0].Activity, filtered[0].Tier)
|
||||
return filtered[0].Activity, loc
|
||||
}
|
||||
|
||||
// ── TwinBee Result ───────────────────────────────────────────────────────────
|
||||
@@ -77,8 +124,15 @@ func (p *AdventurePlugin) runTwinBeeDaily() *TwinBeeResult {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Copy equipment so mutations don't accumulate on the global template.
|
||||
equip := make(map[EquipmentSlot]*AdvEquipment, len(twinBeeEquip))
|
||||
for k, v := range twinBeeEquip {
|
||||
copy := *v
|
||||
equip[k] = ©
|
||||
}
|
||||
|
||||
bonuses := &AdvBonusSummary{} // TwinBee has no treasures/buffs
|
||||
result := resolveAdvAction(&twinBeeChar, twinBeeEquip, loc, bonuses, false)
|
||||
result := resolveAdvAction(&twinBeeChar, equip, loc, bonuses, false)
|
||||
|
||||
// TwinBee never dies — reroll death to empty
|
||||
if result.Outcome == AdvOutcomeDeath {
|
||||
@@ -201,19 +255,48 @@ func (p *AdventurePlugin) distributeTwinBeeRewards(result *TwinBeeResult) TwinBe
|
||||
return summary
|
||||
}
|
||||
|
||||
// Distribute gold
|
||||
share := result.LootValue / int64(len(eligible))
|
||||
if share < 1 {
|
||||
share = 1
|
||||
// Distribute gold weighted by combined adventure level.
|
||||
// Higher-level players get a proportionally larger share.
|
||||
type eligiblePlayer struct {
|
||||
uid id.UserID
|
||||
weight int
|
||||
}
|
||||
summary.GoldShare = share
|
||||
|
||||
var players []eligiblePlayer
|
||||
totalWeight := 0
|
||||
for _, uid := range eligible {
|
||||
p.euro.Credit(uid, float64(share), "twinbee_daily_share")
|
||||
if rollTwinBeeGift(uid) {
|
||||
weight := 3 // minimum (level 1 in all 3 skills)
|
||||
for _, c := range chars {
|
||||
if c.UserID == uid {
|
||||
weight = c.CombatLevel + c.MiningSkill + c.ForagingSkill
|
||||
if weight < 3 {
|
||||
weight = 3
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
weight = weight * weight // quadratic scaling — veterans get much more
|
||||
players = append(players, eligiblePlayer{uid: uid, weight: weight})
|
||||
totalWeight += weight
|
||||
}
|
||||
|
||||
if totalWeight == 0 {
|
||||
totalWeight = 1
|
||||
}
|
||||
|
||||
var totalDistributed int64
|
||||
for _, ep := range players {
|
||||
share := result.LootValue * int64(ep.weight) / int64(totalWeight)
|
||||
if share < 1 {
|
||||
share = 1
|
||||
}
|
||||
p.euro.Credit(ep.uid, float64(share), "twinbee_daily_share")
|
||||
totalDistributed += share
|
||||
if rollTwinBeeGift(ep.uid) {
|
||||
summary.GiftCount++
|
||||
}
|
||||
}
|
||||
// Report average share for the summary display.
|
||||
summary.GoldShare = totalDistributed / int64(len(eligible))
|
||||
|
||||
p.logTwinBeeResult(result, summary)
|
||||
return summary
|
||||
@@ -260,14 +343,10 @@ func rollTwinBeeGift(userID id.UserID) bool {
|
||||
// ── TwinBee Log ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) logTwinBeeResult(result *TwinBeeResult, summary TwinBeeRewardSummary) {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(`
|
||||
INSERT INTO adventure_twinbee_log (activity_type, location, outcome, loot_value, loot_desc, participant_count, gold_share, gift_count)
|
||||
db.Exec("adventure: log twinbee result",
|
||||
`INSERT INTO adventure_twinbee_log (activity_type, location, outcome, loot_value, loot_desc, participant_count, gold_share, gift_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
string(result.Activity), result.Location.Name, string(result.Outcome),
|
||||
result.LootValue, result.LootDesc,
|
||||
summary.Eligible, summary.GoldShare, summary.GiftCount)
|
||||
if err != nil {
|
||||
slog.Error("adventure: failed to log twinbee result", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,12 +111,8 @@ func (p *AnimePlugin) OnMessage(ctx MessageContext) error {
|
||||
parts := strings.SplitN(args, " ", 2)
|
||||
sub := strings.ToLower(parts[0])
|
||||
|
||||
// Subcommands that only touch the DB don't need async
|
||||
switch sub {
|
||||
case "watch":
|
||||
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !anime watch <title>")
|
||||
}
|
||||
return p.handleWatch(ctx, strings.TrimSpace(parts[1]))
|
||||
case "watching":
|
||||
return p.handleWatching(ctx)
|
||||
case "unwatch":
|
||||
@@ -124,13 +120,30 @@ func (p *AnimePlugin) OnMessage(ctx MessageContext) error {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !anime unwatch <id>")
|
||||
}
|
||||
return p.handleUnwatch(ctx, strings.TrimSpace(parts[1]))
|
||||
case "season":
|
||||
return p.handleSeason(ctx)
|
||||
case "upcoming":
|
||||
return p.handleUpcoming(ctx)
|
||||
default:
|
||||
return p.handleSearch(ctx, args)
|
||||
}
|
||||
|
||||
// Subcommands that hit the Jikan API run async to avoid blocking dispatch
|
||||
go func() {
|
||||
var err error
|
||||
switch sub {
|
||||
case "watch":
|
||||
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
||||
err = p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !anime watch <title>")
|
||||
} else {
|
||||
err = p.handleWatch(ctx, strings.TrimSpace(parts[1]))
|
||||
}
|
||||
case "season":
|
||||
err = p.handleSeason(ctx)
|
||||
case "upcoming":
|
||||
err = p.handleUpcoming(ctx)
|
||||
default:
|
||||
err = p.handleSearch(ctx, args)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("anime: handler error", "err", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// rateLimit enforces the 400ms delay between Jikan API calls.
|
||||
@@ -166,8 +179,6 @@ func (p *AnimePlugin) jikanGet(apiURL string, target interface{}) error {
|
||||
}
|
||||
|
||||
func (p *AnimePlugin) handleSearch(ctx MessageContext, query string) error {
|
||||
d := db.Get()
|
||||
|
||||
// Search via API
|
||||
encoded := url.QueryEscape(query)
|
||||
apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime?q=%s&limit=3&sfw=true", encoded)
|
||||
@@ -186,13 +197,11 @@ func (p *AnimePlugin) handleSearch(ctx MessageContext, query string) error {
|
||||
|
||||
// Cache the result
|
||||
data, _ := json.Marshal(anime)
|
||||
if _, err := d.Exec(
|
||||
db.Exec("anime: cache write",
|
||||
`INSERT INTO anime_cache (mal_id, data, cached_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(mal_id) DO UPDATE SET data = ?, cached_at = ?`,
|
||||
anime.MalID, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||
); err != nil {
|
||||
slog.Error("anime: cache write", "err", err)
|
||||
}
|
||||
)
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, p.formatAnime(anime))
|
||||
}
|
||||
@@ -293,7 +302,7 @@ func (p *AnimePlugin) handleWatch(ctx MessageContext, title string) error {
|
||||
|
||||
// Also cache the anime data
|
||||
data, _ := json.Marshal(anime)
|
||||
_, _ = d.Exec(
|
||||
db.Exec("anime: cache watchlist entry",
|
||||
`INSERT INTO anime_cache (mal_id, data, cached_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(mal_id) DO UPDATE SET data = ?, cached_at = ?`,
|
||||
anime.MalID, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||
@@ -514,7 +523,7 @@ func (p *AnimePlugin) PostDailyReleases(roomID id.RoomID) {
|
||||
|
||||
// Update cache
|
||||
data, _ := json.Marshal(animeData)
|
||||
_, _ = d.Exec(
|
||||
db.Exec("anime: update daily cache",
|
||||
`INSERT INTO anime_cache (mal_id, data, cached_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(mal_id) DO UPDATE SET data = ?, cached_at = ?`,
|
||||
entry.malID, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||
|
||||
@@ -304,14 +304,11 @@ func (p *BirthdayPlugin) CheckAndPost(roomID id.RoomID) error {
|
||||
}
|
||||
|
||||
// Mark as fired (for XP/DM dedup)
|
||||
_, err = d.Exec(
|
||||
db.Exec("birthday: mark fired",
|
||||
`INSERT INTO birthday_fired (user_id, year) VALUES (?, ?)
|
||||
ON CONFLICT(user_id, year) DO NOTHING`,
|
||||
m.userID, year,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("birthday: mark fired", "user", m.userID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -337,12 +334,9 @@ func (p *BirthdayPlugin) grantBirthdayXP(userID id.UserID, amount int) {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = d.Exec(
|
||||
db.Exec("birthday: log xp",
|
||||
`INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)`,
|
||||
string(userID), amount, "birthday")
|
||||
if err != nil {
|
||||
slog.Error("birthday: log xp", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// parseBirthdayDate parses MM-DD or MM-DD-YYYY format.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
@@ -288,7 +287,7 @@ func (p *BlackjackPlugin) handleBlackjack(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
table.players = append(table.players, &bjPlayer{UserID: ctx.Sender, Bet: bet})
|
||||
name := p.bjDisplayName(ctx.Sender)
|
||||
name := p.DisplayName(ctx.Sender)
|
||||
|
||||
if len(table.players) >= 4 {
|
||||
_ = p.SendMessage(ctx.RoomID,
|
||||
@@ -323,7 +322,7 @@ func (p *BlackjackPlugin) handleBlackjack(ctx MessageContext) error {
|
||||
}
|
||||
p.tables[ctx.RoomID] = table
|
||||
|
||||
name := p.bjDisplayName(ctx.Sender)
|
||||
name := p.DisplayName(ctx.Sender)
|
||||
_ = p.SendMessage(ctx.RoomID,
|
||||
fmt.Sprintf("🃏 **%s** opens a Blackjack table! Bet: €%d\nJoin with `!blackjack €amount` or `!blackjack deal` to start now (60s to join)",
|
||||
name, int(bet)))
|
||||
@@ -357,7 +356,7 @@ func (p *BlackjackPlugin) handleLeave(ctx MessageContext) error {
|
||||
}
|
||||
player.Bust = true
|
||||
player.Done = true
|
||||
name := p.bjDisplayName(ctx.Sender)
|
||||
name := p.DisplayName(ctx.Sender)
|
||||
_ = p.SendMessage(ctx.RoomID,
|
||||
fmt.Sprintf("🏳️ **%s** forfeits! Bet lost.", name))
|
||||
p.checkAllDone(ctx.RoomID, table)
|
||||
@@ -381,7 +380,7 @@ func (p *BlackjackPlugin) handleLeave(ctx MessageContext) error {
|
||||
delete(p.tables, ctx.RoomID)
|
||||
return p.SendMessage(ctx.RoomID, "🃏 Table closed — all players left.")
|
||||
}
|
||||
name := p.bjDisplayName(ctx.Sender)
|
||||
name := p.DisplayName(ctx.Sender)
|
||||
return p.SendMessage(ctx.RoomID,
|
||||
fmt.Sprintf("🃏 **%s** left the table. Bet refunded.", name))
|
||||
}
|
||||
@@ -440,7 +439,7 @@ func (p *BlackjackPlugin) startRound(roomID id.RoomID, table *bjTable) {
|
||||
for _, pl := range table.players {
|
||||
if !pl.Done {
|
||||
allDone = false
|
||||
activeNames = append(activeNames, p.bjDisplayName(pl.UserID))
|
||||
activeNames = append(activeNames, p.DisplayName(pl.UserID))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,7 +483,7 @@ func (p *BlackjackPlugin) handleHit(ctx MessageContext) error {
|
||||
if v > 21 {
|
||||
player.Bust = true
|
||||
player.Done = true
|
||||
name := p.bjDisplayName(player.UserID)
|
||||
name := p.DisplayName(player.UserID)
|
||||
msgs := []string{fmt.Sprintf("💥 **%s** busts with %s (%d)!", name, handStr(player.Hand), v)}
|
||||
allDoneMsgs := p.collectAllDone(ctx.RoomID, table)
|
||||
p.mu.Unlock()
|
||||
@@ -496,7 +495,7 @@ func (p *BlackjackPlugin) handleHit(ctx MessageContext) error {
|
||||
|
||||
if v == 21 {
|
||||
player.Done = true
|
||||
name := p.bjDisplayName(player.UserID)
|
||||
name := p.DisplayName(player.UserID)
|
||||
msgs := []string{fmt.Sprintf("**%s** has 21! %s", name, handStr(player.Hand))}
|
||||
allDoneMsgs := p.collectAllDone(ctx.RoomID, table)
|
||||
p.mu.Unlock()
|
||||
@@ -528,7 +527,7 @@ func (p *BlackjackPlugin) handleStand(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
player.Done = true
|
||||
name := p.bjDisplayName(player.UserID)
|
||||
name := p.DisplayName(player.UserID)
|
||||
msgs := []string{fmt.Sprintf("**%s** stands at %d.", name, player.value())}
|
||||
allDoneMsgs := p.collectAllDone(ctx.RoomID, table)
|
||||
p.mu.Unlock()
|
||||
@@ -552,7 +551,7 @@ func (p *BlackjackPlugin) collectAllDone(roomID id.RoomID, table *bjTable) []str
|
||||
var waiting []string
|
||||
for _, pl := range table.players {
|
||||
if !pl.Done {
|
||||
waiting = append(waiting, p.bjDisplayName(pl.UserID))
|
||||
waiting = append(waiting, p.DisplayName(pl.UserID))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,7 +586,7 @@ func (p *BlackjackPlugin) startRoundTimer(roomID id.RoomID, table *bjTable) {
|
||||
var waiting []string
|
||||
for _, pl := range tbl.players {
|
||||
if !pl.Done {
|
||||
waiting = append(waiting, p.bjDisplayName(pl.UserID))
|
||||
waiting = append(waiting, p.DisplayName(pl.UserID))
|
||||
}
|
||||
}
|
||||
if len(waiting) > 0 {
|
||||
@@ -613,7 +612,7 @@ func (p *BlackjackPlugin) startRoundTimer(roomID id.RoomID, table *bjTable) {
|
||||
continue
|
||||
}
|
||||
v := pl.value()
|
||||
name := p.bjDisplayName(pl.UserID)
|
||||
name := p.DisplayName(pl.UserID)
|
||||
|
||||
if v >= p.cfg.AutoplayThreshold {
|
||||
_ = p.SendMessage(roomID,
|
||||
@@ -709,7 +708,7 @@ func (p *BlackjackPlugin) collectResolveRound(roomID id.RoomID, table *bjTable)
|
||||
sb.WriteString("🃏 **Round over!**\n\n")
|
||||
|
||||
for _, pl := range table.players {
|
||||
name := p.bjDisplayName(pl.UserID)
|
||||
name := p.DisplayName(pl.UserID)
|
||||
playerValue := pl.value()
|
||||
playerBJ := isBlackjack(pl.Hand)
|
||||
|
||||
@@ -794,7 +793,7 @@ func (p *BlackjackPlugin) renderTable(table *bjTable, showDealer bool) string {
|
||||
sb.WriteString("🃏 **Blackjack** — Round in progress\n\n")
|
||||
|
||||
for _, pl := range table.players {
|
||||
name := p.bjDisplayName(pl.UserID)
|
||||
name := p.DisplayName(pl.UserID)
|
||||
v := pl.value()
|
||||
extra := ""
|
||||
if isBlackjack(pl.Hand) {
|
||||
@@ -842,7 +841,7 @@ func (p *BlackjackPlugin) handleBoard(ctx MessageContext) error {
|
||||
var played, won int
|
||||
rows.Scan(&userID, &earned, &played, &won)
|
||||
rank++
|
||||
name := p.bjDisplayName(id.UserID(userID))
|
||||
name := p.DisplayName(id.UserID(userID))
|
||||
sb.WriteString(fmt.Sprintf("%d. **%s** — €%d (%d W in %d games)\n", rank, name, int(earned), won, played))
|
||||
}
|
||||
|
||||
@@ -854,12 +853,11 @@ func (p *BlackjackPlugin) handleBoard(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
func (p *BlackjackPlugin) recordBJScore(userID id.UserID, net float64) {
|
||||
d := db.Get()
|
||||
won := 0
|
||||
if net > 0 {
|
||||
won = 1
|
||||
}
|
||||
_, _ = d.Exec(
|
||||
db.Exec("blackjack: record score",
|
||||
`INSERT INTO blackjack_scores (user_id, total_earned, games_played, games_won)
|
||||
VALUES (?, ?, 1, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
@@ -873,10 +871,4 @@ func (p *BlackjackPlugin) recordBJScore(userID id.UserID, net float64) {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *BlackjackPlugin) bjDisplayName(userID id.UserID) string {
|
||||
resp, err := p.Client.GetDisplayName(context.Background(), userID)
|
||||
if err != nil || resp.DisplayName == "" {
|
||||
return string(userID)
|
||||
}
|
||||
return resp.DisplayName
|
||||
}
|
||||
|
||||
|
||||
88
internal/plugin/blackjack_test.go
Normal file
88
internal/plugin/blackjack_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package plugin
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHandValue_SimpleHand(t *testing.T) {
|
||||
cards := []card{{Rank: 10, Suit: spades}, {Rank: 7, Suit: hearts}}
|
||||
val, soft := handValue(cards)
|
||||
if val != 17 || soft {
|
||||
t.Errorf("got %d soft=%v, want 17 soft=false", val, soft)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandValue_AceAsSoft(t *testing.T) {
|
||||
cards := []card{{Rank: 1, Suit: spades}, {Rank: 6, Suit: hearts}}
|
||||
val, soft := handValue(cards)
|
||||
if val != 17 || !soft {
|
||||
t.Errorf("got %d soft=%v, want 17 soft=true", val, soft)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandValue_AceDowngradesToOne(t *testing.T) {
|
||||
cards := []card{
|
||||
{Rank: 1, Suit: spades},
|
||||
{Rank: 9, Suit: hearts},
|
||||
{Rank: 5, Suit: diamonds},
|
||||
}
|
||||
val, soft := handValue(cards)
|
||||
// A(11)+9+5 = 25, bust, so A=1: 1+9+5 = 15
|
||||
if val != 15 || soft {
|
||||
t.Errorf("got %d soft=%v, want 15 soft=false", val, soft)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandValue_TwoAces(t *testing.T) {
|
||||
cards := []card{
|
||||
{Rank: 1, Suit: spades},
|
||||
{Rank: 1, Suit: hearts},
|
||||
}
|
||||
val, soft := handValue(cards)
|
||||
// A(11)+A(11) = 22, one A->1: 12, still soft
|
||||
if val != 12 || !soft {
|
||||
t.Errorf("got %d soft=%v, want 12 soft=true", val, soft)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandValue_FaceCards(t *testing.T) {
|
||||
cards := []card{{Rank: 11, Suit: spades}, {Rank: 12, Suit: hearts}} // J + Q
|
||||
val, soft := handValue(cards)
|
||||
if val != 20 || soft {
|
||||
t.Errorf("got %d soft=%v, want 20 soft=false", val, soft)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandValue_Bust(t *testing.T) {
|
||||
cards := []card{
|
||||
{Rank: 10, Suit: spades},
|
||||
{Rank: 8, Suit: hearts},
|
||||
{Rank: 5, Suit: diamonds},
|
||||
}
|
||||
val, _ := handValue(cards)
|
||||
if val != 23 {
|
||||
t.Errorf("got %d, want 23 (bust)", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBlackjack(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cards []card
|
||||
want bool
|
||||
}{
|
||||
{"ace+king", []card{{1, spades}, {13, hearts}}, true},
|
||||
{"ace+ten", []card{{1, spades}, {10, hearts}}, true},
|
||||
{"ace+queen", []card{{1, spades}, {12, hearts}}, true},
|
||||
{"ten+jack", []card{{10, spades}, {11, hearts}}, false}, // 20, not 21
|
||||
{"three cards summing 21", []card{{7, spades}, {7, hearts}, {7, diamonds}}, false},
|
||||
{"ace+five", []card{{1, spades}, {5, hearts}}, false}, // 16
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isBlackjack(tt.cards)
|
||||
if got != tt.want {
|
||||
t.Errorf("isBlackjack(%v) = %v, want %v", tt.cards, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -92,12 +92,8 @@ func (p *ConcertsPlugin) OnMessage(ctx MessageContext) error {
|
||||
parts := strings.SplitN(args, " ", 2)
|
||||
sub := strings.ToLower(parts[0])
|
||||
|
||||
// DB-only subcommands
|
||||
switch sub {
|
||||
case "watch":
|
||||
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !concerts watch <artist>")
|
||||
}
|
||||
return p.handleWatch(ctx, strings.TrimSpace(parts[1]))
|
||||
case "watching":
|
||||
return p.handleWatching(ctx)
|
||||
case "unwatch":
|
||||
@@ -105,9 +101,26 @@ func (p *ConcertsPlugin) OnMessage(ctx MessageContext) error {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !concerts unwatch <artist>")
|
||||
}
|
||||
return p.handleUnwatch(ctx, strings.TrimSpace(parts[1]))
|
||||
default:
|
||||
return p.handleSearch(ctx, args)
|
||||
}
|
||||
|
||||
// API-calling subcommands run async
|
||||
go func() {
|
||||
var err error
|
||||
switch sub {
|
||||
case "watch":
|
||||
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
||||
err = p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !concerts watch <artist>")
|
||||
} else {
|
||||
err = p.handleWatch(ctx, strings.TrimSpace(parts[1]))
|
||||
}
|
||||
default:
|
||||
err = p.handleSearch(ctx, args)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("concerts: handler error", "err", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ConcertsPlugin) handleSearch(ctx MessageContext, artist string) error {
|
||||
@@ -222,14 +235,11 @@ func (p *ConcertsPlugin) fetchEvents(artist string) ([]bandsintownEvent, error)
|
||||
|
||||
// Update cache
|
||||
data, _ := json.Marshal(events)
|
||||
_, err = d.Exec(
|
||||
db.Exec("concerts: cache write",
|
||||
`INSERT INTO concerts_cache (artist, data, cached_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(artist) DO UPDATE SET data = ?, cached_at = ?`,
|
||||
artistKey, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("concerts: cache write", "err", err)
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
@@ -260,13 +260,9 @@ func (p *CountdownPlugin) handleShow(ctx MessageContext, idStr string) error {
|
||||
}
|
||||
|
||||
func (p *CountdownPlugin) completeOldCountdowns() {
|
||||
d := db.Get()
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -7).Format("2006-01-02")
|
||||
_, err := d.Exec(
|
||||
db.Exec("countdown: auto-complete",
|
||||
`UPDATE countdowns SET completed = 1 WHERE target_date < ? AND completed = 0`,
|
||||
cutoff,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("countdown: auto-complete", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -234,8 +233,7 @@ func (p *EuroPlugin) GetBalance(userID id.UserID) float64 {
|
||||
}
|
||||
|
||||
func (p *EuroPlugin) logTransaction(userID id.UserID, amount float64, reason string) {
|
||||
d := db.Get()
|
||||
_, _ = d.Exec(
|
||||
db.Exec("euro: log transaction",
|
||||
"INSERT INTO euro_transactions (user_id, amount, reason) VALUES (?, ?, ?)",
|
||||
string(userID), amount, reason,
|
||||
)
|
||||
@@ -309,7 +307,7 @@ func (p *EuroPlugin) handleBaltop(ctx MessageContext) error {
|
||||
var balance float64
|
||||
rows.Scan(&userID, &balance)
|
||||
rank++
|
||||
name := p.displayName(id.UserID(userID))
|
||||
name := p.DisplayName(id.UserID(userID))
|
||||
medal := ""
|
||||
switch rank {
|
||||
case 1:
|
||||
@@ -362,16 +360,9 @@ func (p *EuroPlugin) handleTransfer(ctx MessageContext) error {
|
||||
}
|
||||
p.Credit(targetID, amount, fmt.Sprintf("transfer from %s", ctx.Sender))
|
||||
|
||||
senderName := p.displayName(ctx.Sender)
|
||||
targetName := p.displayName(targetID)
|
||||
senderName := p.DisplayName(ctx.Sender)
|
||||
targetName := p.DisplayName(targetID)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("💸 **%s** sent €%d to **%s**.", senderName, int(amount), targetName))
|
||||
}
|
||||
|
||||
func (p *EuroPlugin) displayName(userID id.UserID) string {
|
||||
resp, err := p.Client.GetDisplayName(context.Background(), userID)
|
||||
if err != nil || resp.DisplayName == "" {
|
||||
return string(userID)
|
||||
}
|
||||
return resp.DisplayName
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
@@ -143,7 +142,7 @@ func (p *FlipPlugin) handleLoseboard(ctx MessageContext) error {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🐝 **GogoBee's Trophy Wall**\n\n")
|
||||
for i, uid := range order {
|
||||
name := p.flipDisplayName(id.UserID(uid))
|
||||
name := p.DisplayName(id.UserID(uid))
|
||||
stats := users[uid]
|
||||
var parts []string
|
||||
for game, count := range stats.breakdown {
|
||||
@@ -163,8 +162,7 @@ func (p *FlipPlugin) handleLoseboard(ctx MessageContext) error {
|
||||
// recordBotDefeat increments the bot defeat counter for a user in a specific game.
|
||||
// This is a package-level function so all game plugins can call it.
|
||||
func recordBotDefeat(userID id.UserID, game string) {
|
||||
d := db.Get()
|
||||
_, _ = d.Exec(
|
||||
db.Exec("flip: record bot defeat",
|
||||
`INSERT INTO bot_defeats (user_id, game, losses)
|
||||
VALUES (?, ?, 1)
|
||||
ON CONFLICT(user_id, game) DO UPDATE SET
|
||||
@@ -173,13 +171,7 @@ func recordBotDefeat(userID id.UserID, game string) {
|
||||
)
|
||||
}
|
||||
|
||||
func (p *FlipPlugin) flipDisplayName(userID id.UserID) string {
|
||||
resp, err := p.Client.GetDisplayName(context.Background(), userID)
|
||||
if err != nil || resp.DisplayName == "" {
|
||||
return string(userID)
|
||||
}
|
||||
return resp.DisplayName
|
||||
}
|
||||
|
||||
|
||||
// redirectToGamesRoom returns the room ID for games-restricted redirect.
|
||||
func redirectToGamesRoom(sender id.UserID) string {
|
||||
|
||||
262
internal/plugin/forex.go
Normal file
262
internal/plugin/forex.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// ForexPlugin tracks USD exchange rates against EUR and JPY using the
|
||||
// Frankfurter API (ECB-sourced, no API key required). Stores daily history,
|
||||
// computes buy signals, and provides rate alerts.
|
||||
type ForexPlugin struct {
|
||||
Base
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewForexPlugin(client *mautrix.Client) *ForexPlugin {
|
||||
return &ForexPlugin{
|
||||
Base: NewBase(client),
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ForexPlugin) Name() string { return "forex" }
|
||||
|
||||
func (p *ForexPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "fx", Description: "Forex rates and analysis", Usage: "!fx rate [EUR|JPY] · !fx report [EUR|JPY] · !fx setalert <cur> <rate> · !fx alerts · !fx delalert <cur> <rate>", Category: "Entertainment"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ForexPlugin) Init() error {
|
||||
go p.backfill()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ForexPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *ForexPlugin) OnMessage(ctx MessageContext) error {
|
||||
if !p.IsCommand(ctx.Body, "fx") {
|
||||
return nil
|
||||
}
|
||||
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "fx"))
|
||||
if args == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"Usage: `!fx rate [EUR|JPY]` · `!fx report [EUR|JPY]` · `!fx setalert <currency> <rate>` · `!fx alerts` · `!fx delalert <currency> <rate>`")
|
||||
}
|
||||
|
||||
parts := strings.Fields(args)
|
||||
sub := strings.ToLower(parts[0])
|
||||
|
||||
// DB-only and instant subcommands
|
||||
switch sub {
|
||||
case "setalert":
|
||||
return p.cmdSetAlert(ctx, parts[1:])
|
||||
case "alerts":
|
||||
return p.cmdListAlerts(ctx)
|
||||
case "delalert":
|
||||
return p.cmdDelAlert(ctx, parts[1:])
|
||||
case "help":
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fxHelpText)
|
||||
}
|
||||
|
||||
// API-calling subcommands run async
|
||||
switch sub {
|
||||
case "rate", "report":
|
||||
go func() {
|
||||
var err error
|
||||
if sub == "rate" {
|
||||
err = p.cmdRate(ctx, parts[1:])
|
||||
} else {
|
||||
err = p.cmdReport(ctx, parts[1:])
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("forex: handler error", "err", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
default:
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Unknown subcommand `%s`. Try `!fx help`.", parts[0]))
|
||||
}
|
||||
}
|
||||
|
||||
const fxHelpText = "**Forex Commands**\n\n" +
|
||||
"`!fx rate [EUR|JPY]` — current rate + quick signal\n" +
|
||||
"`!fx report [EUR|JPY]` — full analysis (averages, 52w range, buy score)\n" +
|
||||
"`!fx setalert <currency> <rate>` — alert when USD/currency reaches threshold\n" +
|
||||
"`!fx alerts` — list active alerts in this room\n" +
|
||||
"`!fx delalert <currency> <rate>` — remove an alert\n" +
|
||||
"`!fx help` — this message"
|
||||
|
||||
// ── Command Handlers ────────────────────────────────────────────────────────
|
||||
|
||||
func (p *ForexPlugin) cmdRate(ctx MessageContext, args []string) error {
|
||||
currencies := fxParseCurrencies(args)
|
||||
rates, err := p.fxLiveRates(currencies)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not fetch rates: %v", err))
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for _, cur := range currencies {
|
||||
rate, ok := rates[cur]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sig, err := p.fxComputeSignal(cur, rate)
|
||||
if err != nil {
|
||||
lines = append(lines, fmt.Sprintf("%s %s: %s (insufficient data for analysis)", fxMeta[cur].Emoji, cur, fxFormatRate(cur, rate)))
|
||||
continue
|
||||
}
|
||||
lines = append(lines, sig.FormatQuick())
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func (p *ForexPlugin) cmdReport(ctx MessageContext, args []string) error {
|
||||
currencies := fxParseCurrencies(args)
|
||||
rates, err := p.fxLiveRates(currencies)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not fetch rates: %v", err))
|
||||
}
|
||||
|
||||
var sections []string
|
||||
for _, cur := range currencies {
|
||||
rate, ok := rates[cur]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sig, err := p.fxComputeSignal(cur, rate)
|
||||
if err != nil {
|
||||
sections = append(sections, fmt.Sprintf("%s **%s** — %s\n_Insufficient history for full analysis._", fxMeta[cur].Emoji, cur, fxFormatRate(cur, rate)))
|
||||
continue
|
||||
}
|
||||
sections = append(sections, sig.FormatReport())
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, strings.Join(sections, "\n\n---\n\n"))
|
||||
}
|
||||
|
||||
func (p *ForexPlugin) cmdSetAlert(ctx MessageContext, args []string) error {
|
||||
if len(args) < 2 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!fx setalert <currency> <rate>` — e.g. `!fx setalert JPY 155`")
|
||||
}
|
||||
cur := strings.ToUpper(args[0])
|
||||
if !fxIsTracked(cur) {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Unknown currency `%s`. Tracked: %s", cur, strings.Join(fxTrackedCurrencies, ", ")))
|
||||
}
|
||||
threshold, err := strconv.ParseFloat(args[1], 64)
|
||||
if err != nil || threshold <= 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Invalid rate `%s`.", args[1]))
|
||||
}
|
||||
if err := fxSaveAlert(string(ctx.Sender), cur, threshold); err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not save alert: %v", err))
|
||||
}
|
||||
meta := fxMeta[cur]
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("Alert set: I'll notify when 1 USD >= **%s %s** %s", fxFormatRate(cur, threshold), cur, meta.Emoji))
|
||||
}
|
||||
|
||||
func (p *ForexPlugin) cmdListAlerts(ctx MessageContext) error {
|
||||
alerts, err := fxAlertsForUser(string(ctx.Sender))
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Error: %v", err))
|
||||
}
|
||||
if len(alerts) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No active alerts. Set one with `!fx setalert <currency> <rate>`.")
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("**Your FX alerts:**\n")
|
||||
for _, a := range alerts {
|
||||
meta := fxMeta[a.Currency]
|
||||
status := ""
|
||||
if a.FiredAt != 0 {
|
||||
status = fmt.Sprintf(" _(fired %s)_", time.Unix(a.FiredAt, 0).UTC().Format("Jan 2 15:04 UTC"))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %s `%s %s`%s\n",
|
||||
meta.Emoji, fxFormatRate(a.Currency, a.Threshold), a.Currency, status))
|
||||
}
|
||||
sb.WriteString("\nAlerts are delivered via DM.")
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
func (p *ForexPlugin) cmdDelAlert(ctx MessageContext, args []string) error {
|
||||
if len(args) < 2 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!fx delalert <currency> <rate>` — e.g. `!fx delalert JPY 155`")
|
||||
}
|
||||
cur := strings.ToUpper(args[0])
|
||||
threshold, err := strconv.ParseFloat(args[1], 64)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Invalid rate `%s`.", args[1]))
|
||||
}
|
||||
if err := fxDeleteAlert(string(ctx.Sender), cur, threshold); err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Error: %v", err))
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Alert for `%s %s` removed.", fxFormatRate(cur, threshold), cur))
|
||||
}
|
||||
|
||||
// ── Daily Poll (called by cron) ─────────────────────────────────────────────
|
||||
|
||||
func (p *ForexPlugin) DailyPoll() {
|
||||
rates, err := p.fxFetchCurrent(fxTrackedCurrencies)
|
||||
if err != nil {
|
||||
slog.Error("forex: daily poll fetch failed", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
for cur, rate := range rates {
|
||||
fxSaveRate(cur, today, rate)
|
||||
}
|
||||
|
||||
fxResetExpiredAlerts()
|
||||
p.checkAlerts(rates)
|
||||
}
|
||||
|
||||
func (p *ForexPlugin) checkAlerts(rates map[string]float64) {
|
||||
alerts, err := fxAllAlerts()
|
||||
if err != nil {
|
||||
slog.Error("forex: alert check error", "err", err)
|
||||
return
|
||||
}
|
||||
for _, a := range alerts {
|
||||
if a.FiredAt != 0 {
|
||||
continue
|
||||
}
|
||||
rate, ok := rates[a.Currency]
|
||||
if !ok || rate < a.Threshold {
|
||||
continue
|
||||
}
|
||||
meta := fxMeta[a.Currency]
|
||||
msg := fmt.Sprintf("**FX Alert** %s — 1 USD = **%s %s** has reached your threshold of **%s**.",
|
||||
meta.Emoji, fxFormatRate(a.Currency, rate), a.Currency,
|
||||
fxFormatRate(a.Currency, a.Threshold))
|
||||
p.SendDM(id.UserID(a.UserID), msg)
|
||||
fxMarkAlertFired(a)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func fxParseCurrencies(args []string) []string {
|
||||
var out []string
|
||||
for _, a := range args {
|
||||
c := strings.ToUpper(a)
|
||||
if fxIsTracked(c) {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return fxTrackedCurrencies
|
||||
}
|
||||
return out
|
||||
}
|
||||
206
internal/plugin/forex_analysis.go
Normal file
206
internal/plugin/forex_analysis.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ForexSignal holds the computed analysis for a currency pair.
|
||||
type ForexSignal struct {
|
||||
Currency string
|
||||
Rate float64
|
||||
Avg30 float64
|
||||
Avg90 float64
|
||||
High52w float64
|
||||
Low52w float64
|
||||
Percentile float64 // 0-100: where current rate sits in 52w range
|
||||
Score int // 1-10
|
||||
Label string // "Excellent", "Good", "Fair", "Poor"
|
||||
Emoji string // color indicator
|
||||
}
|
||||
|
||||
// fxComputeSignal computes the full signal for a currency at the given rate.
|
||||
func (p *ForexPlugin) fxComputeSignal(currency string, currentRate float64) (*ForexSignal, error) {
|
||||
// Get last 260 trading days (~52 weeks) of data
|
||||
records, err := fxGetRatesByLimit(currency, 260)
|
||||
if err != nil || len(records) < 10 {
|
||||
return nil, fmt.Errorf("insufficient data (%d records)", len(records))
|
||||
}
|
||||
|
||||
// Append today's live rate for analysis
|
||||
rates := make([]float64, len(records)+1)
|
||||
for i, r := range records {
|
||||
rates[i] = r.Rate
|
||||
}
|
||||
rates[len(records)] = currentRate
|
||||
|
||||
n := len(rates)
|
||||
|
||||
// 30-day and 90-day moving averages (trading days, not calendar)
|
||||
avg30 := fxAvg(rates, 30)
|
||||
avg90 := fxAvg(rates, 90)
|
||||
|
||||
// 52-week high/low
|
||||
high52w, low52w := rates[0], rates[0]
|
||||
for _, r := range rates {
|
||||
if r > high52w {
|
||||
high52w = r
|
||||
}
|
||||
if r < low52w {
|
||||
low52w = r
|
||||
}
|
||||
}
|
||||
|
||||
// Percentile in 52w range
|
||||
var percentile float64
|
||||
if high52w != low52w {
|
||||
percentile = (currentRate - low52w) / (high52w - low52w) * 100
|
||||
} else {
|
||||
percentile = 50
|
||||
}
|
||||
|
||||
// Score: weighted combination of percentile position and deviation from averages
|
||||
// Higher = USD is stronger = better time to convert
|
||||
devScore := fxDeviationScore(currentRate, avg30, avg90)
|
||||
rawScore := percentile/100*10*0.6 + devScore*0.4
|
||||
score := int(math.Round(rawScore))
|
||||
if score < 1 {
|
||||
score = 1
|
||||
}
|
||||
if score > 10 {
|
||||
score = 10
|
||||
}
|
||||
|
||||
label, emoji := fxScoreLabel(score)
|
||||
|
||||
_ = n // rates slice used above
|
||||
|
||||
return &ForexSignal{
|
||||
Currency: currency,
|
||||
Rate: currentRate,
|
||||
Avg30: avg30,
|
||||
Avg90: avg90,
|
||||
High52w: high52w,
|
||||
Low52w: low52w,
|
||||
Percentile: percentile,
|
||||
Score: score,
|
||||
Label: label,
|
||||
Emoji: emoji,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fxAvg computes the average of the last n values in a slice.
|
||||
func fxAvg(rates []float64, n int) float64 {
|
||||
if len(rates) < n {
|
||||
n = len(rates)
|
||||
}
|
||||
if n == 0 {
|
||||
return 0
|
||||
}
|
||||
sum := 0.0
|
||||
start := len(rates) - n
|
||||
for i := start; i < len(rates); i++ {
|
||||
sum += rates[i]
|
||||
}
|
||||
return sum / float64(n)
|
||||
}
|
||||
|
||||
// fxDeviationScore computes how far above/below the moving averages the current rate is.
|
||||
// +5% above average = 10, -5% below = 0, linear interpolation between.
|
||||
func fxDeviationScore(current, avg30, avg90 float64) float64 {
|
||||
avgAvg := (avg30 + avg90) / 2
|
||||
if avgAvg == 0 {
|
||||
return 5
|
||||
}
|
||||
dev := (current - avgAvg) / avgAvg * 100 // percent deviation
|
||||
// Map [-5%, +5%] to [0, 10]
|
||||
score := (dev + 5) / 10 * 10
|
||||
if score < 0 {
|
||||
score = 0
|
||||
}
|
||||
if score > 10 {
|
||||
score = 10
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func fxScoreLabel(score int) (string, string) {
|
||||
switch {
|
||||
case score >= 8:
|
||||
return "Excellent", "🟢"
|
||||
case score >= 6:
|
||||
return "Good", "🟡"
|
||||
case score >= 4:
|
||||
return "Fair", "🟠"
|
||||
default:
|
||||
return "Poor", "🔴"
|
||||
}
|
||||
}
|
||||
|
||||
// ── Formatting ──────────────────────────────────────────────────────────────
|
||||
|
||||
// FormatQuick returns a one-line summary.
|
||||
func (s *ForexSignal) FormatQuick() string {
|
||||
meta := fxMeta[s.Currency]
|
||||
return fmt.Sprintf("%s **%s** 1 USD = **%s** %s · 30d avg: %s · USD strength: %d/10 %s %s",
|
||||
meta.Emoji, s.Currency, fxFormatRate(s.Currency, s.Rate), s.Currency,
|
||||
fxFormatRate(s.Currency, s.Avg30),
|
||||
s.Score, s.Emoji, s.Label)
|
||||
}
|
||||
|
||||
// FormatReport returns a detailed analysis block.
|
||||
func (s *ForexSignal) FormatReport() string {
|
||||
meta := fxMeta[s.Currency]
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%s **%s — %s**\n", meta.Emoji, s.Currency, meta.Label))
|
||||
sb.WriteString(fmt.Sprintf("**Current:** 1 USD = **%s %s**\n\n", fxFormatRate(s.Currency, s.Rate), s.Currency))
|
||||
|
||||
// Moving averages
|
||||
sb.WriteString(fmt.Sprintf(" 30-day avg: %s", fxFormatRate(s.Currency, s.Avg30)))
|
||||
if s.Rate > s.Avg30 {
|
||||
sb.WriteString(fmt.Sprintf(" _(+%.1f%% above)_", (s.Rate-s.Avg30)/s.Avg30*100))
|
||||
} else if s.Rate < s.Avg30 {
|
||||
sb.WriteString(fmt.Sprintf(" _(%.1f%% below)_", (s.Rate-s.Avg30)/s.Avg30*100))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(fmt.Sprintf(" 90-day avg: %s", fxFormatRate(s.Currency, s.Avg90)))
|
||||
if s.Rate > s.Avg90 {
|
||||
sb.WriteString(fmt.Sprintf(" _(+%.1f%% above)_", (s.Rate-s.Avg90)/s.Avg90*100))
|
||||
} else if s.Rate < s.Avg90 {
|
||||
sb.WriteString(fmt.Sprintf(" _(%.1f%% below)_", (s.Rate-s.Avg90)/s.Avg90*100))
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// 52-week range bar
|
||||
sb.WriteString(fmt.Sprintf(" 52-week range: %s — %s\n",
|
||||
fxFormatRate(s.Currency, s.Low52w), fxFormatRate(s.Currency, s.High52w)))
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", fxRangeBar(s.Percentile)))
|
||||
sb.WriteString(fmt.Sprintf(" Position: %.0f%%\n\n", s.Percentile))
|
||||
|
||||
// Score
|
||||
sb.WriteString(fmt.Sprintf(" **USD Strength: %d/10** %s %s\n", s.Score, s.Emoji, s.Label))
|
||||
sb.WriteString(" _Higher = USD buys more — good time to convert USD._")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// fxRangeBar renders a text-based position indicator.
|
||||
func fxRangeBar(percentile float64) string {
|
||||
width := 20
|
||||
pos := int(percentile / 100 * float64(width))
|
||||
if pos < 0 {
|
||||
pos = 0
|
||||
}
|
||||
if pos >= width {
|
||||
pos = width - 1
|
||||
}
|
||||
|
||||
bar := make([]byte, width)
|
||||
for i := range bar {
|
||||
bar[i] = '-'
|
||||
}
|
||||
bar[pos] = '|'
|
||||
return "[" + string(bar) + "]"
|
||||
}
|
||||
159
internal/plugin/forex_api.go
Normal file
159
internal/plugin/forex_api.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── Currency Configuration ──────────────────────────────────────────────────
|
||||
|
||||
var fxTrackedCurrencies = []string{"EUR", "JPY"}
|
||||
|
||||
type fxCurrencyMeta struct {
|
||||
Emoji string
|
||||
Label string
|
||||
}
|
||||
|
||||
var fxMeta = map[string]fxCurrencyMeta{
|
||||
"EUR": {Emoji: "🇪🇺", Label: "Euro"},
|
||||
"JPY": {Emoji: "🇯🇵", Label: "Japanese Yen"},
|
||||
}
|
||||
|
||||
func fxIsTracked(cur string) bool {
|
||||
for _, c := range fxTrackedCurrencies {
|
||||
if c == cur {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fxFormatRate formats a rate: JPY uses 2 decimal places, others use 4.
|
||||
func fxFormatRate(currency string, rate float64) string {
|
||||
if currency == "JPY" {
|
||||
return fmt.Sprintf("%.2f", rate)
|
||||
}
|
||||
return fmt.Sprintf("%.4f", rate)
|
||||
}
|
||||
|
||||
// ── Frankfurter v2 API ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Frankfurter v2 (api.frankfurter.dev) tracks rates from 20 central banks,
|
||||
// covering 150 currencies. No API key required, no usage limits.
|
||||
|
||||
const frankfurterBaseURL = "https://api.frankfurter.dev/v2"
|
||||
|
||||
// frankfurterV2Rate is one entry in the v2 response array.
|
||||
type frankfurterV2Rate struct {
|
||||
Date string `json:"date"`
|
||||
Base string `json:"base"`
|
||||
Quote string `json:"quote"`
|
||||
Rate float64 `json:"rate"`
|
||||
}
|
||||
|
||||
// fxFetchCurrent fetches live rates from Frankfurter v2 with a context timeout
|
||||
// so user commands don't block the dispatch pipeline if the API is slow.
|
||||
func (p *ForexPlugin) fxFetchCurrent(currencies []string) (map[string]float64, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
|
||||
url := fmt.Sprintf("%s/rates?base=USD"es=%s", frankfurterBaseURL, strings.Join(currencies, ","))
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("frankfurter API error: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("frankfurter API returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var data []frankfurterV2Rate
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return nil, fmt.Errorf("frankfurter decode error: %w", err)
|
||||
}
|
||||
|
||||
rates := make(map[string]float64, len(data))
|
||||
for _, r := range data {
|
||||
rates[r.Quote] = r.Rate
|
||||
}
|
||||
return rates, nil
|
||||
}
|
||||
|
||||
// fxFetchRange fetches historical rates for a date range from Frankfurter v2.
|
||||
func (p *ForexPlugin) fxFetchRange(from, to time.Time, currencies []string) ([]frankfurterV2Rate, error) {
|
||||
url := fmt.Sprintf("%s/rates?base=USD"es=%s&from=%s&to=%s",
|
||||
frankfurterBaseURL, strings.Join(currencies, ","),
|
||||
from.Format("2006-01-02"), to.Format("2006-01-02"))
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("frankfurter API error: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("frankfurter API returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var data []frankfurterV2Rate
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return nil, fmt.Errorf("frankfurter decode error: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// fxLiveRates fetches current rates, falling back to the most recent stored rate.
|
||||
func (p *ForexPlugin) fxLiveRates(currencies []string) (map[string]float64, error) {
|
||||
rates, err := p.fxFetchCurrent(currencies)
|
||||
if err != nil {
|
||||
slog.Warn("forex: live fetch failed, using stored fallback", "err", err)
|
||||
rates = make(map[string]float64)
|
||||
for _, cur := range currencies {
|
||||
if r, ok := fxLatestRate(cur); ok {
|
||||
rates[cur] = r
|
||||
}
|
||||
}
|
||||
if len(rates) == 0 {
|
||||
return nil, fmt.Errorf("could not fetch rates and no stored data available")
|
||||
}
|
||||
}
|
||||
return rates, nil
|
||||
}
|
||||
|
||||
// backfill fetches the trailing year of rates and saves them.
|
||||
// Safe to call repeatedly — uses INSERT OR IGNORE.
|
||||
func (p *ForexPlugin) backfill() {
|
||||
end := time.Now().UTC()
|
||||
start := end.AddDate(-1, 0, 0)
|
||||
|
||||
slog.Info("forex: starting backfill", "from", start.Format("2006-01-02"), "to", end.Format("2006-01-02"))
|
||||
|
||||
data, err := p.fxFetchRange(start, end, fxTrackedCurrencies)
|
||||
if err != nil {
|
||||
slog.Error("forex: backfill failed", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, r := range data {
|
||||
fxSaveRate(r.Quote, r.Date, r.Rate)
|
||||
}
|
||||
|
||||
slog.Info("forex: backfill complete", "records", len(data))
|
||||
}
|
||||
140
internal/plugin/forex_store.go
Normal file
140
internal/plugin/forex_store.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// ── Rate Storage ────────────────────────────────────────────────────────────
|
||||
|
||||
func fxSaveRate(currency, date string, rate float64) {
|
||||
db.Exec("forex: save rate",
|
||||
`INSERT OR IGNORE INTO forex_rates (currency, date, rate) VALUES (?, ?, ?)`,
|
||||
currency, date, rate)
|
||||
}
|
||||
|
||||
type fxRateRecord struct {
|
||||
Date string
|
||||
Rate float64
|
||||
}
|
||||
|
||||
// fxGetRates returns rates for a currency in the given date range, ordered by date.
|
||||
// Uses ORDER BY date DESC LIMIT n when limit > 0 (for "last N trading days").
|
||||
func fxGetRatesByLimit(currency string, limit int) ([]fxRateRecord, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(
|
||||
`SELECT date, rate FROM forex_rates WHERE currency = ? ORDER BY date DESC LIMIT ?`,
|
||||
currency, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []fxRateRecord
|
||||
for rows.Next() {
|
||||
var r fxRateRecord
|
||||
if err := rows.Scan(&r.Date, &r.Rate); err != nil {
|
||||
continue
|
||||
}
|
||||
records = append(records, r)
|
||||
}
|
||||
|
||||
// Reverse to chronological order (oldest first)
|
||||
for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
|
||||
records[i], records[j] = records[j], records[i]
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// fxLatestRate returns the most recent stored rate for a currency.
|
||||
func fxLatestRate(currency string) (float64, bool) {
|
||||
d := db.Get()
|
||||
var rate float64
|
||||
err := d.QueryRow(
|
||||
`SELECT rate FROM forex_rates WHERE currency = ? ORDER BY date DESC LIMIT 1`,
|
||||
currency).Scan(&rate)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return rate, true
|
||||
}
|
||||
|
||||
// ── Alert Storage ───────────────────────────────────────────────────────────
|
||||
|
||||
type fxAlertRecord struct {
|
||||
UserID string
|
||||
Currency string
|
||||
Threshold float64
|
||||
FiredAt int64
|
||||
}
|
||||
|
||||
func fxSaveAlert(userID, currency string, threshold float64) error {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(
|
||||
`INSERT OR REPLACE INTO forex_alerts (user_id, currency, threshold, fired_at)
|
||||
VALUES (?, ?, ?, 0)`,
|
||||
userID, currency, threshold)
|
||||
return err
|
||||
}
|
||||
|
||||
func fxAlertsForUser(userID string) ([]fxAlertRecord, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(
|
||||
`SELECT user_id, currency, threshold, fired_at
|
||||
FROM forex_alerts WHERE user_id = ? ORDER BY currency, threshold`,
|
||||
userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var alerts []fxAlertRecord
|
||||
for rows.Next() {
|
||||
var a fxAlertRecord
|
||||
if err := rows.Scan(&a.UserID, &a.Currency, &a.Threshold, &a.FiredAt); err != nil {
|
||||
continue
|
||||
}
|
||||
alerts = append(alerts, a)
|
||||
}
|
||||
return alerts, nil
|
||||
}
|
||||
|
||||
func fxAllAlerts() ([]fxAlertRecord, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(
|
||||
`SELECT user_id, currency, threshold, fired_at FROM forex_alerts`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var alerts []fxAlertRecord
|
||||
for rows.Next() {
|
||||
var a fxAlertRecord
|
||||
if err := rows.Scan(&a.UserID, &a.Currency, &a.Threshold, &a.FiredAt); err != nil {
|
||||
continue
|
||||
}
|
||||
alerts = append(alerts, a)
|
||||
}
|
||||
return alerts, nil
|
||||
}
|
||||
|
||||
func fxDeleteAlert(userID, currency string, threshold float64) error {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(
|
||||
`DELETE FROM forex_alerts WHERE user_id = ? AND currency = ? AND threshold = ?`,
|
||||
userID, currency, threshold)
|
||||
return err
|
||||
}
|
||||
|
||||
func fxMarkAlertFired(a fxAlertRecord) {
|
||||
db.Exec("forex: mark alert fired",
|
||||
`UPDATE forex_alerts SET fired_at = unixepoch() WHERE user_id = ? AND currency = ? AND threshold = ?`,
|
||||
a.UserID, a.Currency, a.Threshold)
|
||||
}
|
||||
|
||||
// fxResetExpiredAlerts clears fired_at for alerts that fired more than 24 hours ago,
|
||||
// allowing them to re-fire if the threshold is still met.
|
||||
func fxResetExpiredAlerts() {
|
||||
db.Exec("forex: reset expired alerts",
|
||||
`UPDATE forex_alerts SET fired_at = 0 WHERE fired_at > 0 AND fired_at < unixepoch() - 86400`)
|
||||
}
|
||||
@@ -321,7 +321,7 @@ func (p *FunPlugin) handleHLTB(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
// Cache result
|
||||
_, _ = db.Get().Exec(
|
||||
db.Exec("hltb: cache result",
|
||||
`INSERT INTO hltb_cache (game_name, data, cached_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(game_name) DO UPDATE SET data = ?, cached_at = ?`,
|
||||
strings.ToLower(gameName), result, time.Now().Unix(), result, time.Now().Unix(),
|
||||
|
||||
@@ -73,7 +73,12 @@ func (p *GamingPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *GamingPlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "releases") {
|
||||
return p.handleReleases(ctx)
|
||||
go func() {
|
||||
if err := p.handleReleases(ctx); err != nil {
|
||||
slog.Error("gaming: handler error", "err", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "releasewatch") {
|
||||
return p.handleReleaseWatch(ctx)
|
||||
@@ -244,14 +249,11 @@ func (p *GamingPlugin) fetchReleases(startDate, endDate, cacheKey string) ([]rel
|
||||
|
||||
// Cache results
|
||||
data, _ := json.Marshal(games)
|
||||
_, err = d.Exec(
|
||||
db.Exec("gaming: cache write",
|
||||
`INSERT INTO releases_cache (cache_key, data, cached_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(cache_key) DO UPDATE SET data = ?, cached_at = ?`,
|
||||
fullKey, string(data), time.Now().UTC().Unix(), string(data), time.Now().UTC().Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("gaming: cache write", "err", err)
|
||||
}
|
||||
|
||||
return games, nil
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package plugin
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -630,7 +629,7 @@ func (p *HangmanPlugin) endGame(roomID id.RoomID, game *hangmanGame) error {
|
||||
eligibleParticipants = append(eligibleParticipants, uid)
|
||||
}
|
||||
|
||||
solverName := p.displayName(game.solvedBy)
|
||||
solverName := p.DisplayName(game.solvedBy)
|
||||
participantCount := len(eligibleParticipants)
|
||||
|
||||
var sb strings.Builder
|
||||
@@ -652,7 +651,7 @@ func (p *HangmanPlugin) endGame(roomID id.RoomID, game *hangmanGame) error {
|
||||
}
|
||||
p.euro.Credit(uid, payout, "hangman_win")
|
||||
p.recordHangmanScore(uid, payout)
|
||||
name := p.displayName(uid)
|
||||
name := p.DisplayName(uid)
|
||||
sb.WriteString(fmt.Sprintf(" **%s**: +€%d%s\n", name, int(payout), label))
|
||||
}
|
||||
}
|
||||
@@ -779,7 +778,7 @@ func (p *HangmanPlugin) handleBoard(ctx MessageContext) error {
|
||||
var won int
|
||||
rows.Scan(&userID, &earned, &won)
|
||||
rank++
|
||||
name := p.displayName(id.UserID(userID))
|
||||
name := p.DisplayName(id.UserID(userID))
|
||||
sb.WriteString(fmt.Sprintf("%d. **%s** — €%d earned (%d wins)\n", rank, name, int(earned), won))
|
||||
}
|
||||
|
||||
@@ -791,8 +790,7 @@ func (p *HangmanPlugin) handleBoard(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
func (p *HangmanPlugin) recordHangmanScore(userID id.UserID, earned float64) {
|
||||
d := db.Get()
|
||||
_, _ = d.Exec(
|
||||
db.Exec("hangman: record score",
|
||||
`INSERT INTO hangman_scores (user_id, total_earned, games_played, games_won)
|
||||
VALUES (?, ?, 1, 1)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
@@ -803,10 +801,3 @@ func (p *HangmanPlugin) recordHangmanScore(userID id.UserID, earned float64) {
|
||||
)
|
||||
}
|
||||
|
||||
func (p *HangmanPlugin) displayName(userID id.UserID) string {
|
||||
resp, err := p.Client.GetDisplayName(context.Background(), userID)
|
||||
if err != nil || resp.DisplayName == "" {
|
||||
return string(userID)
|
||||
}
|
||||
return resp.DisplayName
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
@@ -97,6 +96,16 @@ func (p *HoldemPlugin) OnMessage(ctx MessageContext) error {
|
||||
return p.handleTipsToggle(ctx, args, isDM)
|
||||
}
|
||||
|
||||
// If this is a DM, resolve the player's game room so actions route correctly.
|
||||
if isDM {
|
||||
p.mu.Lock()
|
||||
gameRoom := p.findGameRoom(ctx.Sender)
|
||||
p.mu.Unlock()
|
||||
if gameRoom != "" {
|
||||
ctx.RoomID = gameRoom
|
||||
}
|
||||
}
|
||||
|
||||
// Room-only commands need games room check.
|
||||
if !isDM && !isGamesRoom(ctx.RoomID) {
|
||||
gr := gamesRoom()
|
||||
@@ -106,8 +115,10 @@ func (p *HoldemPlugin) OnMessage(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
switch {
|
||||
case args == "join":
|
||||
return p.handleJoin(ctx)
|
||||
case args == "play" || strings.HasPrefix(args, "play "):
|
||||
return p.handlePlay(ctx, strings.TrimSpace(strings.TrimPrefix(args, "play")))
|
||||
case args == "join" || strings.HasPrefix(args, "join "):
|
||||
return p.handleJoin(ctx, strings.TrimSpace(strings.TrimPrefix(args, "join")))
|
||||
case args == "leave":
|
||||
return p.handleLeave(ctx)
|
||||
case args == "start":
|
||||
@@ -139,6 +150,17 @@ func (p *HoldemPlugin) isDMRoom(roomID id.RoomID) bool {
|
||||
return len(members) <= 2
|
||||
}
|
||||
|
||||
// findGameRoom returns the game room ID for a player, or empty if not found.
|
||||
// Must be called with p.mu held.
|
||||
func (p *HoldemPlugin) findGameRoom(userID id.UserID) id.RoomID {
|
||||
for _, game := range p.games {
|
||||
if game.playerByUserID(userID) != nil {
|
||||
return game.RoomID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *HoldemPlugin) handleTipsToggle(ctx MessageContext, args string, isDM bool) error {
|
||||
if !isDM {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Tips can only be toggled in DM. Send `!holdem tips on/off` directly to me.")
|
||||
@@ -170,7 +192,40 @@ func (p *HoldemPlugin) handleTipsToggle(ctx MessageContext, args string, isDM bo
|
||||
}
|
||||
}
|
||||
|
||||
func (p *HoldemPlugin) handleJoin(ctx MessageContext) error {
|
||||
// handlePlay is a shortcut that joins the player, adds the bot, and starts dealing.
|
||||
func (p *HoldemPlugin) handlePlay(ctx MessageContext, amountStr string) error {
|
||||
p.mu.Lock()
|
||||
|
||||
// If a game already exists with a hand in progress, treat as a regular join.
|
||||
if game := p.games[ctx.RoomID]; game != nil && game.HandInProgress {
|
||||
p.mu.Unlock()
|
||||
return p.handleJoin(ctx, amountStr)
|
||||
}
|
||||
|
||||
// If player is already seated, just try to add bot + start.
|
||||
if game := p.games[ctx.RoomID]; game != nil && game.playerByUserID(ctx.Sender) != nil {
|
||||
p.mu.Unlock()
|
||||
if err := p.handleAddBot(ctx); err != nil {
|
||||
slog.Warn("holdem: addbot failed", "room", ctx.RoomID, "err", err)
|
||||
}
|
||||
return p.handleStart(ctx)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
// Join the table.
|
||||
if err := p.handleJoin(ctx, amountStr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := p.handleAddBot(ctx); err != nil {
|
||||
slog.Warn("holdem: addbot failed", "room", ctx.RoomID, "err", err)
|
||||
}
|
||||
|
||||
// Start dealing.
|
||||
return p.handleStart(ctx)
|
||||
}
|
||||
|
||||
func (p *HoldemPlugin) handleJoin(ctx MessageContext, amountStr string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
@@ -186,6 +241,7 @@ func (p *HoldemPlugin) handleJoin(ctx MessageContext) error {
|
||||
WaitingForPlayers: true,
|
||||
}
|
||||
p.games[ctx.RoomID] = game
|
||||
p.startIdleTimer(game)
|
||||
}
|
||||
|
||||
// Check if already seated.
|
||||
@@ -212,12 +268,36 @@ func (p *HoldemPlugin) handleJoin(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
// Get display name.
|
||||
name := p.holdemDisplayName(ctx.Sender)
|
||||
name := p.DisplayName(ctx.Sender)
|
||||
|
||||
// Determine buy-in (capped at MaxBuyin).
|
||||
buyin := int64(balance)
|
||||
if buyin > p.cfg.MaxBuyin {
|
||||
// Determine buy-in: player-specified, or max buy-in, capped by balance.
|
||||
var buyin int64
|
||||
if amountStr != "" {
|
||||
// Strip € prefix if present.
|
||||
amountStr = strings.TrimPrefix(amountStr, "€")
|
||||
requested, err := strconv.ParseInt(amountStr, 10, 64)
|
||||
if err != nil || requested <= 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("Usage: `!holdem join [amount]` — amount between €%d and €%d.", p.cfg.MinBuyin, p.cfg.MaxBuyin))
|
||||
}
|
||||
if requested < p.cfg.MinBuyin {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("Minimum buy-in is €%d.", p.cfg.MinBuyin))
|
||||
}
|
||||
if requested > p.cfg.MaxBuyin {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("Maximum buy-in is €%d.", p.cfg.MaxBuyin))
|
||||
}
|
||||
if requested > int64(balance) {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("You only have €%.0f.", balance))
|
||||
}
|
||||
buyin = requested
|
||||
} else {
|
||||
buyin = p.cfg.MaxBuyin
|
||||
if int64(balance) < buyin {
|
||||
buyin = int64(balance)
|
||||
}
|
||||
}
|
||||
|
||||
// Debit buy-in immediately to prevent double-spending across tables.
|
||||
@@ -270,12 +350,13 @@ func (p *HoldemPlugin) handleLeave(ctx MessageContext) error {
|
||||
|
||||
if !game.HandInProgress || player.SittingOut {
|
||||
// Credit remaining stack back (buy-in was debited at join).
|
||||
if !player.IsNPC && player.Stack > 0 {
|
||||
p.euro.Credit(player.UserID, float64(player.Stack), "holdem_cashout")
|
||||
cashout := player.Stack
|
||||
if !player.IsNPC && cashout > 0 {
|
||||
p.euro.Credit(player.UserID, float64(cashout), "holdem_cashout")
|
||||
}
|
||||
// Remove immediately.
|
||||
p.removePlayer(game, ctx.Sender)
|
||||
p.SendMessage(ctx.RoomID, fmt.Sprintf("**%s** has left the table.", player.DisplayName))
|
||||
p.SendMessage(ctx.RoomID, fmt.Sprintf("**%s** has left the table. (€%d returned)", player.DisplayName, cashout))
|
||||
|
||||
if len(game.Players) == 0 {
|
||||
delete(p.games, ctx.RoomID)
|
||||
@@ -286,6 +367,7 @@ func (p *HoldemPlugin) handleLeave(ctx MessageContext) error {
|
||||
idx := game.playerIdx(ctx.Sender)
|
||||
if idx == game.ActionIdx {
|
||||
// It's their turn — fold and remove.
|
||||
p.stopActionTimer(game)
|
||||
game.doFold(idx)
|
||||
p.SendMessage(ctx.RoomID, fmt.Sprintf("**%s** folds and leaves the table.", player.DisplayName))
|
||||
|
||||
@@ -510,6 +592,8 @@ func (p *HoldemPlugin) handleAddBot(ctx MessageContext) error {
|
||||
// --- Game lifecycle ---
|
||||
|
||||
func (p *HoldemPlugin) startHand(game *HoldemGame) {
|
||||
p.stopIdleTimer(game)
|
||||
|
||||
// Unsit players who were waiting.
|
||||
for _, pl := range game.Players {
|
||||
if pl.SittingOut {
|
||||
@@ -546,6 +630,7 @@ func (p *HoldemPlugin) startHand(game *HoldemGame) {
|
||||
pl.State = PlayerActive
|
||||
pl.Bet = 0
|
||||
pl.TotalBet = 0
|
||||
pl.HasActed = false
|
||||
pl.Hole = [2]poker.Card{}
|
||||
}
|
||||
|
||||
@@ -570,8 +655,9 @@ func (p *HoldemPlugin) startHand(game *HoldemGame) {
|
||||
p.SendMessage(game.RoomID, renderStartAnnouncement(game))
|
||||
|
||||
// Send hole cards to all players via DM.
|
||||
// Skip the first-to-act player — they'll get hand + table from sendTurnNotifications.
|
||||
for i, pl := range game.Players {
|
||||
if pl.IsNPC || pl.State == PlayerSatOut {
|
||||
if pl.IsNPC || pl.State == PlayerSatOut || i == game.ActionIdx {
|
||||
continue
|
||||
}
|
||||
view := renderPrivateHand(game, i)
|
||||
@@ -807,11 +893,15 @@ func (p *HoldemPlugin) endHand(game *HoldemGame) {
|
||||
game.HandInProgress = false
|
||||
game.Street = StreetPreFlop
|
||||
|
||||
// Remove players who want to leave.
|
||||
// Remove players who want to leave or busted out.
|
||||
// Credit their remaining stack (buy-in was debited at join).
|
||||
for i := len(game.Players) - 1; i >= 0; i-- {
|
||||
pl := game.Players[i]
|
||||
if pl.WantsLeave || pl.Stack <= 0 {
|
||||
if !pl.IsNPC {
|
||||
if pl.Stack > 0 {
|
||||
p.euro.Credit(pl.UserID, float64(pl.Stack), "holdem_cashout")
|
||||
}
|
||||
p.SendMessage(game.RoomID, fmt.Sprintf("**%s** has left the table.", pl.DisplayName))
|
||||
}
|
||||
game.Players = append(game.Players[:i], game.Players[i+1:]...)
|
||||
@@ -824,12 +914,19 @@ func (p *HoldemPlugin) endHand(game *HoldemGame) {
|
||||
}
|
||||
|
||||
if len(game.Players) < 2 {
|
||||
// Cash out remaining players.
|
||||
for _, pl := range game.Players {
|
||||
if !pl.IsNPC && pl.Stack > 0 {
|
||||
p.euro.Credit(pl.UserID, float64(pl.Stack), "holdem_cashout")
|
||||
}
|
||||
}
|
||||
p.SendMessage(game.RoomID, "Not enough players for another hand. Game over.")
|
||||
delete(p.games, game.RoomID)
|
||||
return
|
||||
}
|
||||
|
||||
p.SendMessage(game.RoomID, fmt.Sprintf("Hand complete. %d players at the table. Type `!holdem start` for the next hand.", len(game.Players)))
|
||||
p.startIdleTimer(game)
|
||||
}
|
||||
|
||||
// --- Notifications ---
|
||||
@@ -848,11 +945,12 @@ func (p *HoldemPlugin) sendTurnNotifications(game *HoldemGame) {
|
||||
hand := renderPrivateHand(game, game.ActionIdx)
|
||||
p.SendMessage(actionPlayer.DMRoomID, hand+"\n"+view)
|
||||
|
||||
// Generate tip asynchronously — build context under lock, generate outside.
|
||||
// Generate tip asynchronously — snapshot under lock, compute equity + generate outside.
|
||||
if actionPlayer.TipsEnabled {
|
||||
tipCtx := buildTipContext(game, game.ActionIdx)
|
||||
snap := snapshotForTip(game, game.ActionIdx)
|
||||
dmRoom := actionPlayer.DMRoomID
|
||||
go func() {
|
||||
tipCtx := buildTipContext(snap)
|
||||
tip := generateTip(tipCtx)
|
||||
p.SendMessage(dmRoom, tip)
|
||||
}()
|
||||
@@ -892,6 +990,9 @@ func (p *HoldemPlugin) npcAct(game *HoldemGame) {
|
||||
return
|
||||
}
|
||||
|
||||
// Stop the action timer so it can't fire while we process.
|
||||
p.stopActionTimer(game)
|
||||
|
||||
actionStr, amount := cfrActionToGameAction(action, game, npcIdx)
|
||||
|
||||
var result ActionResult
|
||||
@@ -1016,6 +1117,55 @@ func (p *HoldemPlugin) stopActionTimer(game *HoldemGame) {
|
||||
}
|
||||
}
|
||||
|
||||
// startIdleTimer starts a 1-hour timer that closes the table if no hand is started.
|
||||
// A warning is sent at the 45-minute mark.
|
||||
// Call when a game lobby is created or when a hand ends.
|
||||
func (p *HoldemPlugin) startIdleTimer(game *HoldemGame) {
|
||||
p.stopIdleTimer(game)
|
||||
roomID := game.RoomID
|
||||
|
||||
// Warning at 45 minutes.
|
||||
game.idleWarningTimer = time.AfterFunc(45*time.Minute, func() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g := p.games[roomID]
|
||||
if g == nil || g.HandInProgress {
|
||||
return
|
||||
}
|
||||
p.SendMessage(roomID, "⏰ Table will close in **15 minutes** due to inactivity. Type `!holdem start` to play or `!holdem leave` to cash out.")
|
||||
})
|
||||
|
||||
game.idleTimer = time.AfterFunc(1*time.Hour, func() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
g := p.games[roomID]
|
||||
if g == nil || g.HandInProgress {
|
||||
return
|
||||
}
|
||||
|
||||
// Refund all players and close the table.
|
||||
for _, pl := range g.Players {
|
||||
if !pl.IsNPC && pl.Stack > 0 {
|
||||
p.euro.Credit(pl.UserID, float64(pl.Stack), "holdem_idle_refund")
|
||||
}
|
||||
}
|
||||
p.SendMessage(roomID, "Table closed — 1 hour of inactivity. All buy-ins have been refunded.")
|
||||
delete(p.games, roomID)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *HoldemPlugin) stopIdleTimer(game *HoldemGame) {
|
||||
if game.idleWarningTimer != nil {
|
||||
game.idleWarningTimer.Stop()
|
||||
game.idleWarningTimer = nil
|
||||
}
|
||||
if game.idleTimer != nil {
|
||||
game.idleTimer.Stop()
|
||||
game.idleTimer = nil
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func (p *HoldemPlugin) removePlayer(game *HoldemGame, uid id.UserID) {
|
||||
@@ -1023,13 +1173,15 @@ func (p *HoldemPlugin) removePlayer(game *HoldemGame, uid id.UserID) {
|
||||
if pl.UserID == uid {
|
||||
game.Players = append(game.Players[:i], game.Players[i+1:]...)
|
||||
// Adjust seat indices that reference positions after the removed player.
|
||||
if game.DealerIdx >= i && game.DealerIdx > 0 {
|
||||
// Only decrement when strictly greater — if equal, the next player
|
||||
// slides into the same index so no adjustment is needed.
|
||||
if game.DealerIdx > i {
|
||||
game.DealerIdx--
|
||||
}
|
||||
if game.ActionIdx >= i && game.ActionIdx > 0 {
|
||||
if game.ActionIdx > i {
|
||||
game.ActionIdx--
|
||||
}
|
||||
if game.LastAggressorIdx >= i && game.LastAggressorIdx > 0 {
|
||||
if game.LastAggressorIdx > i {
|
||||
game.LastAggressorIdx--
|
||||
}
|
||||
// Wrap indices if they exceed the new length.
|
||||
@@ -1043,13 +1195,6 @@ func (p *HoldemPlugin) removePlayer(game *HoldemGame, uid id.UserID) {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *HoldemPlugin) holdemDisplayName(userID id.UserID) string {
|
||||
resp, err := p.Client.GetDisplayName(context.Background(), userID)
|
||||
if err != nil || resp.DisplayName == "" {
|
||||
return string(userID)
|
||||
}
|
||||
return resp.DisplayName
|
||||
}
|
||||
|
||||
func (p *HoldemPlugin) getNPCBalance() int64 {
|
||||
d := db.Get()
|
||||
@@ -1057,7 +1202,8 @@ func (p *HoldemPlugin) getNPCBalance() int64 {
|
||||
err := d.QueryRow(`SELECT balance FROM holdem_npc_balance WHERE npc_name = ?`, p.cfg.NPCName).Scan(&balance)
|
||||
if err != nil {
|
||||
// Initialize.
|
||||
_, _ = d.Exec(`INSERT OR IGNORE INTO holdem_npc_balance (npc_name, balance) VALUES (?, ?)`,
|
||||
db.Exec("holdem: init npc balance",
|
||||
`INSERT OR IGNORE INTO holdem_npc_balance (npc_name, balance) VALUES (?, ?)`,
|
||||
p.cfg.NPCName, p.cfg.NPCHouseBalance)
|
||||
return p.cfg.NPCHouseBalance
|
||||
}
|
||||
@@ -1065,13 +1211,12 @@ func (p *HoldemPlugin) getNPCBalance() int64 {
|
||||
}
|
||||
|
||||
func (p *HoldemPlugin) updateNPCBalance(delta int64) {
|
||||
d := db.Get()
|
||||
_, _ = d.Exec(`UPDATE holdem_npc_balance SET balance = balance + ?, hands_played = hands_played + 1 WHERE npc_name = ?`,
|
||||
db.Exec("holdem: update npc balance",
|
||||
`UPDATE holdem_npc_balance SET balance = balance + ?, hands_played = hands_played + 1 WHERE npc_name = ?`,
|
||||
delta, p.cfg.NPCName)
|
||||
}
|
||||
|
||||
func (p *HoldemPlugin) recordScores(game *HoldemGame, winnings map[id.UserID]int64) {
|
||||
d := db.Get()
|
||||
for _, pl := range game.Players {
|
||||
if pl.IsNPC {
|
||||
continue
|
||||
@@ -1089,7 +1234,7 @@ func (p *HoldemPlugin) recordScores(game *HoldemGame, winnings map[id.UserID]int
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = d.Exec(
|
||||
db.Exec("holdem: record player score",
|
||||
`INSERT INTO holdem_scores (user_id, hands_played, total_won, total_lost, biggest_pot)
|
||||
VALUES (?, 1, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
|
||||
@@ -56,8 +56,8 @@ func (g *HoldemGame) postBlinds() (sbIdx, bbIdx int) {
|
||||
|
||||
// firstToActPreflop returns the seat index of the first player to act preflop.
|
||||
func (g *HoldemGame) firstToActPreflop(bbIdx int) int {
|
||||
n := len(g.Players)
|
||||
if n == 2 {
|
||||
inHand := len(g.inHandPlayers())
|
||||
if inHand == 2 {
|
||||
// Heads-up: dealer/SB acts first preflop.
|
||||
return g.DealerIdx
|
||||
}
|
||||
@@ -83,6 +83,7 @@ type ActionResult struct {
|
||||
func (g *HoldemGame) doFold(seatIdx int) ActionResult {
|
||||
p := g.Players[seatIdx]
|
||||
p.State = PlayerFolded
|
||||
p.HasActed = true
|
||||
g.StreetHistory += "f"
|
||||
|
||||
ann := renderActionAnnouncement(p.DisplayName, "fold", 0)
|
||||
@@ -105,6 +106,7 @@ func (g *HoldemGame) doCheck(seatIdx int) (ActionResult, string) {
|
||||
return ActionResult{}, "You must call, raise, or fold — there's a bet to you."
|
||||
}
|
||||
|
||||
p.HasActed = true
|
||||
g.StreetHistory += "c"
|
||||
ann := renderActionAnnouncement(p.DisplayName, "check", 0)
|
||||
return ActionResult{
|
||||
@@ -127,6 +129,7 @@ func (g *HoldemGame) doCall(seatIdx int) (ActionResult, string) {
|
||||
p.Stack -= toCall
|
||||
p.Bet += toCall
|
||||
p.TotalBet += toCall
|
||||
p.HasActed = true
|
||||
|
||||
action := "call"
|
||||
if p.Stack == 0 {
|
||||
@@ -171,6 +174,7 @@ func (g *HoldemGame) doRaise(seatIdx int, raiseTo int64) (ActionResult, string)
|
||||
p.Stack -= raiseAmount
|
||||
p.Bet = raiseTo
|
||||
p.TotalBet += raiseAmount
|
||||
p.HasActed = true
|
||||
|
||||
if actualRaise > 0 {
|
||||
g.MinRaise = actualRaise
|
||||
@@ -214,6 +218,7 @@ func (g *HoldemGame) doAllIn(seatIdx int) ActionResult {
|
||||
p.Bet = totalBet
|
||||
p.TotalBet += allInAmount
|
||||
p.State = PlayerAllIn
|
||||
p.HasActed = true
|
||||
|
||||
if totalBet > g.CurrentBet {
|
||||
actualRaise := totalBet - g.CurrentBet
|
||||
@@ -247,11 +252,16 @@ func (g *HoldemGame) isStreetComplete(nextIdx int) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if all Active players have matched the bet.
|
||||
// Check if all Active players have matched the bet AND have acted at least once.
|
||||
// The acted check is critical for preflop: BB posts a blind but hasn't voluntarily
|
||||
// acted yet, so the street can't end before BB gets their option.
|
||||
for _, p := range g.Players {
|
||||
if p.State == PlayerActive && p.Bet != g.CurrentBet {
|
||||
return false
|
||||
}
|
||||
if p.State == PlayerActive && !p.HasActed {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// If the last aggressor is all-in (can't act), the street is done when
|
||||
@@ -364,10 +374,8 @@ func (g *HoldemGame) returnUncalledBet() (name string, amount int64) {
|
||||
p := g.Players[highestIdx]
|
||||
p.Stack += excess
|
||||
p.TotalBet -= excess
|
||||
p.Bet -= excess
|
||||
if p.Bet < 0 {
|
||||
p.Bet = 0
|
||||
}
|
||||
// Don't adjust per-street Bet — it may be smaller than the hand-total
|
||||
// excess, and it's irrelevant since the hand is ending.
|
||||
return p.DisplayName, excess
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
|
||||
"github.com/chehsunliu/poker"
|
||||
)
|
||||
@@ -112,3 +114,224 @@ func Equity(hole [2]poker.Card, community []poker.Card, numOpponents, iterations
|
||||
Loss: float64(losses) / total,
|
||||
}
|
||||
}
|
||||
|
||||
// DrawInfo holds computed draw information for tip generation.
|
||||
type DrawInfo struct {
|
||||
IsDraw bool
|
||||
FlushDrawOuts int
|
||||
StraightDrawOuts int
|
||||
TotalOuts int
|
||||
Description string // e.g. "flush draw + gutshot (13 outs)"
|
||||
}
|
||||
|
||||
// computeDraws analyzes hole cards and community for flush and straight draws.
|
||||
// Only meaningful on flop and turn (not preflop, not river).
|
||||
func computeDraws(hole [2]poker.Card, community []poker.Card) DrawInfo {
|
||||
if len(community) < 3 || len(community) > 4 {
|
||||
return DrawInfo{}
|
||||
}
|
||||
|
||||
all := make([]poker.Card, 0, 7)
|
||||
all = append(all, hole[0], hole[1])
|
||||
all = append(all, community...)
|
||||
|
||||
flushOuts := countFlushOuts(hole, community)
|
||||
straightOuts := countStraightOuts(all, hole, community)
|
||||
|
||||
total := flushOuts + straightOuts
|
||||
if total > 15 {
|
||||
total = 15 // cap to avoid double-counting
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
// Check backdoor draws (only on flop)
|
||||
if len(community) == 3 {
|
||||
return computeBackdoorDraws(hole, community)
|
||||
}
|
||||
return DrawInfo{}
|
||||
}
|
||||
|
||||
var parts []string
|
||||
if flushOuts >= 8 {
|
||||
parts = append(parts, "flush draw")
|
||||
}
|
||||
if straightOuts == 8 {
|
||||
parts = append(parts, "open-ended straight draw")
|
||||
} else if straightOuts == 4 {
|
||||
parts = append(parts, "gutshot straight draw")
|
||||
}
|
||||
|
||||
desc := fmt.Sprintf("%s (%d outs)", strings.Join(parts, " + "), total)
|
||||
|
||||
return DrawInfo{
|
||||
IsDraw: true,
|
||||
FlushDrawOuts: flushOuts,
|
||||
StraightDrawOuts: straightOuts,
|
||||
TotalOuts: total,
|
||||
Description: desc,
|
||||
}
|
||||
}
|
||||
|
||||
// countFlushOuts returns 9 if we have a flush draw (4 to a flush), 0 otherwise.
|
||||
func countFlushOuts(hole [2]poker.Card, community []poker.Card) int {
|
||||
suitCounts := map[int32]int{}
|
||||
holeSuits := map[int32]bool{}
|
||||
|
||||
for _, c := range []poker.Card{hole[0], hole[1]} {
|
||||
s := c.Suit()
|
||||
suitCounts[s]++
|
||||
holeSuits[s] = true
|
||||
}
|
||||
for _, c := range community {
|
||||
suitCounts[c.Suit()]++
|
||||
}
|
||||
|
||||
for s, count := range suitCounts {
|
||||
if count == 4 && holeSuits[s] {
|
||||
return 9 // 13 cards of suit minus 4 seen = 9 outs
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// countStraightOuts returns the number of straight outs (8 for OESD, 4 for gutshot).
|
||||
func countStraightOuts(allCards []poker.Card, hole [2]poker.Card, community []poker.Card) int {
|
||||
// Get unique ranks present (0-12 where 0=2, 12=A)
|
||||
rankSet := uint16(0)
|
||||
for _, c := range allCards {
|
||||
rankSet |= 1 << uint(c.Rank())
|
||||
}
|
||||
|
||||
// Already have a straight? (5+ consecutive bits)
|
||||
if hasStraight(rankSet) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Try adding each rank not already present; if it completes a straight, it's an out.
|
||||
// But only count if at least one hole card is part of the straight.
|
||||
outs := 0
|
||||
for r := int32(0); r < 13; r++ {
|
||||
if rankSet&(1<<uint(r)) != 0 {
|
||||
continue // already have this rank
|
||||
}
|
||||
test := rankSet | (1 << uint(r))
|
||||
if hasStraight(test) {
|
||||
// Verify at least one hole card participates in the completed straight.
|
||||
if holeParticipatesInStraight(test, hole) {
|
||||
// Count available cards of this rank (4 minus those on board)
|
||||
available := 4
|
||||
for _, c := range community {
|
||||
if c.Rank() == r {
|
||||
available--
|
||||
}
|
||||
}
|
||||
outs += available
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize: OESD = 8, gutshot = 4, double gutshot = 8
|
||||
if outs > 8 {
|
||||
outs = 8
|
||||
}
|
||||
return outs
|
||||
}
|
||||
|
||||
// hasStraight checks if a rank bitset contains 5+ consecutive ranks.
|
||||
// Handles A-low straight (A-2-3-4-5) by duplicating ace as rank -1.
|
||||
func hasStraight(ranks uint16) bool {
|
||||
// Check A-low straight: A(12), 2(0), 3(1), 4(2), 5(3)
|
||||
if ranks&0x100F == 0x100F { // bits 0,1,2,3,12
|
||||
return true
|
||||
}
|
||||
|
||||
consecutive := 0
|
||||
for i := uint(0); i < 13; i++ {
|
||||
if ranks&(1<<i) != 0 {
|
||||
consecutive++
|
||||
if consecutive >= 5 {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
consecutive = 0
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// holeParticipatesInStraight checks if at least one hole card rank is part of
|
||||
// any 5-consecutive-rank window in the given rank set.
|
||||
func holeParticipatesInStraight(ranks uint16, hole [2]poker.Card) bool {
|
||||
hr0 := uint(hole[0].Rank())
|
||||
hr1 := uint(hole[1].Rank())
|
||||
|
||||
// Check each possible 5-card window
|
||||
for start := uint(0); start <= 8; start++ {
|
||||
window := uint16(0x1F) << start // 5 consecutive bits
|
||||
if ranks&window == window {
|
||||
if hr0 >= start && hr0 < start+5 {
|
||||
return true
|
||||
}
|
||||
if hr1 >= start && hr1 < start+5 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check A-low straight (A=12, 2=0, 3=1, 4=2, 5=3)
|
||||
if ranks&0x100F == 0x100F && ranks&0x6 == 0x6 { // A,2,3,4,5
|
||||
if hr0 == 12 || hr0 <= 3 || hr1 == 12 || hr1 <= 3 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// computeBackdoorDraws detects backdoor flush/straight draws (flop only).
|
||||
func computeBackdoorDraws(hole [2]poker.Card, community []poker.Card) DrawInfo {
|
||||
var parts []string
|
||||
totalOuts := 0
|
||||
|
||||
// Backdoor flush: 3 to a flush with at least one hole card
|
||||
suitCounts := map[int32]int{}
|
||||
holeSuits := map[int32]bool{}
|
||||
for _, c := range []poker.Card{hole[0], hole[1]} {
|
||||
s := c.Suit()
|
||||
suitCounts[s]++
|
||||
holeSuits[s] = true
|
||||
}
|
||||
for _, c := range community {
|
||||
suitCounts[c.Suit()]++
|
||||
}
|
||||
for s, count := range suitCounts {
|
||||
if count == 3 && holeSuits[s] {
|
||||
parts = append(parts, "backdoor flush")
|
||||
totalOuts += 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Backdoor straight: 3 to a straight with connected hole cards
|
||||
// Simplified: if hole cards are within 4 ranks of each other, count it
|
||||
r0 := hole[0].Rank()
|
||||
r1 := hole[1].Rank()
|
||||
gap := r0 - r1
|
||||
if gap < 0 {
|
||||
gap = -gap
|
||||
}
|
||||
if gap >= 1 && gap <= 4 {
|
||||
parts = append(parts, "backdoor straight")
|
||||
totalOuts += 1
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return DrawInfo{}
|
||||
}
|
||||
|
||||
return DrawInfo{
|
||||
IsDraw: true,
|
||||
TotalOuts: totalOuts,
|
||||
Description: fmt.Sprintf("%s (%d outs)", strings.Join(parts, " + "), totalOuts),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,15 +142,8 @@ func awardPotToLastPlayer(g *HoldemGame) (string, id.UserID) {
|
||||
return ann, winner.UserID
|
||||
}
|
||||
|
||||
// settleNetDeltas credits each player's remaining stack back to their balance.
|
||||
// Buy-in was debited at join time, so we credit the full final stack.
|
||||
func settleNetDeltas(g *HoldemGame, euro *EuroPlugin) {
|
||||
for _, p := range g.Players {
|
||||
if p.IsNPC || p.State == PlayerSatOut {
|
||||
continue
|
||||
}
|
||||
if p.Stack > 0 {
|
||||
euro.Credit(p.UserID, float64(p.Stack), "holdem_cashout")
|
||||
}
|
||||
}
|
||||
}
|
||||
// settleNetDeltas is intentionally a no-op.
|
||||
// Economy settlement happens at leave time: buy-in is debited at join,
|
||||
// full remaining stack is credited at leave/cashout. Per-hand settlement
|
||||
// would double-count because the stack already reflects cumulative results.
|
||||
func settleNetDeltas(_ *HoldemGame, _ *EuroPlugin) {}
|
||||
|
||||
183
internal/plugin/holdem_eval_test.go
Normal file
183
internal/plugin/holdem_eval_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/chehsunliu/poker"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func TestHandRank_HighCard(t *testing.T) {
|
||||
hole := [2]poker.Card{
|
||||
poker.NewCard("2s"),
|
||||
poker.NewCard("7h"),
|
||||
}
|
||||
community := []poker.Card{
|
||||
poker.NewCard("9d"),
|
||||
poker.NewCard("Jc"),
|
||||
poker.NewCard("Ks"),
|
||||
poker.NewCard("4h"),
|
||||
poker.NewCard("3d"),
|
||||
}
|
||||
rank, _ := handRank(hole, community)
|
||||
if rank <= 0 {
|
||||
t.Errorf("expected positive rank, got %d", rank)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandRank_Pair(t *testing.T) {
|
||||
hole := [2]poker.Card{
|
||||
poker.NewCard("As"),
|
||||
poker.NewCard("Ah"),
|
||||
}
|
||||
community := []poker.Card{
|
||||
poker.NewCard("2d"),
|
||||
poker.NewCard("5c"),
|
||||
poker.NewCard("9s"),
|
||||
poker.NewCard("Jh"),
|
||||
poker.NewCard("Kd"),
|
||||
}
|
||||
_, name := handRank(hole, community)
|
||||
if name != "Pair" {
|
||||
t.Errorf("expected Pair, got %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandRank_Flush(t *testing.T) {
|
||||
hole := [2]poker.Card{
|
||||
poker.NewCard("2s"),
|
||||
poker.NewCard("5s"),
|
||||
}
|
||||
community := []poker.Card{
|
||||
poker.NewCard("8s"),
|
||||
poker.NewCard("Js"),
|
||||
poker.NewCard("Ks"),
|
||||
poker.NewCard("3h"),
|
||||
poker.NewCard("7d"),
|
||||
}
|
||||
_, name := handRank(hole, community)
|
||||
if name != "Flush" {
|
||||
t.Errorf("expected Flush, got %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandRank_FullHouse(t *testing.T) {
|
||||
hole := [2]poker.Card{
|
||||
poker.NewCard("As"),
|
||||
poker.NewCard("Ah"),
|
||||
}
|
||||
community := []poker.Card{
|
||||
poker.NewCard("Ad"),
|
||||
poker.NewCard("Kc"),
|
||||
poker.NewCard("Ks"),
|
||||
poker.NewCard("2h"),
|
||||
poker.NewCard("3d"),
|
||||
}
|
||||
_, name := handRank(hole, community)
|
||||
if name != "Full House" {
|
||||
t.Errorf("expected Full House, got %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandRank_BetterHandHasLowerRank(t *testing.T) {
|
||||
community := []poker.Card{
|
||||
poker.NewCard("2d"),
|
||||
poker.NewCard("5c"),
|
||||
poker.NewCard("9s"),
|
||||
poker.NewCard("Jh"),
|
||||
poker.NewCard("Kd"),
|
||||
}
|
||||
// Player 1: pair of aces
|
||||
pairHole := [2]poker.Card{poker.NewCard("As"), poker.NewCard("Ah")}
|
||||
pairRank, _ := handRank(pairHole, community)
|
||||
|
||||
// Player 2: high card 7
|
||||
highHole := [2]poker.Card{poker.NewCard("3s"), poker.NewCard("7h")}
|
||||
highRank, _ := handRank(highHole, community)
|
||||
|
||||
// Lower rank = better hand in poker lib
|
||||
if pairRank >= highRank {
|
||||
t.Errorf("pair (rank %d) should beat high card (rank %d) — lower is better", pairRank, highRank)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributePot_SingleWinner(t *testing.T) {
|
||||
g := &HoldemGame{
|
||||
Players: make([]*HoldemPlayer, 2),
|
||||
}
|
||||
g.Players[0] = &HoldemPlayer{UserID: "@alice:test", Stack: 0}
|
||||
g.Players[1] = &HoldemPlayer{UserID: "@bob:test", Stack: 0}
|
||||
|
||||
eligible := []id.UserID{"@alice:test", "@bob:test"}
|
||||
evaluated := []evaluatedPlayer{
|
||||
{seatIdx: 0, rank: 100, name: "Flush", userID: "@alice:test"},
|
||||
{seatIdx: 1, rank: 500, name: "Pair", userID: "@bob:test"},
|
||||
}
|
||||
winnings := make(map[id.UserID]int64)
|
||||
var results []showdownResult
|
||||
|
||||
distributePot(g, 1000, eligible, evaluated, winnings, &results)
|
||||
|
||||
if winnings["@alice:test"] != 1000 {
|
||||
t.Errorf("alice should win 1000, got %d", winnings["@alice:test"])
|
||||
}
|
||||
if winnings["@bob:test"] != 0 {
|
||||
t.Errorf("bob should win 0, got %d", winnings["@bob:test"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributePot_SplitPot(t *testing.T) {
|
||||
g := &HoldemGame{
|
||||
Players: make([]*HoldemPlayer, 2),
|
||||
}
|
||||
g.Players[0] = &HoldemPlayer{UserID: "@alice:test", Stack: 0}
|
||||
g.Players[1] = &HoldemPlayer{UserID: "@bob:test", Stack: 0}
|
||||
|
||||
eligible := []id.UserID{"@alice:test", "@bob:test"}
|
||||
evaluated := []evaluatedPlayer{
|
||||
{seatIdx: 0, rank: 100, name: "Flush", userID: "@alice:test"},
|
||||
{seatIdx: 1, rank: 100, name: "Flush", userID: "@bob:test"}, // tie
|
||||
}
|
||||
winnings := make(map[id.UserID]int64)
|
||||
var results []showdownResult
|
||||
|
||||
distributePot(g, 1000, eligible, evaluated, winnings, &results)
|
||||
|
||||
// Should split evenly; odd chip goes to leftmost seat
|
||||
if winnings["@alice:test"] != 500 {
|
||||
t.Errorf("alice should win 500, got %d", winnings["@alice:test"])
|
||||
}
|
||||
if winnings["@bob:test"] != 500 {
|
||||
t.Errorf("bob should win 500, got %d", winnings["@bob:test"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributePot_OddChip(t *testing.T) {
|
||||
g := &HoldemGame{
|
||||
Players: make([]*HoldemPlayer, 3),
|
||||
}
|
||||
g.Players[0] = &HoldemPlayer{UserID: "@alice:test", Stack: 0}
|
||||
g.Players[1] = &HoldemPlayer{UserID: "@bob:test", Stack: 0}
|
||||
g.Players[2] = &HoldemPlayer{UserID: "@carol:test", Stack: 0}
|
||||
|
||||
eligible := []id.UserID{"@alice:test", "@bob:test", "@carol:test"}
|
||||
evaluated := []evaluatedPlayer{
|
||||
{seatIdx: 0, rank: 100, name: "Flush", userID: "@alice:test"},
|
||||
{seatIdx: 1, rank: 100, name: "Flush", userID: "@bob:test"},
|
||||
{seatIdx: 2, rank: 100, name: "Flush", userID: "@carol:test"},
|
||||
}
|
||||
winnings := make(map[id.UserID]int64)
|
||||
var results []showdownResult
|
||||
|
||||
distributePot(g, 100, eligible, evaluated, winnings, &results)
|
||||
|
||||
total := winnings["@alice:test"] + winnings["@bob:test"] + winnings["@carol:test"]
|
||||
if total != 100 {
|
||||
t.Errorf("total winnings should be 100, got %d", total)
|
||||
}
|
||||
// Leftmost seat gets the odd chip
|
||||
if winnings["@alice:test"] < winnings["@bob:test"] || winnings["@alice:test"] < winnings["@carol:test"] {
|
||||
t.Errorf("leftmost seat should get odd chip: alice=%d bob=%d carol=%d",
|
||||
winnings["@alice:test"], winnings["@bob:test"], winnings["@carol:test"])
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ type HoldemPlayer struct {
|
||||
Bet int64 // committed this street
|
||||
TotalBet int64 // committed this hand
|
||||
State PlayerState
|
||||
HasActed bool // whether the player has voluntarily acted this street
|
||||
TipsEnabled bool
|
||||
SittingOut bool
|
||||
WantsLeave bool
|
||||
@@ -90,8 +91,10 @@ type HoldemGame struct {
|
||||
HandInProgress bool
|
||||
StreetHistory string // action chars for current street (f/c/r/R/a) for CFR policy lookup
|
||||
|
||||
actionTimer *time.Timer
|
||||
warningTimer *time.Timer
|
||||
actionTimer *time.Timer
|
||||
warningTimer *time.Timer
|
||||
idleTimer *time.Timer
|
||||
idleWarningTimer *time.Timer
|
||||
}
|
||||
|
||||
// newShuffledDeck creates a shuffled 52-card deck.
|
||||
@@ -208,6 +211,7 @@ func (g *HoldemGame) playerIdx(uid id.UserID) int {
|
||||
func (g *HoldemGame) resetStreetBets() {
|
||||
for _, p := range g.Players {
|
||||
p.Bet = 0
|
||||
p.HasActed = false
|
||||
}
|
||||
g.CurrentBet = 0
|
||||
g.MinRaise = g.BigBlind
|
||||
|
||||
@@ -297,8 +297,10 @@ func renderEndAnnouncement(results []showdownResult, g *HoldemGame) string {
|
||||
// renderHelpMessage returns the help text for !holdem help.
|
||||
func renderHelpMessage() string {
|
||||
return "🎰 **Texas Hold'em Commands**\n\n" +
|
||||
"**Quick Start:**\n" +
|
||||
"`!holdem play [amount]` — Join, add bot, and deal in one command\n\n" +
|
||||
"**Lobby:**\n" +
|
||||
"`!holdem join` — Sit down at the table\n" +
|
||||
"`!holdem join [amount]` — Sit down (optional buy-in amount)\n" +
|
||||
"`!holdem leave` — Leave the table\n" +
|
||||
"`!holdem start` — Start dealing (≥2 players)\n" +
|
||||
"`!holdem addbot` — Add an AI opponent\n\n" +
|
||||
@@ -306,7 +308,7 @@ func renderHelpMessage() string {
|
||||
"`!holdem fold` — Fold your hand\n" +
|
||||
"`!holdem check` — Check (no bet to call)\n" +
|
||||
"`!holdem call` — Call the current bet\n" +
|
||||
"`!holdem raise <amount>` — Raise to a total of €amount\n" +
|
||||
"`!holdem raise <amount>` — Raise *to* €amount total (not raise *by*)\n" +
|
||||
"`!holdem allin` — Go all-in\n\n" +
|
||||
"**Other:**\n" +
|
||||
"`!holdem status` — Get current table state (DM)\n" +
|
||||
|
||||
@@ -8,15 +8,17 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"github.com/chehsunliu/poker"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
var holdemTipsClient = &http.Client{Timeout: 15 * time.Second}
|
||||
var holdemTipsClient = &http.Client{Timeout: 60 * time.Second}
|
||||
|
||||
// loadTipsPref loads a user's tip preference from the database.
|
||||
func loadTipsPref(userID id.UserID) bool {
|
||||
@@ -31,12 +33,11 @@ func loadTipsPref(userID id.UserID) bool {
|
||||
|
||||
// saveTipsPref saves a user's tip preference.
|
||||
func saveTipsPref(userID id.UserID, enabled bool) {
|
||||
d := db.Get()
|
||||
val := 0
|
||||
if enabled {
|
||||
val = 1
|
||||
}
|
||||
_, _ = d.Exec(
|
||||
db.Exec("holdem: save tips preference",
|
||||
`INSERT INTO holdem_tips_prefs (user_id, enabled) VALUES (?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET enabled = ?, updated_at = CURRENT_TIMESTAMP`,
|
||||
string(userID), val, val,
|
||||
@@ -45,22 +46,43 @@ func saveTipsPref(userID id.UserID, enabled bool) {
|
||||
|
||||
// holdemTipContext holds the data needed to generate a tip.
|
||||
type holdemTipContext struct {
|
||||
PlayerName string
|
||||
Hole [2]string // rendered card strings
|
||||
Community string // rendered board string
|
||||
Equity EquityResult
|
||||
SPR float64
|
||||
PotOddsPct float64
|
||||
ToCall int64
|
||||
Pot int64
|
||||
Stack int64
|
||||
Street Street
|
||||
Position string
|
||||
NumActive int
|
||||
PlayerName string
|
||||
Hole [2]string // rendered card strings
|
||||
Community string // rendered board string
|
||||
Equity EquityResult
|
||||
SPR float64
|
||||
PotOddsPct float64
|
||||
ToCall int64
|
||||
Pot int64
|
||||
Stack int64
|
||||
Street Street
|
||||
Position string
|
||||
NumActive int
|
||||
HandCategory string // from poker.RankString(), e.g. "Pair", "Flush"
|
||||
Draw DrawInfo // flush/straight draw analysis
|
||||
HeadsUp bool
|
||||
}
|
||||
|
||||
// buildTipContext creates a tip context from the current game state.
|
||||
func buildTipContext(g *HoldemGame, playerIdx int) holdemTipContext {
|
||||
// tipSnapshot holds game data captured under lock for async tip generation.
|
||||
type tipSnapshot struct {
|
||||
playerName string
|
||||
hole [2]poker.Card
|
||||
holeStr [2]string
|
||||
community []poker.Card
|
||||
communityS string
|
||||
numActive int
|
||||
numOpp int
|
||||
toCall int64
|
||||
totalPot int64
|
||||
stack int64
|
||||
street Street
|
||||
position string
|
||||
headsUp bool
|
||||
isDealer bool
|
||||
}
|
||||
|
||||
// snapshotForTip captures game state under lock. Cheap — no MC here.
|
||||
func snapshotForTip(g *HoldemGame, playerIdx int) tipSnapshot {
|
||||
p := g.Players[playerIdx]
|
||||
|
||||
totalPot := g.Pot
|
||||
@@ -79,40 +101,104 @@ func buildTipContext(g *HoldemGame, playerIdx int) holdemTipContext {
|
||||
numOpp = 1
|
||||
}
|
||||
|
||||
iterations := 5000
|
||||
communityS := "—"
|
||||
if len(g.Community) > 0 {
|
||||
communityS = renderCards(g.Community)
|
||||
}
|
||||
|
||||
// Copy community cards so the slice is safe outside the lock.
|
||||
comm := make([]poker.Card, len(g.Community))
|
||||
copy(comm, g.Community)
|
||||
|
||||
headsUp := numActive == 2
|
||||
isDealer := playerIdx == g.DealerIdx
|
||||
|
||||
// Compute position with heads-up street awareness.
|
||||
position := g.positionLabel(playerIdx)
|
||||
if headsUp {
|
||||
position = tipPositionLabel(isDealer, g.Street)
|
||||
}
|
||||
|
||||
return tipSnapshot{
|
||||
playerName: p.DisplayName,
|
||||
hole: p.Hole,
|
||||
holeStr: [2]string{renderCard(p.Hole[0]), renderCard(p.Hole[1])},
|
||||
community: comm,
|
||||
communityS: communityS,
|
||||
numActive: numActive,
|
||||
numOpp: numOpp,
|
||||
toCall: toCall,
|
||||
totalPot: totalPot,
|
||||
stack: p.Stack,
|
||||
street: g.Street,
|
||||
position: position,
|
||||
headsUp: headsUp,
|
||||
isDealer: isDealer,
|
||||
}
|
||||
}
|
||||
|
||||
// tipPositionLabel returns the correct position label for heads-up play,
|
||||
// accounting for the fact that position semantics change between preflop and postflop.
|
||||
// Pre-flop: dealer/SB acts first (out of position for tips), BB acts last.
|
||||
// Post-flop: dealer/BTN acts last (positional advantage), BB acts first (out of position).
|
||||
func tipPositionLabel(isDealer bool, street Street) string {
|
||||
if street == StreetPreFlop {
|
||||
if isDealer {
|
||||
return "SB" // dealer is SB in heads-up, acts first preflop
|
||||
}
|
||||
return "BB"
|
||||
}
|
||||
// Post-flop: dealer has position
|
||||
if isDealer {
|
||||
return "BTN" // acts last = positional advantage
|
||||
}
|
||||
return "BB" // acts first = out of position
|
||||
}
|
||||
|
||||
// buildTipContext computes the full tip context including equity MC.
|
||||
// Call this OUTSIDE the lock — the expensive equity computation runs here.
|
||||
func buildTipContext(snap tipSnapshot) holdemTipContext {
|
||||
iterations := 5000
|
||||
if len(snap.community) > 0 {
|
||||
iterations = 10000
|
||||
}
|
||||
eq := Equity(p.Hole, g.Community, numOpp, iterations)
|
||||
eq := Equity(snap.hole, snap.community, snap.numOpp, iterations)
|
||||
|
||||
spr := 0.0
|
||||
if totalPot > 0 {
|
||||
spr = float64(p.Stack) / float64(totalPot)
|
||||
if snap.totalPot > 0 {
|
||||
spr = float64(snap.stack) / float64(snap.totalPot)
|
||||
}
|
||||
|
||||
potOdds := 0.0
|
||||
if toCall > 0 {
|
||||
potOdds = float64(toCall) / float64(totalPot+toCall) * 100
|
||||
if snap.toCall > 0 {
|
||||
potOdds = float64(snap.toCall) / float64(snap.totalPot+snap.toCall) * 100
|
||||
}
|
||||
|
||||
community := "—"
|
||||
if len(g.Community) > 0 {
|
||||
community = renderCards(g.Community)
|
||||
// Compute hand category from current best 5-card hand.
|
||||
handCategory := ""
|
||||
if len(snap.community) >= 3 {
|
||||
_, handCategory = handRank(snap.hole, snap.community)
|
||||
}
|
||||
|
||||
// Compute draw info (meaningful on flop/turn only).
|
||||
draw := computeDraws(snap.hole, snap.community)
|
||||
|
||||
return holdemTipContext{
|
||||
PlayerName: p.DisplayName,
|
||||
Hole: [2]string{renderCard(p.Hole[0]), renderCard(p.Hole[1])},
|
||||
Community: community,
|
||||
Equity: eq,
|
||||
SPR: spr,
|
||||
PotOddsPct: potOdds,
|
||||
ToCall: toCall,
|
||||
Pot: totalPot,
|
||||
Stack: p.Stack,
|
||||
Street: g.Street,
|
||||
Position: g.positionLabel(playerIdx),
|
||||
NumActive: numActive,
|
||||
PlayerName: snap.playerName,
|
||||
Hole: snap.holeStr,
|
||||
Community: snap.communityS,
|
||||
Equity: eq,
|
||||
SPR: spr,
|
||||
PotOddsPct: potOdds,
|
||||
ToCall: snap.toCall,
|
||||
Pot: snap.totalPot,
|
||||
Stack: snap.stack,
|
||||
Street: snap.street,
|
||||
Position: snap.position,
|
||||
NumActive: snap.numActive,
|
||||
HandCategory: handCategory,
|
||||
Draw: draw,
|
||||
HeadsUp: snap.headsUp,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +210,7 @@ func generateTip(ctx holdemTipContext) string {
|
||||
if host != "" && model != "" {
|
||||
tip, err := generateLLMTip(host, model, ctx)
|
||||
if err != nil {
|
||||
slog.Debug("holdem: LLM tip failed, using fallback", "err", err)
|
||||
slog.Warn("holdem: LLM tip failed, using fallback", "err", err)
|
||||
} else if tip != "" {
|
||||
return "💡 " + tip
|
||||
}
|
||||
@@ -138,32 +224,62 @@ type chatMessage struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type chatRequest struct {
|
||||
type ollamaChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []chatMessage `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
type chatChoice struct {
|
||||
Message chatMessage `json:"message"`
|
||||
type ollamaChatResponse struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
}
|
||||
|
||||
type chatResponse struct {
|
||||
Choices []chatChoice `json:"choices"`
|
||||
var thinkTagRe = regexp.MustCompile(`(?s)<think>.*?</think>`)
|
||||
|
||||
// extractTipFromResponse strips thinking tags from LLM output.
|
||||
func extractTipFromResponse(raw string) string {
|
||||
cleaned := thinkTagRe.ReplaceAllString(raw, "")
|
||||
return strings.TrimSpace(cleaned)
|
||||
}
|
||||
|
||||
func generateLLMTip(host, model string, ctx holdemTipContext) (string, error) {
|
||||
systemPrompt := `You are a concise Texas Hold'em coach embedded in a Matrix chat bot.
|
||||
You will be given structured game context including pre-computed equity.
|
||||
Give exactly 2-4 sentences of actionable advice for the player's current decision.
|
||||
Lead with the equity vs pot odds relationship when a bet is to call.
|
||||
Be direct. No preamble. No praise. No "great hand" filler.`
|
||||
// buildTipSystemPrompt returns the system prompt for poker tip generation.
|
||||
func buildTipSystemPrompt() string {
|
||||
return `You are a Texas Hold'em coach giving advice to a single player via private message.
|
||||
You will receive structured game context. Reason through it in this order:
|
||||
|
||||
var userPrompt strings.Builder
|
||||
userPrompt.WriteString(fmt.Sprintf("Street: %s\n", ctx.Street.String()))
|
||||
userPrompt.WriteString(fmt.Sprintf("Your hand: %s %s\n", ctx.Hole[0], ctx.Hole[1]))
|
||||
userPrompt.WriteString(fmt.Sprintf("Board: %s\n", ctx.Community))
|
||||
userPrompt.WriteString(fmt.Sprintf("Equity vs %d opponents: Win %.0f%% | Tie %.0f%% | Loss %.0f%%\n",
|
||||
1. What type of hand do I have — made hand, drawing hand, or air?
|
||||
2. If drawing: how many outs, and do pot odds justify continuing?
|
||||
3. If made hand: is it strong enough to bet for value, or weak enough to just pot control?
|
||||
4. Does position affect what I should do here?
|
||||
5. Is a free card available, and if so, is taking it correct?
|
||||
|
||||
Then write ONE piece of advice — 2 to 3 sentences maximum — that tells the player
|
||||
what to do and why, using the specific cards and numbers provided.
|
||||
Do not list concepts. Do not use generic poker vocabulary without connecting it to
|
||||
this specific hand. If the correct play is obvious (e.g. free card with a draw),
|
||||
say so plainly and briefly.`
|
||||
}
|
||||
|
||||
// buildTipUserPrompt builds the structured user prompt with hand context.
|
||||
func buildTipUserPrompt(ctx holdemTipContext) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("Street: %s\n", ctx.Street.String()))
|
||||
|
||||
if ctx.HandCategory != "" {
|
||||
b.WriteString(fmt.Sprintf("Your hand: %s %s [%s]\n", ctx.Hole[0], ctx.Hole[1], ctx.HandCategory))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("Your hand: %s %s\n", ctx.Hole[0], ctx.Hole[1]))
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("Board: %s\n", ctx.Community))
|
||||
|
||||
if ctx.Draw.IsDraw {
|
||||
b.WriteString(fmt.Sprintf("Draw outs: %d (%s)\n", ctx.Draw.TotalOuts, ctx.Draw.Description))
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("Equity vs %d opponent(s): Win %.0f%% | Tie %.0f%% | Loss %.0f%%\n",
|
||||
ctx.NumActive-1, ctx.Equity.Win*100, ctx.Equity.Tie*100, ctx.Equity.Loss*100))
|
||||
|
||||
if ctx.ToCall > 0 {
|
||||
@@ -171,20 +287,27 @@ Be direct. No preamble. No praise. No "great hand" filler.`
|
||||
if ctx.Equity.Win*100 < ctx.PotOddsPct {
|
||||
exceeds = "falls short of"
|
||||
}
|
||||
userPrompt.WriteString(fmt.Sprintf("Pot odds to call: %.0f%% — equity %s price\n", ctx.PotOddsPct, exceeds))
|
||||
b.WriteString(fmt.Sprintf("Pot odds to call: %.0f%% — equity %s price\n", ctx.PotOddsPct, exceeds))
|
||||
} else {
|
||||
userPrompt.WriteString("Check available — no bet to call\n")
|
||||
b.WriteString("Free card available — no bet to call\n")
|
||||
}
|
||||
|
||||
userPrompt.WriteString(fmt.Sprintf("SPR: %.1f | Position: %s | Active players: %d\n",
|
||||
ctx.SPR, ctx.Position, ctx.NumActive))
|
||||
userPrompt.WriteString("\nWhat should I consider for my decision?")
|
||||
headsUp := "no"
|
||||
if ctx.HeadsUp {
|
||||
headsUp = "yes"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("SPR: %.1f | Position: %s | Heads-up: %s | Active players: %d\n",
|
||||
ctx.SPR, ctx.Position, headsUp, ctx.NumActive))
|
||||
|
||||
req := chatRequest{
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func generateLLMTip(host, model string, ctx holdemTipContext) (string, error) {
|
||||
req := ollamaChatRequest{
|
||||
Model: model,
|
||||
Messages: []chatMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userPrompt.String()},
|
||||
{Role: "system", Content: buildTipSystemPrompt()},
|
||||
{Role: "user", Content: buildTipUserPrompt(ctx)},
|
||||
},
|
||||
Stream: false,
|
||||
}
|
||||
@@ -194,7 +317,7 @@ Be direct. No preamble. No praise. No "great hand" filler.`
|
||||
return "", fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
|
||||
url := strings.TrimRight(host, "/") + "/v1/chat/completions"
|
||||
url := strings.TrimRight(host, "/") + "/api/chat"
|
||||
resp, err := holdemTipsClient.Post(url, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request: %w", err)
|
||||
@@ -206,23 +329,16 @@ Be direct. No preamble. No praise. No "great hand" filler.`
|
||||
return "", fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var chatResp chatResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
|
||||
var ollamaResp ollamaChatResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil {
|
||||
return "", fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
|
||||
if len(chatResp.Choices) == 0 || chatResp.Choices[0].Message.Content == "" {
|
||||
tip := extractTipFromResponse(ollamaResp.Message.Content)
|
||||
if tip == "" {
|
||||
return "", fmt.Errorf("empty response")
|
||||
}
|
||||
|
||||
tip := strings.TrimSpace(chatResp.Choices[0].Message.Content)
|
||||
// Strip thinking tags if present.
|
||||
if i := strings.Index(tip, "<think>"); i != -1 {
|
||||
if j := strings.Index(tip, "</think>"); j != -1 {
|
||||
tip = strings.TrimSpace(tip[:i] + tip[j+len("</think>"):])
|
||||
}
|
||||
}
|
||||
|
||||
return tip, nil
|
||||
}
|
||||
|
||||
@@ -234,18 +350,34 @@ func generateRulesTip(ctx holdemTipContext) string {
|
||||
switch {
|
||||
case equity > 0.80:
|
||||
tip = fmt.Sprintf("Strong hand (%.0f%% equity). Size your bet for value — you want to get paid off.", equity*100)
|
||||
|
||||
case ctx.Draw.IsDraw && ctx.ToCall == 0:
|
||||
tip = fmt.Sprintf("You have a %s. With a free card available, check and see the next card without risk.", ctx.Draw.Description)
|
||||
|
||||
case ctx.Draw.IsDraw && ctx.ToCall > 0 && equity*100 > ctx.PotOddsPct:
|
||||
tip = fmt.Sprintf("Drawing hand (%s) with %.0f%% equity vs %.0f%% pot odds — the price is right to call.", ctx.Draw.Description, equity*100, ctx.PotOddsPct)
|
||||
|
||||
case ctx.Draw.IsDraw && ctx.ToCall > 0 && equity*100 <= ctx.PotOddsPct:
|
||||
tip = fmt.Sprintf("Drawing hand (%s) but equity %.0f%% falls short of pot odds %.0f%% — consider folding unless implied odds justify calling.", ctx.Draw.Description, equity*100, ctx.PotOddsPct)
|
||||
|
||||
case ctx.ToCall > 0 && equity*100 > ctx.PotOddsPct:
|
||||
tip = fmt.Sprintf("Equity %.0f%% exceeds pot odds %.0f%% — calling is +EV here.", equity*100, ctx.PotOddsPct)
|
||||
|
||||
case ctx.ToCall > 0 && equity*100 <= ctx.PotOddsPct:
|
||||
tip = fmt.Sprintf("Equity %.0f%% falls short of pot odds %.0f%% — consider folding unless you have a strong draw.", equity*100, ctx.PotOddsPct)
|
||||
tip = fmt.Sprintf("Equity %.0f%% falls short of pot odds %.0f%% — consider folding.", equity*100, ctx.PotOddsPct)
|
||||
|
||||
case ctx.ToCall == 0 && equity > 0.65:
|
||||
tip = fmt.Sprintf("%.0f%% equity with check available — bet for value and deny free cards to draws.", equity*100)
|
||||
|
||||
case ctx.ToCall == 0 && equity < 0.40:
|
||||
tip = fmt.Sprintf("%.0f%% equity — check to control pot size. Not enough equity to bet.", equity*100)
|
||||
tip = fmt.Sprintf("%.0f%% equity — check to control pot size.", equity*100)
|
||||
|
||||
case ctx.SPR < 1:
|
||||
tip = "Shallow stack (SPR < 1) — commit or fold. No room to maneuver."
|
||||
|
||||
case ctx.SPR > 10 && ctx.Street == StreetPreFlop:
|
||||
tip = "Deep stacked preflop — implied odds outweigh raw equity. Speculative hands gain value."
|
||||
|
||||
default:
|
||||
tip = fmt.Sprintf("%.0f%% equity. Evaluate your position and pot odds before acting.", equity*100)
|
||||
}
|
||||
|
||||
324
internal/plugin/holdem_tips_test.go
Normal file
324
internal/plugin/holdem_tips_test.go
Normal file
@@ -0,0 +1,324 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/chehsunliu/poker"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Position label — heads-up street awareness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestTipPositionLabel_HeadsUp_PreFlop(t *testing.T) {
|
||||
// Pre-flop: dealer is SB (acts first), other is BB
|
||||
if pos := tipPositionLabel(true, StreetPreFlop); pos != "SB" {
|
||||
t.Errorf("dealer preflop HU: got %q, want SB", pos)
|
||||
}
|
||||
if pos := tipPositionLabel(false, StreetPreFlop); pos != "BB" {
|
||||
t.Errorf("non-dealer preflop HU: got %q, want BB", pos)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTipPositionLabel_HeadsUp_PostFlop(t *testing.T) {
|
||||
// Post-flop: dealer is BTN (acts last = positional advantage)
|
||||
for _, street := range []Street{StreetFlop, StreetTurn, StreetRiver} {
|
||||
if pos := tipPositionLabel(true, street); pos != "BTN" {
|
||||
t.Errorf("dealer %s HU: got %q, want BTN", street, pos)
|
||||
}
|
||||
if pos := tipPositionLabel(false, street); pos != "BB" {
|
||||
t.Errorf("non-dealer %s HU: got %q, want BB", street, pos)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Draw detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestComputeDraws_GutShotStraight(t *testing.T) {
|
||||
// 9♠ 8♠ on 7♣ 5♦ 2♥ — gutshot (need 6 for 5-6-7-8-9)
|
||||
hole := [2]poker.Card{poker.NewCard("9s"), poker.NewCard("8s")}
|
||||
community := []poker.Card{poker.NewCard("7c"), poker.NewCard("5d"), poker.NewCard("2h")}
|
||||
|
||||
draw := computeDraws(hole, community)
|
||||
if !draw.IsDraw {
|
||||
t.Fatal("should detect a draw")
|
||||
}
|
||||
if draw.StraightDrawOuts != 4 {
|
||||
t.Errorf("straight outs: got %d, want 4 (gutshot)", draw.StraightDrawOuts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeDraws_SpecScenario(t *testing.T) {
|
||||
// 8♥ 7♥ on Q♥ K♠ 10♦ — the spec scenario
|
||||
// This is a backdoor flush (3 hearts) + backdoor straight, NOT a standard gutshot.
|
||||
// A straight here needs 9+J (two cards), so it's runner-runner.
|
||||
hole := [2]poker.Card{poker.NewCard("8h"), poker.NewCard("7h")}
|
||||
community := []poker.Card{poker.NewCard("Qh"), poker.NewCard("Ks"), poker.NewCard("Td")}
|
||||
|
||||
draw := computeDraws(hole, community)
|
||||
if !draw.IsDraw {
|
||||
t.Fatal("should detect backdoor draws")
|
||||
}
|
||||
if draw.TotalOuts == 0 {
|
||||
t.Error("should have some backdoor outs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeDraws_FlushDraw(t *testing.T) {
|
||||
// 5♥ 6♥ on 7♥ 8♣ 2♥ — flush draw (4 hearts, need 1 more)
|
||||
hole := [2]poker.Card{poker.NewCard("5h"), poker.NewCard("6h")}
|
||||
community := []poker.Card{poker.NewCard("7h"), poker.NewCard("8c"), poker.NewCard("2h")}
|
||||
|
||||
draw := computeDraws(hole, community)
|
||||
if !draw.IsDraw {
|
||||
t.Fatal("should detect a draw")
|
||||
}
|
||||
if draw.FlushDrawOuts != 9 {
|
||||
t.Errorf("flush outs: got %d, want 9", draw.FlushDrawOuts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeDraws_OESDPlusFlush(t *testing.T) {
|
||||
// 5♥ 6♥ on 7♥ 8♣ 2♥ — OESD (4-5-6-7-8) + flush draw
|
||||
hole := [2]poker.Card{poker.NewCard("5h"), poker.NewCard("6h")}
|
||||
community := []poker.Card{poker.NewCard("7h"), poker.NewCard("8c"), poker.NewCard("2h")}
|
||||
|
||||
draw := computeDraws(hole, community)
|
||||
if draw.TotalOuts > 15 {
|
||||
t.Errorf("total outs should be capped at 15, got %d", draw.TotalOuts)
|
||||
}
|
||||
if draw.FlushDrawOuts == 0 && draw.StraightDrawOuts == 0 {
|
||||
t.Error("should detect flush and/or straight draw")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeDraws_MadeHand_NoDraw(t *testing.T) {
|
||||
// Q♣ Q♦ on Q♥ 2♠ 7♣ — trips, no draw
|
||||
hole := [2]poker.Card{poker.NewCard("Qc"), poker.NewCard("Qd")}
|
||||
community := []poker.Card{poker.NewCard("Qh"), poker.NewCard("2s"), poker.NewCard("7c")}
|
||||
|
||||
draw := computeDraws(hole, community)
|
||||
if draw.IsDraw && draw.TotalOuts > 2 {
|
||||
t.Errorf("set of queens should not report significant draw, got %d outs", draw.TotalOuts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeDraws_PreFlop_NoDraw(t *testing.T) {
|
||||
// No community cards — draws not applicable
|
||||
hole := [2]poker.Card{poker.NewCard("As"), poker.NewCard("Ks")}
|
||||
draw := computeDraws(hole, nil)
|
||||
if draw.IsDraw {
|
||||
t.Error("preflop should not report draws")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeDraws_River_NoDraw(t *testing.T) {
|
||||
// 5 community cards — draws not applicable (no more cards to come)
|
||||
hole := [2]poker.Card{poker.NewCard("5h"), poker.NewCard("6h")}
|
||||
community := []poker.Card{
|
||||
poker.NewCard("7h"), poker.NewCard("8c"), poker.NewCard("2h"),
|
||||
poker.NewCard("Ks"), poker.NewCard("3d"),
|
||||
}
|
||||
draw := computeDraws(hole, community)
|
||||
if draw.IsDraw {
|
||||
t.Error("river should not report draws")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Straight detection helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHasStraight(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ranks uint16
|
||||
want bool
|
||||
}{
|
||||
{"A-high straight (T-J-Q-K-A)", 0x1F00, true}, // bits 8-12
|
||||
{"low straight (A-2-3-4-5)", 0x100F, true}, // bits 0,1,2,3,12
|
||||
{"5-6-7-8-9", 0x00F8, true}, // bits 3-7
|
||||
{"four consecutive", 0x000F, false}, // bits 0-3
|
||||
{"scattered", 0x1111, false}, // bits 0,4,8,12
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := hasStraight(tt.ranks); got != tt.want {
|
||||
t.Errorf("hasStraight(0x%04X) = %v, want %v", tt.ranks, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Think tag stripping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestExtractTipFromResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"no think tags",
|
||||
"Check here and take the free card.",
|
||||
"Check here and take the free card.",
|
||||
},
|
||||
{
|
||||
"with think tags",
|
||||
"<think>Let me analyze this hand...</think>Check here and take the free card.",
|
||||
"Check here and take the free card.",
|
||||
},
|
||||
{
|
||||
"multiline think block",
|
||||
"<think>\nStep 1: assess hand type\nStep 2: evaluate outs\n</think>\n\nYou have a gutshot. Take the free card.",
|
||||
"You have a gutshot. Take the free card.",
|
||||
},
|
||||
{
|
||||
"only think block",
|
||||
"<think>reasoning only</think>",
|
||||
"",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractTipFromResponse(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User prompt structure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBuildTipUserPrompt_IncludesDrawInfo(t *testing.T) {
|
||||
ctx := holdemTipContext{
|
||||
Hole: [2]string{"8♥", "7♥"},
|
||||
Community: "Q♥ K♠ 10♦",
|
||||
Street: StreetFlop,
|
||||
Position: "BB",
|
||||
NumActive: 2,
|
||||
HeadsUp: true,
|
||||
Equity: EquityResult{Win: 0.29, Tie: 0.01, Loss: 0.70},
|
||||
Draw: DrawInfo{
|
||||
IsDraw: true,
|
||||
StraightDrawOuts: 4,
|
||||
TotalOuts: 4,
|
||||
Description: "gutshot straight draw (4 outs)",
|
||||
},
|
||||
HandCategory: "High Card",
|
||||
}
|
||||
|
||||
prompt := buildTipUserPrompt(ctx)
|
||||
|
||||
if !contains(prompt, "gutshot straight draw") {
|
||||
t.Error("prompt should include draw description")
|
||||
}
|
||||
if !contains(prompt, "[High Card]") {
|
||||
t.Error("prompt should include hand category")
|
||||
}
|
||||
if !contains(prompt, "Heads-up: yes") {
|
||||
t.Error("prompt should indicate heads-up")
|
||||
}
|
||||
if !contains(prompt, "Free card available") {
|
||||
t.Error("prompt should indicate free card when ToCall=0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTipUserPrompt_NoDrawWhenMadeHand(t *testing.T) {
|
||||
ctx := holdemTipContext{
|
||||
Hole: [2]string{"Q♣", "Q♦"},
|
||||
Community: "Q♥ 2♠ 7♣",
|
||||
Street: StreetFlop,
|
||||
Position: "BTN",
|
||||
NumActive: 3,
|
||||
Equity: EquityResult{Win: 0.90, Tie: 0.02, Loss: 0.08},
|
||||
HandCategory: "Three of a Kind",
|
||||
}
|
||||
|
||||
prompt := buildTipUserPrompt(ctx)
|
||||
|
||||
if contains(prompt, "Draw outs:") {
|
||||
t.Error("prompt should not include draw line for made hand without draws")
|
||||
}
|
||||
if !contains(prompt, "[Three of a Kind]") {
|
||||
t.Error("prompt should include hand category")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTipUserPrompt_PotOddsWhenFacingBet(t *testing.T) {
|
||||
ctx := holdemTipContext{
|
||||
Hole: [2]string{"A♠", "K♠"},
|
||||
Community: "2♣ 7♦ J♠",
|
||||
Street: StreetFlop,
|
||||
Position: "CO",
|
||||
NumActive: 4,
|
||||
Equity: EquityResult{Win: 0.45, Tie: 0.02, Loss: 0.53},
|
||||
ToCall: 50,
|
||||
PotOddsPct: 25.0,
|
||||
HandCategory: "High Card",
|
||||
}
|
||||
|
||||
prompt := buildTipUserPrompt(ctx)
|
||||
|
||||
if !contains(prompt, "Pot odds to call:") {
|
||||
t.Error("prompt should include pot odds when facing a bet")
|
||||
}
|
||||
if contains(prompt, "Free card available") {
|
||||
t.Error("prompt should not say free card when facing a bet")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rules-based fallback — draw awareness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRulesTip_DrawWithFreeCard(t *testing.T) {
|
||||
ctx := holdemTipContext{
|
||||
Equity: EquityResult{Win: 0.29, Tie: 0.01, Loss: 0.70},
|
||||
ToCall: 0,
|
||||
Draw: DrawInfo{IsDraw: true, TotalOuts: 4, Description: "gutshot straight draw (4 outs)"},
|
||||
Position: "BB",
|
||||
Street: StreetFlop,
|
||||
}
|
||||
|
||||
tip := generateRulesTip(ctx)
|
||||
if !contains(tip, "free card") {
|
||||
t.Errorf("draw + free card tip should mention free card, got: %s", tip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesTip_DrawFacingBet_GoodOdds(t *testing.T) {
|
||||
ctx := holdemTipContext{
|
||||
Equity: EquityResult{Win: 0.35, Tie: 0.01, Loss: 0.64},
|
||||
ToCall: 20,
|
||||
PotOddsPct: 20.0,
|
||||
Draw: DrawInfo{IsDraw: true, TotalOuts: 9, Description: "flush draw (9 outs)"},
|
||||
Position: "BB",
|
||||
Street: StreetFlop,
|
||||
}
|
||||
|
||||
tip := generateRulesTip(ctx)
|
||||
if !contains(tip, "price is right") {
|
||||
t.Errorf("draw with good odds should mention price, got: %s", tip)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && searchSubstring(s, substr)
|
||||
}
|
||||
|
||||
func searchSubstring(s, sub string) bool {
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -65,14 +65,15 @@ func (p *HowAmIPlugin) OnMessage(ctx MessageContext) error {
|
||||
slog.Error("howami: send thinking", "err", err)
|
||||
}
|
||||
|
||||
profile := p.gatherProfile(target)
|
||||
go func() {
|
||||
profile := p.gatherProfile(target)
|
||||
|
||||
botName := os.Getenv("BOT_DISPLAY_NAME")
|
||||
if botName == "" {
|
||||
botName = "GogoBee"
|
||||
}
|
||||
prompt := fmt.Sprintf(
|
||||
`You are a witty, playful community bot called %s. Based on the following user profile data, write a fun, lighthearted "roast" of this user in 3-5 sentences. Be creative, funny, and reference specific stats. Keep it friendly — no truly mean insults.
|
||||
botName := os.Getenv("BOT_DISPLAY_NAME")
|
||||
if botName == "" {
|
||||
botName = "GogoBee"
|
||||
}
|
||||
prompt := fmt.Sprintf(
|
||||
`You are a witty, playful community bot called %s. Based on the following user profile data, write a fun, lighthearted "roast" of this user in 3-5 sentences. Be creative, funny, and reference specific stats. Keep it friendly — no truly mean insults.
|
||||
|
||||
User: %s
|
||||
|
||||
@@ -80,16 +81,20 @@ Profile data:
|
||||
%s
|
||||
|
||||
Write the roast now. Do not include any preamble or explanation, just the roast text.`,
|
||||
botName, string(target), profile,
|
||||
)
|
||||
botName, string(target), profile,
|
||||
)
|
||||
|
||||
response, err := callOllama(ollamaHost, ollamaModel, prompt)
|
||||
if err != nil {
|
||||
slog.Error("howami: ollama call", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to generate profile. LLM might be offline.")
|
||||
}
|
||||
response, err := callOllama(ollamaHost, ollamaModel, prompt)
|
||||
if err != nil {
|
||||
slog.Error("howami: ollama call", "err", err)
|
||||
p.SendReply(ctx.RoomID, ctx.EventID, "Failed to generate profile. LLM might be offline.")
|
||||
return
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, response)
|
||||
p.SendReply(ctx.RoomID, ctx.EventID, response)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *HowAmIPlugin) gatherProfile(userID id.UserID) string {
|
||||
|
||||
@@ -326,8 +326,6 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
|
||||
}
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
|
||||
// Store classification
|
||||
topicsJSON, _ := json.Marshal(result.Topics)
|
||||
profanityInt := 0
|
||||
@@ -339,16 +337,13 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
|
||||
wotdInt = 1
|
||||
}
|
||||
|
||||
_, err = d.Exec(
|
||||
db.Exec("llm: store classification",
|
||||
`INSERT INTO llm_classifications (user_id, room_id, message_text, sentiment, sentiment_score, topics, profanity, profanity_severity, insult_target, wotd_used, gratitude_target)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
string(item.UserID), string(item.RoomID), item.Body,
|
||||
result.Sentiment, result.SentimentScore, string(topicsJSON),
|
||||
profanityInt, result.ProfanitySeverity, result.InsultTarget, wotdInt, result.GratitudeTarget,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("llm: store classification", "err", err)
|
||||
}
|
||||
|
||||
// Aggregate sentiment stats
|
||||
sentimentCol := "neutral"
|
||||
@@ -360,7 +355,7 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
|
||||
if validSentiments[result.Sentiment] {
|
||||
sentimentCol = result.Sentiment
|
||||
}
|
||||
_, _ = d.Exec(
|
||||
db.Exec("llm: update user sentiment stats",
|
||||
fmt.Sprintf(
|
||||
`INSERT INTO sentiment_stats (user_id, %s, total_score) VALUES (?, 1, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET %s = %s + 1, total_score = total_score + ?`,
|
||||
@@ -369,7 +364,7 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
|
||||
)
|
||||
|
||||
// Aggregate room sentiment stats
|
||||
_, _ = d.Exec(
|
||||
db.Exec("llm: update room sentiment stats",
|
||||
fmt.Sprintf(
|
||||
`INSERT INTO room_sentiment_stats (room_id, %s, total_score) VALUES (?, 1, ?)
|
||||
ON CONFLICT(room_id) DO UPDATE SET %s = %s + 1, total_score = total_score + ?`,
|
||||
@@ -395,7 +390,7 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
|
||||
case 3:
|
||||
scorchInc = 1
|
||||
}
|
||||
_, _ = d.Exec(
|
||||
db.Exec("llm: track profanity",
|
||||
`INSERT INTO potty_mouth (user_id, count, mild, moderate, scorching) VALUES (?, 1, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET count = count + 1, mild = mild + ?, moderate = moderate + ?, scorching = scorching + ?`,
|
||||
string(item.UserID), mildInc, modInc, scorchInc, mildInc, modInc, scorchInc,
|
||||
@@ -417,12 +412,12 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
|
||||
|
||||
// Track insults
|
||||
if result.InsultTarget != "" {
|
||||
_, _ = d.Exec(
|
||||
db.Exec("llm: track insult sender",
|
||||
`INSERT INTO insult_log (user_id, times_insulting) VALUES (?, 1)
|
||||
ON CONFLICT(user_id) DO UPDATE SET times_insulting = times_insulting + 1`,
|
||||
string(item.UserID),
|
||||
)
|
||||
_, _ = d.Exec(
|
||||
db.Exec("llm: track insult target",
|
||||
`INSERT INTO insult_log (user_id, times_insulted) VALUES (?, 1)
|
||||
ON CONFLICT(user_id) DO UPDATE SET times_insulted = times_insulted + 1`,
|
||||
result.InsultTarget,
|
||||
|
||||
@@ -48,16 +48,24 @@ func (p *LookupPlugin) Init() error { return nil }
|
||||
func (p *LookupPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *LookupPlugin) OnMessage(ctx MessageContext) error {
|
||||
var handler func(MessageContext) error
|
||||
switch {
|
||||
case p.IsCommand(ctx.Body, "wiki"):
|
||||
return p.handleWiki(ctx)
|
||||
handler = p.handleWiki
|
||||
case p.IsCommand(ctx.Body, "define"):
|
||||
return p.handleDefine(ctx)
|
||||
handler = p.handleDefine
|
||||
case p.IsCommand(ctx.Body, "urban"):
|
||||
return p.handleUrban(ctx)
|
||||
handler = p.handleUrban
|
||||
case p.IsCommand(ctx.Body, "translate"):
|
||||
return p.handleTranslate(ctx)
|
||||
handler = p.handleTranslate
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
go func() {
|
||||
if err := handler(ctx); err != nil {
|
||||
slog.Error("lookup: handler error", "err", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -266,14 +274,11 @@ func (p *LookupPlugin) handleUrban(ctx MessageContext) error {
|
||||
msg := sb.String()
|
||||
|
||||
// Cache the result
|
||||
_, err = d.Exec(
|
||||
db.Exec("lookup: urban cache",
|
||||
`INSERT INTO urban_cache (term, data, cached_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(term) DO UPDATE SET data = ?, cached_at = ?`,
|
||||
termLower, msg, now, msg, now,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("lookup: urban cache", "err", err)
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
@@ -68,15 +68,12 @@ func (p *MarkovPlugin) collectMessage(userID id.UserID, text string) {
|
||||
}
|
||||
|
||||
// Cap at 10,000 messages per user — delete oldest excess
|
||||
_, err = d.Exec(
|
||||
db.Exec("markov: prune corpus",
|
||||
`DELETE FROM markov_corpus WHERE user_id = ? AND id NOT IN (
|
||||
SELECT id FROM markov_corpus WHERE user_id = ? ORDER BY id DESC LIMIT 10000
|
||||
)`,
|
||||
string(userID), string(userID),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("markov: prune corpus", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MarkovPlugin) handleMarkov(ctx MessageContext) error {
|
||||
|
||||
@@ -240,36 +240,44 @@ func (p *MilkCartonPlugin) getMissingMembers() []missingMember {
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []missingMember
|
||||
type candidate struct {
|
||||
userID, lastDate string
|
||||
}
|
||||
var candidates []candidate
|
||||
for rows.Next() {
|
||||
var userID, lastDate string
|
||||
if err := rows.Scan(&userID, &lastDate); err != nil {
|
||||
var c candidate
|
||||
if err := rows.Scan(&c.userID, &c.lastDate); err != nil {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, c)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
var result []missingMember
|
||||
for _, c := range candidates {
|
||||
// Skip excluded users
|
||||
if p.excludeUsers[userID] {
|
||||
if p.excludeUsers[c.userID] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip bots (our own user ID)
|
||||
if id.UserID(userID) == p.Client.UserID {
|
||||
if id.UserID(c.userID) == p.Client.UserID {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip users with active away/afk status
|
||||
var awayStatus int
|
||||
_ = d.QueryRow(`SELECT 1 FROM presence WHERE user_id = ? AND status IN ('away', 'afk')`, userID).Scan(&awayStatus)
|
||||
_ = d.QueryRow(`SELECT 1 FROM presence WHERE user_id = ? AND status IN ('away', 'afk')`, c.userID).Scan(&awayStatus)
|
||||
if awayStatus == 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
lastTime, err := time.Parse("2006-01-02", lastDate)
|
||||
lastTime, err := time.Parse("2006-01-02", c.lastDate)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
days := int(now.Sub(lastTime).Hours() / 24)
|
||||
result = append(result, missingMember{userID: userID, daysSince: days})
|
||||
result = append(result, missingMember{userID: c.userID, daysSince: days})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -860,15 +860,11 @@ func (p *ModerationPlugin) notifyAdmin(text string) {
|
||||
}
|
||||
|
||||
func (p *ModerationPlugin) logAction(userID id.UserID, roomID id.RoomID, action, reason, takenBy string) {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(
|
||||
db.Exec("moderation: log action",
|
||||
`INSERT INTO mod_actions (user_id, room_id, action, reason, taken_at, taken_by)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, ?)`,
|
||||
string(userID), string(roomID), action, reason, takenBy,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("moderation: failed to log action", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func modTruncate(s string, max int) string {
|
||||
|
||||
@@ -119,18 +119,26 @@ func (p *MoviesPlugin) Init() error { return nil }
|
||||
func (p *MoviesPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *MoviesPlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "movie") {
|
||||
return p.handleMovie(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "tv") {
|
||||
return p.handleTV(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "upcoming") {
|
||||
var handler func(MessageContext) error
|
||||
switch {
|
||||
case p.IsCommand(ctx.Body, "movie"):
|
||||
handler = p.handleMovie
|
||||
case p.IsCommand(ctx.Body, "tv"):
|
||||
handler = p.handleTV
|
||||
case p.IsCommand(ctx.Body, "upcoming"):
|
||||
args := p.GetArgs(ctx.Body, "upcoming")
|
||||
if strings.ToLower(strings.TrimSpace(args)) == "movies" {
|
||||
return p.handleUpcoming(ctx)
|
||||
handler = p.handleUpcoming
|
||||
}
|
||||
}
|
||||
if handler == nil {
|
||||
return nil
|
||||
}
|
||||
go func() {
|
||||
if err := handler(ctx); err != nil {
|
||||
slog.Error("movies: handler error", "err", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -221,14 +229,11 @@ func (p *MoviesPlugin) fetchMovieDetail(movieID int) (*tmdbMovieDetail, error) {
|
||||
|
||||
// Update cache
|
||||
data, _ := json.Marshal(detail)
|
||||
_, err = d.Exec(
|
||||
db.Exec("movies: cache write",
|
||||
`INSERT INTO movie_cache (tmdb_id, data, cached_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(tmdb_id) DO UPDATE SET data = ?, cached_at = ?`,
|
||||
movieID, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("movies: cache write", "err", err)
|
||||
}
|
||||
|
||||
return &detail, nil
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ package plugin
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"log/slog"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -96,6 +98,20 @@ func (b *Base) IsAdmin(userID id.UserID) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// DisplayName returns the Matrix display name for a user, falling back
|
||||
// to the localpart extracted from the user ID (e.g., "@alice:server" -> "alice").
|
||||
func (b *Base) DisplayName(userID id.UserID) string {
|
||||
resp, err := b.Client.GetDisplayName(context.Background(), userID)
|
||||
if err != nil || resp.DisplayName == "" {
|
||||
s := string(userID)
|
||||
if idx := strings.Index(s, ":"); idx > 0 {
|
||||
s = s[1:idx]
|
||||
}
|
||||
return s
|
||||
}
|
||||
return resp.DisplayName
|
||||
}
|
||||
|
||||
// RoomMembers returns the set of user IDs visible from a room. If space groups
|
||||
// are enabled, this returns the union of all members across rooms in the same
|
||||
// space group. Otherwise falls back to the single room's membership.
|
||||
@@ -289,11 +305,11 @@ func (sg *SpaceGroupManager) Refresh() {
|
||||
slog.Error("space_groups: begin tx", "err", err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// Delete only rooms we have fresh data for (preserve entries for rooms that failed to fetch)
|
||||
for r := range newRoomToGroup {
|
||||
if _, err := tx.Exec(`DELETE FROM space_groups WHERE room_id = ?`, string(r)); err != nil {
|
||||
slog.Error("space_groups: delete row", "room", r, "err", err)
|
||||
tx.Rollback()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -301,7 +317,6 @@ func (sg *SpaceGroupManager) Refresh() {
|
||||
if _, err := tx.Exec(`INSERT INTO space_groups (room_id, group_id, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)`,
|
||||
string(r), gid); err != nil {
|
||||
slog.Error("space_groups: insert row", "room", r, "err", err)
|
||||
tx.Rollback()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -453,12 +468,64 @@ func (b *Base) ResolveUser(input string, roomIDs ...id.RoomID) (id.UserID, bool)
|
||||
return "", false
|
||||
}
|
||||
|
||||
// SendMessage sends a plain text message to a room.
|
||||
func (b *Base) SendMessage(roomID id.RoomID, text string) error {
|
||||
content := &event.MessageEventContent{
|
||||
// simpleMarkdownToHTML converts the limited Markdown subset used in bot messages
|
||||
// (**bold**, _italic_, `code`, newlines) to Matrix-compatible HTML.
|
||||
var (
|
||||
mdBoldRe = regexp.MustCompile(`\*\*(.+?)\*\*`)
|
||||
mdItalicRe = regexp.MustCompile(`(?:^|[ (])_([^_]+?)_(?:$|[ ).,!?])`)
|
||||
mdCodeRe = regexp.MustCompile("`([^`]+)`")
|
||||
mdHasFmt = regexp.MustCompile(`\*\*|(?:^|[ (])_[^_]+_(?:$|[ ).,!?])|` + "`")
|
||||
)
|
||||
|
||||
func simpleMarkdownToHTML(text string) string {
|
||||
h := html.EscapeString(text)
|
||||
h = mdBoldRe.ReplaceAllString(h, "<strong>$1</strong>")
|
||||
// Italic needs careful handling to not match snake_case
|
||||
h = mdItalicRe.ReplaceAllStringFunc(h, func(m string) string {
|
||||
// Preserve leading/trailing non-underscore chars
|
||||
start := 0
|
||||
for start < len(m) && m[start] != '_' {
|
||||
start++
|
||||
}
|
||||
end := len(m) - 1
|
||||
for end > start && m[end] != '_' {
|
||||
end--
|
||||
}
|
||||
return m[:start] + "<em>" + m[start+1:end] + "</em>" + m[end+1:]
|
||||
})
|
||||
h = mdCodeRe.ReplaceAllString(h, "<code>$1</code>")
|
||||
h = strings.ReplaceAll(h, "\n", "<br>\n")
|
||||
return h
|
||||
}
|
||||
|
||||
var mdStripItalicRe = regexp.MustCompile(`_([^_]+?)_`)
|
||||
|
||||
func stripMarkdown(text string) string {
|
||||
s := mdBoldRe.ReplaceAllString(text, "$1")
|
||||
s = mdStripItalicRe.ReplaceAllString(s, "$1")
|
||||
s = mdCodeRe.ReplaceAllString(s, "$1")
|
||||
return s
|
||||
}
|
||||
|
||||
// textContent builds a MessageEventContent, adding HTML formatting when Markdown is detected.
|
||||
func textContent(text string) *event.MessageEventContent {
|
||||
if mdHasFmt.MatchString(text) {
|
||||
return &event.MessageEventContent{
|
||||
MsgType: event.MsgText,
|
||||
Body: stripMarkdown(text),
|
||||
Format: event.FormatHTML,
|
||||
FormattedBody: simpleMarkdownToHTML(text),
|
||||
}
|
||||
}
|
||||
return &event.MessageEventContent{
|
||||
MsgType: event.MsgText,
|
||||
Body: text,
|
||||
}
|
||||
}
|
||||
|
||||
// SendMessage sends a message to a room, auto-formatting Markdown as HTML.
|
||||
func (b *Base) SendMessage(roomID id.RoomID, text string) error {
|
||||
content := textContent(text)
|
||||
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||
if err != nil {
|
||||
slog.Error("failed to send message", "room", roomID, "err", err)
|
||||
@@ -466,12 +533,9 @@ func (b *Base) SendMessage(roomID id.RoomID, text string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// SendMessageID sends a plain text message and returns the event ID.
|
||||
// SendMessageID sends a message and returns the event ID.
|
||||
func (b *Base) SendMessageID(roomID id.RoomID, text string) (id.EventID, error) {
|
||||
content := &event.MessageEventContent{
|
||||
MsgType: event.MsgText,
|
||||
Body: text,
|
||||
}
|
||||
content := textContent(text)
|
||||
resp, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||
if err != nil {
|
||||
slog.Error("failed to send message", "room", roomID, "err", err)
|
||||
@@ -482,17 +546,14 @@ func (b *Base) SendMessageID(roomID id.RoomID, text string) (id.EventID, error)
|
||||
|
||||
// SendThread sends a message in a thread rooted at threadID.
|
||||
func (b *Base) SendThread(roomID id.RoomID, threadID id.EventID, text string) error {
|
||||
content := &event.MessageEventContent{
|
||||
MsgType: event.MsgText,
|
||||
Body: text,
|
||||
RelatesTo: &event.RelatesTo{
|
||||
Type: event.RelThread,
|
||||
content := textContent(text)
|
||||
content.RelatesTo = &event.RelatesTo{
|
||||
Type: event.RelThread,
|
||||
EventID: threadID,
|
||||
InReplyTo: &event.InReplyTo{
|
||||
EventID: threadID,
|
||||
InReplyTo: &event.InReplyTo{
|
||||
EventID: threadID,
|
||||
},
|
||||
IsFallingBack: true,
|
||||
},
|
||||
IsFallingBack: true,
|
||||
}
|
||||
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||
if err != nil {
|
||||
@@ -503,23 +564,18 @@ func (b *Base) SendThread(roomID id.RoomID, threadID id.EventID, text string) er
|
||||
|
||||
// SendNotice sends an m.notice message to a room.
|
||||
func (b *Base) SendNotice(roomID id.RoomID, text string) error {
|
||||
content := &event.MessageEventContent{
|
||||
MsgType: event.MsgNotice,
|
||||
Body: text,
|
||||
}
|
||||
content := textContent(text)
|
||||
content.MsgType = event.MsgNotice
|
||||
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||
return err
|
||||
}
|
||||
|
||||
// SendReply sends a reply to a specific event.
|
||||
func (b *Base) SendReply(roomID id.RoomID, eventID id.EventID, text string) error {
|
||||
content := &event.MessageEventContent{
|
||||
MsgType: event.MsgText,
|
||||
Body: text,
|
||||
RelatesTo: &event.RelatesTo{
|
||||
InReplyTo: &event.InReplyTo{
|
||||
EventID: eventID,
|
||||
},
|
||||
content := textContent(text)
|
||||
content.RelatesTo = &event.RelatesTo{
|
||||
InReplyTo: &event.InReplyTo{
|
||||
EventID: eventID,
|
||||
},
|
||||
}
|
||||
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||
@@ -530,12 +586,12 @@ func (b *Base) SendReply(roomID id.RoomID, eventID id.EventID, text string) erro
|
||||
}
|
||||
|
||||
// SendHTML sends an HTML-formatted message.
|
||||
func (b *Base) SendHTML(roomID id.RoomID, plain, html string) error {
|
||||
func (b *Base) SendHTML(roomID id.RoomID, plain, htmlBody string) error {
|
||||
content := &event.MessageEventContent{
|
||||
MsgType: event.MsgText,
|
||||
Body: plain,
|
||||
Format: event.FormatHTML,
|
||||
FormattedBody: html,
|
||||
FormattedBody: htmlBody,
|
||||
}
|
||||
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||
return err
|
||||
|
||||
@@ -55,14 +55,10 @@ func (p *ReactionsPlugin) OnReaction(ctx ReactionContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
_, err = d.Exec(
|
||||
db.Exec("reactions: log reaction",
|
||||
`INSERT INTO reaction_log (room_id, event_id, sender, target_user, emoji) VALUES (?, ?, ?, ?, ?)`,
|
||||
string(ctx.RoomID), string(ctx.TargetEvent), string(ctx.Sender), string(targetUser), ctx.Emoji,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("reactions: log reaction", "err", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -174,22 +174,33 @@ func FirePendingReminders(client *mautrix.Client) {
|
||||
|
||||
base := NewBase(client)
|
||||
|
||||
// Collect all pending reminders first, then close the rows.
|
||||
// Iterating rows while also writing to the DB can cause SQLite lock issues.
|
||||
type pendingReminder struct {
|
||||
ID, UserID, RoomID, Message string
|
||||
}
|
||||
var pending []pendingReminder
|
||||
for rows.Next() {
|
||||
var reminderID, userID, roomID, message string
|
||||
if err := rows.Scan(&reminderID, &userID, &roomID, &message); err != nil {
|
||||
var r pendingReminder
|
||||
if err := rows.Scan(&r.ID, &r.UserID, &r.RoomID, &r.Message); err != nil {
|
||||
slog.Error("reminders: scan row", "err", err)
|
||||
continue
|
||||
}
|
||||
pending = append(pending, r)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
msg := fmt.Sprintf("⏰ Reminder for %s: %s", userID, message)
|
||||
if err := base.SendMessage(id.RoomID(roomID), msg); err != nil {
|
||||
slog.Error("reminders: send reminder", "err", err, "id", reminderID)
|
||||
for _, r := range pending {
|
||||
// Mark fired BEFORE sending so a crash doesn't re-fire on restart.
|
||||
_, err := db.Get().Exec(`UPDATE reminders SET fired = 1 WHERE id = ?`, r.ID)
|
||||
if err != nil {
|
||||
slog.Error("reminders: mark fired", "err", err, "id", r.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
_, err := db.Get().Exec(`UPDATE reminders SET fired = 1 WHERE id = ?`, reminderID)
|
||||
if err != nil {
|
||||
slog.Error("reminders: mark fired", "err", err, "id", reminderID)
|
||||
msg := fmt.Sprintf("⏰ Reminder for %s: %s", r.UserID, r.Message)
|
||||
if err := base.SendMessage(id.RoomID(r.RoomID), msg); err != nil {
|
||||
slog.Error("reminders: send reminder", "err", err, "id", r.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,12 +87,20 @@ func (p *RetroPlugin) Init() error { return nil }
|
||||
func (p *RetroPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *RetroPlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "game") {
|
||||
return p.handleSearch(ctx, p.GetArgs(ctx.Body, "game"))
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "retro") {
|
||||
return p.handleSearch(ctx, p.GetArgs(ctx.Body, "retro"))
|
||||
var query string
|
||||
switch {
|
||||
case p.IsCommand(ctx.Body, "game"):
|
||||
query = p.GetArgs(ctx.Body, "game")
|
||||
case p.IsCommand(ctx.Body, "retro"):
|
||||
query = p.GetArgs(ctx.Body, "retro")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
go func() {
|
||||
if err := p.handleSearch(ctx, query); err != nil {
|
||||
slog.Error("retro: handler error", "err", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -187,14 +195,11 @@ func (p *RetroPlugin) fetchGames(query string) (*retroCacheEntry, error) {
|
||||
|
||||
// Update cache
|
||||
data, _ := json.Marshal(entry)
|
||||
_, err = d.Exec(
|
||||
db.Exec("retro: cache write",
|
||||
`INSERT INTO retro_cache (search_term, data, cached_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(search_term) DO UPDATE SET data = ?, cached_at = ?`,
|
||||
cacheKey, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("retro: cache write", "err", err)
|
||||
}
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ func (p *StatsPlugin) Commands() []CommandDef {
|
||||
{Name: "stats", Description: "Show message statistics for a user", Usage: "!stats [@user]", Category: "Leveling & Stats"},
|
||||
{Name: "rankings", Description: "Show rankings for a stat category", Usage: "!rankings [words|links|questions|emojis]", Category: "Leveling & Stats"},
|
||||
{Name: "personality", Description: "Show your chat personality archetype", Usage: "!personality", Category: "Leveling & Stats"},
|
||||
{Name: "superstatsexplusalpha", Description: "Everything we know about you", Usage: "!superstatsexplusalpha [@user]", Category: "Leveling & Stats"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +56,9 @@ func (p *StatsPlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "personality") {
|
||||
return p.handlePersonality(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "superstatsexplusalpha") {
|
||||
return p.handleSuperStats(ctx)
|
||||
}
|
||||
|
||||
// Skip tracking for bot commands
|
||||
if ctx.IsCommand {
|
||||
@@ -316,3 +320,238 @@ func (p *StatsPlugin) handlePersonality(ctx MessageContext) error {
|
||||
)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error {
|
||||
target := ctx.Sender
|
||||
args := p.GetArgs(ctx.Body, "superstatsexplusalpha")
|
||||
if args != "" {
|
||||
if resolved, ok := p.ResolveUser(args, ctx.RoomID); ok {
|
||||
target = resolved
|
||||
}
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
uid := string(target)
|
||||
displayName := p.DisplayName(target)
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("📋 **SUPER STATS EX+α — %s**\n", displayName))
|
||||
sb.WriteString("═══════════════════════════════\n\n")
|
||||
|
||||
// ── Level & Economy ──
|
||||
var xp, level int
|
||||
var balance float64
|
||||
_ = d.QueryRow(`SELECT xp, level FROM users WHERE user_id = ?`, uid).Scan(&xp, &level)
|
||||
_ = d.QueryRow(`SELECT balance FROM euro_balances WHERE user_id = ?`, uid).Scan(&balance)
|
||||
|
||||
var achievementCount int
|
||||
_ = d.QueryRow(`SELECT COUNT(*) FROM achievements WHERE user_id = ?`, uid).Scan(&achievementCount)
|
||||
|
||||
sb.WriteString(fmt.Sprintf("⭐ **Level %d** · %s XP · 💰 €%.0f · 🏅 %d achievements\n\n",
|
||||
level, formatNumber(xp), balance, achievementCount))
|
||||
|
||||
// ── Chat Stats ──
|
||||
var totalMsg, totalWords, totalLinks, totalEmojis, totalQuestions int
|
||||
err := d.QueryRow(
|
||||
`SELECT total_messages, total_words, total_links, total_emojis, total_questions
|
||||
FROM user_stats WHERE user_id = ?`, uid,
|
||||
).Scan(&totalMsg, &totalWords, &totalLinks, &totalEmojis, &totalQuestions)
|
||||
if err == nil && totalMsg > 0 {
|
||||
sb.WriteString(fmt.Sprintf("💬 **Chat:** %s msgs · %s words · %s links · %s emoji · %s questions\n",
|
||||
formatNumber(totalMsg), formatNumber(totalWords), formatNumber(totalLinks),
|
||||
formatNumber(totalEmojis), formatNumber(totalQuestions)))
|
||||
|
||||
// Personality
|
||||
msgStats := util.MessageStats{Words: totalWords, Links: totalLinks, Emojis: totalEmojis, Questions: totalQuestions}
|
||||
archetype := util.DeriveArchetype(msgStats, totalMsg)
|
||||
sb.WriteString(fmt.Sprintf(" Personality: **%s**\n", archetype.Name))
|
||||
}
|
||||
|
||||
// ── Sentiment ──
|
||||
var positive, negative, neutral int
|
||||
err = d.QueryRow(
|
||||
`SELECT COALESCE(positive,0), COALESCE(negative,0), COALESCE(neutral,0)
|
||||
FROM sentiment_stats WHERE user_id = ?`, uid,
|
||||
).Scan(&positive, &negative, &neutral)
|
||||
if err == nil {
|
||||
total := positive + negative + neutral
|
||||
if total > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" Sentiment: +%d / -%d / ~%d", positive, negative, neutral))
|
||||
pct := float64(positive) / float64(total) * 100
|
||||
if pct >= 60 {
|
||||
sb.WriteString(" 😊")
|
||||
} else if float64(negative)/float64(total)*100 >= 40 {
|
||||
sb.WriteString(" 😤")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Potty Mouth ──
|
||||
var mild, moderate, scorching int
|
||||
err = d.QueryRow(
|
||||
`SELECT COALESCE(mild,0), COALESCE(moderate,0), COALESCE(scorching,0)
|
||||
FROM potty_mouth WHERE user_id = ?`, uid,
|
||||
).Scan(&mild, &moderate, &scorching)
|
||||
if err == nil && (mild+moderate+scorching) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" Potty mouth: 🤬 %d mild · %d moderate · %d scorching\n", mild, moderate, scorching))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// ── Adventure ──
|
||||
var combatLv, miningLv, forageLv, combatXP, miningXP, forageXP int
|
||||
var alive bool
|
||||
var streak, bestStreak int
|
||||
err = d.QueryRow(
|
||||
`SELECT combat_level, mining_skill, foraging_skill,
|
||||
combat_xp, mining_xp, foraging_xp,
|
||||
alive, current_streak, best_streak
|
||||
FROM adventure_characters WHERE user_id = ?`, uid,
|
||||
).Scan(&combatLv, &miningLv, &forageLv, &combatXP, &miningXP, &forageXP,
|
||||
&alive, &streak, &bestStreak)
|
||||
if err == nil {
|
||||
status := "Alive"
|
||||
if !alive {
|
||||
status = "💀 Dead"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("⚔️ **Adventure:** %s\n", status))
|
||||
sb.WriteString(fmt.Sprintf(" Combat Lv.%d (%d XP) · Mining Lv.%d (%d XP) · Forage Lv.%d (%d XP)\n",
|
||||
combatLv, combatXP, miningLv, miningXP, forageLv, forageXP))
|
||||
if streak > 0 || bestStreak > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" 🔥 Streak: %d days (best: %d)\n", streak, bestStreak))
|
||||
}
|
||||
|
||||
// Equipment score (use canonical scoring function)
|
||||
equip, eqErr := loadAdvEquipment(id.UserID(uid))
|
||||
if eqErr == nil && len(equip) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" Equipment score: %d\n", advEquipmentScore(equip)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// ── Game Records ──
|
||||
sb.WriteString("🎮 **Games:**\n")
|
||||
hasGames := false
|
||||
|
||||
// Holdem
|
||||
var hPlayed int
|
||||
var hWon, hLost, hBiggest int64
|
||||
err = d.QueryRow(
|
||||
`SELECT hands_played, total_won, total_lost, biggest_pot
|
||||
FROM holdem_scores WHERE user_id = ?`, uid,
|
||||
).Scan(&hPlayed, &hWon, &hLost, &hBiggest)
|
||||
if err == nil && hPlayed > 0 {
|
||||
net := hWon - hLost
|
||||
sign := "+"
|
||||
if net < 0 {
|
||||
sign = ""
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" 🃏 Holdem: %d hands · %s€%d net · biggest pot €%d\n",
|
||||
hPlayed, sign, net, hBiggest))
|
||||
hasGames = true
|
||||
}
|
||||
|
||||
// Blackjack
|
||||
var bjPlayed, bjWon int
|
||||
var bjEarned float64
|
||||
err = d.QueryRow(
|
||||
`SELECT games_played, games_won, total_earned
|
||||
FROM blackjack_scores WHERE user_id = ?`, uid,
|
||||
).Scan(&bjPlayed, &bjWon, &bjEarned)
|
||||
if err == nil && bjPlayed > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" 🂡 Blackjack: %d/%d W/L · €%.0f earned\n",
|
||||
bjWon, bjPlayed-bjWon, bjEarned))
|
||||
hasGames = true
|
||||
}
|
||||
|
||||
// Hangman
|
||||
var hmPlayed, hmWon int
|
||||
var hmEarned float64
|
||||
err = d.QueryRow(
|
||||
`SELECT games_played, games_won, total_earned
|
||||
FROM hangman_scores WHERE user_id = ?`, uid,
|
||||
).Scan(&hmPlayed, &hmWon, &hmEarned)
|
||||
if err == nil && hmPlayed > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" 📝 Hangman: %d/%d W/L · €%.0f earned\n",
|
||||
hmWon, hmPlayed-hmWon, hmEarned))
|
||||
hasGames = true
|
||||
}
|
||||
|
||||
// Wordle
|
||||
var wPlayed, wSolved, wGuesses int
|
||||
err = d.QueryRow(
|
||||
`SELECT puzzles_played, puzzles_solved, total_guesses
|
||||
FROM wordle_stats WHERE user_id = ?`, uid,
|
||||
).Scan(&wPlayed, &wSolved, &wGuesses)
|
||||
if err == nil && wPlayed > 0 {
|
||||
avg := float64(wGuesses) / float64(wPlayed)
|
||||
sb.WriteString(fmt.Sprintf(" 🟩 Wordle: %d/%d solved · %.1f avg guesses\n",
|
||||
wSolved, wPlayed, avg))
|
||||
hasGames = true
|
||||
}
|
||||
|
||||
// Trivia
|
||||
var tCorrect, tWrong int
|
||||
var tFastest int64
|
||||
err = d.QueryRow(
|
||||
`SELECT COALESCE(SUM(correct),0), COALESCE(SUM(wrong),0), COALESCE(MIN(fastest_ms),0)
|
||||
FROM trivia_scores WHERE user_id = ?`, uid,
|
||||
).Scan(&tCorrect, &tWrong, &tFastest)
|
||||
if (tCorrect + tWrong) > 0 {
|
||||
fastStr := ""
|
||||
if tFastest > 0 {
|
||||
fastStr = fmt.Sprintf(" · fastest: %.1fs", float64(tFastest)/1000)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" 🧠 Trivia: %d/%d correct%s\n",
|
||||
tCorrect, tCorrect+tWrong, fastStr))
|
||||
hasGames = true
|
||||
}
|
||||
|
||||
// UNO (single + multi combined)
|
||||
var unoSingleWins, unoSingleTotal int
|
||||
_ = d.QueryRow(
|
||||
`SELECT COUNT(*), COALESCE(SUM(CASE WHEN result = 'win' THEN 1 ELSE 0 END), 0)
|
||||
FROM uno_games WHERE player_id = ?`, uid,
|
||||
).Scan(&unoSingleTotal, &unoSingleWins)
|
||||
var unoMultiWins, unoMultiTotal int
|
||||
_ = d.QueryRow(
|
||||
`SELECT COUNT(*), COALESCE(SUM(CASE WHEN winner_id = ? THEN 1 ELSE 0 END), 0)
|
||||
FROM uno_multi_games WHERE player_ids LIKE ?`, uid, "%"+uid+"%",
|
||||
).Scan(&unoMultiTotal, &unoMultiWins)
|
||||
unoTotal := unoSingleTotal + unoMultiTotal
|
||||
unoWins := unoSingleWins + unoMultiWins
|
||||
if unoTotal > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" 🎴 UNO: %d/%d W/L\n",
|
||||
unoWins, unoTotal-unoWins))
|
||||
hasGames = true
|
||||
}
|
||||
|
||||
// Bot defeats
|
||||
var botDefeats int
|
||||
_ = d.QueryRow(`SELECT COALESCE(SUM(losses), 0) FROM bot_defeats WHERE user_id = ?`, uid).Scan(&botDefeats)
|
||||
if botDefeats > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" 🤖 Lost to TwinBee: %d times\n", botDefeats))
|
||||
hasGames = true
|
||||
}
|
||||
|
||||
if !hasGames {
|
||||
sb.WriteString(" No game records yet.\n")
|
||||
}
|
||||
|
||||
// ── Commands ──
|
||||
var cmdCount int
|
||||
_ = d.QueryRow(`SELECT COALESCE(SUM(count), 0) FROM command_usage WHERE user_id = ?`, uid).Scan(&cmdCount)
|
||||
if cmdCount > 0 {
|
||||
sb.WriteString(fmt.Sprintf("\n⌨️ %s commands used\n", formatNumber(cmdCount)))
|
||||
}
|
||||
|
||||
// ── Account Age ──
|
||||
var createdAt time.Time
|
||||
err = d.QueryRow(`SELECT created_at FROM users WHERE user_id = ?`, uid).Scan(&createdAt)
|
||||
if err == nil {
|
||||
days := int(time.Since(createdAt).Hours() / 24)
|
||||
sb.WriteString(fmt.Sprintf("📅 Member for %d days\n", days))
|
||||
}
|
||||
|
||||
return p.SendMessage(ctx.RoomID, sb.String())
|
||||
}
|
||||
|
||||
@@ -76,7 +76,12 @@ func (p *StocksPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *StocksPlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "stock") {
|
||||
return p.handleStock(ctx)
|
||||
go func() {
|
||||
if err := p.handleStock(ctx); err != nil {
|
||||
slog.Error("stocks: handler error", "err", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "stockwatch") {
|
||||
return p.handleStockwatch(ctx)
|
||||
@@ -152,14 +157,11 @@ func (p *StocksPlugin) fetchStock(ticker string) (*stockCacheEntry, error) {
|
||||
|
||||
// Update cache
|
||||
data, _ := json.Marshal(entry)
|
||||
_, err = d.Exec(
|
||||
db.Exec("stocks: cache write",
|
||||
`INSERT INTO stocks_cache (ticker, data, cached_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(ticker) DO UPDATE SET data = ?, cached_at = ?`,
|
||||
ticker, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("stocks: cache write", "err", err)
|
||||
}
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
@@ -59,32 +59,24 @@ func (p *StreaksPlugin) OnMessage(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
func (p *StreaksPlugin) trackDailyActivity(ctx MessageContext) {
|
||||
d := db.Get()
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
_, err := d.Exec(
|
||||
db.Exec("streaks: track daily activity",
|
||||
`INSERT INTO daily_activity (user_id, date, message_count) VALUES (?, ?, 1)
|
||||
ON CONFLICT(user_id, date) DO UPDATE SET message_count = message_count + 1`,
|
||||
string(ctx.Sender), today,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("streaks: track daily activity", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *StreaksPlugin) trackFirstPoster(ctx MessageContext) {
|
||||
d := db.Get()
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
now := time.Now().UTC().Unix()
|
||||
|
||||
// INSERT OR IGNORE — only the first poster for this room+date wins
|
||||
_, err := d.Exec(
|
||||
db.Exec("streaks: track first poster",
|
||||
`INSERT OR IGNORE INTO daily_first (room_id, date, user_id, timestamp) VALUES (?, ?, ?, ?)`,
|
||||
string(ctx.RoomID), today, string(ctx.Sender), now,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("streaks: track first poster", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *StreaksPlugin) handleStreak(ctx MessageContext) error {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -583,7 +581,7 @@ func (p *UnoPlugin) handleChallenge(ctx MessageContext, announceRoom id.RoomID,
|
||||
p.mu.Unlock()
|
||||
|
||||
// Room announcement
|
||||
playerName := p.unoDisplayName(ctx.Sender)
|
||||
playerName := p.DisplayName(ctx.Sender)
|
||||
botName := unoBotName()
|
||||
modeTag := ""
|
||||
startComment := pickCommentary("start")
|
||||
@@ -1741,7 +1739,7 @@ func (p *UnoPlugin) playerWins(game *unoGame) error {
|
||||
p.euro.Credit(game.playerID, totalPayout, "uno_win")
|
||||
|
||||
newPot := p.getPot()
|
||||
playerName := p.unoDisplayName(game.playerID)
|
||||
playerName := p.DisplayName(game.playerID)
|
||||
|
||||
p.SendMessage(game.dmRoomID, "🎉 **Uno out! You win!**")
|
||||
|
||||
@@ -1772,7 +1770,7 @@ func (p *UnoPlugin) botWins(game *unoGame) error {
|
||||
potBefore := p.getPot()
|
||||
p.addToPot(game.wager)
|
||||
newPot := p.getPot()
|
||||
playerName := p.unoDisplayName(game.playerID)
|
||||
playerName := p.DisplayName(game.playerID)
|
||||
|
||||
p.SendMessage(game.dmRoomID, "💀 **"+unoBotName()+" wins.** Better luck next time.")
|
||||
p.SendMessage(game.roomID, fmt.Sprintf(
|
||||
@@ -1796,7 +1794,7 @@ func (p *UnoPlugin) forfeitGame(game *unoGame, timeout bool) error {
|
||||
|
||||
potBefore := p.getPot()
|
||||
p.addToPot(game.wager)
|
||||
playerName := p.unoDisplayName(game.playerID)
|
||||
playerName := p.DisplayName(game.playerID)
|
||||
|
||||
if timeout {
|
||||
p.SendMessage(game.dmRoomID,
|
||||
@@ -1881,23 +1879,13 @@ func (p *UnoPlugin) cleanupGame(game *unoGame) {
|
||||
}
|
||||
|
||||
func (p *UnoPlugin) recordGame(game *unoGame, result string, potBefore float64) {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(
|
||||
db.Exec("uno: record game",
|
||||
`INSERT INTO uno_games (player_id, wager, result, pot_before, pot_after, turns, started_at, ended_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
string(game.playerID), game.wager, result, potBefore, p.getPot(),
|
||||
game.turns, game.startedAt.UTC().Format("2006-01-02 15:04:05"),
|
||||
time.Now().UTC().Format("2006-01-02 15:04:05"),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("uno: failed to record game", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *UnoPlugin) unoDisplayName(userID id.UserID) string {
|
||||
resp, err := p.Client.GetDisplayName(context.Background(), userID)
|
||||
if err != nil || resp.DisplayName == "" {
|
||||
return string(userID)
|
||||
}
|
||||
return resp.DisplayName
|
||||
}
|
||||
|
||||
|
||||
@@ -279,7 +279,7 @@ func (p *UnoPlugin) handleMultiStart(ctx MessageContext, amountStr string, noMer
|
||||
p.lobbies[ctx.RoomID] = lobby
|
||||
p.mu.Unlock()
|
||||
|
||||
creatorName := p.unoDisplayName(ctx.Sender)
|
||||
creatorName := p.DisplayName(ctx.Sender)
|
||||
modeTag := ""
|
||||
if noMercy {
|
||||
modeTag = " 🔥 NO MERCY"
|
||||
@@ -354,7 +354,7 @@ func (p *UnoPlugin) handleMultiJoin(ctx MessageContext) error {
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("🃏 **UNO Lobby**%s — Ante: €%d\nPlayers (%d/4):\n", lobbyModeTag, int(lobby.ante), count))
|
||||
for i, uid := range lobby.players {
|
||||
name := p.unoDisplayName(uid)
|
||||
name := p.DisplayName(uid)
|
||||
label := ""
|
||||
if uid == lobby.creator {
|
||||
label = " (host)"
|
||||
@@ -403,7 +403,7 @@ func (p *UnoPlugin) handleMultiLeave(ctx MessageContext) error {
|
||||
|
||||
// Refund the leaving player
|
||||
p.euro.Credit(ctx.Sender, lobby.ante, "uno_multi_refund")
|
||||
name := p.unoDisplayName(ctx.Sender)
|
||||
name := p.DisplayName(ctx.Sender)
|
||||
return p.SendMessage(ctx.RoomID, fmt.Sprintf("🃏 **%s** left the lobby. (%d/4 players)", name, len(lobby.players)))
|
||||
}
|
||||
|
||||
@@ -489,7 +489,7 @@ func (p *UnoPlugin) handleMultiGo(ctx MessageContext) error {
|
||||
for _, u := range players {
|
||||
p.euro.Credit(u, ante, "uno_multi_refund")
|
||||
}
|
||||
return p.SendMessage(roomID, fmt.Sprintf("🃏 Game cancelled — couldn't open DMs with %s.", p.unoDisplayName(uid)))
|
||||
return p.SendMessage(roomID, fmt.Sprintf("🃏 Game cancelled — couldn't open DMs with %s.", p.DisplayName(uid)))
|
||||
}
|
||||
resolved = append(resolved, playerDMPair{uid, dmRoom})
|
||||
}
|
||||
@@ -518,7 +518,7 @@ func (p *UnoPlugin) handleMultiGo(ctx MessageContext) error {
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("🃏 **Multiplayer UNO!**%s Pot: €%d\n\nPlayers:\n", modeTag, int(ante)*len(players)))
|
||||
for i, pl := range game.players {
|
||||
name := p.unoDisplayName(pl.userID)
|
||||
name := p.DisplayName(pl.userID)
|
||||
if pl.isBot {
|
||||
name = bn
|
||||
}
|
||||
@@ -651,6 +651,13 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) {
|
||||
|
||||
player := game.currentPlayer()
|
||||
|
||||
// Skip eliminated players (e.g. mercy-killed during their own turn)
|
||||
if !player.active {
|
||||
game.currentIdx = game.nextActiveIdx()
|
||||
game.turnID++
|
||||
continue
|
||||
}
|
||||
|
||||
if player.isBot {
|
||||
botTurnsInRow++
|
||||
if botTurnsInRow > 10 {
|
||||
@@ -676,7 +683,7 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) {
|
||||
// No Mercy stacking: check if player must absorb
|
||||
if game.noMercy && game.stackMinValue > 0 {
|
||||
if !hasStackableCard(player.hand, game.topColor, game.stackMinValue) {
|
||||
name := p.unoDisplayName(player.userID)
|
||||
name := p.DisplayName(player.userID)
|
||||
drawn := game.draw(game.stackTotal)
|
||||
player.hand = append(player.hand, drawn...)
|
||||
p.SendMessage(player.dmRoomID, fmt.Sprintf("💥 No stackable card! You draw %d cards.\n%s",
|
||||
@@ -735,7 +742,7 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) {
|
||||
if !player.active {
|
||||
continue // mercy-killed
|
||||
}
|
||||
name := p.unoDisplayName(player.userID)
|
||||
name := p.DisplayName(player.userID)
|
||||
if len(allDrawn) == 0 {
|
||||
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s has no playable cards and deck is empty. Turn passes. (%d cards)", name, len(player.hand)))
|
||||
p.SendMessage(player.dmRoomID, "No playable cards and deck is empty. Turn passes.")
|
||||
@@ -764,7 +771,7 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) {
|
||||
// Classic: draw 1
|
||||
drawn := game.draw(1)
|
||||
if len(drawn) == 0 {
|
||||
name := p.unoDisplayName(player.userID)
|
||||
name := p.DisplayName(player.userID)
|
||||
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s has no playable cards and deck is empty. Turn passes. (%d cards)", name, len(player.hand)))
|
||||
p.SendMessage(player.dmRoomID, "No playable cards and deck is empty. Turn passes.")
|
||||
game.currentIdx = game.nextActiveIdx()
|
||||
@@ -784,7 +791,7 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) {
|
||||
return
|
||||
}
|
||||
|
||||
name := p.unoDisplayName(player.userID)
|
||||
name := p.DisplayName(player.userID)
|
||||
p.SendMessage(player.dmRoomID,
|
||||
fmt.Sprintf("No playable cards — drew automatically: %s\nNot playable. Turn passes.", card.Display()))
|
||||
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s draws a card. Turn passes. (%d cards)", name, len(player.hand)))
|
||||
@@ -879,7 +886,7 @@ func (p *UnoPlugin) handleMultiDMInput(ctx MessageContext, game *unoMultiGame) e
|
||||
}
|
||||
// No Mercy stacking: accept
|
||||
if game.noMercy && game.stackMinValue > 0 && (input == "accept" || input == "a") {
|
||||
name := p.unoDisplayName(player.userID)
|
||||
name := p.DisplayName(player.userID)
|
||||
drawn := game.draw(game.stackTotal)
|
||||
player.hand = append(player.hand, drawn...)
|
||||
p.SendMessage(player.dmRoomID, fmt.Sprintf("💥 You accept the stack and draw %d cards.\n%s",
|
||||
@@ -929,7 +936,7 @@ func (p *UnoPlugin) handleMultiDMInput(ctx MessageContext, game *unoMultiGame) e
|
||||
func (p *UnoPlugin) handleMultiChallengeInput(game *unoMultiGame, player *unoMultiPlayer, input string) error {
|
||||
switch input {
|
||||
case "challenge", "c":
|
||||
name := p.unoDisplayName(player.userID)
|
||||
name := p.DisplayName(player.userID)
|
||||
p.SendMessage(game.roomID, fmt.Sprintf("⚡ %s challenges the Wild Draw Four!", name))
|
||||
p.resolveWD4Challenge(game, true)
|
||||
case "accept", "a":
|
||||
@@ -963,7 +970,7 @@ func (p *UnoPlugin) handleMultiPlayerPlay(game *unoMultiGame, player *unoMultiPl
|
||||
player.hand = append(player.hand, drawn...)
|
||||
player.calledUno = false
|
||||
p.SendMessage(player.dmRoomID, "⚠️ You forgot to call UNO! Draw 2 as penalty.")
|
||||
name := p.unoDisplayName(player.userID)
|
||||
name := p.DisplayName(player.userID)
|
||||
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s forgot to call UNO! 2 card penalty.", name))
|
||||
if game.noMercy {
|
||||
if p.checkMultiMercyElimination(game, player) {
|
||||
@@ -1024,7 +1031,7 @@ func (p *UnoPlugin) handleMultiPlayerPlay(game *unoMultiGame, player *unoMultiPl
|
||||
if pl == player || !pl.active {
|
||||
continue
|
||||
}
|
||||
name := p.unoDisplayName(pl.userID)
|
||||
name := p.DisplayName(pl.userID)
|
||||
if pl.isBot {
|
||||
name = unoBotName()
|
||||
}
|
||||
@@ -1040,8 +1047,8 @@ func (p *UnoPlugin) handleMultiPlayerPlay(game *unoMultiGame, player *unoMultiPl
|
||||
for _, pl := range game.players {
|
||||
if pl != player && pl.active {
|
||||
swapHandsMulti(player, pl)
|
||||
name := p.unoDisplayName(player.userID)
|
||||
otherName := p.unoDisplayName(pl.userID)
|
||||
name := p.DisplayName(player.userID)
|
||||
otherName := p.DisplayName(pl.userID)
|
||||
if pl.isBot {
|
||||
otherName = unoBotName()
|
||||
}
|
||||
@@ -1068,7 +1075,7 @@ func (p *UnoPlugin) handleMultiPlayerPlay(game *unoMultiGame, player *unoMultiPl
|
||||
|
||||
// Check win (after discard all, after swap)
|
||||
if len(player.hand) == 0 {
|
||||
name := p.unoDisplayName(player.userID)
|
||||
name := p.DisplayName(player.userID)
|
||||
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s plays: %s", name, card.Display()))
|
||||
p.multiPlayerWins(game, player)
|
||||
return nil
|
||||
@@ -1104,7 +1111,7 @@ func (p *UnoPlugin) handleMultiColorChoice(game *unoMultiGame, player *unoMultiP
|
||||
|
||||
// Check win
|
||||
if len(player.hand) == 0 {
|
||||
name := p.unoDisplayName(player.userID)
|
||||
name := p.DisplayName(player.userID)
|
||||
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s plays: %s (chose %s %s)",
|
||||
name, pendingCard.Display(), color.Emoji(), color))
|
||||
p.multiPlayerWins(game, player)
|
||||
@@ -1113,10 +1120,10 @@ func (p *UnoPlugin) handleMultiColorChoice(game *unoMultiGame, player *unoMultiP
|
||||
|
||||
// No Mercy: Color Roulette — next player flips until chosen color
|
||||
if game.noMercy && pendingCard != nil && pendingCard.Value == unoWildColorRoulette {
|
||||
name := p.unoDisplayName(player.userID)
|
||||
name := p.DisplayName(player.userID)
|
||||
nextIdx := game.nextActiveIdx()
|
||||
target := game.players[nextIdx]
|
||||
targetName := p.unoDisplayName(target.userID)
|
||||
targetName := p.DisplayName(target.userID)
|
||||
if target.isBot {
|
||||
targetName = unoBotName()
|
||||
}
|
||||
@@ -1157,7 +1164,7 @@ func (p *UnoPlugin) handleMultiPlayerDraw(game *unoMultiGame, player *unoMultiPl
|
||||
drawn := game.draw(1)
|
||||
if len(drawn) == 0 {
|
||||
p.SendMessage(player.dmRoomID, "No cards left to draw! Turn passes.")
|
||||
name := p.unoDisplayName(player.userID)
|
||||
name := p.DisplayName(player.userID)
|
||||
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s can't draw — deck is empty. Turn passes.", name))
|
||||
p.advanceAndExecute(game)
|
||||
return nil
|
||||
@@ -1175,7 +1182,7 @@ func (p *UnoPlugin) handleMultiPlayerDraw(game *unoMultiGame, player *unoMultiPl
|
||||
return nil
|
||||
}
|
||||
|
||||
name := p.unoDisplayName(player.userID)
|
||||
name := p.DisplayName(player.userID)
|
||||
p.SendMessage(player.dmRoomID, fmt.Sprintf("You drew: %s\nNot playable. Turn passes.", card.Display()))
|
||||
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s draws a card. Turn passes. (%d cards)", name, len(player.hand)))
|
||||
p.advanceAndExecute(game)
|
||||
@@ -1207,7 +1214,7 @@ func (p *UnoPlugin) handleMultiPlayerDrawNoMercy(game *unoMultiGame, player *uno
|
||||
}
|
||||
}
|
||||
|
||||
name := p.unoDisplayName(player.userID)
|
||||
name := p.DisplayName(player.userID)
|
||||
|
||||
if len(allDrawn) == 0 {
|
||||
p.SendMessage(player.dmRoomID, "No cards left to draw! Turn passes.")
|
||||
@@ -1245,7 +1252,7 @@ func (p *UnoPlugin) handleMultiDrawnPlayable(game *unoMultiGame, player *unoMult
|
||||
game.phase = unoMultiPhasePlay
|
||||
|
||||
if input == "no" || input == "n" {
|
||||
name := p.unoDisplayName(player.userID)
|
||||
name := p.DisplayName(player.userID)
|
||||
p.SendMessage(player.dmRoomID, "Card kept. Turn passes.")
|
||||
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s draws a card. Turn passes. (%d cards)", name, len(player.hand)))
|
||||
p.advanceAndExecute(game)
|
||||
@@ -1281,7 +1288,7 @@ func (p *UnoPlugin) handleMultiDrawnPlayable(game *unoMultiGame, player *unoMult
|
||||
game.topColor = drawnCard.Color
|
||||
|
||||
if len(player.hand) == 0 {
|
||||
name := p.unoDisplayName(player.userID)
|
||||
name := p.DisplayName(player.userID)
|
||||
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s plays: %s", name, drawnCard.Display()))
|
||||
p.multiPlayerWins(game, player)
|
||||
return nil
|
||||
@@ -1311,7 +1318,7 @@ func (p *UnoPlugin) applyCardEffects(game *unoMultiGame, card unoCard) cardEffec
|
||||
|
||||
nextIdx := game.nextActiveIdx()
|
||||
nextPlayer := game.players[nextIdx]
|
||||
nextName := p.unoDisplayName(nextPlayer.userID)
|
||||
nextName := p.DisplayName(nextPlayer.userID)
|
||||
if nextPlayer.isBot {
|
||||
nextName = unoBotName()
|
||||
}
|
||||
@@ -1329,7 +1336,7 @@ func (p *UnoPlugin) applyCardEffects(game *unoMultiGame, card unoCard) cardEffec
|
||||
var skippedNames []string
|
||||
for _, pl := range game.players {
|
||||
if pl != game.currentPlayer() && pl.active {
|
||||
name := p.unoDisplayName(pl.userID)
|
||||
name := p.DisplayName(pl.userID)
|
||||
if pl.isBot {
|
||||
name = unoBotName()
|
||||
}
|
||||
@@ -1407,7 +1414,7 @@ func (p *UnoPlugin) applyCardEffects(game *unoMultiGame, card unoCard) cardEffec
|
||||
// After reverse, get next player in new direction
|
||||
nextIdx = game.nextActiveIdx()
|
||||
nextPlayer = game.players[nextIdx]
|
||||
nextName = p.unoDisplayName(nextPlayer.userID)
|
||||
nextName = p.DisplayName(nextPlayer.userID)
|
||||
if nextPlayer.isBot {
|
||||
nextName = unoBotName()
|
||||
}
|
||||
@@ -1468,7 +1475,7 @@ func writeEffectLines(sb *strings.Builder, eff cardEffectResult) {
|
||||
}
|
||||
|
||||
func (p *UnoPlugin) applyAndAnnounce(game *unoMultiGame, player *unoMultiPlayer, card unoCard) {
|
||||
name := p.unoDisplayName(player.userID)
|
||||
name := p.DisplayName(player.userID)
|
||||
var roomMsg strings.Builder
|
||||
|
||||
roomMsg.WriteString(fmt.Sprintf("🃏 %s plays: %s", name, card.DisplayWithColor(game.topColor)))
|
||||
@@ -1501,7 +1508,7 @@ func (p *UnoPlugin) applyAndAnnounce(game *unoMultiGame, player *unoMultiPlayer,
|
||||
|
||||
// Next player
|
||||
nextUp := game.currentPlayer()
|
||||
nextUpName := p.unoDisplayName(nextUp.userID)
|
||||
nextUpName := p.DisplayName(nextUp.userID)
|
||||
if nextUp.isBot {
|
||||
nextUpName = unoBotName()
|
||||
}
|
||||
@@ -1527,7 +1534,7 @@ func (p *UnoPlugin) startWD4Challenge(game *unoMultiGame, wd4Player *unoMultiPla
|
||||
game.currentIdx = nextIdx
|
||||
game.turnID++
|
||||
|
||||
playerName := p.unoDisplayName(wd4Player.userID)
|
||||
playerName := p.DisplayName(wd4Player.userID)
|
||||
if wd4Player.isBot {
|
||||
playerName = unoBotName()
|
||||
}
|
||||
@@ -1548,11 +1555,11 @@ func (p *UnoPlugin) startWD4Challenge(game *unoMultiGame, wd4Player *unoMultiPla
|
||||
func (p *UnoPlugin) resolveWD4Challenge(game *unoMultiGame, challenged bool) {
|
||||
wd4Player := game.wd4Player
|
||||
victim := game.wd4Victim
|
||||
wd4Name := p.unoDisplayName(wd4Player.userID)
|
||||
wd4Name := p.DisplayName(wd4Player.userID)
|
||||
if wd4Player.isBot {
|
||||
wd4Name = unoBotName()
|
||||
}
|
||||
victimName := p.unoDisplayName(victim.userID)
|
||||
victimName := p.DisplayName(victim.userID)
|
||||
if victim.isBot {
|
||||
victimName = unoBotName()
|
||||
}
|
||||
@@ -1898,7 +1905,7 @@ func (p *UnoPlugin) botMultiTurn(game *unoMultiGame, roomBuf *strings.Builder) {
|
||||
// applyCardEffects already advanced currentIdx to the victim
|
||||
if game.noMercy && card.Value == unoWildColorRoulette {
|
||||
target := game.players[game.currentIdx]
|
||||
targetName := p.unoDisplayName(target.userID)
|
||||
targetName := p.DisplayName(target.userID)
|
||||
if target.isBot {
|
||||
targetName = bn
|
||||
}
|
||||
@@ -1973,7 +1980,7 @@ func (p *UnoPlugin) startMultiAutoPlayTimer(game *unoMultiGame) {
|
||||
player.autoPlays++
|
||||
maxAutoPlays := envInt("UNO_MULTI_MAX_AUTOPLAY", 3)
|
||||
|
||||
name := p.unoDisplayName(player.userID)
|
||||
name := p.DisplayName(player.userID)
|
||||
|
||||
if player.autoPlays >= maxAutoPlays {
|
||||
p.SendMessage(mg.roomID, fmt.Sprintf("🃏 %s was auto-played %d times in a row — forfeited!", name, maxAutoPlays))
|
||||
@@ -2031,7 +2038,7 @@ func (p *UnoPlugin) startInactivityTimer(game *unoMultiGame) {
|
||||
}
|
||||
|
||||
func (p *UnoPlugin) autoPlayMultiTurn(game *unoMultiGame, player *unoMultiPlayer) {
|
||||
name := p.unoDisplayName(player.userID)
|
||||
name := p.DisplayName(player.userID)
|
||||
|
||||
switch game.phase {
|
||||
case unoMultiPhaseChallenge:
|
||||
@@ -2065,7 +2072,7 @@ func (p *UnoPlugin) autoPlayMultiTurn(game *unoMultiGame, player *unoMultiPlayer
|
||||
if game.noMercy && pendingCard != nil && pendingCard.Value == unoWildColorRoulette {
|
||||
nextIdx := game.nextActiveIdx()
|
||||
target := game.players[nextIdx]
|
||||
targetName := p.unoDisplayName(target.userID)
|
||||
targetName := p.DisplayName(target.userID)
|
||||
if target.isBot {
|
||||
targetName = unoBotName()
|
||||
}
|
||||
@@ -2145,7 +2152,7 @@ func (p *UnoPlugin) autoPlayMultiTurn(game *unoMultiGame, player *unoMultiPlayer
|
||||
target := botChooseSwapTarget(game, player)
|
||||
if target != nil {
|
||||
swapHandsMulti(player, target)
|
||||
targetName := p.unoDisplayName(target.userID)
|
||||
targetName := p.DisplayName(target.userID)
|
||||
if target.isBot {
|
||||
targetName = unoBotName()
|
||||
}
|
||||
@@ -2327,7 +2334,7 @@ func (p *UnoPlugin) multiPlayerWins(game *unoMultiGame, winner *unoMultiPlayer)
|
||||
}
|
||||
totalPot := game.ante * float64(humanCount)
|
||||
|
||||
name := p.unoDisplayName(winner.userID)
|
||||
name := p.DisplayName(winner.userID)
|
||||
p.euro.Credit(winner.userID, totalPot, "uno_multi_win")
|
||||
|
||||
p.SendMessage(game.roomID, fmt.Sprintf(
|
||||
@@ -2378,7 +2385,7 @@ func (p *UnoPlugin) multiBotWins(game *unoMultiGame) {
|
||||
|
||||
func (p *UnoPlugin) multiPlayerForfeit(game *unoMultiGame, player *unoMultiPlayer) {
|
||||
player.active = false
|
||||
name := p.unoDisplayName(player.userID)
|
||||
name := p.DisplayName(player.userID)
|
||||
|
||||
// Shuffle cards back into draw pile
|
||||
game.drawPile = append(game.drawPile, player.hand...)
|
||||
@@ -2428,8 +2435,6 @@ func (p *UnoPlugin) cleanupMultiGame(game *unoMultiGame) {
|
||||
}
|
||||
|
||||
func (p *UnoPlugin) recordMultiGame(game *unoMultiGame, winnerID id.UserID, result string) {
|
||||
d := db.Get()
|
||||
|
||||
playerIDs := make([]string, 0)
|
||||
for _, pl := range game.players {
|
||||
if !pl.isBot {
|
||||
@@ -2440,7 +2445,7 @@ func (p *UnoPlugin) recordMultiGame(game *unoMultiGame, winnerID id.UserID, resu
|
||||
humanCount := len(playerIDs)
|
||||
totalPot := game.ante * float64(humanCount)
|
||||
|
||||
_, err := d.Exec(
|
||||
db.Exec("uno_multi: record game",
|
||||
`INSERT INTO uno_multi_games (room_id, ante, pot_total, winner_id, player_ids, result, turns, started_at, ended_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
string(game.roomID), game.ante, totalPot, string(winnerID),
|
||||
@@ -2448,9 +2453,6 @@ func (p *UnoPlugin) recordMultiGame(game *unoMultiGame, winnerID id.UserID, resu
|
||||
game.startedAt.UTC().Format("2006-01-02 15:04:05"),
|
||||
time.Now().UTC().Format("2006-01-02 15:04:05"),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("uno_multi: failed to record game", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2461,7 +2463,7 @@ func (p *UnoPlugin) sendMultiStatus(game *unoMultiGame, player *unoMultiPlayer)
|
||||
var sb strings.Builder
|
||||
bn := unoBotName()
|
||||
current := game.currentPlayer()
|
||||
currentName := p.unoDisplayName(current.userID)
|
||||
currentName := p.DisplayName(current.userID)
|
||||
if current.isBot {
|
||||
currentName = bn
|
||||
}
|
||||
@@ -2484,7 +2486,7 @@ func (p *UnoPlugin) sendMultiStatus(game *unoMultiGame, player *unoMultiPlayer)
|
||||
if pl == player || !pl.active {
|
||||
continue
|
||||
}
|
||||
name := p.unoDisplayName(pl.userID)
|
||||
name := p.DisplayName(pl.userID)
|
||||
if pl.isBot {
|
||||
name = bn
|
||||
}
|
||||
@@ -2516,7 +2518,7 @@ func (p *UnoPlugin) sendMultiHandDisplay(game *unoMultiGame, player *unoMultiPla
|
||||
if pl == player || !pl.active {
|
||||
continue
|
||||
}
|
||||
name := p.unoDisplayName(pl.userID)
|
||||
name := p.DisplayName(pl.userID)
|
||||
if pl.isBot {
|
||||
name = bn
|
||||
}
|
||||
@@ -2554,7 +2556,7 @@ func (p *UnoPlugin) sendMultiHandDisplayStacking(game *unoMultiGame, player *uno
|
||||
if pl == player || !pl.active {
|
||||
continue
|
||||
}
|
||||
name := p.unoDisplayName(pl.userID)
|
||||
name := p.DisplayName(pl.userID)
|
||||
if pl.isBot {
|
||||
name = bn
|
||||
}
|
||||
@@ -2598,8 +2600,8 @@ func (p *UnoPlugin) handleMultiSwapChoice(game *unoMultiGame, player *unoMultiPl
|
||||
swapHandsMulti(player, target)
|
||||
game.phase = unoMultiPhasePlay
|
||||
|
||||
name := p.unoDisplayName(player.userID)
|
||||
targetName := p.unoDisplayName(target.userID)
|
||||
name := p.DisplayName(player.userID)
|
||||
targetName := p.DisplayName(target.userID)
|
||||
if target.isBot {
|
||||
targetName = unoBotName()
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ func (p *UnoPlugin) checkMultiMercyElimination(game *unoMultiGame, player *unoMu
|
||||
}
|
||||
|
||||
player.active = false
|
||||
name := p.unoDisplayName(player.userID)
|
||||
name := p.DisplayName(player.userID)
|
||||
if player.isBot {
|
||||
name = unoBotName()
|
||||
}
|
||||
|
||||
290
internal/plugin/uno_test.go
Normal file
290
internal/plugin/uno_test.go
Normal file
@@ -0,0 +1,290 @@
|
||||
package plugin
|
||||
|
||||
import "testing"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card properties
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestUnoCard_IsWild(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
card unoCard
|
||||
want bool
|
||||
}{
|
||||
{"wild", unoCard{unoWild, unoWildCard}, true},
|
||||
{"wild draw four", unoCard{unoWild, unoWildDrawFour}, true},
|
||||
{"wild draw six", unoCard{unoWild, unoWildDrawSix}, true},
|
||||
{"wild draw ten", unoCard{unoWild, unoWildDrawTen}, true},
|
||||
{"wild reverse draw 4", unoCard{unoWild, unoWildReverseDraw4}, true},
|
||||
{"wild color roulette", unoCard{unoWild, unoWildColorRoulette}, true},
|
||||
{"red skip", unoCard{unoRed, unoSkip}, false},
|
||||
{"blue 5", unoCard{unoBlue, unoFive}, false},
|
||||
{"green draw two", unoCard{unoGreen, unoDrawTwo}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.card.isWild(); got != tt.want {
|
||||
t.Errorf("isWild() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnoValue_IsAction(t *testing.T) {
|
||||
actions := []unoValue{unoSkip, unoReverse, unoDrawTwo, unoWildCard, unoWildDrawFour,
|
||||
unoSkipEveryone, unoDrawFour, unoDiscardAll, unoWildReverseDraw4,
|
||||
unoWildDrawSix, unoWildDrawTen, unoWildColorRoulette}
|
||||
for _, v := range actions {
|
||||
if !v.isAction() {
|
||||
t.Errorf("%s should be an action card", v)
|
||||
}
|
||||
}
|
||||
|
||||
numbers := []unoValue{unoZero, unoOne, unoTwo, unoThree, unoFour, unoFive, unoSix, unoSeven, unoEight, unoNine}
|
||||
for _, v := range numbers {
|
||||
if v.isAction() {
|
||||
t.Errorf("%s should NOT be an action card", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// canPlayOn
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCanPlayOn(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
card unoCard
|
||||
top unoCard
|
||||
topColor unoColor
|
||||
want bool
|
||||
}{
|
||||
{"wild always playable", unoCard{unoWild, unoWildCard}, unoCard{unoRed, unoFive}, unoRed, true},
|
||||
{"wd4 always playable", unoCard{unoWild, unoWildDrawFour}, unoCard{unoBlue, unoThree}, unoBlue, true},
|
||||
{"same color", unoCard{unoRed, unoThree}, unoCard{unoRed, unoSeven}, unoRed, true},
|
||||
{"same value diff color", unoCard{unoBlue, unoFive}, unoCard{unoRed, unoFive}, unoRed, true},
|
||||
{"diff color diff value", unoCard{unoBlue, unoThree}, unoCard{unoRed, unoSeven}, unoRed, false},
|
||||
{"matches chosen color not card color", unoCard{unoGreen, unoTwo}, unoCard{unoWild, unoWildCard}, unoGreen, true},
|
||||
{"doesnt match chosen color", unoCard{unoRed, unoTwo}, unoCard{unoWild, unoWildCard}, unoGreen, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.card.canPlayOn(tt.top, tt.topColor); got != tt.want {
|
||||
t.Errorf("canPlayOn() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deck sizes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNewUnoDeck_Size(t *testing.T) {
|
||||
deck := newUnoDeck()
|
||||
// Standard UNO: 4 colors × (1 zero + 2×12 other) + 8 wilds = 4×25 + 8 = 108
|
||||
if len(deck) != 108 {
|
||||
t.Errorf("standard deck size = %d, want 108", len(deck))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewNoMercyDeck_Size(t *testing.T) {
|
||||
deck := newNoMercyDeck()
|
||||
if len(deck) < 108 {
|
||||
t.Errorf("no mercy deck should be larger than standard, got %d", len(deck))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bot AI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBotPickCard_NoPlayable(t *testing.T) {
|
||||
hand := []unoCard{
|
||||
{unoRed, unoOne},
|
||||
{unoRed, unoTwo},
|
||||
}
|
||||
top := unoCard{unoBlue, unoSeven}
|
||||
_, idx := botPickCard(hand, top, unoBlue, false, 5)
|
||||
if idx != -1 {
|
||||
t.Errorf("should return -1 when nothing playable, got %d", idx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotPickCard_PlaysPlayable(t *testing.T) {
|
||||
hand := []unoCard{
|
||||
{unoRed, unoOne},
|
||||
{unoBlue, unoSeven}, // matches top
|
||||
}
|
||||
top := unoCard{unoBlue, unoFive}
|
||||
card, idx := botPickCard(hand, top, unoBlue, false, 5)
|
||||
if idx == -1 {
|
||||
t.Fatal("should find a playable card")
|
||||
}
|
||||
if !card.canPlayOn(top, unoBlue) {
|
||||
t.Error("picked card should be playable on top")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotPickCard_AggressiveUsesWD4(t *testing.T) {
|
||||
hand := []unoCard{
|
||||
{unoRed, unoOne},
|
||||
{unoWild, unoWildDrawFour},
|
||||
{unoBlue, unoSeven},
|
||||
}
|
||||
top := unoCard{unoBlue, unoFive}
|
||||
// bookDown=true triggers aggressive mode
|
||||
card, idx := botPickCard(hand, top, unoBlue, true, 5)
|
||||
if idx == -1 {
|
||||
t.Fatal("should find a playable card")
|
||||
}
|
||||
if card.Value != unoWildDrawFour {
|
||||
t.Errorf("aggressive mode should prefer WD4, got %s", card.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotPickColor_MostCommon(t *testing.T) {
|
||||
hand := []unoCard{
|
||||
{unoRed, unoOne},
|
||||
{unoRed, unoTwo},
|
||||
{unoRed, unoThree},
|
||||
{unoBlue, unoFive},
|
||||
{unoWild, unoWildCard}, // wilds don't count
|
||||
}
|
||||
color := botPickColor(hand)
|
||||
if color != unoRed {
|
||||
t.Errorf("should pick red (3 cards), got %s", color)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotPickColor_EmptyHand(t *testing.T) {
|
||||
// Should default to red with no cards
|
||||
color := botPickColor([]unoCard{})
|
||||
if color != unoRed {
|
||||
t.Errorf("empty hand should default to red, got %s", color)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// No Mercy helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCardDrawValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
value unoValue
|
||||
want int
|
||||
}{
|
||||
{unoDrawTwo, 2},
|
||||
{unoDrawFour, 4},
|
||||
{unoWildReverseDraw4, 4},
|
||||
{unoWildDrawSix, 6},
|
||||
{unoWildDrawTen, 10},
|
||||
{unoOne, 0},
|
||||
{unoSkip, 0},
|
||||
{unoWildCard, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := cardDrawValue(tt.value); got != tt.want {
|
||||
t.Errorf("cardDrawValue(%s) = %d, want %d", tt.value, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDrawCard(t *testing.T) {
|
||||
if !isDrawCard(unoDrawTwo) {
|
||||
t.Error("Draw Two should be a draw card")
|
||||
}
|
||||
if !isDrawCard(unoWildDrawTen) {
|
||||
t.Error("Wild Draw Ten should be a draw card")
|
||||
}
|
||||
if isDrawCard(unoSkip) {
|
||||
t.Error("Skip should not be a draw card")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanPlayOnStacking(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
card unoCard
|
||||
topColor unoColor
|
||||
stackMinValue int
|
||||
want bool
|
||||
}{
|
||||
{"wild draw six stacks on 2", unoCard{unoWild, unoWildDrawSix}, unoRed, 2, true},
|
||||
{"draw two on draw two same color", unoCard{unoRed, unoDrawTwo}, unoRed, 2, true},
|
||||
{"draw two wrong color", unoCard{unoBlue, unoDrawTwo}, unoRed, 2, false},
|
||||
{"number cant stack", unoCard{unoRed, unoFive}, unoRed, 2, false},
|
||||
{"draw two cant stack on 4", unoCard{unoRed, unoDrawTwo}, unoRed, 4, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.card.canPlayOnStacking(tt.topColor, tt.stackMinValue); got != tt.want {
|
||||
t.Errorf("canPlayOnStacking() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasStackableCard(t *testing.T) {
|
||||
hand := []unoCard{
|
||||
{unoRed, unoFive},
|
||||
{unoBlue, unoDrawTwo},
|
||||
{unoGreen, unoSkip},
|
||||
}
|
||||
if hasStackableCard(hand, unoBlue, 2) != true {
|
||||
t.Error("should find blue draw two as stackable")
|
||||
}
|
||||
if hasStackableCard(hand, unoRed, 2) != false {
|
||||
t.Error("no red draw cards in hand")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNoMercyFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
noMercy bool
|
||||
sevenZero bool
|
||||
amount string
|
||||
}{
|
||||
{"nomercy", true, false, ""},
|
||||
{"nomercy 50", true, false, "50"},
|
||||
{"nomercy7-0", true, true, ""},
|
||||
{"nomercy7-0 100", true, true, "100"},
|
||||
{"50", false, false, "50"},
|
||||
{"", false, false, ""},
|
||||
{"NOMERCY", true, false, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
nm, sz, amt := parseNoMercyFlags(tt.input)
|
||||
if nm != tt.noMercy || sz != tt.sevenZero || amt != tt.amount {
|
||||
t.Errorf("parseNoMercyFlags(%q) = (%v, %v, %q), want (%v, %v, %q)",
|
||||
tt.input, nm, sz, amt, tt.noMercy, tt.sevenZero, tt.amount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color/Value string representations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestUnoColor_String(t *testing.T) {
|
||||
if unoRed.String() != "Red" {
|
||||
t.Errorf("red: got %q", unoRed.String())
|
||||
}
|
||||
if unoWild.String() != "Wild" {
|
||||
t.Errorf("wild: got %q", unoWild.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnoColor_Emoji(t *testing.T) {
|
||||
if unoRed.Emoji() != "🟥" {
|
||||
t.Errorf("red emoji: got %q", unoRed.Emoji())
|
||||
}
|
||||
if unoBlue.Emoji() != "🟦" {
|
||||
t.Errorf("blue emoji: got %q", unoBlue.Emoji())
|
||||
}
|
||||
}
|
||||
@@ -70,38 +70,39 @@ func (p *URLsPlugin) OnMessage(ctx MessageContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, u := range urls {
|
||||
title, desc, err := p.fetchPreview(u)
|
||||
if err != nil {
|
||||
slog.Debug("urls: fetch preview failed", "url", u, "err", err)
|
||||
continue
|
||||
}
|
||||
go p.previewURL(ctx, urls[0])
|
||||
return nil
|
||||
}
|
||||
|
||||
if title == "" && desc == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var preview strings.Builder
|
||||
if title != "" {
|
||||
preview.WriteString(fmt.Sprintf("Title: %s", title))
|
||||
}
|
||||
if desc != "" {
|
||||
if preview.Len() > 0 {
|
||||
preview.WriteString("\n")
|
||||
}
|
||||
// Truncate long descriptions
|
||||
if len(desc) > 200 {
|
||||
desc = desc[:200] + "..."
|
||||
}
|
||||
preview.WriteString(fmt.Sprintf("Description: %s", desc))
|
||||
}
|
||||
|
||||
if err := p.SendReply(ctx.RoomID, ctx.EventID, preview.String()); err != nil {
|
||||
slog.Error("urls: send preview", "err", err)
|
||||
}
|
||||
func (p *URLsPlugin) previewURL(ctx MessageContext, targetURL string) {
|
||||
title, desc, err := p.fetchPreview(targetURL)
|
||||
if err != nil {
|
||||
slog.Debug("urls: fetch preview failed", "url", targetURL, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
return nil
|
||||
if title == "" && desc == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var preview strings.Builder
|
||||
if title != "" {
|
||||
preview.WriteString(fmt.Sprintf("Title: %s", title))
|
||||
}
|
||||
if desc != "" {
|
||||
if preview.Len() > 0 {
|
||||
preview.WriteString("\n")
|
||||
}
|
||||
// Truncate long descriptions
|
||||
if len(desc) > 200 {
|
||||
desc = desc[:200] + "..."
|
||||
}
|
||||
preview.WriteString(fmt.Sprintf("Description: %s", desc))
|
||||
}
|
||||
|
||||
if err := p.SendReply(ctx.RoomID, ctx.EventID, preview.String()); err != nil {
|
||||
slog.Error("urls: send preview", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fetchPreview retrieves og:title and og:description, checking cache first.
|
||||
@@ -127,14 +128,11 @@ func (p *URLsPlugin) fetchPreview(rawURL string) (string, string, error) {
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
_, cacheErr := d.Exec(
|
||||
db.Exec("urls: cache write",
|
||||
`INSERT INTO url_cache (url, title, description, cached_at) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(url) DO UPDATE SET title = ?, description = ?, cached_at = ?`,
|
||||
rawURL, title, desc, now, title, desc, now,
|
||||
)
|
||||
if cacheErr != nil {
|
||||
slog.Error("urls: cache write", "err", cacheErr)
|
||||
}
|
||||
|
||||
return title, desc, nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -21,6 +20,7 @@ import (
|
||||
// WordlePlugin provides a daily cooperative Wordle game.
|
||||
type WordlePlugin struct {
|
||||
Base
|
||||
euro *EuroPlugin
|
||||
apiKey string
|
||||
httpClient *http.Client
|
||||
defaultLength int
|
||||
@@ -33,7 +33,7 @@ type WordlePlugin struct {
|
||||
}
|
||||
|
||||
// NewWordlePlugin creates a new WordlePlugin.
|
||||
func NewWordlePlugin(client *mautrix.Client) *WordlePlugin {
|
||||
func NewWordlePlugin(client *mautrix.Client, euro *EuroPlugin) *WordlePlugin {
|
||||
length := 5
|
||||
if v := os.Getenv("WORDLE_DEFAULT_LENGTH"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 5 && n <= 7 {
|
||||
@@ -43,6 +43,7 @@ func NewWordlePlugin(client *mautrix.Client) *WordlePlugin {
|
||||
|
||||
return &WordlePlugin{
|
||||
Base: NewBase(client),
|
||||
euro: euro,
|
||||
apiKey: os.Getenv("WORDNIK_API_KEY"),
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
defaultLength: length,
|
||||
@@ -163,7 +164,7 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
|
||||
}
|
||||
|
||||
// Get display name.
|
||||
displayName := p.displayName(ctx.Sender)
|
||||
displayName := p.DisplayName(ctx.Sender)
|
||||
|
||||
// Score the guess.
|
||||
results := scoreGuess(guess, puzzle.Answer)
|
||||
@@ -187,8 +188,9 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
|
||||
definition := p.fetchDefinition(puzzle.Answer)
|
||||
p.updateStats(puzzle)
|
||||
p.markPuzzleDone(puzzle)
|
||||
payouts := p.awardPrize(puzzle)
|
||||
|
||||
return p.SendMessage(ctx.RoomID, renderSolvedAnnouncement(puzzle, definition))
|
||||
return p.SendMessage(ctx.RoomID, renderSolvedAnnouncement(puzzle, definition, payouts))
|
||||
}
|
||||
|
||||
// Check for failure (all guesses used).
|
||||
@@ -216,8 +218,12 @@ func (p *WordlePlugin) handleGrid(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
if len(puzzle.Guesses) == 0 {
|
||||
hint := ""
|
||||
if isCustomAllowedWord(puzzle.Answer) {
|
||||
hint = "Today's word is video game related"
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
renderWordleStartAnnouncement(puzzle.PuzzleNumber, puzzle.WordLength))
|
||||
renderWordleStartAnnouncement(puzzle.PuzzleNumber, puzzle.WordLength, hint))
|
||||
}
|
||||
|
||||
return p.SendMessage(ctx.RoomID, renderWordleGrid(puzzle))
|
||||
@@ -306,18 +312,11 @@ func (p *WordlePlugin) handleStats(ctx MessageContext) error {
|
||||
|
||||
// createAndPostPuzzle creates a new puzzle, persists it, and posts the announcement.
|
||||
func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int) error {
|
||||
if p.apiKey == "" {
|
||||
return p.SendMessage(roomID, "Wordle is unavailable — WORDNIK_API_KEY not configured.")
|
||||
}
|
||||
|
||||
// Try Wordnik first, fall back to local word list.
|
||||
word, err := wordnikFetchRandomWord(p.apiKey, p.httpClient, wordLength)
|
||||
if err != nil {
|
||||
slog.Warn("wordle: Wordnik fetch failed, trying fallback", "err", err)
|
||||
word = pickFallbackWord(wordLength)
|
||||
if word == "" {
|
||||
return p.SendMessage(roomID, "Failed to select a puzzle word. Try again later.")
|
||||
}
|
||||
// Use local word list for puzzle selection. Wordnik's randomWord endpoint
|
||||
// requires a paid API tier; we keep Wordnik for guess validation and definitions only.
|
||||
word := pickFallbackWord(wordLength)
|
||||
if word == "" {
|
||||
return p.SendMessage(roomID, "Failed to select a puzzle word — no words available for that length.")
|
||||
}
|
||||
|
||||
puzzleNumber := p.nextPuzzleNumber()
|
||||
@@ -336,24 +335,30 @@ func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int) err
|
||||
}
|
||||
|
||||
// Persist to DB.
|
||||
d := db.Get()
|
||||
_, err = d.Exec(
|
||||
db.Exec("wordle: persist puzzle",
|
||||
`INSERT INTO wordle_puzzles (puzzle_id, room_id, answer, word_length, solved, guess_count, started_at)
|
||||
VALUES (?, ?, ?, ?, 0, 0, ?)
|
||||
ON CONFLICT(puzzle_id, room_id) DO UPDATE SET answer = ?, word_length = ?, solved = 0, guess_count = 0, started_at = ?`,
|
||||
today, string(roomID), word, wordLength, now,
|
||||
word, wordLength, now,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("wordle: persist puzzle", "err", err)
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
p.puzzles[roomID] = puzzle
|
||||
// Evict stale cache entries from previous days.
|
||||
for key := range p.validCache {
|
||||
if key != today {
|
||||
delete(p.validCache, key)
|
||||
}
|
||||
}
|
||||
p.validCache[today] = make(map[string]bool)
|
||||
p.mu.Unlock()
|
||||
|
||||
return p.SendMessage(roomID, renderWordleStartAnnouncement(puzzleNumber, wordLength))
|
||||
hint := ""
|
||||
if isCustomAllowedWord(word) {
|
||||
hint = "Today's word is video game related"
|
||||
}
|
||||
return p.SendMessage(roomID, renderWordleStartAnnouncement(puzzleNumber, wordLength, hint))
|
||||
}
|
||||
|
||||
// PostDailyPuzzle is called by the scheduler to post today's puzzle.
|
||||
@@ -372,7 +377,8 @@ func (p *WordlePlugin) PostDailyPuzzle(roomID id.RoomID) error {
|
||||
return p.createAndPostPuzzle(roomID, p.defaultLength)
|
||||
}
|
||||
|
||||
// isValidWord checks if a word is valid, using the in-memory cache first.
|
||||
// isValidWord checks if a word is valid, using the in-memory cache first,
|
||||
// then the custom allow-list, then Wordnik.
|
||||
// Returns (valid, apiError). apiError is true when the API is unreachable.
|
||||
func (p *WordlePlugin) isValidWord(puzzleID, word string) (bool, bool) {
|
||||
cache := p.validCache[puzzleID]
|
||||
@@ -385,6 +391,12 @@ func (p *WordlePlugin) isValidWord(puzzleID, word string) (bool, bool) {
|
||||
return valid, false
|
||||
}
|
||||
|
||||
// Check custom allow-list (game titles, etc.) before hitting the API.
|
||||
if isCustomAllowedWord(word) {
|
||||
cache[word] = true
|
||||
return true, false
|
||||
}
|
||||
|
||||
valid, apiErr := wordnikValidateWord(p.apiKey, p.httpClient, word)
|
||||
if !apiErr {
|
||||
cache[word] = valid // only cache definitive results
|
||||
@@ -396,13 +408,6 @@ func (p *WordlePlugin) fetchDefinition(word string) string {
|
||||
return wordnikFetchDefinitionText(p.apiKey, p.httpClient, word)
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) displayName(userID id.UserID) string {
|
||||
resp, err := p.Client.GetDisplayName(context.Background(), userID)
|
||||
if err != nil || resp.DisplayName == "" {
|
||||
return string(userID)
|
||||
}
|
||||
return resp.DisplayName
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) nextPuzzleNumber() int {
|
||||
d := db.Get()
|
||||
@@ -415,18 +420,14 @@ func (p *WordlePlugin) nextPuzzleNumber() int {
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) markPuzzleDone(puzzle *WordlePuzzle) {
|
||||
d := db.Get()
|
||||
solved := 0
|
||||
if puzzle.Solved {
|
||||
solved = 1
|
||||
}
|
||||
_, err := d.Exec(
|
||||
db.Exec("wordle: mark puzzle done",
|
||||
`UPDATE wordle_puzzles SET solved = ?, guess_count = ?, solved_at = ? WHERE puzzle_id = ? AND room_id = ?`,
|
||||
solved, len(puzzle.Guesses), puzzle.SolvedAt, puzzle.PuzzleID, string(puzzle.RoomID),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("wordle: mark puzzle done", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) updateStats(puzzle *WordlePuzzle) {
|
||||
@@ -491,6 +492,66 @@ func (p *WordlePlugin) updateStats(puzzle *WordlePuzzle) {
|
||||
}
|
||||
}
|
||||
|
||||
// WordlePayout tracks a payout for rendering.
|
||||
type WordlePayout struct {
|
||||
Name string
|
||||
Amount int
|
||||
Solver bool
|
||||
}
|
||||
|
||||
// wordleBasePots maps guess count to euro prize pot. Fewer guesses = bigger reward.
|
||||
var wordleBasePots = [7]int{0, 100, 80, 60, 45, 35, 25}
|
||||
|
||||
// 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.
|
||||
func (p *WordlePlugin) awardPrize(puzzle *WordlePuzzle) []WordlePayout {
|
||||
if !puzzle.Solved || p.euro == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
guessesUsed := len(puzzle.Guesses)
|
||||
pot := 25
|
||||
if guessesUsed >= 1 && guessesUsed < len(wordleBasePots) {
|
||||
pot = wordleBasePots[guessesUsed]
|
||||
}
|
||||
|
||||
// Tally contributors.
|
||||
type info struct {
|
||||
name string
|
||||
solver bool
|
||||
}
|
||||
contributors := map[id.UserID]*info{}
|
||||
var order []id.UserID
|
||||
for i, g := range puzzle.Guesses {
|
||||
if _, ok := contributors[g.PlayerID]; !ok {
|
||||
contributors[g.PlayerID] = &info{name: g.PlayerName}
|
||||
order = append(order, g.PlayerID)
|
||||
}
|
||||
if i == len(puzzle.Guesses)-1 {
|
||||
contributors[g.PlayerID].solver = true
|
||||
}
|
||||
}
|
||||
|
||||
numPlayers := len(contributors)
|
||||
share := pot / numPlayers
|
||||
if share < 5 {
|
||||
share = 5
|
||||
}
|
||||
solverBonus := share / 2
|
||||
|
||||
var payouts []WordlePayout
|
||||
for _, uid := range order {
|
||||
c := contributors[uid]
|
||||
amount := share
|
||||
if c.solver {
|
||||
amount += solverBonus
|
||||
}
|
||||
p.euro.Credit(uid, float64(amount), "wordle_win")
|
||||
payouts = append(payouts, WordlePayout{Name: c.name, Amount: amount, Solver: c.solver})
|
||||
}
|
||||
return payouts
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) communityStreak() int {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(
|
||||
@@ -520,6 +581,17 @@ func (p *WordlePlugin) rehydratePuzzles() {
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
d := db.Get()
|
||||
|
||||
// Collect rows first, then close the cursor before doing any nested queries.
|
||||
// SQLite deadlocks if a nested query runs while rows are still open on a
|
||||
// single-connection pool.
|
||||
type puzzleRow struct {
|
||||
pid string
|
||||
roomStr string
|
||||
answer string
|
||||
wordLength int
|
||||
startedAt time.Time
|
||||
}
|
||||
|
||||
rows, err := d.Query(
|
||||
`SELECT puzzle_id, room_id, answer, word_length, solved, guess_count, started_at
|
||||
FROM wordle_puzzles WHERE puzzle_id = ?`, today)
|
||||
@@ -527,8 +599,8 @@ func (p *WordlePlugin) rehydratePuzzles() {
|
||||
slog.Warn("wordle: rehydrate query failed", "err", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var pending []puzzleRow
|
||||
for rows.Next() {
|
||||
var pid, roomStr, answer string
|
||||
var wordLength, solved, guessCount int
|
||||
@@ -541,32 +613,37 @@ func (p *WordlePlugin) rehydratePuzzles() {
|
||||
continue // already done
|
||||
}
|
||||
|
||||
roomID := id.RoomID(roomStr)
|
||||
pending = append(pending, puzzleRow{pid, roomStr, answer, wordLength, startedAt})
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Get puzzle number.
|
||||
for _, pr := range pending {
|
||||
roomID := id.RoomID(pr.roomStr)
|
||||
|
||||
// Get puzzle number (safe now — no open cursor).
|
||||
var puzzleNumber int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM wordle_puzzles WHERE puzzle_id <= ?`, pid).Scan(&puzzleNumber)
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM wordle_puzzles WHERE puzzle_id <= ?`, pr.pid).Scan(&puzzleNumber)
|
||||
if err != nil {
|
||||
puzzleNumber = 1
|
||||
}
|
||||
|
||||
puzzle := &WordlePuzzle{
|
||||
PuzzleID: pid,
|
||||
PuzzleID: pr.pid,
|
||||
PuzzleNumber: puzzleNumber,
|
||||
RoomID: roomID,
|
||||
Answer: answer,
|
||||
WordLength: wordLength,
|
||||
Answer: pr.answer,
|
||||
WordLength: pr.wordLength,
|
||||
MaxGuesses: 6,
|
||||
StartedAt: startedAt,
|
||||
StartedAt: pr.startedAt,
|
||||
LetterStates: make(map[rune]LetterResult),
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
p.puzzles[roomID] = puzzle
|
||||
p.validCache[pid] = make(map[string]bool)
|
||||
p.validCache[pr.pid] = make(map[string]bool)
|
||||
p.mu.Unlock()
|
||||
|
||||
slog.Info("wordle: rehydrated puzzle", "room", roomID, "answer_len", wordLength)
|
||||
slog.Info("wordle: rehydrated puzzle", "room", roomID, "answer_len", pr.wordLength)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,13 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
var (
|
||||
fallbackWords map[int][]string
|
||||
customAllowSet map[string]bool // words valid as guesses but not in dictionary
|
||||
fallbackWordsOnce sync.Once
|
||||
)
|
||||
|
||||
@@ -43,9 +46,41 @@ func loadFallbackWords() {
|
||||
for n, words := range fallbackWords {
|
||||
slog.Info("wordle: loaded fallback words", "length", n, "count", len(words))
|
||||
}
|
||||
|
||||
// Load custom word lists (game titles, etc.) — these are added to the
|
||||
// puzzle pool AND accepted as valid guesses even if not in the dictionary.
|
||||
customAllowSet = make(map[string]bool)
|
||||
loadCustomWordFile("data/wordle_games.txt")
|
||||
}
|
||||
|
||||
// pickFallbackWord picks a random word of the given length from the fallback list.
|
||||
func loadCustomWordFile(path string) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
slog.Warn("wordle: custom word list not found", "path", path)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
count := 0
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
word := strings.TrimSpace(scanner.Text())
|
||||
if word == "" || strings.HasPrefix(word, "#") {
|
||||
continue
|
||||
}
|
||||
word = strings.ToUpper(word)
|
||||
n := len([]rune(word))
|
||||
if n >= 5 && n <= 7 {
|
||||
fallbackWords[n] = append(fallbackWords[n], word)
|
||||
customAllowSet[word] = true
|
||||
count++
|
||||
}
|
||||
}
|
||||
slog.Info("wordle: loaded custom words", "path", path, "count", count)
|
||||
}
|
||||
|
||||
// pickFallbackWord picks a random word of the given length from the fallback list,
|
||||
// excluding words used in the last 500 puzzles.
|
||||
func pickFallbackWord(length int) string {
|
||||
fallbackWordsOnce.Do(loadFallbackWords)
|
||||
|
||||
@@ -53,5 +88,47 @@ func pickFallbackWord(length int) string {
|
||||
if len(words) == 0 {
|
||||
return ""
|
||||
}
|
||||
return words[rand.IntN(len(words))]
|
||||
|
||||
recent := loadRecentWordleAnswers(500)
|
||||
|
||||
var candidates []string
|
||||
for _, w := range words {
|
||||
if !recent[w] {
|
||||
candidates = append(candidates, w)
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to full list if all words have been used recently.
|
||||
if len(candidates) == 0 {
|
||||
candidates = words
|
||||
}
|
||||
return candidates[rand.IntN(len(candidates))]
|
||||
}
|
||||
|
||||
// isCustomAllowedWord checks if a word is in the custom allow-list (game titles, etc.).
|
||||
func isCustomAllowedWord(word string) bool {
|
||||
fallbackWordsOnce.Do(loadFallbackWords)
|
||||
return customAllowSet[strings.ToUpper(word)]
|
||||
}
|
||||
|
||||
// loadRecentWordleAnswers returns a set of answers from the most recent N puzzles.
|
||||
func loadRecentWordleAnswers(limit int) map[string]bool {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT DISTINCT answer FROM wordle_puzzles
|
||||
ORDER BY started_at DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
slog.Error("wordle: failed to load recent answers", "err", err)
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
recent := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var word string
|
||||
if err := rows.Scan(&word); err != nil {
|
||||
continue
|
||||
}
|
||||
recent[word] = true
|
||||
}
|
||||
return recent
|
||||
}
|
||||
|
||||
148
internal/plugin/wordle_game_test.go
Normal file
148
internal/plugin/wordle_game_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScoreGuess_ExactMatch(t *testing.T) {
|
||||
results := scoreGuess("HELLO", "HELLO")
|
||||
for i, r := range results {
|
||||
if r != LetterCorrect {
|
||||
t.Errorf("position %d: got %d, want LetterCorrect", i, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreGuess_AllAbsent(t *testing.T) {
|
||||
results := scoreGuess("XXXYZ", "HELLO")
|
||||
for i, r := range results {
|
||||
if r != LetterAbsent {
|
||||
t.Errorf("position %d: got %d, want LetterAbsent", i, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreGuess_MixedResults(t *testing.T) {
|
||||
// WORLD vs HELLO:
|
||||
// W=absent, O=present, R=absent, L=correct(pos3 matches), D=absent
|
||||
results := scoreGuess("WORLD", "HELLO")
|
||||
expected := []LetterResult{LetterAbsent, LetterPresent, LetterAbsent, LetterCorrect, LetterAbsent}
|
||||
for i, r := range results {
|
||||
if r != expected[i] {
|
||||
t.Errorf("position %d: got %d, want %d", i, r, expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreGuess_DuplicateLetters(t *testing.T) {
|
||||
// SPEED vs ABIDE:
|
||||
// S=absent, P=absent, E=present(E is in ABIDE pos 4), E=present? no, only one E in answer
|
||||
// Actually: ABIDE has E at position 4 (0-indexed: A=0,B=1,I=2,D=3,E=4)
|
||||
// SPEED: S=absent, P=absent, E=absent(no more E after first match), E=present, D=present
|
||||
results := scoreGuess("SPEED", "ABIDE")
|
||||
// S not in ABIDE -> absent
|
||||
// P not in ABIDE -> absent
|
||||
// E in ABIDE pos4 -> present (consumes the E)
|
||||
// E again but no more E in pool -> absent
|
||||
// D in ABIDE pos3 -> present
|
||||
expected := []LetterResult{LetterAbsent, LetterAbsent, LetterPresent, LetterAbsent, LetterPresent}
|
||||
for i, r := range results {
|
||||
if r != expected[i] {
|
||||
t.Errorf("position %d: got %d, want %d", i, r, expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreGuess_DuplicateWithCorrect(t *testing.T) {
|
||||
// LLAMA vs HELLO:
|
||||
// L at 0: not correct (H), L is in HELLO -> present (consumes one L)
|
||||
// L at 1: not correct (E), L is in HELLO -> present (consumes second L)
|
||||
// A at 2: absent
|
||||
// M at 3: absent
|
||||
// A at 4: absent
|
||||
// Wait, HELLO has L at positions 2,3. So pool after first pass has H,E,L,L,O
|
||||
// Actually no first pass: no exact matches for LLAMA vs HELLO
|
||||
// Pool = H,E,L,L,O
|
||||
// L at 0: found L in pool, present (pool = H,E,L,O)
|
||||
// L at 1: found L in pool, present (pool = H,E,O)
|
||||
// A at 2: not found, absent
|
||||
// M at 3: not found, absent
|
||||
// A at 4: not found, absent
|
||||
results := scoreGuess("LLAMA", "HELLO")
|
||||
expected := []LetterResult{LetterPresent, LetterPresent, LetterAbsent, LetterAbsent, LetterAbsent}
|
||||
for i, r := range results {
|
||||
if r != expected[i] {
|
||||
t.Errorf("position %d: got %d, want %d", i, r, expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreGuess_CorrectTakesPriority(t *testing.T) {
|
||||
// HELLO vs LLAMA: H=absent, E=absent, L=correct(pos2? no)
|
||||
// HELLO vs LEMON: H=absent, E=present, L=present, L=absent, O=present
|
||||
// Let's use a clearer case:
|
||||
// ALLAY vs LLAMA:
|
||||
// A at 0: not L -> but A is in LLAMA. First pass: check exact matches
|
||||
// pos0: A vs L -> no
|
||||
// pos1: L vs L -> CORRECT
|
||||
// pos2: L vs A -> no
|
||||
// pos3: A vs M -> no
|
||||
// pos4: Y vs A -> no
|
||||
// Pool (unmatched answer letters): L(0), A(2), M(3), A(4)
|
||||
// Second pass:
|
||||
// pos0: A -> found A in pool, present (pool: L, M, A)
|
||||
// pos2: L -> found L in pool, present (pool: M, A)
|
||||
// pos3: A -> found A in pool, present (pool: M)
|
||||
// pos4: Y -> not found, absent
|
||||
results := scoreGuess("ALLAY", "LLAMA")
|
||||
expected := []LetterResult{LetterPresent, LetterCorrect, LetterPresent, LetterPresent, LetterAbsent}
|
||||
for i, r := range results {
|
||||
if r != expected[i] {
|
||||
t.Errorf("position %d: got %d, want %d", i, r, expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateLetterStates_Upgrades(t *testing.T) {
|
||||
states := map[rune]LetterResult{}
|
||||
|
||||
// First guess: A is absent
|
||||
updateLetterStates(states, "A", []LetterResult{LetterAbsent})
|
||||
if states['A'] != LetterAbsent {
|
||||
t.Errorf("A should be Absent, got %d", states['A'])
|
||||
}
|
||||
|
||||
// Second guess: A is present — should upgrade
|
||||
updateLetterStates(states, "A", []LetterResult{LetterPresent})
|
||||
if states['A'] != LetterPresent {
|
||||
t.Errorf("A should be Present after upgrade, got %d", states['A'])
|
||||
}
|
||||
|
||||
// Third guess: A is correct — should upgrade again
|
||||
updateLetterStates(states, "A", []LetterResult{LetterCorrect})
|
||||
if states['A'] != LetterCorrect {
|
||||
t.Errorf("A should be Correct after upgrade, got %d", states['A'])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateLetterStates_NoDowngrade(t *testing.T) {
|
||||
states := map[rune]LetterResult{
|
||||
'A': LetterCorrect,
|
||||
}
|
||||
|
||||
// A was correct, now appears absent in a different position — should NOT downgrade
|
||||
updateLetterStates(states, "A", []LetterResult{LetterAbsent})
|
||||
if states['A'] != LetterCorrect {
|
||||
t.Errorf("A should remain Correct, got %d", states['A'])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWordleBasePots(t *testing.T) {
|
||||
// Verify reward structure: fewer guesses = more money
|
||||
for i := 1; i < len(wordleBasePots)-1; i++ {
|
||||
if wordleBasePots[i] <= wordleBasePots[i+1] {
|
||||
t.Errorf("pot for %d guesses (%d) should be > pot for %d guesses (%d)",
|
||||
i, wordleBasePots[i], i+1, wordleBasePots[i+1])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,15 +72,20 @@ func renderKeyboard(states map[rune]LetterResult) string {
|
||||
}
|
||||
|
||||
// renderWordleStartAnnouncement renders the puzzle start message.
|
||||
func renderWordleStartAnnouncement(puzzleNumber, wordLength int) string {
|
||||
return fmt.Sprintf(
|
||||
"🟩 **Daily Wordle #%d**\nA new %d-letter puzzle is ready! Work together — 6 guesses shared.\n\nGuess with: `!wordle <word>`",
|
||||
func renderWordleStartAnnouncement(puzzleNumber, wordLength int, hint string) string {
|
||||
base := fmt.Sprintf(
|
||||
"🟩 **Daily Wordle #%d**\nA new %d-letter puzzle is ready! Work together — 6 guesses shared.",
|
||||
puzzleNumber, wordLength,
|
||||
)
|
||||
if hint != "" {
|
||||
base += fmt.Sprintf("\n🎮 **Hint:** %s", hint)
|
||||
}
|
||||
base += "\n\nGuess with: `!wordle <word>`"
|
||||
return base
|
||||
}
|
||||
|
||||
// renderSolvedAnnouncement renders the solved puzzle message.
|
||||
func renderSolvedAnnouncement(puzzle *WordlePuzzle, definition string) string {
|
||||
func renderSolvedAnnouncement(puzzle *WordlePuzzle, definition string, payouts []WordlePayout) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Find the solver.
|
||||
@@ -107,18 +112,29 @@ func renderSolvedAnnouncement(puzzle *WordlePuzzle, definition string) string {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Contributors.
|
||||
sb.WriteString("\n🏆 Today's contributors:\n")
|
||||
contributors := wordleContributors(puzzle)
|
||||
for _, c := range contributors {
|
||||
line := fmt.Sprintf(" %s — %d guess", c.name, c.guesses)
|
||||
if c.guesses != 1 {
|
||||
line += "es"
|
||||
// Contributors with payouts.
|
||||
if len(payouts) > 0 {
|
||||
sb.WriteString("\n💰 **Payouts:**\n")
|
||||
for _, pay := range payouts {
|
||||
bonus := ""
|
||||
if pay.Solver {
|
||||
bonus = " 🏆 (solver bonus!)"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" **%s**: +€%d%s\n", pay.Name, pay.Amount, bonus))
|
||||
}
|
||||
if c.solved {
|
||||
line += " 🏆"
|
||||
} else {
|
||||
sb.WriteString("\n🏆 Today's contributors:\n")
|
||||
contributors := wordleContributors(puzzle)
|
||||
for _, c := range contributors {
|
||||
line := fmt.Sprintf(" %s — %d guess", c.name, c.guesses)
|
||||
if c.guesses != 1 {
|
||||
line += "es"
|
||||
}
|
||||
if c.solved {
|
||||
line += " 🏆"
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
|
||||
@@ -67,7 +67,12 @@ func (p *WOTDPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *WOTDPlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "wotd") {
|
||||
return p.handleWOTD(ctx)
|
||||
go func() {
|
||||
if err := p.handleWOTD(ctx); err != nil {
|
||||
slog.Error("wotd: handler error", "err", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Passive: track WOTD usage in messages
|
||||
@@ -383,10 +388,7 @@ func (p *WOTDPlugin) grantWOTDXP(userID id.UserID, amount int) {
|
||||
}
|
||||
|
||||
// Log XP grant
|
||||
_, err = d.Exec(
|
||||
db.Exec("wotd: log xp",
|
||||
`INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)`,
|
||||
string(userID), amount, "wotd_usage")
|
||||
if err != nil {
|
||||
slog.Error("wotd: log xp", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,12 +144,9 @@ func (p *XPPlugin) grantXPInternal(userID id.UserID, amount int, reason string)
|
||||
}
|
||||
|
||||
// Log the XP grant
|
||||
_, err = d.Exec(
|
||||
db.Exec("xp: log grant",
|
||||
`INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)`,
|
||||
string(userID), amount, reason)
|
||||
if err != nil {
|
||||
slog.Error("xp: log grant", "err", err)
|
||||
}
|
||||
|
||||
return newXP, leveledUp, newLevel
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user