package plugin import ( "testing" "time" "gogobee/internal/db" "maunium.net/go/mautrix/id" ) // Adv 2.0 scenario playthrough: drives a realistic player session against // a copy of the prod DB, verifying happy-path behavior end-to-end across // the !zone state machine, the !expedition multi-day system, and the // !harvest economy. SendDM is a no-op without a Matrix client, so the // asserts target persisted state. Logs are verbose (t.Logf) so the run // reads like a transcript. // ── Scenario A — single-session zone run ──────────────────────────────────── func TestAdv2Scenario_ZoneRunGoblinWarrens(t *testing.T) { setupAuditTestDB(t) uid := id.UserID("@adv2-scenA:example") t.Cleanup(func() { cleanupZoneRuns(uid); cleanupExpeditions(uid) }) // Beefy L5 fighter so combat doesn't trivially TPK (still RNG, but 5 // is well past goblin warrens' L1–L3 band). if err := createAdvCharacter(uid, "scenA"); err != nil { t.Fatalf("createAdvCharacter: %v", err) } c := &DnDCharacter{ UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 5, STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10, HPMax: 60, HPCurrent: 60, ArmorClass: 18, } if err := SaveDnDCharacter(c); err != nil { t.Fatalf("SaveDnDCharacter: %v", err) } euro := &EuroPlugin{} euro.ensureBalance(uid) euro.Credit(uid, 500, "scenA bankroll") p := &AdventurePlugin{euro: euro} // !zone (list) — should not error, no run created. if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, ""); err != nil { t.Fatalf("zone list: %v", err) } if got, _ := getActiveZoneRun(uid); got != nil { t.Fatal("zone list should not create a run") } // !zone enter goblin_warrens if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "enter goblin_warrens"); err != nil { t.Fatalf("zone enter: %v", err) } run, err := getActiveZoneRun(uid) if err != nil || run == nil { t.Fatalf("expected active run after enter: %v", err) } t.Logf("entered %s — %d rooms, mood=%d, first room=%s", run.ZoneID, run.TotalRooms, run.DMMood, run.CurrentRoomType()) if run.DMMood != 50 { t.Errorf("starting mood = %d, want 50", run.DMMood) } if run.CurrentRoom != 0 || run.CurrentRoomType() != RoomEntry { t.Errorf("first room not entry: idx=%d type=%s", run.CurrentRoom, run.CurrentRoomType()) } // !zone status (cheap smoke) if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "status"); err != nil { t.Fatalf("zone status: %v", err) } if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "map"); err != nil { t.Fatalf("zone map: %v", err) } // Drive !zone advance until the run terminates (cleared, died, or // abandoned). Branches the test handles: // - Fork queued (Phase G branching graphs): commit with `!zone go 1`. // - Elite/Boss doorway (commit 886eb5a moved these off auto-resolve): // !advance now stops at the door and the player engages with // !fight, then resolves one round per !attack. The test mirrors // that: call !fight to open the session, !attack until the session // closes, then the next !advance clears the room and walks the // graph. Cap the per-fight !attack loop generously — a real fight // resolves in <20 rounds, but RNG can drag. maxSteps := (run.TotalRooms + 4) * 4 const maxAttacksPerFight = 60 clearedRoomTypes := []RoomType{} for step := 0; step < maxSteps; step++ { before, _ := getActiveZoneRun(uid) if before == nil { t.Logf("step %d: run terminated (cleared/died/abandoned)", step) break } prevType := before.CurrentRoomType() prevHP := c.HPCurrent if cur, _ := LoadDnDCharacter(uid); cur != nil { prevHP = cur.HPCurrent } t.Logf("step %d: in room %d/%d (%s), mood=%d, HP=%d", step, before.CurrentRoom+1, before.TotalRooms, prevType, before.DMMood, prevHP) if len(before.NodeChoices) > 0 { // A fork is queued from the previous advance — commit it first. if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "go 1"); err != nil { t.Fatalf("zone go step %d: %v", step, err) } continue } // Elite/Boss doorway: !advance won't progress past the door // without a won CombatSession for the encounter. Open the fight // if it isn't already open, then attack until the session // terminates. After the loop, fall through to !advance — the // won session lets advance clear the room and walk the graph; // a lost/fled session terminates the run on the next pass. if prevType == RoomElite || prevType == RoomBoss { sess, _ := getCombatSessionForEncounter(before.RunID, encounterIDForRoom(before.CurrentRoom)) if sess == nil { if err := p.handleFightCmd(MessageContext{Sender: uid}); err != nil { t.Fatalf("zone fight step %d: %v", step, err) } } // Drain the round loop. handleAttackCmd no-ops if there's no // active session, so the inner loop self-terminates either way. for atk := 0; atk < maxAttacksPerFight; atk++ { active, _ := getActiveCombatSession(uid) if active == nil { break } if err := p.handleAttackCmd(MessageContext{Sender: uid}); err != nil { t.Fatalf("attack step %d.%d: %v", step, atk, err) } } } if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "advance"); err != nil { t.Fatalf("zone advance step %d: %v", step, err) } clearedRoomTypes = append(clearedRoomTypes, prevType) } final, _ := getActiveZoneRun(uid) raw := getZoneRunByUser(t, uid) // helper below — most-recent row if final != nil { t.Logf("final state: still active at room %d/%d — likely advance regression", final.CurrentRoom+1, final.TotalRooms) t.Errorf("run did not terminate within %d steps", maxSteps) } else if raw != nil { boss := raw.BossDefeated t.Logf("run %s ended: bossDefeated=%v abandoned=%v rooms_cleared=%d/%d loot=%d mood=%d", raw.RunID, boss, raw.Abandoned, len(raw.RoomsCleared), raw.TotalRooms, len(raw.LootCollected), raw.DMMood) t.Logf("clearedRoomTypes: %v", clearedRoomTypes) if !boss && !raw.Abandoned && raw.CompletedAt == nil { t.Errorf("run row in indeterminate state: %+v", raw) } } } // getZoneRunByUser fetches the most-recent zone-run row for a user // regardless of active/abandoned/cleared. Helper for the scenario test. func getZoneRunByUser(t *testing.T, uid id.UserID) *DungeonRun { t.Helper() row := db.Get().QueryRow(` SELECT run_id FROM dnd_zone_run WHERE user_id = ? ORDER BY started_at DESC LIMIT 1 `, string(uid)) var runID string if err := row.Scan(&runID); err != nil { return nil } r, _ := getZoneRun(runID) return r } // ── Scenario B — multi-day expedition ─────────────────────────────────────── func TestAdv2Scenario_ExpeditionCryptValdris(t *testing.T) { setupAuditTestDB(t) uid := id.UserID("@adv2-scenB:example") t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) }) if err := createAdvCharacter(uid, "scenB"); err != nil { t.Fatalf("createAdvCharacter: %v", err) } c := &DnDCharacter{ UserID: uid, Race: RaceHuman, Class: ClassRanger, Level: 5, STR: 14, DEX: 18, CON: 14, INT: 10, WIS: 14, CHA: 10, HPMax: 50, HPCurrent: 50, ArmorClass: 16, } if err := SaveDnDCharacter(c); err != nil { t.Fatalf("SaveDnDCharacter: %v", err) } euro := &EuroPlugin{} euro.ensureBalance(uid) euro.Credit(uid, 1000, "scenB bankroll") p := &AdventurePlugin{euro: euro} // !expedition list if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "list"); err != nil { t.Fatalf("expedition list: %v", err) } // !expedition start crypt_valdris with 2 standard packs balBefore := euro.GetBalance(uid) if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start crypt_valdris 2s"); err != nil { t.Fatalf("expedition start: %v", err) } exp, err := getActiveExpedition(uid) if err != nil || exp == nil { t.Fatalf("expected active expedition: %v", err) } balAfter := euro.GetBalance(uid) t.Logf("expedition %s started: zone=%s supplies=%d/%d threat=%d cost=%.0f→%.0f", exp.ID, exp.ZoneID, int(exp.Supplies.Current), int(exp.Supplies.Max), exp.ThreatLevel, balBefore, balAfter) if exp.ZoneID != ZoneCryptValdris { t.Errorf("zone = %s, want crypt_valdris", exp.ZoneID) } if balAfter >= balBefore { t.Errorf("expected outfitting to debit coins (%.2f → %.2f)", balBefore, balAfter) } // Backdate start so deliverBriefing's same-day guard passes. if _, err := dbExecExpeditionBackdate(exp.ID, 24*time.Hour); err != nil { t.Fatalf("backdate: %v", err) } // Drive 3 daily briefings — verify supply burn + day advance. now := time.Date(2026, 5, 8, 6, 0, 0, 0, time.UTC) for day := 1; day <= 3; day++ { fresh, _ := getExpedition(exp.ID) if fresh == nil { t.Logf("expedition ended before day %d", day) break } preDay := fresh.CurrentDay preSupplies := fresh.Supplies.Current preThreat := fresh.ThreatLevel if err := p.deliverBriefing(fresh, now); err != nil { t.Fatalf("deliverBriefing day %d: %v", day, err) } // Advance the wallclock + roll the start back another 24h so the // next briefing's day-guard fires. now = now.Add(24 * time.Hour) if _, err := dbExecExpeditionBackdate(exp.ID, 24*time.Hour); err != nil { t.Fatalf("backdate day %d: %v", day, err) } post, _ := getExpedition(exp.ID) t.Logf("day %d: day=%d→%d supplies=%v→%v threat=%d→%d", day, preDay, post.CurrentDay, preSupplies, post.Supplies.Current, preThreat, post.ThreatLevel) if post.CurrentDay <= preDay { t.Errorf("day did not advance: %d → %d", preDay, post.CurrentDay) } } // !expedition status / log if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "status"); err != nil { t.Fatalf("expedition status: %v", err) } if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "log"); err != nil { t.Fatalf("expedition log: %v", err) } entries, _ := recentExpeditionLog(exp.ID, 10) t.Logf("expedition log has %d entries", len(entries)) for i, e := range entries { t.Logf(" [%d] day=%d type=%s summary=%q", i, e.Day, e.Type, e.Summary) } if len(entries) == 0 { t.Error("expected at least one log entry") } // !expedition abandon — clean state for next test runs. if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "abandon"); err != nil { t.Fatalf("expedition abandon: %v", err) } if got, _ := getActiveExpedition(uid); got != nil { t.Error("expected no active expedition after abandon") } } // ── Scenario C — harvest in an active expedition ──────────────────────────── func TestAdv2Scenario_HarvestForestShadows(t *testing.T) { setupAuditTestDB(t) uid := id.UserID("@adv2-scenC:example") t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) }) if err := createAdvCharacter(uid, "scenC"); err != nil { t.Fatalf("createAdvCharacter: %v", err) } c := &DnDCharacter{ UserID: uid, Race: RaceElf, Class: ClassRanger, Level: 4, STR: 12, DEX: 18, CON: 14, INT: 10, WIS: 16, CHA: 10, HPMax: 40, HPCurrent: 40, ArmorClass: 15, } if err := SaveDnDCharacter(c); err != nil { t.Fatalf("SaveDnDCharacter: %v", err) } euro := &EuroPlugin{} euro.ensureBalance(uid) euro.Credit(uid, 500, "scenC bankroll") p := &AdventurePlugin{euro: euro} // Forage-friendly expedition. if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start forest_shadows"); err != nil { t.Fatalf("expedition start: %v", err) } exp, _ := getActiveExpedition(uid) if exp == nil { t.Fatal("no expedition") } // Drive a region run so harvest has a current room context. run, err := ensureRegionRun(exp, c.Level) if err != nil { t.Fatalf("ensureRegionRun: %v", err) } t.Logf("region run %s in zone %s (%d rooms)", run.RunID, run.ZoneID, run.TotalRooms) // Seed harvest nodes for the entry room (they're auto-seeded by // loadHarvestNodes on first read, but confirm we get at least one). roomIdx := currentRoomIndexFor(exp) nodeID := currentNodeIDFor(exp) nodes := loadHarvestNodes(exp, nodeID) t.Logf("room %d has %d harvest nodes", roomIdx, len(nodes)) if len(nodes) == 0 { t.Error("expected at least one harvest node seeded") } // Drive the auto-harvest pass (post-H3 there is no manual command // surface). Outcomes are RNG-driven; we mainly want no panics + // coherent state. for i := 0; i < 3; i++ { if _, err := p.autoHarvestRoom(uid, run, c, exp); err != nil { t.Fatalf("autoHarvestRoom attempt %d: %v", i, err) } fresh, _ := getExpedition(exp.ID) if fresh == nil { t.Logf("expedition ended during auto-harvest #%d", i) break } t.Logf("after auto-harvest #%d: supplies=%v threat=%d", i, fresh.Supplies.Current, fresh.ThreatLevel) } // !resources output path (no-op SendDM but should not error). if err := p.handleResourcesCmd(MessageContext{Sender: uid}); err != nil { t.Fatalf("handleResourcesCmd: %v", err) } }