mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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:
@@ -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.
|
||||
|
||||
421
internal/plugin/dnd_expedition.go
Normal file
421
internal/plugin/dnd_expedition.go
Normal 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, ®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
|
||||
}
|
||||
|
||||
222
internal/plugin/dnd_expedition_test.go
Normal file
222
internal/plugin/dnd_expedition_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user