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

View File

@@ -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),
}
}