diff --git a/internal/plugin/adventure_npcs.go b/internal/plugin/adventure_npcs.go index 0f2ba94..bd3d24e 100644 --- a/internal/plugin/adventure_npcs.go +++ b/internal/plugin/adventure_npcs.go @@ -268,7 +268,9 @@ func (p *AdventurePlugin) resolveArina(ctx MessageContext, char *AdventureCharac if err := saveAdvCharacter(char); err != nil { slog.Error("npc: failed to save arina buff", "user", ctx.Sender, "err", err) } - return p.SendDM(ctx.Sender, fmt.Sprintf("_%s_", arinaAcceptLine)) + return p.SendDM(ctx.Sender, fmt.Sprintf( + "_She snatches the money from you, almost rudely, then looks at you expectantly._\n\n\"%s\"\n\n_She walks away just as quickly as she came._", + arinaAcceptLine)) } // Declined — no mechanical effect, just insults diff --git a/internal/plugin/coop_dungeon_db.go b/internal/plugin/coop_dungeon_db.go index 3543f63..01f2d20 100644 --- a/internal/plugin/coop_dungeon_db.go +++ b/internal/plugin/coop_dungeon_db.go @@ -177,6 +177,21 @@ func completeCoopRun(runID int, status string, reward int) error { // reset (which would otherwise zero them) and at bot startup. Combat stays // locked for the full duration of the run — players in a co-op cannot also // solo-grind dungeons or arena. +// unlockCoopCombatActionsForRun clears the 99-sentinel lock on the given run's +// members and pins combat_actions_used to the daily cap (= maxCombatActions), +// so they can't squeeze in extra combat today but are not blocked into +// tomorrow if the midnight reset is delayed. Called when a run ends (wipe or +// complete). Idempotent. +func unlockCoopCombatActionsForRun(runID int) error { + d := db.Get() + _, err := d.Exec(`UPDATE adventure_characters + SET combat_actions_used = ? + WHERE combat_actions_used > ? + AND user_id IN (SELECT user_id FROM coop_dungeon_members WHERE run_id = ?)`, + maxCombatActions, maxCombatActions, runID) + return err +} + func lockCoopCombatActions() error { d := db.Get() _, err := d.Exec(`UPDATE adventure_characters diff --git a/internal/plugin/coop_dungeon_scheduler.go b/internal/plugin/coop_dungeon_scheduler.go index 5792071..44f8b52 100644 --- a/internal/plugin/coop_dungeon_scheduler.go +++ b/internal/plugin/coop_dungeon_scheduler.go @@ -341,6 +341,7 @@ func (p *AdventurePlugin) coopResolveFloor(run *CoopRun) error { _ = p.SendMessage(gr, strings.Join(parts, "\n\n")) } _ = completeCoopRun(run.ID, "wiped", 0) + _ = unlockCoopCombatActionsForRun(run.ID) _ = setCoopRunLastResolvedDay(run.ID, day) return nil } @@ -350,6 +351,7 @@ func (p *AdventurePlugin) coopResolveFloor(run *CoopRun) error { reward := def.rewardBase p.coopDistributeReward(run, reward, members) _ = completeCoopRun(run.ID, "complete", reward) + _ = unlockCoopCombatActionsForRun(run.ID) _ = setCoopRunLastResolvedDay(run.ID, day) return nil } diff --git a/internal/plugin/forex.go b/internal/plugin/forex.go index da5da67..5af66b0 100644 --- a/internal/plugin/forex.go +++ b/internal/plugin/forex.go @@ -33,7 +33,7 @@ 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 · !fx alerts · !fx delalert ", Category: "Entertainment"}, + {Name: "fx", Description: "Forex rates, conversion, and analysis", Usage: "!fx rate [EUR|JPY] · !fx 1500 USD to EUR · !fx report [EUR|JPY] · !fx setalert · !fx alerts · !fx delalert ", Category: "Entertainment"}, } } @@ -52,12 +52,23 @@ func (p *ForexPlugin) OnMessage(ctx MessageContext) error { 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 ` · `!fx alerts` · `!fx delalert `") + "Usage: `!fx rate [EUR|JPY]` · `!fx 1500 USD to EUR` · `!fx report [EUR|JPY]` · `!fx setalert ` · `!fx alerts` · `!fx delalert `") } parts := strings.Fields(args) sub := strings.ToLower(parts[0]) + // Auto-detect conversion: if the first token parses as a number, + // treat the whole arg list as a convert request (e.g. `!fx 1500 USD EUR`). + if _, err := fxParseAmount(parts[0]); err == nil { + safeGo("forex-handler", func() { + if err := p.cmdConvert(ctx, parts); err != nil { + slog.Error("forex: handler error", "err", err) + } + }) + return nil + } + // DB-only and instant subcommands switch sub { case "setalert": @@ -85,6 +96,13 @@ func (p *ForexPlugin) OnMessage(ctx MessageContext) error { } }) return nil + case "convert": + safeGo("forex-handler", func() { + if err := p.cmdConvert(ctx, parts[1:]); 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])) } @@ -94,6 +112,7 @@ const fxHelpText = "**Forex Commands**\n\n" + "`!fx rate [EUR|JPY|CAD]` — current rate + quick signal\n" + "`!fx rate EUR/USD` — cross-pair rate from first currency's perspective\n" + "`!fx report [EUR|JPY|CAD]` — full analysis (averages, 52w range, buy score)\n" + + "`!fx 1500 USD to EUR` — convert an amount (also: `!fx convert 1500 USD EUR`)\n" + "`!fx setalert ` — alert when USD/currency reaches threshold\n" + "`!fx alerts` — list active alerts in this room\n" + "`!fx delalert ` — remove an alert\n" + diff --git a/internal/plugin/forex_convert.go b/internal/plugin/forex_convert.go new file mode 100644 index 0000000..024c813 --- /dev/null +++ b/internal/plugin/forex_convert.go @@ -0,0 +1,193 @@ +package plugin + +import ( + "fmt" + "log/slog" + "math" + "strconv" + "strings" +) + +// fxParseAmount parses a numeric token, allowing thousands separators +// ("1,500", "1500", "1500.50", "1.5k"). Returns an error on a non-numeric +// token. Negative amounts are rejected. +func fxParseAmount(tok string) (float64, error) { + t := strings.TrimSpace(tok) + t = strings.ReplaceAll(t, ",", "") + t = strings.ReplaceAll(t, "_", "") + multiplier := 1.0 + if len(t) > 0 { + switch strings.ToLower(t[len(t)-1:]) { + case "k": + multiplier = 1_000 + t = t[:len(t)-1] + case "m": + multiplier = 1_000_000 + t = t[:len(t)-1] + } + } + v, err := strconv.ParseFloat(t, 64) + if err != nil { + return 0, err + } + if v < 0 || math.IsNaN(v) || math.IsInf(v, 0) { + return 0, fmt.Errorf("non-positive amount") + } + return v * multiplier, nil +} + +// fxParseConvert parses a conversion request from free-form args. +// Accepts: `1500 USD EUR`, `1500 USD to EUR`, `1500 USD/EUR`, `USD/EUR 1500`, +// `1500USD EUR` is NOT supported (keep parsing simple — require whitespace). +// "to" / "into" / "in" are filtered as noise tokens. +func fxParseConvert(args []string) (amount float64, base, quote string, err error) { + if len(args) == 0 { + return 0, "", "", fmt.Errorf("no arguments") + } + tokens := make([]string, 0, len(args)) + for _, a := range args { + switch strings.ToLower(a) { + case "to", "into", "in", "=": + continue + } + tokens = append(tokens, a) + } + + var amountSet bool + var currencies []string + for _, tok := range tokens { + // Pair form: USD/EUR + if strings.ContainsAny(tok, "/|-") { + pair := fxParsePair([]string{tok}) + if pair != nil { + if base != "" { + return 0, "", "", fmt.Errorf("multiple currency pairs") + } + base, quote = pair.Base, pair.Quote + continue + } + } + // Number? + if v, perr := fxParseAmount(tok); perr == nil { + if amountSet { + return 0, "", "", fmt.Errorf("multiple amounts") + } + amount = v + amountSet = true + continue + } + // Currency code? + up := strings.ToUpper(tok) + if fxIsSupported(up) { + currencies = append(currencies, up) + continue + } + return 0, "", "", fmt.Errorf("unrecognized token: %s", tok) + } + + if !amountSet { + return 0, "", "", fmt.Errorf("missing amount") + } + if base == "" { + if len(currencies) != 2 { + return 0, "", "", fmt.Errorf("expected two currencies, got %d", len(currencies)) + } + base, quote = currencies[0], currencies[1] + } else if len(currencies) != 0 { + return 0, "", "", fmt.Errorf("currency pair plus extra currency") + } + if base == quote { + return 0, "", "", fmt.Errorf("base and quote are the same") + } + return amount, base, quote, nil +} + +func (p *ForexPlugin) cmdConvert(ctx MessageContext, args []string) error { + amount, base, quote, err := fxParseConvert(args) + if err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, + "Usage: `!fx 1500 USD to EUR` (or `!fx convert 1500 USD EUR`, `!fx convert USD/EUR 1500`)") + } + + pair := &fxPair{Base: base, Quote: quote} + rate, err := p.fxLivePairRate(pair) + if err != nil { + slog.Error("forex: fetch rates failed", "err", err) + return p.SendReply(ctx.RoomID, ctx.EventID, "Could not fetch rates. Try again shortly.") + } + + converted := amount * rate + baseEmoji := fxEmoji(base) + quoteEmoji := fxEmoji(quote) + + msg := fmt.Sprintf("%s **%s %s** = %s **%s %s**\n_1 %s = %s %s_", + baseEmoji, fxFormatAmount(base, amount), base, + quoteEmoji, fxFormatAmount(quote, converted), quote, + base, fxFormatRate(quote, rate), quote) + return p.SendReply(ctx.RoomID, ctx.EventID, msg) +} + +// fxEmoji returns the flag emoji for a supported currency, falling back +// to USD's flag. +func fxEmoji(cur string) string { + if m, ok := fxMeta[cur]; ok && m.Emoji != "" { + return m.Emoji + } + if cur == "USD" { + return "🇺🇸" + } + return "" +} + +// fxFormatAmount formats a converted amount with thousands separators. +// JPY uses 0 decimal places (yen are not subdivided in practice for display), +// other currencies use 2. +func fxFormatAmount(currency string, amount float64) string { + decimals := 2 + if currency == "JPY" { + decimals = 0 + } + return fxAddCommas(amount, decimals) +} + +// fxAddCommas formats a float with N decimal places and thousands separators. +func fxAddCommas(v float64, decimals int) string { + s := strconv.FormatFloat(v, 'f', decimals, 64) + intPart := s + fracPart := "" + if i := strings.IndexByte(s, '.'); i >= 0 { + intPart = s[:i] + fracPart = s[i:] + } + neg := false + if strings.HasPrefix(intPart, "-") { + neg = true + intPart = intPart[1:] + } + n := len(intPart) + if n <= 3 { + if neg { + return "-" + intPart + fracPart + } + return intPart + fracPart + } + var b strings.Builder + if neg { + b.WriteByte('-') + } + pre := n % 3 + if pre > 0 { + b.WriteString(intPart[:pre]) + if n > pre { + b.WriteByte(',') + } + } + for i := pre; i < n; i += 3 { + b.WriteString(intPart[i : i+3]) + if i+3 < n { + b.WriteByte(',') + } + } + b.WriteString(fracPart) + return b.String() +} diff --git a/internal/plugin/forex_convert_test.go b/internal/plugin/forex_convert_test.go new file mode 100644 index 0000000..80d2910 --- /dev/null +++ b/internal/plugin/forex_convert_test.go @@ -0,0 +1,70 @@ +package plugin + +import "testing" + +func TestFxParseConvert(t *testing.T) { + cases := []struct { + in []string + amt float64 + base string + quote string + shouldErr bool + }{ + {[]string{"1500", "USD", "EUR"}, 1500, "USD", "EUR", false}, + {[]string{"1500", "USD", "to", "EUR"}, 1500, "USD", "EUR", false}, + {[]string{"1500", "USD", "into", "EUR"}, 1500, "USD", "EUR", false}, + {[]string{"1,500.50", "USD", "EUR"}, 1500.50, "USD", "EUR", false}, + {[]string{"1.5k", "USD", "EUR"}, 1500, "USD", "EUR", false}, + {[]string{"1500", "USD/EUR"}, 1500, "USD", "EUR", false}, + {[]string{"USD/EUR", "1500"}, 1500, "USD", "EUR", false}, + {[]string{"1500", "EUR/USD"}, 1500, "EUR", "USD", false}, + {[]string{"1500", "JPY", "USD"}, 1500, "JPY", "USD", false}, + + {[]string{"USD", "EUR"}, 0, "", "", true}, // no amount + {[]string{"1500", "USD"}, 0, "", "", true}, // missing quote + {[]string{"1500", "USD", "USD"}, 0, "", "", true}, // same currency + {[]string{"1500", "ZZZ", "EUR"}, 0, "", "", true}, // unknown currency + {[]string{"1500", "USD", "EUR", "JPY"}, 0, "", "", true}, // too many currencies + {[]string{"1500", "100", "USD", "EUR"}, 0, "", "", true}, // multiple amounts + {[]string{"-100", "USD", "EUR"}, 0, "", "", true}, // negative + } + + for _, c := range cases { + amt, base, quote, err := fxParseConvert(c.in) + if c.shouldErr { + if err == nil { + t.Errorf("%v: expected error, got %v %s/%s", c.in, amt, base, quote) + } + continue + } + if err != nil { + t.Errorf("%v: unexpected error: %v", c.in, err) + continue + } + if amt != c.amt || base != c.base || quote != c.quote { + t.Errorf("%v: got %v %s/%s, want %v %s/%s", c.in, amt, base, quote, c.amt, c.base, c.quote) + } + } +} + +func TestFxAddCommas(t *testing.T) { + cases := []struct { + v float64 + decimals int + want string + }{ + {1500, 2, "1,500.00"}, + {1500.5, 2, "1,500.50"}, + {1234567.89, 2, "1,234,567.89"}, + {100, 2, "100.00"}, + {0, 2, "0.00"}, + {232500, 0, "232,500"}, + {-1500, 2, "-1,500.00"}, + } + for _, c := range cases { + got := fxAddCommas(c.v, c.decimals) + if got != c.want { + t.Errorf("fxAddCommas(%v, %d) = %q, want %q", c.v, c.decimals, got, c.want) + } + } +} diff --git a/internal/plugin/holidays.go b/internal/plugin/holidays.go index d34e45c..3b42616 100644 --- a/internal/plugin/holidays.go +++ b/internal/plugin/holidays.go @@ -239,7 +239,7 @@ func (p *HolidaysPlugin) PostHolidays(roomID id.RoomID) error { return nil } - msg := p.formatHolidays(today, data.Holidays) + msg := p.formatHolidays(today, data.Holidays, false) if err := p.SendMessage(roomID, msg); err != nil { return fmt.Errorf("holidays: send: %w", err) } @@ -291,7 +291,7 @@ func (p *HolidaysPlugin) handleHolidaysToday(ctx MessageContext) error { return p.SendReply(ctx.RoomID, ctx.EventID, "No holidays today.") } - msg := p.formatHolidays(today, data.Holidays) + msg := p.formatHolidays(today, data.Holidays, true) return p.SendReply(ctx.RoomID, ctx.EventID, msg) } @@ -340,13 +340,16 @@ func (p *HolidaysPlugin) handleHolidaysRange(ctx MessageContext, days int) error return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) } -func (p *HolidaysPlugin) formatHolidays(date string, holidays []Holiday) string { +func (p *HolidaysPlugin) formatHolidays(date string, holidays []Holiday, full bool) string { var sb strings.Builder t, _ := time.Parse("2006-01-02", date) sb.WriteString(fmt.Sprintf("Today's Holidays — %s\n", t.Format("Monday, January 2, 2006"))) - featured := p.selectFeatured(holidays) - for _, h := range featured { + shown := holidays + if !full { + shown = p.selectFeatured(holidays) + } + for _, h := range shown { sb.WriteString(fmt.Sprintf("\n- %s", h.Name)) if h.Country != "" && h.Country != "International" { sb.WriteString(fmt.Sprintf(" [%s]", h.Country)) @@ -356,9 +359,11 @@ func (p *HolidaysPlugin) formatHolidays(date string, holidays []Holiday) string } } - remaining := len(holidays) - len(featured) - if remaining > 0 { - sb.WriteString(fmt.Sprintf("\n\n...and %d more. Use !holidays for the full list.", remaining)) + if !full { + remaining := len(holidays) - len(shown) + if remaining > 0 { + sb.WriteString(fmt.Sprintf("\n\n...and %d more. Use !holidays for the full list.", remaining)) + } } return sb.String() diff --git a/internal/plugin/lottery.go b/internal/plugin/lottery.go index f694327..cc14b04 100644 --- a/internal/plugin/lottery.go +++ b/internal/plugin/lottery.go @@ -201,7 +201,6 @@ func (p *LotteryPlugin) handleLotteryOdds(ctx MessageContext) error { | 5 of 5 | Jackpot (split among winners) | 1 in 142,506 | | 4 of 5 | €5,000 (fixed) | 1 in 3,062 | | 3 of 5 | €500 (fixed) | 1 in 141 | -| 2 of 5 | €25 (fixed) | 1 in 16 | Tickets: €1 each. Max 100 per week. 5 numbers from 1–30. Minimum €500 pot required for jackpot payout.` @@ -227,8 +226,8 @@ func (p *LotteryPlugin) handleLotteryHistory(ctx MessageContext) error { if h.RolledOver > 0 { sb.WriteString(fmt.Sprintf(" | Rolled over: €%d", h.RolledOver)) } - sb.WriteString(fmt.Sprintf("\n 4-match: %d | 3-match: %d | 2-match: %d\n\n", - h.Match4Winners, h.Match3Winners, h.Match2Winners)) + sb.WriteString(fmt.Sprintf("\n 4-match: %d | 3-match: %d\n\n", + h.Match4Winners, h.Match3Winners)) } return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) diff --git a/internal/plugin/lottery_db.go b/internal/plugin/lottery_db.go index 3dc0d28..3810ed5 100644 --- a/internal/plugin/lottery_db.go +++ b/internal/plugin/lottery_db.go @@ -37,14 +37,22 @@ type lotteryHistoryRow struct { // ── Week Helpers ──────────────────────────────────────────────────────────── -// lotteryCurrentWeekStart returns Monday of the current week as "2006-01-02". +// lotteryCurrentWeekStart returns the Monday of the lottery week currently +// accepting tickets. Mon–Fri returns this week's Monday; Sat–Sun returns +// next week's Monday — the Friday 23:59 UTC draw resets the week boundary, +// so Saturday onward, ticket purchases count toward the next draw. func lotteryCurrentWeekStart() string { now := time.Now().UTC() - weekday := int(now.Weekday()) - if weekday == 0 { - weekday = 7 // Sunday = 7 + wd := now.Weekday() // Sunday=0, Monday=1, ..., Saturday=6 + var monday time.Time + switch wd { + case time.Sunday: + monday = now.AddDate(0, 0, 1) + case time.Saturday: + monday = now.AddDate(0, 0, 2) + default: + monday = now.AddDate(0, 0, -(int(wd) - 1)) } - monday := now.AddDate(0, 0, -(weekday - 1)) return monday.Format("2006-01-02") } diff --git a/internal/plugin/lottery_draw.go b/internal/plugin/lottery_draw.go index 7001bf7..497b17c 100644 --- a/internal/plugin/lottery_draw.go +++ b/internal/plugin/lottery_draw.go @@ -16,7 +16,6 @@ import ( var lotteryFixedPrizes = map[int]int{ 4: 5000, 3: 500, - 2: 25, } // ── Draw Ticker ───────────────────────────────────────────────────────────── @@ -100,7 +99,7 @@ func (p *LotteryPlugin) executeDraw(weekStart string) { tickets[i].MatchCount = &mc prize := 0 - if mc >= 2 && mc <= 4 { + if mc >= 3 && mc <= 4 { prize = lotteryFixedPrizes[mc] } tickets[i].Prize = &prize @@ -114,7 +113,7 @@ func (p *LotteryPlugin) executeDraw(weekStart string) { // Calculate fixed tier payouts. fixedTotal := 0 - for tier := 4; tier >= 1; tier-- { + for tier := 4; tier >= 3; tier-- { count := len(matchBuckets[tier]) fixedTotal += count * lotteryFixedPrizes[tier] } @@ -141,7 +140,7 @@ func (p *LotteryPlugin) executeDraw(weekStart string) { } if actualFixed > 0 { - for tier := 4; tier >= 1; tier-- { + for tier := 4; tier >= 3; tier-- { for _, t := range matchBuckets[tier] { amount := int(float64(lotteryFixedPrizes[tier]) * prorateRatio) if amount > 0 { @@ -190,7 +189,7 @@ func (p *LotteryPlugin) executeDraw(weekStart string) { JackpotAmount: jackpotAmount, Match4Winners: len(matchBuckets[4]), Match3Winners: len(matchBuckets[3]), - Match2Winners: len(matchBuckets[2]), + Match2Winners: 0, Match1Winners: 0, PotTotal: initialPot, RolledOver: rolledOver, @@ -221,7 +220,7 @@ func (p *LotteryPlugin) dmWinners(winning []int, tickets []lotteryTicket) { // Group winning tickets by user. byUser := make(map[id.UserID][]lotteryTicket) for _, t := range tickets { - if t.MatchCount == nil || *t.MatchCount < 2 { + if t.MatchCount == nil || *t.MatchCount < 3 { continue } byUser[t.UserID] = append(byUser[t.UserID], t) @@ -295,7 +294,6 @@ func (p *LotteryPlugin) buildDrawAnnouncement(winning []int, h *lotteryHistoryRo // Fixed tiers. sb.WriteString(fmt.Sprintf("4 match: %d winner(s) — €5,000 each\n", h.Match4Winners)) sb.WriteString(fmt.Sprintf("3 match: %d winner(s) — €500 each\n", h.Match3Winners)) - sb.WriteString(fmt.Sprintf("2 match: %d winner(s) — €25 each\n", h.Match2Winners)) distributed := h.PotTotal - h.RolledOver sb.WriteString(fmt.Sprintf("\nPot distributed: %s. Next draw: Friday. Tickets on sale now.", fmtEuro(distributed)))