diff --git a/internal/db/db.go b/internal/db/db.go index 01dfa20..c47fc6b 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1630,6 +1630,60 @@ CREATE TABLE IF NOT EXISTS dnd_zone_run ( ); CREATE INDEX IF NOT EXISTS idx_zone_run_active ON dnd_zone_run(user_id, completed_at, abandoned); + +-- ── D&D Layer (Phase 12 E1a — expeditions) ───────────────────────────────── +-- A multi-day expedition wrapping a zone run. The expedition tracks +-- real-world day count, supplies, camp state, threat clock, and zone-specific +-- temporal stacks (heat / instability / time distortion). One active row +-- per player (status='active'); enforced in code. +-- +-- supplies_json: serialized ExpeditionSupplies (current, max, daily_burn, harsh_mod, foraged_today) +-- camp_json: serialized *CampState (NULL when no camp pitched) +-- threat_events: serialized []ThreatEvent log +-- region_state: serialized region progression for Tier 4-5 zones (E4) +CREATE TABLE IF NOT EXISTS dnd_expedition ( + expedition_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + zone_id TEXT NOT NULL, + run_id TEXT, -- FK-ish: dnd_zone_run.run_id + status TEXT NOT NULL DEFAULT 'active', -- active|extracting|complete|failed|abandoned + start_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + current_day INTEGER NOT NULL DEFAULT 1, + current_region TEXT NOT NULL DEFAULT '', + boss_defeated INTEGER NOT NULL DEFAULT 0, + supplies_json TEXT NOT NULL DEFAULT '{}', + camp_json TEXT, + threat_level INTEGER NOT NULL DEFAULT 0, + threat_siege INTEGER NOT NULL DEFAULT 0, + threat_events TEXT NOT NULL DEFAULT '[]', + temporal_stack INTEGER NOT NULL DEFAULT 0, -- heat / instability / etc. + region_state TEXT NOT NULL DEFAULT '{}', + xp_earned INTEGER NOT NULL DEFAULT 0, + coins_earned INTEGER NOT NULL DEFAULT 0, + gm_mood INTEGER NOT NULL DEFAULT 50, + last_briefing_at DATETIME, + last_recap_at DATETIME, + last_activity DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at DATETIME +); +CREATE INDEX IF NOT EXISTS idx_expedition_active + ON dnd_expedition(user_id, status); +CREATE INDEX IF NOT EXISTS idx_expedition_run + ON dnd_expedition(run_id); + +-- One row per ExpeditionEntry (§9). Cheap to append; queried last-N for +-- !expedition log and to reconstruct overnight events for morning briefings. +CREATE TABLE IF NOT EXISTS dnd_expedition_log ( + entry_id INTEGER PRIMARY KEY AUTOINCREMENT, + expedition_id TEXT NOT NULL, + day INTEGER NOT NULL, + timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + entry_type TEXT NOT NULL, -- action|combat|rest|event|narrative|briefing|recap + summary TEXT NOT NULL DEFAULT '', + flavor TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_expedition_log_recent + ON dnd_expedition_log(expedition_id, timestamp DESC); ` // SeedSchedulerDefaults inserts default scheduler jobs if they don't exist. diff --git a/internal/plugin/dnd_expedition.go b/internal/plugin/dnd_expedition.go new file mode 100644 index 0000000..227dfd3 --- /dev/null +++ b/internal/plugin/dnd_expedition.go @@ -0,0 +1,421 @@ +package plugin + +import ( + cryptorand "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "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"` +} + +// 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 + GMMood int + LastBriefingAt *time.Time + LastRecapAt *time.Time + 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() + exp := &Expedition{ + ID: newExpeditionID(), + UserID: string(userID), + ZoneID: zoneID, + RunID: runID, + Status: ExpeditionStatusActive, + StartDate: now, + CurrentDay: 1, + Supplies: supplies, + ThreatEvents: []ThreatEvent{}, + RegionState: map[string]any{}, + GMMood: 50, + 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, supplies_json, + threat_events, region_state, gm_mood, last_activity) + VALUES (?, ?, ?, ?, 'active', ?, 1, ?, ?, ?, 50, ?)`, + exp.ID, exp.UserID, string(zoneID), nullableString(runID), + now, string(supJSON), string(threatJSON), string(regJSON), now, + ); err != nil { + return nil, fmt.Errorf("insert expedition: %w", err) + } + return exp, nil +} + +// getActiveExpedition returns the player's in-flight expedition, or (nil, nil). +func getActiveExpedition(userID id.UserID) (*Expedition, error) { + row := db.Get().QueryRow(` + SELECT expedition_id, user_id, zone_id, run_id, status, + start_date, current_day, current_region, boss_defeated, + supplies_json, camp_json, threat_level, threat_siege, + threat_events, temporal_stack, region_state, + xp_earned, coins_earned, gm_mood, + last_briefing_at, last_recap_at, last_activity, completed_at + FROM dnd_expedition + WHERE user_id = ? + AND status IN ('active', 'extracting') + ORDER BY 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 expedition_id, user_id, zone_id, run_id, status, + start_date, current_day, current_region, boss_defeated, + supplies_json, camp_json, threat_level, threat_siege, + threat_events, temporal_stack, region_state, + xp_earned, coins_earned, gm_mood, + last_briefing_at, last_recap_at, last_activity, completed_at + FROM dnd_expedition WHERE 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.GMMood, + &lastBriefingRaw, &lastRecapRaw, &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 != "" { + _ = json.Unmarshal([]byte(regionJSON), &e.RegionState) + } + 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 +} + +// 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 +} + +// abandonExpedition flags the active expedition as abandoned. Idempotent. +func abandonExpedition(userID id.UserID) error { + e, err := getActiveExpedition(userID) + if err != nil { + return err + } + if e == nil { + return ErrNoActiveExpedition + } + _, err = db.Get().Exec(` + UPDATE dnd_expedition + SET status = 'abandoned', + completed_at = CURRENT_TIMESTAMP, + last_activity = CURRENT_TIMESTAMP + WHERE expedition_id = ?`, e.ID) + return err +} + +// completeExpedition marks the expedition complete (boss defeated or extracted). +func completeExpedition(expID string, status string) error { + _, err := db.Get().Exec(` + UPDATE dnd_expedition + SET status = ?, + completed_at = CURRENT_TIMESTAMP, + last_activity = CURRENT_TIMESTAMP + WHERE expedition_id = ?`, status, expID) + return 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 + } + 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 +} + +// 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 +} + +// 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 +} + diff --git a/internal/plugin/dnd_expedition_test.go b/internal/plugin/dnd_expedition_test.go new file mode 100644 index 0000000..1709dd2 --- /dev/null +++ b/internal/plugin/dnd_expedition_test.go @@ -0,0 +1,222 @@ +package plugin + +import ( + "testing" + + "gogobee/internal/db" + "maunium.net/go/mautrix/id" +) + +func cleanupExpeditions(uid id.UserID) { + _, _ = db.Get().Exec(`DELETE FROM dnd_expedition_log + WHERE expedition_id IN (SELECT expedition_id FROM dnd_expedition WHERE user_id = ?)`, + string(uid)) + _, _ = db.Get().Exec(`DELETE FROM dnd_expedition WHERE user_id = ?`, string(uid)) +} + +func TestStartExpedition_RoundTrip(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-roundtrip:example.org") + defer cleanupExpeditions(uid) + + supplies := ExpeditionSupplies{ + Current: 10, + Max: 10, + DailyBurn: 1, + HarshMod: 1, + PacksStandard: 1, + } + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", supplies) + if err != nil { + t.Fatalf("startExpedition: %v", err) + } + if exp.Status != ExpeditionStatusActive { + t.Errorf("status = %q", exp.Status) + } + if exp.CurrentDay != 1 { + t.Errorf("current_day = %d", exp.CurrentDay) + } + if !exp.IsActive() { + t.Error("expected IsActive") + } + + got, err := getActiveExpedition(uid) + if err != nil || got == nil { + t.Fatalf("getActiveExpedition: %v / %v", got, err) + } + if got.ID != exp.ID { + t.Errorf("id mismatch") + } + if got.ZoneID != ZoneGoblinWarrens { + t.Errorf("zone = %s", got.ZoneID) + } + if got.Supplies.Current != 10 || got.Supplies.DailyBurn != 1 { + t.Errorf("supplies round-trip wrong: %+v", got.Supplies) + } + if got.GMMood != 50 { + t.Errorf("gm_mood = %d", got.GMMood) + } + if got.Camp != nil { + t.Errorf("expected no camp at start") + } +} + +func TestStartExpedition_RejectsConcurrent(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-concur:example.org") + defer cleanupExpeditions(uid) + + if _, err := startExpedition(uid, ZoneGoblinWarrens, "", ExpeditionSupplies{Max: 5, Current: 5, DailyBurn: 1}); err != nil { + t.Fatal(err) + } + if _, err := startExpedition(uid, ZoneCryptValdris, "", ExpeditionSupplies{Max: 5, Current: 5, DailyBurn: 1}); err != ErrExpeditionAlreadyActive { + t.Errorf("err = %v, want ErrExpeditionAlreadyActive", err) + } +} + +func TestExpedition_AbandonAndRestart(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-abandon:example.org") + defer cleanupExpeditions(uid) + + if _, err := startExpedition(uid, ZoneGoblinWarrens, "", ExpeditionSupplies{Max: 5, Current: 5, DailyBurn: 1}); err != nil { + t.Fatal(err) + } + if err := abandonExpedition(uid); err != nil { + t.Fatal(err) + } + got, err := getActiveExpedition(uid) + if err != nil { + t.Fatal(err) + } + if got != nil { + t.Errorf("expected no active expedition after abandon, got %+v", got) + } + // Should be allowed to start again now. + if _, err := startExpedition(uid, ZoneGoblinWarrens, "", ExpeditionSupplies{Max: 5, Current: 5, DailyBurn: 1}); err != nil { + t.Errorf("expected restart allowed, got %v", err) + } +} + +func TestExpedition_UpdateSuppliesAndCamp(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-update:example.org") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1}) + if err != nil { + t.Fatal(err) + } + exp.Supplies.Current = 7.5 + if err := updateSupplies(exp.ID, exp.Supplies); err != nil { + t.Fatal(err) + } + camp := &CampState{Active: true, Type: "standard", RoomIndex: 2} + if err := updateCamp(exp.ID, camp); err != nil { + t.Fatal(err) + } + got, err := getActiveExpedition(uid) + if err != nil || got == nil { + t.Fatalf("get: %v / %v", got, err) + } + if got.Supplies.Current != 7.5 { + t.Errorf("supplies.current = %v", got.Supplies.Current) + } + if got.Camp == nil || got.Camp.Type != "standard" || got.Camp.RoomIndex != 2 { + t.Errorf("camp = %+v", got.Camp) + } + // Break camp. + if err := updateCamp(exp.ID, nil); err != nil { + t.Fatal(err) + } + got, _ = getActiveExpedition(uid) + if got.Camp != nil { + t.Errorf("expected camp nil after break, got %+v", got.Camp) + } +} + +func TestExpedition_ThreatDeltaAndSiege(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-threat:example.org") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", ExpeditionSupplies{Max: 5, Current: 5, DailyBurn: 1}) + if err != nil { + t.Fatal(err) + } + if err := applyThreatDelta(exp.ID, 30, "test bump"); err != nil { + t.Fatal(err) + } + if err := applyThreatDelta(exp.ID, 80, "siege test"); err != nil { + t.Fatal(err) + } + got, _ := getExpedition(exp.ID) + if got.ThreatLevel != 100 { + t.Errorf("threat = %d, want clamped to 100", got.ThreatLevel) + } + if !got.SiegeMode { + t.Error("expected siege mode at 100") + } + if len(got.ThreatEvents) != 2 { + t.Errorf("threat_events len = %d, want 2", len(got.ThreatEvents)) + } + // Negative delta clamps to 0. + if err := applyThreatDelta(exp.ID, -200, "reset"); err != nil { + t.Fatal(err) + } + got, _ = getExpedition(exp.ID) + if got.ThreatLevel != 0 { + t.Errorf("threat after negative delta = %d", got.ThreatLevel) + } + // Siege mode is sticky once tripped. + if !got.SiegeMode { + t.Error("expected siege mode to remain set") + } +} + +func TestExpedition_LogAppendAndRecent(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-log:example.org") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", ExpeditionSupplies{Max: 5, Current: 5, DailyBurn: 1}) + if err != nil { + t.Fatal(err) + } + for i := 1; i <= 7; i++ { + if err := appendExpeditionLog(exp.ID, i, "action", "advance", "TwinBee notes."); err != nil { + t.Fatal(err) + } + } + entries, err := recentExpeditionLog(exp.ID, 5) + if err != nil { + t.Fatal(err) + } + if len(entries) != 5 { + t.Fatalf("entries = %d", len(entries)) + } + if entries[0].Day != 7 { + t.Errorf("newest day = %d, want 7", entries[0].Day) + } +} + +func TestExpedition_AdvanceDay(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-day:example.org") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", ExpeditionSupplies{Max: 5, Current: 5, DailyBurn: 1}) + if err != nil { + t.Fatal(err) + } + if err := advanceExpeditionDay(exp.ID); err != nil { + t.Fatal(err) + } + if err := advanceExpeditionDay(exp.ID); err != nil { + t.Fatal(err) + } + got, _ := getExpedition(exp.ID) + if got.CurrentDay != 3 { + t.Errorf("current_day = %d, want 3", got.CurrentDay) + } +}