Files
gogobee/internal/plugin/coop_dungeon_db.go
prosolis 9e1a1f606c Adv 2.0 D&D layer: Phases 1-8 + Phase 9 SP1+SP2
Combines all uncommitted D&D work into one checkpoint:

Phases 1-8 (per gogobee_dnd_session_summary.md):
- Race/class/stats system, !setup flow, !sheet, !respec
- d20-vs-AC combat with race/class passives, auto-migration
- XP/level-up, skill checks, NPC refund hooks
- Equipment slot mapping, rarity, !rest short/long
- Pre-arm active abilities, resource pools
- Bestiary, !roll/!stats/!level, onboarding DM
- Audit fixes A-I, flavor wiring under internal/flavor/
- Phase 8 weapon dice + armor AC tables (gogobee_equipment_appendix)

Phase 9 SP1 — spell system foundations:
- 79 SpellDefinitions (76 in-scope + 3 reaction-deferred)
- dnd_known_spells, dnd_spell_slots tables
- pending_cast / concentration_* columns on dnd_character
- Slot tables for Mage/Cleric/Ranger, DC + attack-bonus math
- Long-rest refreshes slots; respec wipes spell state
- Auto-grant default known list on !setup confirm and auto-migration

Phase 9 SP2 — !cast / !spells / !prepare commands:
- Out-of-combat HEAL and UTILITY resolve immediately
- Damage/control/buff queue as pending_cast for next combat
- Audit-style save-then-debit, slot refund on save failure
- !cast --drop, concentration supersession, prep-cap enforcement
- Reaction spells refused with Phase 11 note

SP3 (combat-time resolution of pending_cast) and SP4 (full prep/learn
tests) are next. Build clean, full test suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00

503 lines
15 KiB
Go

