mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Add adventure plugin, holdem CFR fixes, and wordle plugin
Adventure: Complete v1 daily idle RPG with DM-driven gameplay, equipment shop, treasure system, TwinBee NPC, streak/grudge/party mechanics, flavor text, and scheduled morning/evening/midnight tickers. Holdem CFR: Fix three critical training bugs (fold not forfeiting pot, free calls after raises, training/runtime key mismatch). Add performance optimizations (preflop lookup table, zero-alloc equity, integer keys, raise cap, regret pruning). Enrich abstraction with 12 equity buckets, board texture dimension, and 6-char action history. Replace validation with proper multi-street simulation. Also includes wordle plugin, holdem seed tooling, and schema additions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
308
internal/plugin/adventure_scheduler.go
Normal file
308
internal/plugin/adventure_scheduler.go
Normal file
@@ -0,0 +1,308 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// ── Morning DM Ticker ────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) morningTicker() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
if now.Hour() != p.morningHour || now.Minute() != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
dateKey := now.Format("2006-01-02")
|
||||
jobName := "adventure_morning"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("adventure: sending morning DMs")
|
||||
p.sendMorningDMs()
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) sendMorningDMs() {
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
slog.Error("adventure: failed to load characters for morning DMs", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
for _, char := range chars {
|
||||
char := char
|
||||
|
||||
// Check if dead and ready to respawn
|
||||
if !char.Alive && char.DeadUntil != nil && now.After(*char.DeadUntil) {
|
||||
// Revive
|
||||
char.Alive = true
|
||||
char.DeadUntil = nil
|
||||
if err := saveAdvCharacter(&char); err != nil {
|
||||
slog.Error("adventure: failed to revive character", "user", char.UserID, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Send respawn DM
|
||||
text := renderAdvRespawnDM(&char)
|
||||
if err := p.SendDM(char.UserID, text); err != nil {
|
||||
slog.Error("adventure: failed to send respawn DM", "user", char.UserID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// If still dead, send death status
|
||||
if !char.Alive {
|
||||
text := renderAdvDeathStatusDM(&char)
|
||||
if err := p.SendDM(char.UserID, text); err != nil {
|
||||
slog.Error("adventure: failed to send death status DM", "user", char.UserID, "err", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// If already acted today, skip
|
||||
if char.ActionTakenToday {
|
||||
continue
|
||||
}
|
||||
|
||||
// Send morning DM with choices
|
||||
equip, err := loadAdvEquipment(char.UserID)
|
||||
if err != nil {
|
||||
slog.Error("adventure: failed to load equipment for morning DM", "user", char.UserID, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
treasures, _ := loadAdvTreasureBonuses(char.UserID)
|
||||
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
||||
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
|
||||
balance := p.euro.GetBalance(char.UserID)
|
||||
|
||||
text := renderAdvMorningDM(&char, equip, balance, bonuses)
|
||||
if err := p.SendDM(char.UserID, text); err != nil {
|
||||
slog.Error("adventure: failed to send morning DM", "user", char.UserID, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Register DM room for reply routing
|
||||
p.registerDMRoom(char.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Evening Summary Ticker ───────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) summaryTicker() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
if now.Hour() != p.summaryHour || now.Minute() != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
dateKey := now.Format("2006-01-02")
|
||||
jobName := "adventure_summary"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("adventure: posting daily summary")
|
||||
p.postDailySummary()
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) postDailySummary() {
|
||||
gr := gamesRoom()
|
||||
if gr == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Run TwinBee daily action
|
||||
tbResult := p.runTwinBeeDaily()
|
||||
tbRewards := p.distributeTwinBeeRewards(tbResult)
|
||||
|
||||
// Load all characters and today's logs
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
slog.Error("adventure: failed to load characters for summary", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
todayLogs, _ := loadAdvTodayLogs()
|
||||
logMap := make(map[id.UserID]*AdvDayLog)
|
||||
for i := range todayLogs {
|
||||
logMap[todayLogs[i].UserID] = &todayLogs[i]
|
||||
}
|
||||
|
||||
// Build player summaries
|
||||
var players []AdvPlayerDaySummary
|
||||
for _, c := range chars {
|
||||
ps := AdvPlayerDaySummary{
|
||||
DisplayName: c.DisplayName,
|
||||
CombatLevel: c.CombatLevel,
|
||||
MiningSkill: c.MiningSkill,
|
||||
ForagingSkill: c.ForagingSkill,
|
||||
}
|
||||
|
||||
if !c.Alive {
|
||||
ps.IsDead = true
|
||||
if c.DeadUntil != nil {
|
||||
ps.DeadUntil = c.DeadUntil.Format("15:04") + " UTC"
|
||||
}
|
||||
// Check if they died today
|
||||
if log, ok := logMap[c.UserID]; ok {
|
||||
ps.Activity = log.ActivityType
|
||||
ps.Location = log.Location
|
||||
ps.Outcome = log.Outcome
|
||||
ps.LootValue = log.LootValue
|
||||
ps.SummaryLine = advSummaryOneLiner(c.UserID, AdvActivityType(log.ActivityType), AdvOutcomeType(log.Outcome), log.LootValue, log.Location)
|
||||
}
|
||||
players = append(players, ps)
|
||||
continue
|
||||
}
|
||||
|
||||
if !c.ActionTakenToday {
|
||||
ps.IsResting = true
|
||||
if len(SummaryResting) > 0 {
|
||||
ps.SummaryLine = SummaryResting[time.Now().Nanosecond()%len(SummaryResting)]
|
||||
}
|
||||
players = append(players, ps)
|
||||
continue
|
||||
}
|
||||
|
||||
// Active player with today's log
|
||||
if log, ok := logMap[c.UserID]; ok {
|
||||
ps.Activity = log.ActivityType
|
||||
ps.Location = log.Location
|
||||
ps.Outcome = log.Outcome
|
||||
ps.LootValue = log.LootValue
|
||||
ps.SummaryLine = advSummaryOneLiner(c.UserID, AdvActivityType(log.ActivityType), AdvOutcomeType(log.Outcome), log.LootValue, log.Location)
|
||||
}
|
||||
|
||||
players = append(players, ps)
|
||||
}
|
||||
|
||||
// Check party bonuses and add to summary
|
||||
for i := range players {
|
||||
if players[i].Location != "" && !players[i].IsDead && !players[i].IsResting {
|
||||
for j := i + 1; j < len(players); j++ {
|
||||
if players[j].Location == players[i].Location && !players[j].IsDead && !players[j].IsResting {
|
||||
players[i].SummaryLine += fmt.Sprintf(" (Party bonus with %s!)", players[j].DisplayName)
|
||||
players[j].SummaryLine += fmt.Sprintf(" (Party bonus with %s!)", players[i].DisplayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
date := time.Now().UTC().Format("2006-01-02")
|
||||
summary := renderAdvDailySummary(date, tbResult, tbRewards, players)
|
||||
|
||||
if err := p.SendMessage(id.RoomID(gr), summary); err != nil {
|
||||
slog.Error("adventure: failed to post daily summary", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Midnight Reset Ticker ────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) midnightTicker() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
if now.Hour() != 0 || now.Minute() != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
dateKey := now.Format("2006-01-02")
|
||||
jobName := "adventure_midnight"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("adventure: midnight reset")
|
||||
p.midnightReset()
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) midnightReset() {
|
||||
// Send idle shame DMs to players who didn't act
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
slog.Error("adventure: midnight reset failed to load chars", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
for _, char := range chars {
|
||||
if !char.Alive {
|
||||
continue
|
||||
}
|
||||
|
||||
if !char.ActionTakenToday {
|
||||
// Idle shame DM
|
||||
text := renderAdvIdleShameDM(&char)
|
||||
if err := p.SendDM(char.UserID, text); err != nil {
|
||||
slog.Error("adventure: failed to send idle shame DM", "user", char.UserID, "err", err)
|
||||
}
|
||||
|
||||
// Reset streak
|
||||
if char.CurrentStreak > 0 {
|
||||
char.CurrentStreak = 0
|
||||
_ = saveAdvCharacter(&char)
|
||||
}
|
||||
} else {
|
||||
// Update streak
|
||||
if char.LastActionDate == time.Now().UTC().Add(-24*time.Hour).Format("2006-01-02") {
|
||||
char.CurrentStreak++
|
||||
} else if char.LastActionDate != today {
|
||||
char.CurrentStreak = 1
|
||||
}
|
||||
if char.CurrentStreak > char.BestStreak {
|
||||
char.BestStreak = char.CurrentStreak
|
||||
}
|
||||
char.LastActionDate = today
|
||||
_ = saveAdvCharacter(&char)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset all daily actions
|
||||
if err := resetAllAdvDailyActions(); err != nil {
|
||||
slog.Error("adventure: failed to reset daily actions", "err", err)
|
||||
}
|
||||
|
||||
// Prune expired buffs
|
||||
if err := pruneAdvExpiredBuffs(); err != nil {
|
||||
slog.Error("adventure: failed to prune expired buffs", "err", err)
|
||||
}
|
||||
|
||||
// Clear flavor history to prevent unbounded memory growth.
|
||||
// Entries are only used for dedup within a day, so clearing at midnight is fine.
|
||||
advClearFlavorHistory()
|
||||
}
|
||||
|
||||
// ── Helper ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) registerDMRoom(userID id.UserID) {
|
||||
room, err := p.GetDMRoom(userID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.dmToPlayer[room] = userID
|
||||
p.mu.Unlock()
|
||||
}
|
||||
Reference in New Issue
Block a user