diff --git a/internal/plugin/adventure_activity_unified.go b/internal/plugin/adventure_activity_unified.go new file mode 100644 index 0000000..cc06cdb --- /dev/null +++ b/internal/plugin/adventure_activity_unified.go @@ -0,0 +1,197 @@ +package plugin + +import ( + "fmt" + "time" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +// AdvDailyActivity is one row of "what a player did" on a given UTC date. +// Unifies the legacy adventure_activity_log table with the D&D-layer zone +// run / expedition log tables so the daily report can render a coherent +// view regardless of which subsystem the action ran through. +type AdvDailyActivity struct { + UserID id.UserID + Source string // "zone_run" | "expedition" | "rest_legacy" | "legacy" + Activity string // "zone" | "expedition" | "rest" | legacy activity_type + Location string // zone display name or legacy location + Outcome string // "completed" | "abandoned" | "boss_defeated" | "in_progress" | "rest" | legacy outcome + Summary string // one-line human description + LootValue int64 // 0 for D&D-layer entries (loot stays in run/expedition state) + Timestamp time.Time +} + +// loadAdvDailyActivity unions activity rows from every action surface for +// the given UTC date (format "2006-01-02"). Returns map keyed by user_id. +// +// Sources (in chronological order on output): +// - adventure_activity_log (legacy, mostly !rest post-Phase-R) +// - dnd_zone_run (zone runs whose last_action_at falls on date) +// - dnd_expedition_log (expedition log entries dated today, deduped per +// expedition into one rollup line) +// +// Each user's slice is sorted oldest→newest. Empty slice when the player +// had no activity on this date. +func loadAdvDailyActivity(date string) (map[id.UserID][]AdvDailyActivity, error) { + out := make(map[id.UserID][]AdvDailyActivity) + d := db.Get() + + // 1. Legacy adventure_activity_log + rows, err := d.Query(` + SELECT user_id, activity_type, COALESCE(location,''), outcome, loot_value, logged_at + FROM adventure_activity_log + WHERE logged_at >= ? AND logged_at < DATE(?, '+1 day') + ORDER BY logged_at`, date, date) + if err != nil { + return nil, fmt.Errorf("legacy log: %w", err) + } + for rows.Next() { + var ( + uid, activity, location, outcome string + loot int64 + ts time.Time + ) + if err := rows.Scan(&uid, &activity, &location, &outcome, &loot, &ts); err != nil { + rows.Close() + return nil, fmt.Errorf("legacy log scan: %w", err) + } + userID := id.UserID(uid) + entry := AdvDailyActivity{ + UserID: userID, + Source: "legacy", + Activity: activity, + Location: location, + Outcome: outcome, + LootValue: loot, + Timestamp: ts, + } + if activity == string(AdvActivityRest) { + entry.Source = "rest_legacy" + entry.Summary = "Rested. Streak preserved." + } else { + entry.Summary = fmt.Sprintf("%s in %s (%s)", activity, location, outcome) + } + out[userID] = append(out[userID], entry) + } + rows.Close() + + // 2. dnd_zone_run — rows touched today. + rows, err = d.Query(` + SELECT user_id, zone_id, current_room, total_rooms, abandoned, + boss_defeated, completed_at, last_action_at + FROM dnd_zone_run + WHERE last_action_at >= ? AND last_action_at < DATE(?, '+1 day') + ORDER BY last_action_at`, date, date) + if err != nil { + return nil, fmt.Errorf("zone runs: %w", err) + } + for rows.Next() { + var ( + uid, zoneID string + currentRoom, totalRooms int + abandoned, bossDefeated int + completedAt *time.Time + lastAction time.Time + ) + if err := rows.Scan(&uid, &zoneID, ¤tRoom, &totalRooms, + &abandoned, &bossDefeated, &completedAt, &lastAction); err != nil { + rows.Close() + return nil, fmt.Errorf("zone run scan: %w", err) + } + userID := id.UserID(uid) + zoneDef := zoneOrFallback(ZoneID(zoneID)) + + var outcome, summary string + switch { + case bossDefeated == 1: + outcome = "boss_defeated" + summary = fmt.Sprintf("Cleared %s — boss down (%d/%d rooms).", zoneDef.Display, currentRoom, totalRooms) + case completedAt != nil && abandoned == 0: + outcome = "completed" + summary = fmt.Sprintf("Cleared %s (%d/%d rooms).", zoneDef.Display, currentRoom, totalRooms) + case abandoned == 1: + outcome = "abandoned" + summary = fmt.Sprintf("Withdrew from %s at room %d/%d.", zoneDef.Display, currentRoom, totalRooms) + default: + outcome = "in_progress" + summary = fmt.Sprintf("Mid-run in %s (%d/%d rooms).", zoneDef.Display, currentRoom, totalRooms) + } + out[userID] = append(out[userID], AdvDailyActivity{ + UserID: userID, + Source: "zone_run", + Activity: "zone", + Location: zoneDef.Display, + Outcome: outcome, + Summary: summary, + Timestamp: lastAction, + }) + } + rows.Close() + + // 3. dnd_expedition_log — pick the most-recent player-visible entry + // per expedition for users active today. We rollup per expedition + // rather than per row so the report doesn't list every camp/combat + // beat separately. + rows, err = d.Query(` + SELECT e.user_id, e.zone_id, e.current_day, e.status, + l.entry_type, l.summary, l.timestamp + FROM dnd_expedition_log l + JOIN dnd_expedition e ON e.expedition_id = l.expedition_id + WHERE l.timestamp >= ? AND l.timestamp < DATE(?, '+1 day') + AND l.entry_type IN ('action','combat','event','recap') + ORDER BY l.timestamp`, date, date) + if err != nil { + return nil, fmt.Errorf("expedition log: %w", err) + } + type expKey struct { + uid, zoneID string + } + mostRecentExp := make(map[expKey]AdvDailyActivity) + for rows.Next() { + var ( + uid, zoneID, status, entryType, summary string + currentDay int + ts time.Time + ) + if err := rows.Scan(&uid, &zoneID, ¤tDay, &status, + &entryType, &summary, &ts); err != nil { + rows.Close() + return nil, fmt.Errorf("expedition log scan: %w", err) + } + userID := id.UserID(uid) + zoneDef := zoneOrFallback(ZoneID(zoneID)) + k := expKey{string(userID), zoneID} + mostRecentExp[k] = AdvDailyActivity{ + UserID: userID, + Source: "expedition", + Activity: "expedition", + Location: zoneDef.Display, + Outcome: status, + Summary: fmt.Sprintf("Expedition in %s, day %d — %s", + zoneDef.Display, currentDay, summary), + Timestamp: ts, + } + } + rows.Close() + for _, entry := range mostRecentExp { + out[entry.UserID] = append(out[entry.UserID], entry) + } + + // Sort each user's slice oldest→newest so callers can pick the + // representative entry deterministically (typically the latest). + for uid := range out { + entries := out[uid] + // Insertion-sort is fine here — slices are tiny. + for i := 1; i < len(entries); i++ { + for j := i; j > 0 && entries[j-1].Timestamp.After(entries[j].Timestamp); j-- { + entries[j-1], entries[j] = entries[j], entries[j-1] + } + } + out[uid] = entries + } + + return out, nil +} diff --git a/internal/plugin/adventure_render.go b/internal/plugin/adventure_render.go index c22d2ea..f4d6a4f 100644 --- a/internal/plugin/adventure_render.go +++ b/internal/plugin/adventure_render.go @@ -711,11 +711,24 @@ func renderAdvOnboardingDM(userID id.UserID) string { // ── Daily Summary ──────────────────────────────────────────────────────────── type AdvPlayerDaySummary struct { - DisplayName string - Level int - MiningSkill int - ForagingSkill int - FishingSkill int + DisplayName string + + // D&D layer (preferred render). HasDnDChar=false → fall back to legacy + // skill-line render and "needs !setup" hint. + HasDnDChar bool + DnDLevel int + DnDRace string + DnDClass string + HPCurrent int + HPMax int + + // Legacy AdventureCharacter skill levels (rendered as fallback when + // HasDnDChar is false). + Level int // legacy combat level + MiningSkill int + ForagingSkill int + FishingSkill int + Activity string Location string Outcome string @@ -729,6 +742,36 @@ type AdvPlayerDaySummary struct { DeathLocation string } +// advPlayerHeadline renders the per-player headline for the daily report. +// D&D shape ("Lv.4 Half-Elf Cleric — HP 27/27") is preferred; falls back to +// the legacy four-skill line for accounts that haven't run !setup yet. +func advPlayerHeadline(p *AdvPlayerDaySummary) string { + if p.HasDnDChar { + race := titleizeDnDToken(p.DnDRace) + class := titleizeDnDToken(p.DnDClass) + return fmt.Sprintf("Lv.%d %s %s — HP %d/%d", + p.DnDLevel, race, class, p.HPCurrent, p.HPMax) + } + return fmt.Sprintf("Combat Lv.%d | Mining Lv.%d | Forage Lv.%d | Fishing Lv.%d _(no D&D sheet — run `!setup`)_", + p.Level, p.MiningSkill, p.ForagingSkill, p.FishingSkill) +} + +// titleizeDnDToken converts a snake_case D&D enum token like "half_elf" into +// a human-readable label like "Half-Elf". Single-word tokens just title-case. +func titleizeDnDToken(s string) string { + if s == "" { + return "" + } + parts := strings.Split(s, "_") + for i, p := range parts { + if p == "" { + continue + } + parts[i] = strings.ToUpper(p[:1]) + p[1:] + } + return strings.Join(parts, "-") +} + func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewardSummary, players []AdvPlayerDaySummary, holidayName string) string { var sb strings.Builder @@ -809,8 +852,7 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa if !diedOnAdventure { icon = "⚔️" } - sb.WriteString(fmt.Sprintf("%s **%s** — Combat Lv.%d | Mining Lv.%d | Forage Lv.%d | Fishing Lv.%d\n", - icon, p.DisplayName, p.Level, p.MiningSkill, p.ForagingSkill, p.FishingSkill)) + sb.WriteString(fmt.Sprintf("%s **%s** — %s\n", icon, p.DisplayName, advPlayerHeadline(p))) sb.WriteString(fmt.Sprintf(" Went to: %s\n", p.Location)) sb.WriteString(fmt.Sprintf(" Outcome: %s\n", p.SummaryLine)) if !diedOnAdventure { @@ -832,8 +874,7 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa continue } - sb.WriteString(fmt.Sprintf("⚔️ **%s** — Combat Lv.%d | Mining Lv.%d | Forage Lv.%d | Fishing Lv.%d\n", - p.DisplayName, p.Level, p.MiningSkill, p.ForagingSkill, p.FishingSkill)) + sb.WriteString(fmt.Sprintf("⚔️ **%s** — %s\n", p.DisplayName, advPlayerHeadline(p))) if p.Location != "" { sb.WriteString(fmt.Sprintf(" Went to: %s\n", p.Location)) sb.WriteString(fmt.Sprintf(" Outcome: %s\n\n", p.SummaryLine)) diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index 11fb01f..1fd9f9e 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -205,28 +205,25 @@ func (p *AdventurePlugin) postDailySummary() { return } - // Load logs for today and yesterday — players may act across the UTC boundary + // Load activity for today and yesterday — players may act across the UTC + // boundary. Activity unifies legacy adventure_activity_log + dnd_zone_run + // + dnd_expedition_log so the report sees every action regardless of + // which subsystem produced it. now := time.Now().UTC() - todayLogs, _ := loadAdvLogsForDate(now.Format("2006-01-02")) - yesterdayLogs, _ := loadAdvLogsForDate(now.AddDate(0, 0, -1).Format("2006-01-02")) - todayLogs = append(todayLogs, yesterdayLogs...) - // Group logs per user — holiday days may produce 2 entries per user - logsPerUser := make(map[id.UserID][]*AdvDayLog) - for i := range todayLogs { - uid := todayLogs[i].UserID - logsPerUser[uid] = append(logsPerUser[uid], &todayLogs[i]) + todayActs, _ := loadAdvDailyActivity(now.Format("2006-01-02")) + yesterdayActs, _ := loadAdvDailyActivity(now.AddDate(0, 0, -1).Format("2006-01-02")) + activityMap := make(map[id.UserID][]AdvDailyActivity) + for uid, e := range yesterdayActs { + activityMap[uid] = append(activityMap[uid], e...) + } + for uid, e := range todayActs { + activityMap[uid] = append(activityMap[uid], e...) } - // logMap picks the last (most recent) log entry for summary display - // lootSums aggregates loot across all actions (relevant on holiday double-action days) - logMap := make(map[id.UserID]*AdvDayLog) lootSums := make(map[id.UserID]int64) - for uid, logs := range logsPerUser { - logMap[uid] = logs[len(logs)-1] - var total int64 - for _, l := range logs { - total += l.LootValue + for uid, acts := range activityMap { + for _, a := range acts { + lootSums[uid] += a.LootValue } - lootSums[uid] = total } isHol, holName := isHolidayToday() @@ -237,15 +234,25 @@ func (p *AdventurePlugin) postDailySummary() { dispName, _ := loadDisplayName(c.UserID) ps := AdvPlayerDaySummary{ DisplayName: dispName, - Level: dndLevelForUser(c.UserID), + Level: c.CombatLevel, MiningSkill: c.MiningSkill, ForagingSkill: c.ForagingSkill, FishingSkill: c.FishingSkill, } + if dnd, _ := LoadDnDCharacter(c.UserID); dnd != nil { + ps.HasDnDChar = true + ps.DnDLevel = dnd.Level + ps.DnDRace = string(dnd.Race) + ps.DnDClass = string(dnd.Class) + ps.HPCurrent = dnd.HPCurrent + ps.HPMax = dnd.HPMax + } - // Holiday action count from log entries + acts := activityMap[c.UserID] + + // Holiday action count from activity entries if isHol { - ps.HolidayActions = len(logsPerUser[c.UserID]) + ps.HolidayActions = len(acts) } if !c.Alive { @@ -255,19 +262,19 @@ func (p *AdventurePlugin) postDailySummary() { 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 = lootSums[c.UserID] // aggregate across all actions - ps.SummaryLine = advSummaryOneLiner(c.UserID, AdvActivityType(log.ActivityType), AdvOutcomeType(log.Outcome), lootSums[c.UserID], log.Location) + if len(acts) > 0 { + last := acts[len(acts)-1] + ps.Activity = last.Activity + ps.Location = last.Location + ps.Outcome = last.Outcome + ps.LootValue = lootSums[c.UserID] + ps.SummaryLine = last.Summary } players = append(players, ps) continue } - if !c.HasActedToday() { + if len(acts) == 0 { ps.IsResting = true if len(SummaryResting) > 0 { ps.SummaryLine = SummaryResting[time.Now().Nanosecond()%len(SummaryResting)] @@ -276,14 +283,13 @@ func (p *AdventurePlugin) postDailySummary() { 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 = lootSums[c.UserID] // aggregate across all actions - ps.SummaryLine = advSummaryOneLiner(c.UserID, AdvActivityType(log.ActivityType), AdvOutcomeType(log.Outcome), lootSums[c.UserID], log.Location) - } + // Active player — represent with most-recent activity row. + last := acts[len(acts)-1] + ps.Activity = last.Activity + ps.Location = last.Location + ps.Outcome = last.Outcome + ps.LootValue = lootSums[c.UserID] + ps.SummaryLine = last.Summary players = append(players, ps) } diff --git a/internal/plugin/proddb_daily_report_test.go b/internal/plugin/proddb_daily_report_test.go new file mode 100644 index 0000000..2d2930b --- /dev/null +++ b/internal/plugin/proddb_daily_report_test.go @@ -0,0 +1,101 @@ +package plugin + +import ( + "os" + "path/filepath" + "testing" + "time" + + "gogobee/internal/db" +) + +// TestProdDB_DailyReportPreview renders the Adv 2.0 daily summary against a +// copy of the prod DB and prints it. Manual smoke for the D&D-aware +// renderer. +func TestProdDB_DailyReportPreview(t *testing.T) { + src := "/home/reala-misaki/git/gogobee/data/gogobee.db" + if _, err := os.Stat(src); err != nil { + t.Skip("prod db not present") + } + dir := t.TempDir() + dst := filepath.Join(dir, "gogobee.db") + copyTestFile(t, src, dst) + + db.Close() + if err := db.Init(dir); err != nil { + t.Fatalf("db.Init: %v", err) + } + t.Cleanup(db.Close) + + bootstrapPlayerMetaFromLegacy() + + chars, err := loadAllAdvCharacters() + if err != nil { + t.Fatalf("loadAllAdvCharacters: %v", err) + } + + // Use the most recent activity date in the DB so we render against real + // data instead of an empty "today". + now := time.Now().UTC() + dateKey := now.Format("2006-01-02") + if acts, _ := loadAdvDailyActivity(dateKey); len(acts) == 0 { + // No activity today; walk back up to 7 days for a date that has rows. + for i := 1; i <= 7; i++ { + cand := now.AddDate(0, 0, -i).Format("2006-01-02") + if acts, _ := loadAdvDailyActivity(cand); len(acts) > 0 { + dateKey = cand + break + } + } + } + t.Logf("rendering report for date=%s", dateKey) + + todayActs, _ := loadAdvDailyActivity(dateKey) + activityMap := make(map[string][]AdvDailyActivity) + for uid, e := range todayActs { + activityMap[string(uid)] = e + } + + var players []AdvPlayerDaySummary + for _, c := range chars { + dispName, _ := loadDisplayName(c.UserID) + ps := AdvPlayerDaySummary{ + DisplayName: dispName, + Level: c.CombatLevel, + MiningSkill: c.MiningSkill, + ForagingSkill: c.ForagingSkill, + FishingSkill: c.FishingSkill, + } + if dnd, _ := LoadDnDCharacter(c.UserID); dnd != nil { + ps.HasDnDChar = true + ps.DnDLevel = dnd.Level + ps.DnDRace = string(dnd.Race) + ps.DnDClass = string(dnd.Class) + ps.HPCurrent = dnd.HPCurrent + ps.HPMax = dnd.HPMax + } + + acts := activityMap[string(c.UserID)] + if !c.Alive { + ps.IsDead = true + ps.DeathSource = c.DeathSource + ps.DeathLocation = c.DeathLocation + if c.DeadUntil != nil { + ps.DeadUntil = c.DeadUntil.Format("15:04") + " UTC" + } + } else if len(acts) == 0 { + ps.IsResting = true + ps.SummaryLine = "Stayed in town. The world's still here tomorrow." + } else { + last := acts[len(acts)-1] + ps.Activity = last.Activity + ps.Location = last.Location + ps.Outcome = last.Outcome + ps.SummaryLine = last.Summary + } + players = append(players, ps) + } + + out := renderAdvDailySummary(dateKey, nil, TwinBeeRewardSummary{}, players, "") + t.Logf("\n%s", out) +}