Adv 2.0 D&D Phase 12 E1a: Expedition data model + persistence

Lays the multi-day expedition substrate per gogobee_expedition_system.md
§4.4/§5.3/§8.4/§9. Schema dnd_expedition + dnd_expedition_log added in
db.go. Expedition struct, ExpeditionSupplies, CampState, ThreatEvent
shipped in dnd_expedition.go with start/get/scan/abandon/complete,
updateSupplies, updateCamp, applyThreatDelta (clamped + sticky siege),
advanceExpeditionDay, append/recent log helpers. Round-trip + concurrent
rejection + threat clamp/siege + log ordering covered.

E1b (supply procurement) and E1c (commands) wire on top in subsequent
commits; day cycle (E1d) and camp commands (E1e) follow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 15:12:06 -07:00
parent c5defc9b34
commit 9608ce554a
3 changed files with 697 additions and 0 deletions

View File

@@ -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.

View File

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

View File

@@ -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)
}
}