Files
gogobee/internal/plugin/dnd_expedition.go
prosolis aaa45eab14 N7/B3: the Omen — one rotating world modifier per ISO week
activeOmen() is a pure function of the UTC ISO (year, week): omenTable
indexed by (year*53+week)%len, so it advances weekly with no schema, no
ticker state, no persistence. Five non-combat seams read it — harvest yield,
supply freebie, expedition start mood, arena payout (scales gross earnings
before the pot tax), and ingredient drop chance. TwinBee reveals the active
omen in the existing morning DM (no net-new scheduled message).

Launch set is buffs-with-texture on non-combat levers only: Bountiful
Harvest, Quartermaster's Blessing, Golden Purse, Overflowing Satchels, Still
Waters. Nothing touches SimulateCombat or the turn engine — the omen is keyed
on the real clock, so a combat mutator would make the golden and the balance
corpus week-dependent. The plan's "elites +2 ATK" is deliberately dropped for
that reason.

The balance sim drives the real expedition loop and would otherwise traverse
all five seams, making corpus sweeps depend on the wall-clock week. NewSimRunner
sets simOmenDisabled (mirrors simAutoArmEnabled), so activeOmen returns a
no-effect omen under the sim. Still Waters subtracts from the daily threat
*rise* only, floored at hold-steady — it never forces active decay.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 20:09:30 -07:00

610 lines
20 KiB
Go

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, &regionJSON,
&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
}