package plugin
import (
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
type CoopRun struct {
ID int
Tier int
Status string
LeaderID id.UserID
CurrentDay int
TotalDays int
BaseDifficulty int
GoldPool int
RewardTotal int
LastResolvedDay int
InvitePostID id.EventID
CreatedAt time.Time
LockedAt *time.Time
CompletedAt *time.Time
}
type CoopMember struct {
RunID int
UserID id.UserID
TurnOrder int
TotalContributed int
IsLiability bool
DailyFunding map[int]CoopFundingTier // day → tier
}
func createCoopRun(tier int, leader id.UserID, totalDays, baseDifficulty int) (*CoopRun, error) {
d := db.Get()
res, err := d.Exec(`INSERT INTO coop_dungeon_runs (tier, leader_id, total_days, base_difficulty)
VALUES (?, ?, ?, ?)`, tier, string(leader), totalDays, baseDifficulty)
if err != nil {
return nil, err
}
id64, _ := res.LastInsertId()
return loadCoopRun(int(id64))
}
func loadCoopRun(runID int) (*CoopRun, error) {
d := db.Get()
r := &CoopRun{}
var leader string
var lockedAt, completedAt sql.NullTime
err := d.QueryRow(`SELECT id, tier, status, leader_id, current_day, total_days,
base_difficulty, gold_pool, reward_total, last_resolved_day, invite_post_id, created_at, locked_at, completed_at
FROM coop_dungeon_runs WHERE id = ?`, runID).Scan(
&r.ID, &r.Tier, &r.Status, &leader, &r.CurrentDay, &r.TotalDays,
&r.BaseDifficulty, &r.GoldPool, &r.RewardTotal, &r.LastResolvedDay,
(*string)(&r.InvitePostID),
&r.CreatedAt, &lockedAt, &completedAt)
if err != nil {
return nil, err
}
r.LeaderID = id.UserID(leader)
if lockedAt.Valid {
t := lockedAt.Time
r.LockedAt = &t
}
if completedAt.Valid {
t := completedAt.Time
r.CompletedAt = &t
}
return r, nil
}
func loadOpenCoopRuns() ([]*CoopRun, error) {
return queryCoopRuns(`status = 'open' ORDER BY created_at DESC`)
}
func loadActiveCoopRuns() ([]*CoopRun, error) {
return queryCoopRuns(`status = 'active' ORDER BY id`)
}
func loadMostRecentOpenCoopRun() (*CoopRun, error) {
runs, err := queryCoopRuns(`status = 'open' ORDER BY created_at DESC LIMIT 1`)
if err != nil || len(runs) == 0 {
return nil, err
}
return runs[0], nil
}
func queryCoopRuns(whereClause string) ([]*CoopRun, error) {
d := db.Get()
rows, err := d.Query(`SELECT id, tier, status, leader_id, current_day, total_days,
base_difficulty, gold_pool, reward_total, last_resolved_day, invite_post_id, created_at, locked_at, completed_at
FROM coop_dungeon_runs WHERE ` + whereClause)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*CoopRun
for rows.Next() {
r := &CoopRun{}
var leader string
var lockedAt, completedAt sql.NullTime
if err := rows.Scan(&r.ID, &r.Tier, &r.Status, &leader, &r.CurrentDay, &r.TotalDays,
&r.BaseDifficulty, &r.GoldPool, &r.RewardTotal, &r.LastResolvedDay,
(*string)(&r.InvitePostID),
&r.CreatedAt, &lockedAt, &completedAt); err != nil {
return nil, err
}
r.LeaderID = id.UserID(leader)
if lockedAt.Valid {
t := lockedAt.Time
r.LockedAt = &t
}
if completedAt.Valid {
t := completedAt.Time
r.CompletedAt = &t
}
out = append(out, r)
}
return out, rows.Err()
}
func loadCoopRunForUser(userID id.UserID) (*CoopRun, error) {
d := db.Get()
var runID int
err := d.QueryRow(`SELECT m.run_id FROM coop_dungeon_members m
JOIN coop_dungeon_runs r ON r.id = m.run_id
WHERE m.user_id = ? AND r.status IN ('open','active')
ORDER BY r.id DESC LIMIT 1`, string(userID)).Scan(&runID)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return loadCoopRun(runID)
}
func setCoopRunStatus(runID int, status string) error {
d := db.Get()
_, err := d.Exec(`UPDATE coop_dungeon_runs SET status = ? WHERE id = ?`, status, runID)
return err
}
func lockCoopRun(runID int) error {
d := db.Get()
_, err := d.Exec(`UPDATE coop_dungeon_runs
SET status = 'active', locked_at = CURRENT_TIMESTAMP, current_day = 1
WHERE id = ? AND status = 'open'`, runID)
return err
}
func advanceCoopDay(runID, newDay int) error {
d := db.Get()
_, err := d.Exec(`UPDATE coop_dungeon_runs SET current_day = ? WHERE id = ?`, newDay, runID)
return err
}
func completeCoopRun(runID int, status string, reward int) error {
d := db.Get()
_, err := d.Exec(`UPDATE coop_dungeon_runs
SET status = ?, reward_total = ?, completed_at = CURRENT_TIMESTAMP
WHERE id = ?`, status, reward, runID)
return err
}
// lockCoopCombatActions sets combat_actions_used to a value that exceeds the
// holiday cap on every active co-op participant. Called after the midnight
// 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
}
// activeCoopMemberSet returns the set of user IDs currently locked into an
// active co-op run. The midnight reset uses this to skip the streak/babysit
// logic for those players — their co-op participation auto-resolves daily,
// so they should not be treated as idle even though they take no manual
// actions.
func activeCoopMemberSet() (map[string]bool, error) {
d := db.Get()
rows, err := d.Query(`SELECT m.user_id FROM coop_dungeon_members m
JOIN coop_dungeon_runs r ON r.id = m.run_id
WHERE r.status = 'active'`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var u string
if err := rows.Scan(&u); err != nil {
return nil, err
}
out[u] = true
}
return out, rows.Err()
}
func lockCoopCombatActions() error {
d := db.Get()
_, err := d.Exec(`UPDATE adventure_characters
SET combat_actions_used = 99
WHERE user_id IN (
SELECT m.user_id FROM coop_dungeon_members m
JOIN coop_dungeon_runs r ON r.id = m.run_id
WHERE r.status = 'active'
)`)
return err
}
func setCoopRunInvitePostID(runID int, postID id.EventID) error {
d := db.Get()
_, err := d.Exec(`UPDATE coop_dungeon_runs SET invite_post_id = ? WHERE id = ?`, string(postID), runID)
return err
}
// setCoopRunLastResolvedDay marks a day as resolved for crash-resume idempotency.
// Resolution is idempotent: re-entering coopResolveFloor for the same day is a no-op.
func setCoopRunLastResolvedDay(runID, day int) error {
d := db.Get()
_, err := d.Exec(`UPDATE coop_dungeon_runs SET last_resolved_day = ? WHERE id = ?`, day, runID)
return err
}
func addCoopRunPool(runID, delta int) error {
d := db.Get()
_, err := d.Exec(`UPDATE coop_dungeon_runs SET gold_pool = gold_pool + ? WHERE id = ?`, delta, runID)
return err
}
// ── Members ─────────────────────────────────────────────────────────────────
func addCoopMember(runID int, userID id.UserID, turnOrder int, liability bool) error {
d := db.Get()
libVal := 0
if liability {
libVal = 1
}
_, err := d.Exec(`INSERT INTO coop_dungeon_members
(run_id, user_id, turn_order, is_liability, daily_funding) VALUES (?, ?, ?, ?, '{}')`,
runID, string(userID), turnOrder, libVal)
return err
}
func loadCoopMembers(runID int) ([]CoopMember, error) {
d := db.Get()
rows, err := d.Query(`SELECT run_id, user_id, turn_order, total_contributed, is_liability, daily_funding
FROM coop_dungeon_members WHERE run_id = ? ORDER BY turn_order`, runID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CoopMember
for rows.Next() {
m := CoopMember{}
var userID, fundingJSON string
var liability int
if err := rows.Scan(&m.RunID, &userID, &m.TurnOrder, &m.TotalContributed, &liability, &fundingJSON); err != nil {
return nil, err
}
m.UserID = id.UserID(userID)
m.IsLiability = liability != 0
m.DailyFunding = parseCoopFundingMap(fundingJSON)
out = append(out, m)
}
return out, rows.Err()
}
func loadCoopMember(runID int, userID id.UserID) (*CoopMember, error) {
d := db.Get()
m := &CoopMember{}
var uid, fundingJSON string
var liability int
err := d.QueryRow(`SELECT run_id, user_id, turn_order, total_contributed, is_liability, daily_funding
FROM coop_dungeon_members WHERE run_id = ? AND user_id = ?`, runID, string(userID)).Scan(
&m.RunID, &uid, &m.TurnOrder, &m.TotalContributed, &liability, &fundingJSON)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
m.UserID = id.UserID(uid)
m.IsLiability = liability != 0
m.DailyFunding = parseCoopFundingMap(fundingJSON)
return m, nil
}
// claimCoopMemberPayout atomically claims a member's reward slot. Returns true
// only if this call wins the race (member_payout was NULL and we set it).
// Prevents double-credit on crash-retry.
func claimCoopMemberPayout(runID int, userID id.UserID, payout int) (bool, error) {
d := db.Get()
res, err := d.Exec(`UPDATE coop_dungeon_members SET member_payout = ?
WHERE run_id = ? AND user_id = ? AND member_payout IS NULL`,
payout, runID, string(userID))
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
func saveCoopMemberFunding(m *CoopMember) error {
d := db.Get()
jsonBytes, err := json.Marshal(serializeCoopFundingMap(m.DailyFunding))
if err != nil {
return err
}
_, err = d.Exec(`UPDATE coop_dungeon_members
SET daily_funding = ?, total_contributed = ?
WHERE run_id = ? AND user_id = ?`,
string(jsonBytes), m.TotalContributed, m.RunID, string(m.UserID))
return err
}
func parseCoopFundingMap(s string) map[int]CoopFundingTier {
out := map[int]CoopFundingTier{}
if s == "" || s == "{}" {
return out
}
raw := map[string]string{}
if err := json.Unmarshal([]byte(s), &raw); err != nil {
slog.Error("coop: parse funding map", "raw", s, "err", err)
return out
}
for k, v := range raw {
day, err := strconv.Atoi(k)
if err != nil {
continue
}
out[day] = CoopFundingTier(v)
}
return out
}
func serializeCoopFundingMap(m map[int]CoopFundingTier) map[string]string {
out := make(map[string]string, len(m))
for k, v := range m {
out[fmt.Sprintf("%d", k)] = string(v)
}
return out
}
// ── Floor Events ────────────────────────────────────────────────────────────
type CoopEvent struct {
RunID int
Day int
Category CoopEventCategory
EventIndex int
Recommended string
Votes map[id.UserID]string // userID → option label
WinningVote string // "" until resolved
Outcome string // "" until resolved; "success" or "failure"
ModifierApplied int
PostEventID id.EventID
}
func createCoopEvent(runID, day int, cat CoopEventCategory, idx int, recommended string) error {
d := db.Get()
// INSERT OR IGNORE: idempotent on retry. If the row already exists from a
// prior tick, leave it untouched (don't re-roll the event index).
_, err := d.Exec(`INSERT OR IGNORE INTO coop_dungeon_events
(run_id, day, category, event_index, recommended) VALUES (?, ?, ?, ?, ?)`,
runID, day, string(cat), idx, recommended)
return err
}
func loadCoopEvent(runID, day int) (*CoopEvent, error) {
d := db.Get()
e := &CoopEvent{}
var cat, votesJSON string
var winning, outcome, postID sql.NullString
err := d.QueryRow(`SELECT run_id, day, category, event_index, recommended,
votes, winning_vote, outcome, modifier_applied, post_event_id
FROM coop_dungeon_events WHERE run_id = ? AND day = ?`, runID, day).Scan(
&e.RunID, &e.Day, &cat, &e.EventIndex, &e.Recommended,
&votesJSON, &winning, &outcome, &e.ModifierApplied, &postID)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
e.Category = CoopEventCategory(cat)
e.Votes = parseCoopVotes(votesJSON)
if winning.Valid {
e.WinningVote = winning.String
}
if outcome.Valid {
e.Outcome = outcome.String
}
if postID.Valid {
e.PostEventID = id.EventID(postID.String)
}
return e, nil
}
func saveCoopEventVotes(e *CoopEvent) error {
d := db.Get()
jsonBytes, err := json.Marshal(serializeCoopVotes(e.Votes))
if err != nil {
return err
}
_, err = d.Exec(`UPDATE coop_dungeon_events SET votes = ?
WHERE run_id = ? AND day = ?`, string(jsonBytes), e.RunID, e.Day)
return err
}
func saveCoopEventPostID(runID, day int, eventID id.EventID) error {
d := db.Get()
_, err := d.Exec(`UPDATE coop_dungeon_events SET post_event_id = ?
WHERE run_id = ? AND day = ?`, string(eventID), runID, day)
return err
}
func resolveCoopEvent(runID, day int, winning, outcome string, modifier int) error {
d := db.Get()
_, err := d.Exec(`UPDATE coop_dungeon_events
SET winning_vote = ?, outcome = ?, modifier_applied = ?
WHERE run_id = ? AND day = ?`, winning, outcome, modifier, runID, day)
return err
}
// CoopTwinBeeHelpfulness returns a score in [0,1] where 1 means TwinBee's
// recommendations have been winning + succeeding (or losing + failing). Used
// by phase 3 betting odds.
func coopTwinBeeHelpfulness(window int) float64 {
d := db.Get()
rows, err := d.Query(`SELECT recommended, winning_vote, outcome
FROM coop_dungeon_events
WHERE outcome IS NOT NULL
ORDER BY rowid DESC LIMIT ?`, window)
if err != nil {
return 0.5
}
defer rows.Close()
hits, total := 0, 0
for rows.Next() {
var rec, win, out string
if err := rows.Scan(&rec, &win, &out); err != nil {
continue
}
total++
if rec == win && out == "success" {
hits++
} else if rec != win && out == "failure" {
hits++
}
}
if total == 0 {
return 0.5
}
return float64(hits) / float64(total)
}
func parseCoopVotes(s string) map[id.UserID]string {
out := map[id.UserID]string{}
if s == "" || s == "{}" {
return out
}
raw := map[string]string{}
if err := json.Unmarshal([]byte(s), &raw); err != nil {
slog.Error("coop: parse votes map", "raw", s, "err", err)
return out
}
for k, v := range raw {
out[id.UserID(k)] = v
}
return out
}
func serializeCoopVotes(m map[id.UserID]string) map[string]string {
out := make(map[string]string, len(m))
for k, v := range m {
out[string(k)] = v
}
return out
}