mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32: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:
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user