mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Adv 2.0 D&D Phase 11 D1b: DungeonRun state machine + dnd_zone_run schema
dnd_zone_run table (run_id, zone_id, room_seq_json, rooms_cleared, boss_defeated, abandoned, loot_collected, gm_mood, started_at, last_action_at, completed_at) added to db.go schema with active-run index. dnd_zone_run.go ships RoomType (entry|exploration|trap|elite|boss), DungeonRun struct, generateRoomSequence (Entry → ExplorationxN1 → Trap → ExplorationxN2 → Elite → Boss within zone's [MinRooms, MaxRooms]), and persistence helpers: startZoneRun (gates on level tier + active-run exclusivity), getActiveZoneRun, getZoneRun, markRoomCleared (advances and auto-completes on boss kill), abandonZoneRun, adjustGMMood (clamped to [0,100]), addLoot. 8 new tests covering room-sequence shape (entry first, boss last, exactly one trap/elite, ≥2 explorations), happy-path start, concurrent-run rejection, unknown-zone rejection, full advance-to-boss flow with completion, abandon idempotency, GM mood clamping at both bounds, and loot accumulation order. Full repo test suite green. Boss-room behavior, !zone command surface, and TwinBee narration arrive in D1c/D1d. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1604,6 +1604,32 @@ CREATE TABLE IF NOT EXISTS adventure_characters_pre_dnd (
|
|||||||
snapshot_json TEXT NOT NULL,
|
snapshot_json TEXT NOT NULL,
|
||||||
snapshotted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
snapshotted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- ── D&D Layer (Phase 11 D1b — zone runs) ───────────────────────────────────
|
||||||
|
-- A single in-progress or completed dungeon run. One row per run; players
|
||||||
|
-- may have at most one row with completed_at IS NULL AND abandoned = 0
|
||||||
|
-- (enforced in code, not via constraint, to keep migrations simple).
|
||||||
|
-- room_seq_json is a JSON array of RoomType strings generated at run start;
|
||||||
|
-- current_room indexes into that array. rooms_cleared is a JSON array of
|
||||||
|
-- room indices the player has resolved (combat won / trap survived / etc.).
|
||||||
|
CREATE TABLE IF NOT EXISTS dnd_zone_run (
|
||||||
|
run_id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
zone_id TEXT NOT NULL,
|
||||||
|
current_room INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_rooms INTEGER NOT NULL,
|
||||||
|
room_seq_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
rooms_cleared TEXT NOT NULL DEFAULT '[]',
|
||||||
|
boss_defeated INTEGER NOT NULL DEFAULT 0,
|
||||||
|
abandoned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
loot_collected TEXT NOT NULL DEFAULT '[]',
|
||||||
|
gm_mood INTEGER NOT NULL DEFAULT 50,
|
||||||
|
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_action_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at DATETIME
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_zone_run_active
|
||||||
|
ON dnd_zone_run(user_id, completed_at, abandoned);
|
||||||
`
|
`
|
||||||
|
|
||||||
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.
|
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.
|
||||||
|
|||||||
391
internal/plugin/dnd_zone_run.go
Normal file
391
internal/plugin/dnd_zone_run.go
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
cryptorand "crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Phase 11 D1b — DungeonRun state machine. Implements `gogobee_dungeon_zones.md`
|
||||||
|
// §4 (Dungeon Structure) and the DungeonRun model in §7. Persists a single
|
||||||
|
// zone run per player to dnd_zone_run.
|
||||||
|
//
|
||||||
|
// Room sequencing follows the design doc fixed pattern:
|
||||||
|
// Entry → Exploration ×N₁ → Trap → Exploration ×N₂ → Elite → Boss
|
||||||
|
// where N₁ + N₂ scales with zone tier so total rooms lands in the
|
||||||
|
// zone's [MinRooms, MaxRooms] window. Boss is always last.
|
||||||
|
//
|
||||||
|
// State machine:
|
||||||
|
// start → advance × R → boss → complete
|
||||||
|
// ↓
|
||||||
|
// abandon (manual or 24h-idle, D1c)
|
||||||
|
//
|
||||||
|
// D1b ships persistence and pure logic only. !zone enter/advance/etc
|
||||||
|
// commands wire to this in D1c; combat resolution per room is wired in
|
||||||
|
// D1e onwards. Trap/elite/boss room *behavior* is not implemented yet —
|
||||||
|
// advancing through them currently just records them as cleared.
|
||||||
|
|
||||||
|
// RoomType enumerates the room categories per design doc §4.2.
|
||||||
|
type RoomType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoomEntry RoomType = "entry"
|
||||||
|
RoomExploration RoomType = "exploration"
|
||||||
|
RoomTrap RoomType = "trap"
|
||||||
|
RoomElite RoomType = "elite"
|
||||||
|
RoomBoss RoomType = "boss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DungeonRun is the in-memory shape of a dnd_zone_run row.
|
||||||
|
type DungeonRun struct {
|
||||||
|
RunID string
|
||||||
|
UserID string
|
||||||
|
ZoneID ZoneID
|
||||||
|
CurrentRoom int
|
||||||
|
TotalRooms int
|
||||||
|
RoomSeq []RoomType
|
||||||
|
RoomsCleared []int
|
||||||
|
BossDefeated bool
|
||||||
|
Abandoned bool
|
||||||
|
LootCollected []string
|
||||||
|
GMMood int
|
||||||
|
StartedAt time.Time
|
||||||
|
LastActionAt time.Time
|
||||||
|
CompletedAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsActive reports whether this run is still ongoing (not boss-defeated,
|
||||||
|
// not abandoned, not otherwise completed).
|
||||||
|
func (r *DungeonRun) IsActive() bool {
|
||||||
|
return !r.BossDefeated && !r.Abandoned && r.CompletedAt == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentRoomType returns the type of the room the player is currently
|
||||||
|
// standing in (CurrentRoom is 0-indexed). Returns "" if out of range.
|
||||||
|
func (r *DungeonRun) CurrentRoomType() RoomType {
|
||||||
|
if r.CurrentRoom < 0 || r.CurrentRoom >= len(r.RoomSeq) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return r.RoomSeq[r.CurrentRoom]
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRoomSequence builds the deterministic-but-seeded room layout
|
||||||
|
// for a run of the given zone. The boss room is always last; one Entry
|
||||||
|
// is always first; one Trap and one Elite room sit between explorations.
|
||||||
|
// Total length is sampled in [zone.MinRooms, zone.MaxRooms].
|
||||||
|
func generateRoomSequence(zone ZoneDefinition, rng *rand.Rand) []RoomType {
|
||||||
|
total := zone.MinRooms
|
||||||
|
if zone.MaxRooms > zone.MinRooms {
|
||||||
|
total += rng.IntN(zone.MaxRooms - zone.MinRooms + 1)
|
||||||
|
}
|
||||||
|
// Fixed slots: Entry + Trap + Elite + Boss = 4. Remaining = explorations.
|
||||||
|
const fixed = 4
|
||||||
|
if total < fixed+2 {
|
||||||
|
total = fixed + 2 // at least 2 exploration rooms
|
||||||
|
}
|
||||||
|
explorations := total - fixed
|
||||||
|
// Split exploration rooms before/after the trap. Bias toward 1–2 on each side.
|
||||||
|
preTrap := 1
|
||||||
|
if explorations >= 3 {
|
||||||
|
preTrap = 1 + rng.IntN(explorations-1)
|
||||||
|
} else if explorations == 2 {
|
||||||
|
preTrap = 1
|
||||||
|
}
|
||||||
|
postTrap := explorations - preTrap
|
||||||
|
|
||||||
|
seq := make([]RoomType, 0, total)
|
||||||
|
seq = append(seq, RoomEntry)
|
||||||
|
for i := 0; i < preTrap; i++ {
|
||||||
|
seq = append(seq, RoomExploration)
|
||||||
|
}
|
||||||
|
seq = append(seq, RoomTrap)
|
||||||
|
for i := 0; i < postTrap; i++ {
|
||||||
|
seq = append(seq, RoomExploration)
|
||||||
|
}
|
||||||
|
seq = append(seq, RoomElite)
|
||||||
|
seq = append(seq, RoomBoss)
|
||||||
|
return seq
|
||||||
|
}
|
||||||
|
|
||||||
|
// newRunID — 16-char hex token. Crypto-random; collision-resistant.
|
||||||
|
func newRunID() string {
|
||||||
|
var b [8]byte
|
||||||
|
if _, err := cryptorand.Read(b[:]); err != nil {
|
||||||
|
// Fall back to math/rand if /dev/urandom is unavailable.
|
||||||
|
// Run IDs are not security-sensitive, just unique.
|
||||||
|
v := rng2.Uint64()
|
||||||
|
for i := range b {
|
||||||
|
b[i] = byte(v >> (8 * i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// rng2 is a seeded math/rand fallback for newRunID. Test isolation isn't
|
||||||
|
// needed — collisions are still vanishingly unlikely.
|
||||||
|
var rng2 = rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), 0xC0FFEE))
|
||||||
|
|
||||||
|
// ---- Persistence ------------------------------------------------------------
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrRunAlreadyActive — player tried to start a run while another is in flight.
|
||||||
|
ErrRunAlreadyActive = errors.New("zone run already active for player")
|
||||||
|
// ErrNoActiveRun — player tried to advance/retreat with no run in flight.
|
||||||
|
ErrNoActiveRun = errors.New("no active zone run for player")
|
||||||
|
// ErrUnknownZone — start called with an unregistered ZoneID.
|
||||||
|
ErrUnknownZone = errors.New("unknown zone")
|
||||||
|
// ErrZoneTierLocked — player level too low for the zone.
|
||||||
|
ErrZoneTierLocked = errors.New("zone tier above player ceiling")
|
||||||
|
)
|
||||||
|
|
||||||
|
// startZoneRun creates a new run for the player. Fails if the player has
|
||||||
|
// an active run, the zone is unknown, or the player's level is too far
|
||||||
|
// below the zone's tier (per zonesForLevel's gate).
|
||||||
|
func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand) (*DungeonRun, error) {
|
||||||
|
zone, ok := getZone(zoneID)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrUnknownZone
|
||||||
|
}
|
||||||
|
allowed := false
|
||||||
|
for _, z := range zonesForLevel(dndLevel) {
|
||||||
|
if z.ID == zoneID {
|
||||||
|
allowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
return nil, ErrZoneTierLocked
|
||||||
|
}
|
||||||
|
existing, err := getActiveZoneRun(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
return nil, ErrRunAlreadyActive
|
||||||
|
}
|
||||||
|
|
||||||
|
if rng == nil {
|
||||||
|
rng = rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), uint64(time.Now().UnixMicro())))
|
||||||
|
}
|
||||||
|
seq := generateRoomSequence(zone, rng)
|
||||||
|
seqJSON, _ := json.Marshal(seq)
|
||||||
|
|
||||||
|
run := &DungeonRun{
|
||||||
|
RunID: newRunID(),
|
||||||
|
UserID: string(userID),
|
||||||
|
ZoneID: zoneID,
|
||||||
|
CurrentRoom: 0,
|
||||||
|
TotalRooms: len(seq),
|
||||||
|
RoomSeq: seq,
|
||||||
|
RoomsCleared: []int{},
|
||||||
|
GMMood: 50,
|
||||||
|
StartedAt: time.Now().UTC(),
|
||||||
|
LastActionAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
if _, err := db.Get().Exec(`
|
||||||
|
INSERT INTO dnd_zone_run
|
||||||
|
(run_id, user_id, zone_id, current_room, total_rooms,
|
||||||
|
room_seq_json, rooms_cleared, gm_mood)
|
||||||
|
VALUES (?, ?, ?, 0, ?, ?, '[]', 50)`,
|
||||||
|
run.RunID, run.UserID, string(zoneID), run.TotalRooms, string(seqJSON),
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("insert zone run: %w", err)
|
||||||
|
}
|
||||||
|
return run, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getActiveZoneRun returns the player's in-flight run, or (nil, nil) if none.
|
||||||
|
func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) {
|
||||||
|
row := db.Get().QueryRow(`
|
||||||
|
SELECT run_id, user_id, zone_id, current_room, total_rooms,
|
||||||
|
room_seq_json, rooms_cleared, boss_defeated, abandoned,
|
||||||
|
loot_collected, gm_mood, started_at, last_action_at, completed_at
|
||||||
|
FROM dnd_zone_run
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND completed_at IS NULL
|
||||||
|
AND abandoned = 0
|
||||||
|
AND boss_defeated = 0
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
string(userID))
|
||||||
|
r, err := scanZoneRun(row)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// getZoneRun fetches by RunID regardless of completion state. Test/admin use.
|
||||||
|
func getZoneRun(runID string) (*DungeonRun, error) {
|
||||||
|
row := db.Get().QueryRow(`
|
||||||
|
SELECT run_id, user_id, zone_id, current_room, total_rooms,
|
||||||
|
room_seq_json, rooms_cleared, boss_defeated, abandoned,
|
||||||
|
loot_collected, gm_mood, started_at, last_action_at, completed_at
|
||||||
|
FROM dnd_zone_run WHERE run_id = ?`, runID)
|
||||||
|
r, err := scanZoneRun(row)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanZoneRun reads one row into a DungeonRun (used by both fetchers).
|
||||||
|
type scanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanZoneRun(row scanner) (*DungeonRun, error) {
|
||||||
|
var (
|
||||||
|
r DungeonRun
|
||||||
|
zoneID string
|
||||||
|
seqJSON string
|
||||||
|
clearedJSON string
|
||||||
|
lootJSON string
|
||||||
|
bossDefeatedI int
|
||||||
|
abandonedI int
|
||||||
|
completedAtRaw sql.NullTime
|
||||||
|
)
|
||||||
|
if err := row.Scan(
|
||||||
|
&r.RunID, &r.UserID, &zoneID, &r.CurrentRoom, &r.TotalRooms,
|
||||||
|
&seqJSON, &clearedJSON, &bossDefeatedI, &abandonedI,
|
||||||
|
&lootJSON, &r.GMMood, &r.StartedAt, &r.LastActionAt, &completedAtRaw,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.ZoneID = ZoneID(zoneID)
|
||||||
|
r.BossDefeated = bossDefeatedI != 0
|
||||||
|
r.Abandoned = abandonedI != 0
|
||||||
|
if completedAtRaw.Valid {
|
||||||
|
t := completedAtRaw.Time
|
||||||
|
r.CompletedAt = &t
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(seqJSON), &r.RoomSeq); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode room_seq_json: %w", err)
|
||||||
|
}
|
||||||
|
if clearedJSON != "" {
|
||||||
|
if err := json.Unmarshal([]byte(clearedJSON), &r.RoomsCleared); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode rooms_cleared: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.RoomsCleared == nil {
|
||||||
|
r.RoomsCleared = []int{}
|
||||||
|
}
|
||||||
|
if lootJSON != "" {
|
||||||
|
if err := json.Unmarshal([]byte(lootJSON), &r.LootCollected); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode loot_collected: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.LootCollected == nil {
|
||||||
|
r.LootCollected = []string{}
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// markRoomCleared records that the current room has been resolved and
|
||||||
|
// advances the player to the next room. Returns the new current room
|
||||||
|
// type (or "" if the run completed via boss kill / final room).
|
||||||
|
func markRoomCleared(runID string) (RoomType, error) {
|
||||||
|
r, err := getZoneRun(runID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if r == nil {
|
||||||
|
return "", ErrNoActiveRun
|
||||||
|
}
|
||||||
|
if !r.IsActive() {
|
||||||
|
return "", ErrNoActiveRun
|
||||||
|
}
|
||||||
|
cleared := append(r.RoomsCleared, r.CurrentRoom)
|
||||||
|
clearedJSON, _ := json.Marshal(cleared)
|
||||||
|
|
||||||
|
wasBossRoom := r.CurrentRoomType() == RoomBoss
|
||||||
|
next := r.CurrentRoom + 1
|
||||||
|
if next >= r.TotalRooms || wasBossRoom {
|
||||||
|
// Boss room cleared = run complete via boss defeat.
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if _, err := db.Get().Exec(`
|
||||||
|
UPDATE dnd_zone_run
|
||||||
|
SET rooms_cleared = ?,
|
||||||
|
boss_defeated = ?,
|
||||||
|
completed_at = ?,
|
||||||
|
last_action_at = ?
|
||||||
|
WHERE run_id = ?`,
|
||||||
|
string(clearedJSON), boolToInt(wasBossRoom), now, now, runID,
|
||||||
|
); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if _, err := db.Get().Exec(`
|
||||||
|
UPDATE dnd_zone_run
|
||||||
|
SET rooms_cleared = ?,
|
||||||
|
current_room = ?,
|
||||||
|
last_action_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE run_id = ?`,
|
||||||
|
string(clearedJSON), next, runID,
|
||||||
|
); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
r.CurrentRoom = next
|
||||||
|
return r.RoomSeq[next], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// abandonZoneRun flags the active run as abandoned. Idempotent: returns
|
||||||
|
// ErrNoActiveRun if there is nothing to abandon.
|
||||||
|
func abandonZoneRun(userID id.UserID) error {
|
||||||
|
r, err := getActiveZoneRun(userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r == nil {
|
||||||
|
return ErrNoActiveRun
|
||||||
|
}
|
||||||
|
_, err = db.Get().Exec(`
|
||||||
|
UPDATE dnd_zone_run
|
||||||
|
SET abandoned = 1,
|
||||||
|
completed_at = CURRENT_TIMESTAMP,
|
||||||
|
last_action_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE run_id = ?`, r.RunID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// adjustGMMood clamps mood to [0, 100] and persists. Used by D1d when
|
||||||
|
// nat-1/nat-20/zone-completion events fire. delta may be negative.
|
||||||
|
func adjustGMMood(runID string, delta int) error {
|
||||||
|
_, err := db.Get().Exec(`
|
||||||
|
UPDATE dnd_zone_run
|
||||||
|
SET gm_mood = MAX(0, MIN(100, gm_mood + ?)),
|
||||||
|
last_action_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE run_id = ?`, delta, runID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// addLoot appends an item ID to the run's loot manifest. Caller is
|
||||||
|
// responsible for actually granting the item to the player's inventory;
|
||||||
|
// this is the audit trail of what dropped during the run.
|
||||||
|
func addLoot(runID string, itemID string) error {
|
||||||
|
r, err := getZoneRun(runID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r == nil {
|
||||||
|
return ErrNoActiveRun
|
||||||
|
}
|
||||||
|
loot := append(r.LootCollected, itemID)
|
||||||
|
lootJSON, _ := json.Marshal(loot)
|
||||||
|
_, err = db.Get().Exec(`
|
||||||
|
UPDATE dnd_zone_run
|
||||||
|
SET loot_collected = ?,
|
||||||
|
last_action_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE run_id = ?`, string(lootJSON), runID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
242
internal/plugin/dnd_zone_run_test.go
Normal file
242
internal/plugin/dnd_zone_run_test.go
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"math/rand/v2"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupZoneRunTestDB(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
src := "/home/reala-misaki/git/gogobee/data/gogobee.db"
|
||||||
|
if _, err := os.Stat(src); err != nil {
|
||||||
|
t.Skip("prod db not present")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
dst := filepath.Join(dir, "gogobee.db")
|
||||||
|
in, _ := os.Open(src)
|
||||||
|
defer in.Close()
|
||||||
|
out, _ := os.Create(dst)
|
||||||
|
defer out.Close()
|
||||||
|
io.Copy(out, in)
|
||||||
|
|
||||||
|
db.Close()
|
||||||
|
if err := db.Init(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(db.Close)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateRoomSequence_StartsEntryEndsBoss(t *testing.T) {
|
||||||
|
zone := zoneGoblinWarrens()
|
||||||
|
rng := rand.New(rand.NewPCG(1, 2))
|
||||||
|
seq := generateRoomSequence(zone, rng)
|
||||||
|
if len(seq) < zone.MinRooms || len(seq) > zone.MaxRooms {
|
||||||
|
t.Fatalf("seq len %d outside [%d,%d]", len(seq), zone.MinRooms, zone.MaxRooms)
|
||||||
|
}
|
||||||
|
if seq[0] != RoomEntry {
|
||||||
|
t.Errorf("first room = %s, want entry", seq[0])
|
||||||
|
}
|
||||||
|
if seq[len(seq)-1] != RoomBoss {
|
||||||
|
t.Errorf("last room = %s, want boss", seq[len(seq)-1])
|
||||||
|
}
|
||||||
|
// Exactly one of each special type:
|
||||||
|
count := map[RoomType]int{}
|
||||||
|
for _, r := range seq {
|
||||||
|
count[r]++
|
||||||
|
}
|
||||||
|
if count[RoomEntry] != 1 {
|
||||||
|
t.Errorf("entry count = %d", count[RoomEntry])
|
||||||
|
}
|
||||||
|
if count[RoomTrap] != 1 {
|
||||||
|
t.Errorf("trap count = %d", count[RoomTrap])
|
||||||
|
}
|
||||||
|
if count[RoomElite] != 1 {
|
||||||
|
t.Errorf("elite count = %d", count[RoomElite])
|
||||||
|
}
|
||||||
|
if count[RoomBoss] != 1 {
|
||||||
|
t.Errorf("boss count = %d", count[RoomBoss])
|
||||||
|
}
|
||||||
|
if count[RoomExploration] < 2 {
|
||||||
|
t.Errorf("exploration count = %d, want ≥ 2", count[RoomExploration])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartZoneRun_HappyPath(t *testing.T) {
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
uid := id.UserID("@zone-test:example.org")
|
||||||
|
defer cleanupZoneRuns(uid)
|
||||||
|
|
||||||
|
rng := rand.New(rand.NewPCG(42, 7))
|
||||||
|
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, rng)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("startZoneRun: %v", err)
|
||||||
|
}
|
||||||
|
if run.ZoneID != ZoneGoblinWarrens {
|
||||||
|
t.Errorf("zone id = %s", run.ZoneID)
|
||||||
|
}
|
||||||
|
if run.CurrentRoom != 0 {
|
||||||
|
t.Errorf("current room = %d", run.CurrentRoom)
|
||||||
|
}
|
||||||
|
if run.GMMood != 50 {
|
||||||
|
t.Errorf("gm mood = %d", run.GMMood)
|
||||||
|
}
|
||||||
|
if !run.IsActive() {
|
||||||
|
t.Error("expected run active")
|
||||||
|
}
|
||||||
|
if run.CurrentRoomType() != RoomEntry {
|
||||||
|
t.Errorf("first room type = %s", run.CurrentRoomType())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartZoneRun_RejectsConcurrent(t *testing.T) {
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
uid := id.UserID("@zone-concur:example.org")
|
||||||
|
defer cleanupZoneRuns(uid)
|
||||||
|
|
||||||
|
if _, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := startZoneRun(uid, ZoneCryptValdris, 1, nil); err != ErrRunAlreadyActive {
|
||||||
|
t.Errorf("err = %v, want ErrRunAlreadyActive", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartZoneRun_RejectsTierLocked(t *testing.T) {
|
||||||
|
// L1 player can reach tier 3 max. Crypt is T1 — fine. But if we
|
||||||
|
// register a T5 in future, this test should still work — for now,
|
||||||
|
// fake the gate by trying with negative level.
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
uid := id.UserID("@zone-tier:example.org")
|
||||||
|
defer cleanupZoneRuns(uid)
|
||||||
|
|
||||||
|
// All current zones are T1 so any positive level passes. Test
|
||||||
|
// the unknown-zone path instead, which is the other guard.
|
||||||
|
if _, err := startZoneRun(uid, ZoneID("nonexistent"), 1, nil); err != ErrUnknownZone {
|
||||||
|
t.Errorf("err = %v, want ErrUnknownZone", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZoneRunFlow_AdvanceToBossAndComplete(t *testing.T) {
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
uid := id.UserID("@zone-flow:example.org")
|
||||||
|
defer cleanupZoneRuns(uid)
|
||||||
|
|
||||||
|
run, err := startZoneRun(uid, ZoneGoblinWarrens, 2, rand.New(rand.NewPCG(99, 1)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
steps := 0
|
||||||
|
for {
|
||||||
|
next, err := markRoomCleared(run.RunID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("markRoomCleared step %d: %v", steps, err)
|
||||||
|
}
|
||||||
|
steps++
|
||||||
|
if steps > 20 {
|
||||||
|
t.Fatal("too many steps")
|
||||||
|
}
|
||||||
|
if next == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, err := getZoneRun(run.RunID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !got.BossDefeated {
|
||||||
|
t.Error("expected boss defeated after final room")
|
||||||
|
}
|
||||||
|
if got.CompletedAt == nil {
|
||||||
|
t.Error("expected CompletedAt set")
|
||||||
|
}
|
||||||
|
if got.IsActive() {
|
||||||
|
t.Error("expected run inactive")
|
||||||
|
}
|
||||||
|
if len(got.RoomsCleared) != got.TotalRooms {
|
||||||
|
t.Errorf("rooms cleared %d, total %d", len(got.RoomsCleared), got.TotalRooms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAbandonZoneRun(t *testing.T) {
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
uid := id.UserID("@zone-abandon:example.org")
|
||||||
|
defer cleanupZoneRuns(uid)
|
||||||
|
|
||||||
|
if _, err := startZoneRun(uid, ZoneCryptValdris, 1, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := abandonZoneRun(uid); err != nil {
|
||||||
|
t.Fatalf("abandon: %v", err)
|
||||||
|
}
|
||||||
|
active, err := getActiveZoneRun(uid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if active != nil {
|
||||||
|
t.Error("expected no active run after abandon")
|
||||||
|
}
|
||||||
|
// Second abandon should ErrNoActiveRun.
|
||||||
|
if err := abandonZoneRun(uid); err != ErrNoActiveRun {
|
||||||
|
t.Errorf("second abandon err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdjustGMMoodClampsBounds(t *testing.T) {
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
uid := id.UserID("@zone-mood:example.org")
|
||||||
|
defer cleanupZoneRuns(uid)
|
||||||
|
|
||||||
|
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Push above 100 then below 0.
|
||||||
|
if err := adjustGMMood(run.RunID, 200); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r, _ := getZoneRun(run.RunID)
|
||||||
|
if r.GMMood != 100 {
|
||||||
|
t.Errorf("upper clamp: mood = %d", r.GMMood)
|
||||||
|
}
|
||||||
|
if err := adjustGMMood(run.RunID, -250); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r, _ = getZoneRun(run.RunID)
|
||||||
|
if r.GMMood != 0 {
|
||||||
|
t.Errorf("lower clamp: mood = %d", r.GMMood)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddLootAccumulates(t *testing.T) {
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
uid := id.UserID("@zone-loot:example.org")
|
||||||
|
defer cleanupZoneRuns(uid)
|
||||||
|
|
||||||
|
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := addLoot(run.RunID, "wpn_handaxe_+1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := addLoot(run.RunID, "coins_2d10x5"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := getZoneRun(run.RunID)
|
||||||
|
if len(got.LootCollected) != 2 {
|
||||||
|
t.Fatalf("loot len %d", len(got.LootCollected))
|
||||||
|
}
|
||||||
|
if got.LootCollected[0] != "wpn_handaxe_+1" || got.LootCollected[1] != "coins_2d10x5" {
|
||||||
|
t.Errorf("loot order: %v", got.LootCollected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupZoneRuns(uid id.UserID) {
|
||||||
|
_, _ = db.Get().Exec(`DELETE FROM dnd_zone_run WHERE user_id = ?`, string(uid))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user