package plugin import ( cryptorand "crypto/rand" "database/sql" "encoding/hex" "encoding/json" "errors" "fmt" "log/slog" "time" "gogobee/internal/db" "maunium.net/go/mautrix/id" ) // Phase 12 E1a — Expedition data model and persistence. // Implements `gogobee_expedition_system.md` §4.4 (Supplies), §5.3 (Camp), // §8.4 (Threat Clock), and §9 (Expedition State). One persisted Expedition // per active campaign; players may have at most one row with status='active' // (enforced in code). // // E1a ships the data layer only: structs, schema (in db.go), persistence // helpers, and a round-trip test. Day cycle (E1d), supply procurement (E1b), // commands (E1c), and camp interactions (E1e) wire on top in subsequent // commits. Threat clock events / siege mode are scaffolded but not driven // until E2. // Expedition status values. const ( ExpeditionStatusActive = "active" ExpeditionStatusExtracting = "extracting" ExpeditionStatusComplete = "complete" ExpeditionStatusFailed = "failed" ExpeditionStatusAbandoned = "abandoned" ) // ExpeditionSupplies — §4.4. type ExpeditionSupplies struct { Current float32 `json:"current"` // current SU Max float32 `json:"max"` // purchased at outset DailyBurn float32 `json:"daily_burn"` // base rate for this zone tier HarshMod float32 `json:"harsh_mod"` // multiplier if harsh conditions active ForagedToday bool `json:"foraged_today"` // Ranger forage attempt used today PacksStandard int `json:"packs_standard"` // count purchased (max 3) PacksDeluxe int `json:"packs_deluxe"` // count purchased (max 1) } // CampState — §5.3. type CampState struct { Active bool `json:"active"` Type string `json:"camp_type"` // rough|standard|fortified|base RoomIndex int `json:"room_index"` EstablishedAt time.Time `json:"established_at"` NightEvents []string `json:"night_events"` // RestApplied is set when the long-rest effects (HP refill, spell slots, // threat -5 etc.) have already been applied at pitch time. processOvernightCamp // uses it to skip re-applying so the night cycle just breaks the camp. RestApplied bool `json:"rest_applied,omitempty"` // AutoPitched is set when the long-expedition autopilot pitched this // camp. The autorun ticker breaks an auto-pitched camp itself after a // minimum dwell so the walk can keep moving; player-pitched camps stay // up until the player breaks them (or moves on). AutoPitched bool `json:"auto_pitched,omitempty"` } // ThreatEvent — §8.4. type ThreatEvent struct { Timestamp time.Time `json:"timestamp"` Delta int `json:"delta"` Reason string `json:"reason"` } // Expedition — §9. The in-memory shape of a dnd_expedition row. type Expedition struct { ID string UserID string ZoneID ZoneID RunID string // optional pointer to dnd_zone_run.run_id Status string StartDate time.Time CurrentDay int CurrentRegion string BossDefeated bool Supplies ExpeditionSupplies Camp *CampState ThreatLevel int SiegeMode bool ThreatEvents []ThreatEvent TemporalStack int RegionState map[string]any XPEarned int CoinsEarned int DMMood int LastBriefingAt *time.Time LastRecapAt *time.Time // LastAmbientKind — the Kind of the most recent ambient event the // ticker fired (e.g. "pack_rat", "monologue"). Empty when no ambient // has fired yet. Used by pickAmbientEvent for back-to-back anti-repeat. LastAmbientKind string LastActivity time.Time CompletedAt *time.Time } // IsActive reports whether the expedition is in flight. func (e *Expedition) IsActive() bool { return e.Status == ExpeditionStatusActive || e.Status == ExpeditionStatusExtracting } // ExpeditionEntry — one row in dnd_expedition_log; mirrors §9. type ExpeditionEntry struct { EntryID int64 ExpeditionID string Day int Timestamp time.Time Type string Summary string Flavor string } // Errors returned by the expedition layer. var ( ErrExpeditionAlreadyActive = errors.New("expedition already active for player") ErrNoActiveExpedition = errors.New("no active expedition for player") ) // newExpeditionID — 16-char hex token. Same scheme as zone runs. func newExpeditionID() string { var b [8]byte if _, err := cryptorand.Read(b[:]); err != nil { // Vanishingly unlikely; fall through with a zeroed prefix. } return hex.EncodeToString(b[:]) } // startExpedition creates a new expedition for the player. The caller must // have already validated zone access and supply purchase totals. // runID may be empty — the zone run is created lazily by E1c command flow. func startExpedition(userID id.UserID, zoneID ZoneID, runID string, supplies ExpeditionSupplies) (*Expedition, error) { existing, err := getActiveExpedition(userID) if err != nil { return nil, err } if existing != nil { return nil, ErrExpeditionAlreadyActive } now := time.Now().UTC() // Multi-region zones (§11) start the player in the first region; // single-region zones leave CurrentRegion empty. currentRegion := "" if first, ok := firstRegion(zoneID); ok { currentRegion = first.ID } regionState := map[string]any{} if currentRegion != "" { regionState[regionStateVisitedKey] = []string{currentRegion} } startMood := 50 if isHol, _ := isHolidayToday(); isHol { startMood = 55 } if b := activeOmen().StartMoodBonus; b > 0 { // N7/B3 the Omen startMood += b } exp := &Expedition{ ID: newExpeditionID(), UserID: string(userID), ZoneID: zoneID, RunID: runID, Status: ExpeditionStatusActive, StartDate: now, CurrentDay: 1, CurrentRegion: currentRegion, Supplies: supplies, ThreatEvents: []ThreatEvent{}, RegionState: regionState, DMMood: startMood, LastActivity: now, } supJSON, _ := json.Marshal(supplies) regJSON, _ := json.Marshal(exp.RegionState) threatJSON, _ := json.Marshal(exp.ThreatEvents) if _, err := db.Get().Exec(` INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, run_id, status, start_date, current_day, current_region, supplies_json, threat_events, region_state, gm_mood, last_activity) VALUES (?, ?, ?, ?, 'active', ?, 1, ?, ?, ?, ?, ?, ?)`, exp.ID, exp.UserID, string(zoneID), nullableString(runID), now, currentRegion, string(supJSON), string(threatJSON), string(regJSON), startMood, now, ); err != nil { return nil, fmt.Errorf("insert expedition: %w", err) } return exp, nil } // expeditionSelectCols is the column list scanExpedition reads, in the order it // reads them. scanExpedition's Scan is positional, so every query that feeds it // must project exactly this — hence one constant rather than a copy per query. // The `e.` alias means a query can JOIN expedition_party without ambiguity; a // query with no JOIN just aliases dnd_expedition to e. const expeditionSelectCols = ` e.expedition_id, e.user_id, e.zone_id, e.run_id, e.status, e.start_date, e.current_day, e.current_region, e.boss_defeated, e.supplies_json, e.camp_json, e.threat_level, e.threat_siege, e.threat_events, e.temporal_stack, e.region_state, e.xp_earned, e.coins_earned, e.gm_mood, e.last_briefing_at, e.last_recap_at, e.last_ambient_kind, e.last_activity, e.completed_at` // getActiveExpedition returns the player's in-flight expedition, or (nil, nil). // 'extracting' rows are post-extraction (resumable) — see getResumableExpedition. func getActiveExpedition(userID id.UserID) (*Expedition, error) { row := db.Get().QueryRow(` SELECT`+expeditionSelectCols+` FROM dnd_expedition e WHERE e.user_id = ? AND e.status = 'active' ORDER BY e.start_date DESC LIMIT 1`, string(userID)) e, err := scanExpedition(row) if errors.Is(err, sql.ErrNoRows) { return nil, nil } return e, err } // ownedLiveExpedition returns the expedition this player owns and has not yet // finished with — 'active' or 'extracting'. An extracted expedition is not // over: it holds its roster for the whole resume window (releaseParty is not // called on it), so the owner is still the only person who can close it. // // Active rows sort first, so a leader who owns both — extracted from one, then // started another — resolves to the one they are standing in. func ownedLiveExpedition(userID id.UserID) (*Expedition, error) { row := db.Get().QueryRow(` SELECT`+expeditionSelectCols+` FROM dnd_expedition e WHERE e.user_id = ? AND e.status IN ('active', 'extracting') ORDER BY (e.status = 'active') DESC, e.start_date DESC LIMIT 1`, string(userID)) e, err := scanExpedition(row) if errors.Is(err, sql.ErrNoRows) { return nil, nil } return e, err } // getExpedition fetches by ID regardless of status. Test/admin use. func getExpedition(id string) (*Expedition, error) { row := db.Get().QueryRow(` SELECT`+expeditionSelectCols+` FROM dnd_expedition e WHERE e.expedition_id = ?`, id) e, err := scanExpedition(row) if errors.Is(err, sql.ErrNoRows) { return nil, nil } return e, err } func scanExpedition(row scanner) (*Expedition, error) { var ( e Expedition zoneID string runID sql.NullString suppliesJSON string campJSON sql.NullString threatJSON string regionJSON string bossI int siegeI int lastBriefingRaw sql.NullTime lastRecapRaw sql.NullTime completedRaw sql.NullTime ) if err := row.Scan( &e.ID, &e.UserID, &zoneID, &runID, &e.Status, &e.StartDate, &e.CurrentDay, &e.CurrentRegion, &bossI, &suppliesJSON, &campJSON, &e.ThreatLevel, &siegeI, &threatJSON, &e.TemporalStack, ®ionJSON, &e.XPEarned, &e.CoinsEarned, &e.DMMood, &lastBriefingRaw, &lastRecapRaw, &e.LastAmbientKind, &e.LastActivity, &completedRaw, ); err != nil { return nil, err } e.ZoneID = ZoneID(zoneID) if runID.Valid { e.RunID = runID.String } e.BossDefeated = bossI != 0 e.SiegeMode = siegeI != 0 if err := json.Unmarshal([]byte(suppliesJSON), &e.Supplies); err != nil { return nil, fmt.Errorf("decode supplies_json: %w", err) } if campJSON.Valid && campJSON.String != "" { var c CampState if err := json.Unmarshal([]byte(campJSON.String), &c); err != nil { return nil, fmt.Errorf("decode camp_json: %w", err) } e.Camp = &c } if threatJSON != "" { if err := json.Unmarshal([]byte(threatJSON), &e.ThreatEvents); err != nil { return nil, fmt.Errorf("decode threat_events: %w", err) } } if e.ThreatEvents == nil { e.ThreatEvents = []ThreatEvent{} } if regionJSON != "" { if err := json.Unmarshal([]byte(regionJSON), &e.RegionState); err != nil { slog.Warn("expedition: region_state decode failed; falling back to empty", "expedition", e.ID, "err", err) } } if e.RegionState == nil { e.RegionState = map[string]any{} } if lastBriefingRaw.Valid { t := lastBriefingRaw.Time e.LastBriefingAt = &t } if lastRecapRaw.Valid { t := lastRecapRaw.Time e.LastRecapAt = &t } if completedRaw.Valid { t := completedRaw.Time e.CompletedAt = &t } return &e, nil } // updateSupplies persists a new supplies snapshot. func updateSupplies(expID string, s ExpeditionSupplies) error { b, _ := json.Marshal(s) _, err := db.Get().Exec(` UPDATE dnd_expedition SET supplies_json = ?, last_activity = CURRENT_TIMESTAMP WHERE expedition_id = ?`, string(b), expID) return err } // withExpeditionSupplies serializes one read-modify-write of the shared supply // pool. updateSupplies rewrites supplies_json wholesale, so a caller that folds // its delta onto an *Expedition it read earlier silently discards anything that // landed in between — a member's pooled packs, another writer's spend. Handlers // run one goroutine per event, so those writers genuinely interleave. // // fn is handed a freshly-read expedition under the pool's own lock and returns // the supplies to persist. Callers that keep using their own *Expedition // afterwards must copy the returned pool back onto it. // // advUserLock cannot stand in here: it is keyed by sender, so two members // racing the same expedition row take two different mutexes and exclude nobody. func (p *AdventurePlugin) withExpeditionSupplies( expID string, fn func(fresh *Expedition) (ExpeditionSupplies, error), ) (ExpeditionSupplies, error) { mu := p.advExpeditionLock(expID) mu.Lock() defer mu.Unlock() fresh, err := getExpedition(expID) if err != nil { return ExpeditionSupplies{}, err } if fresh == nil { return ExpeditionSupplies{}, fmt.Errorf("expedition %s not found", expID) } next, err := fn(fresh) if err != nil { return ExpeditionSupplies{}, err } if err := updateSupplies(expID, next); err != nil { return ExpeditionSupplies{}, err } return next, nil } // updateCamp persists camp state. Pass nil to break camp. func updateCamp(expID string, c *CampState) error { var arg any if c == nil { arg = nil } else { b, _ := json.Marshal(c) arg = string(b) } _, err := db.Get().Exec(` UPDATE dnd_expedition SET camp_json = ?, last_activity = CURRENT_TIMESTAMP WHERE expedition_id = ?`, arg, expID) return err } // setExpeditionRunID writes the expedition's linked zone-run pointer. // Used during region transitions and on initial spawn (R2). func setExpeditionRunID(expID, runID string) error { _, err := db.Get().Exec(` UPDATE dnd_expedition SET run_id = ?, last_activity = CURRENT_TIMESTAMP WHERE expedition_id = ?`, nullableString(runID), expID) return err } // abandonExpedition flags the player's live expedition as abandoned. Idempotent. // // It spans 'extracting' as well as 'active': an extracted expedition still owns // its roster, so abandoning is the leader's only way to free their party // without paying to `!resume` first. Character reset (dnd_setup) leans on this // too — a rerolled leader who left an extracted party behind would otherwise // strand every member until the sweeper reaped the row. // // One row per call, active first, so the run-spawn rollback in expeditionCmdStart // tears down the expedition it just created rather than an older extracted one. func abandonExpedition(userID id.UserID) error { e, err := ownedLiveExpedition(userID) if err != nil { return err } if e == nil { return ErrNoActiveExpedition } if _, err = db.Get().Exec(` UPDATE dnd_expedition SET status = 'abandoned', completed_at = CURRENT_TIMESTAMP, last_activity = CURRENT_TIMESTAMP WHERE expedition_id = ?`, e.ID); err != nil { return err } releaseParty(e.ID) return nil } // completeExpedition marks the expedition complete (boss defeated or extracted). func completeExpedition(expID string, status string) error { if _, err := db.Get().Exec(` UPDATE dnd_expedition SET status = ?, completed_at = CURRENT_TIMESTAMP, last_activity = CURRENT_TIMESTAMP WHERE expedition_id = ?`, status, expID); err != nil { return err } releaseParty(expID) return nil } // releaseParty clears the roster of an expedition that has reached a terminal // status, freeing every member to start a run of their own. A member is barred // from adventuring anywhere else while seated (assertNotAdventuring), so a // roster that outlives its expedition strands the whole party. // // It is deliberately *not* called on the 'extracting' status: that is a // seven-day resumable limbo, and `!resume` must bring the party back with it. // The roster is cleared when the resume window lapses and the row flips to // 'failed' — which routes through completeExpedition like everything else. // // A failure here is logged, not returned: the expedition is already terminal by // the time we get here, and refusing to finish it over a stale roster row would // leave the leader stuck instead of the members. func releaseParty(expID string) { if err := disbandParty(expID); err != nil { slog.Warn("expedition: disband party", "expedition", expID, "err", err) } // An unanswered invite to a finished expedition would otherwise sit there // until its TTL, letting someone accept their way onto a corpse. if err := clearExpeditionInvites(expID); err != nil { slog.Warn("expedition: clear invites", "expedition", expID, "err", err) } } // applyThreatDelta clamps the threat level to [0,100], records the event, // and flips siege_mode when the level reaches 100. func applyThreatDelta(expID string, delta int, reason string) error { e, err := getExpedition(expID) if err != nil { return err } if e == nil { return ErrNoActiveExpedition } level := e.ThreatLevel + delta if level < 0 { level = 0 } if level > 100 { level = 100 } // Spec §8.3: Siege Mode is one-way — the OR keeps siege sticky // even if a subsequent negative delta drops the underlying level. siege := e.SiegeMode || level >= 100 events := append(e.ThreatEvents, ThreatEvent{ Timestamp: time.Now().UTC(), Delta: delta, Reason: reason, }) eventsJSON, _ := json.Marshal(events) _, err = db.Get().Exec(` UPDATE dnd_expedition SET threat_level = ?, threat_siege = ?, threat_events = ?, last_activity = CURRENT_TIMESTAMP WHERE expedition_id = ?`, level, boolToInt(siege), string(eventsJSON), expID) return err } // updateTemporalStack persists the zone-temporal stack (heat / instability). func updateTemporalStack(expID string, stack int) error { _, err := db.Get().Exec(` UPDATE dnd_expedition SET temporal_stack = ?, last_activity = CURRENT_TIMESTAMP WHERE expedition_id = ?`, stack, expID) return err } // advanceExpeditionDay bumps current_day by one. Real-time day cycle (E1d) // calls this from the 06:00 cron. func advanceExpeditionDay(expID string) error { _, err := db.Get().Exec(` UPDATE dnd_expedition SET current_day = current_day + 1, last_activity = CURRENT_TIMESTAMP WHERE expedition_id = ?`, expID) return err } // appendExpeditionLog inserts one log entry. func appendExpeditionLog(expID string, day int, entryType, summary, flavor string) error { _, err := db.Get().Exec(` INSERT INTO dnd_expedition_log (expedition_id, day, entry_type, summary, flavor) VALUES (?, ?, ?, ?, ?)`, expID, day, entryType, summary, flavor) return err } // dayExpeditionLog returns every log entry recorded against the given // (expedition, day) pair, oldest first. Used by the D4-a end-of-day // digest to bundle a single rollup DM at night-camp pitch. func dayExpeditionLog(expID string, day int) ([]ExpeditionEntry, error) { rows, err := db.Get().Query(` SELECT entry_id, expedition_id, day, timestamp, entry_type, summary, flavor FROM dnd_expedition_log WHERE expedition_id = ? AND day = ? ORDER BY entry_id`, expID, day) if err != nil { return nil, err } defer rows.Close() var out []ExpeditionEntry for rows.Next() { var e ExpeditionEntry if err := rows.Scan( &e.EntryID, &e.ExpeditionID, &e.Day, &e.Timestamp, &e.Type, &e.Summary, &e.Flavor, ); err != nil { return nil, err } out = append(out, e) } return out, rows.Err() } // recentExpeditionLog returns the last `limit` entries, newest first. func recentExpeditionLog(expID string, limit int) ([]ExpeditionEntry, error) { if limit <= 0 { limit = 5 } rows, err := db.Get().Query(` SELECT entry_id, expedition_id, day, timestamp, entry_type, summary, flavor FROM dnd_expedition_log WHERE expedition_id = ? ORDER BY timestamp DESC, entry_id DESC LIMIT ?`, expID, limit) if err != nil { return nil, err } defer rows.Close() var out []ExpeditionEntry for rows.Next() { var e ExpeditionEntry if err := rows.Scan( &e.EntryID, &e.ExpeditionID, &e.Day, &e.Timestamp, &e.Type, &e.Summary, &e.Flavor, ); err != nil { return nil, err } out = append(out, e) } return out, rows.Err() } func nullableString(s string) any { if s == "" { return nil } return s }