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:
prosolis
2026-03-26 09:22:02 -07:00
parent c7c1b76589
commit 9c6ded13fa
68 changed files with 5784 additions and 724 deletions

290
internal/plugin/uno_test.go Normal file
View 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())
}
}