mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
High-priority fixes from the multi-agent audit of Adventure 2.0: - Phase 11 nil-deref cluster: zoneOrFallback() helper replaces 8 unsafe getZone(_) sites in dnd_zone_cmd.go. Corrupted zone IDs render a placeholder instead of panicking. - Briefing/recap idempotency: deliverBriefing/deliverRecap now claim the rollover via a conditional UPDATE. Double-fires from clock skew or restart become no-ops; supply burn and day++ no longer reapply. - Graceful ticker shutdown: AdventurePlugin gains stopCh + Stop(); all 11 background tickers now select on stopCh in addition to ticker.C. - Mood decay (§3.2): math.Round(elapsed*2) replaces int() truncation so sub-hour gaps decay correctly. - 24h auto-abandon (§4.3): getActiveZoneRun returns clean slate and abandons stale runs whose LastActionAt is over 24h old. - Respec / auto-migrate orphan cleanup: !respec and the auto-migrated draft wipe now abandon active zone runs and expeditions before deleting the dnd_character row. - Phase R combat-link: applyBossDefeatThreat now wired from resolveBossRoom (-20 threat); applyRoomCombatThreatForUser adds +5/+8 from non-boss/elite kills (§8.1). - Starvation → forced extraction: briefing-time check forces extract with §10.2 coin tax when supplies hit zero. - GMNat20/Nat1 narration wired into resolveCombatRoom and resolveBossRoom (nat-20 takes precedence over nat-1). - Treasure-undo race: LoadAndDelete on both timer-fire and `undo` paths so only one side wins. - Battle Master: Disarming, Menacing, Parry maneuvers added (3 → 6 of 10). Remaining 4 (Pushing, Goading, Riposte, Commander's Strike) documented inline as needing ally/reaction mechanics the engine doesn't model. - Threat-70 warning: tracked in RegionState["siege_warning_fired"] so a drop-and-recross doesn't re-fire the beat. - region_state JSON decode error now logged via slog.Warn instead of silently discarded. Failing TestProdDB_DnDLayer fixed via option (a): track migrated characters and only run round-trip / idempotency assertions on those, skipping pre-existing prod-DB rows accumulated from live bot use. New tests in dnd_audit_phase_R7_test.go cover: 24h auto-abandon, briefing double-fire idempotency, threat-70 warning idempotency, multi-region extract→resume state preservation, and starvation forced-extraction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
461 lines
14 KiB
Go
461 lines
14 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"`
|
|
}
|
|
|
|
// 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()
|
|
// 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}
|
|
}
|
|
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,
|
|
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, current_region, supplies_json,
|
|
threat_events, region_state, gm_mood, last_activity)
|
|
VALUES (?, ?, ?, ?, 'active', ?, 1, ?, ?, ?, ?, 50, ?)`,
|
|
exp.ID, exp.UserID, string(zoneID), nullableString(runID),
|
|
now, currentRegion, 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).
|
|
// 'extracting' rows are post-extraction (resumable) — see getResumableExpedition.
|
|
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 = 'active'
|
|
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 != "" {
|
|
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
|
|
}
|
|
|
|
// 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 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
|
|
}
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|