mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
GM→DM rename across docs and code (GMNarrationType→DMNarrationType, GMState→DMState, narration constants, comments) so the system reads as "Dungeon Master" everywhere. Player-visible "GM mood" wording stays where it appears in flavor. Streamed zone/expedition combat: zone advance now stages patrol → patrol play-by-play → patrol resolution → room intro → room play-by-play → final outcome through sendZoneCombatMessages with 2–3s pacing (arena keeps its 5–8s window). Combat narrative lines pick up a compact d20-vs-AC roll annotation for hit/crit/miss/block events. Combat outcome polish: dndHPSnapshot lets narration show sheet HP rather than legacy combat-engine HP, and markAdventureDead clears the zombie state where hp_current was 0 but the legacy alive flag stayed true after a D&D-layer KO. Adv 2.0 announcement (ADVENTURE_2.0_ANNOUNCEMENT.md), README rewrite covering the new layer, and adv2_scenario_test.go — a full zone-run + expedition + harvest playthrough against a copy of the prod DB asserting persisted state end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
414 lines
12 KiB
Go
414 lines
12 KiB
Go
package plugin
|
||
|
||
import (
|
||
"testing"
|
||
|
||
"maunium.net/go/mautrix/id"
|
||
)
|
||
|
||
// Phase 11 D1c — !zone command surface tests. Side-effect coverage only;
|
||
// SendDM is a no-op without a Matrix client (see plugin.SendDM), so these
|
||
// tests check the persisted state changes the command path produced
|
||
// rather than the rendered text.
|
||
|
||
func zoneCmdTestCharacter(t *testing.T, uid id.UserID, level int) {
|
||
t.Helper()
|
||
if err := createAdvCharacter(uid, "zonecmd"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
c := &DnDCharacter{
|
||
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: level,
|
||
STR: 14, DEX: 12, CON: 14, INT: 10, WIS: 10, CHA: 10,
|
||
HPMax: 20, HPCurrent: 20, ArmorClass: 14,
|
||
}
|
||
if err := SaveDnDCharacter(c); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
func TestZoneCmd_ListNoCharIsBlocked(t *testing.T) {
|
||
setupAuditTestDB(t)
|
||
uid := id.UserID("@zone-cmd-nochar:example")
|
||
defer cleanupZoneRuns(uid)
|
||
p := &AdventurePlugin{}
|
||
// No character row → handler returns the setup nudge, no error.
|
||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, ""); err != nil {
|
||
t.Fatalf("handleDnDZoneCmd: %v", err)
|
||
}
|
||
if active, _ := getActiveZoneRun(uid); active != nil {
|
||
t.Error("no run should exist")
|
||
}
|
||
}
|
||
|
||
func TestZoneCmd_ListWithCharSucceeds(t *testing.T) {
|
||
setupAuditTestDB(t)
|
||
uid := id.UserID("@zone-cmd-list:example")
|
||
zoneCmdTestCharacter(t, uid, 1)
|
||
defer cleanupZoneRuns(uid)
|
||
p := &AdventurePlugin{}
|
||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, ""); err != nil {
|
||
t.Fatalf("list: %v", err)
|
||
}
|
||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "list"); err != nil {
|
||
t.Fatalf("list explicit: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestZoneCmd_EnterStartsRun(t *testing.T) {
|
||
setupAuditTestDB(t)
|
||
uid := id.UserID("@zone-cmd-enter:example")
|
||
zoneCmdTestCharacter(t, uid, 1)
|
||
defer cleanupZoneRuns(uid)
|
||
p := &AdventurePlugin{}
|
||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "enter goblin_warrens"); err != nil {
|
||
t.Fatalf("enter: %v", err)
|
||
}
|
||
run, err := getActiveZoneRun(uid)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if run == nil {
|
||
t.Fatal("expected active run after enter")
|
||
}
|
||
if run.ZoneID != ZoneGoblinWarrens {
|
||
t.Errorf("zone = %s", run.ZoneID)
|
||
}
|
||
}
|
||
|
||
func TestZoneCmd_EnterByIndex(t *testing.T) {
|
||
setupAuditTestDB(t)
|
||
uid := id.UserID("@zone-cmd-enter-idx:example")
|
||
zoneCmdTestCharacter(t, uid, 1)
|
||
defer cleanupZoneRuns(uid)
|
||
p := &AdventurePlugin{}
|
||
available := zonesForLevel(1)
|
||
if len(available) == 0 {
|
||
t.Skip("no zones registered for L1")
|
||
}
|
||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "enter 1"); err != nil {
|
||
t.Fatalf("enter 1: %v", err)
|
||
}
|
||
run, _ := getActiveZoneRun(uid)
|
||
if run == nil {
|
||
t.Fatal("expected active run after enter 1")
|
||
}
|
||
if run.ZoneID != available[0].ID {
|
||
t.Errorf("zone = %s, want %s", run.ZoneID, available[0].ID)
|
||
}
|
||
}
|
||
|
||
func TestZoneCmd_EnterUnknownDoesNotStart(t *testing.T) {
|
||
setupAuditTestDB(t)
|
||
uid := id.UserID("@zone-cmd-unk:example")
|
||
zoneCmdTestCharacter(t, uid, 1)
|
||
defer cleanupZoneRuns(uid)
|
||
p := &AdventurePlugin{}
|
||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "enter not_a_real_zone"); err != nil {
|
||
t.Fatalf("enter unknown: %v", err)
|
||
}
|
||
if run, _ := getActiveZoneRun(uid); run != nil {
|
||
t.Error("unknown zone should not start a run")
|
||
}
|
||
}
|
||
|
||
func TestZoneCmd_EnterRejectedWhileRunActive(t *testing.T) {
|
||
setupAuditTestDB(t)
|
||
uid := id.UserID("@zone-cmd-dup:example")
|
||
zoneCmdTestCharacter(t, uid, 1)
|
||
defer cleanupZoneRuns(uid)
|
||
p := &AdventurePlugin{}
|
||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "enter goblin_warrens"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
first, _ := getActiveZoneRun(uid)
|
||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "enter crypt_valdris"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
now, _ := getActiveZoneRun(uid)
|
||
if now == nil || now.RunID != first.RunID {
|
||
t.Error("second enter should not replace the active run")
|
||
}
|
||
}
|
||
|
||
func TestZoneCmd_StatusAndMapNoActiveRun(t *testing.T) {
|
||
setupAuditTestDB(t)
|
||
uid := id.UserID("@zone-cmd-noactive:example")
|
||
zoneCmdTestCharacter(t, uid, 1)
|
||
defer cleanupZoneRuns(uid)
|
||
p := &AdventurePlugin{}
|
||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "status"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "map"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
func TestZoneCmd_AdvanceMovesRoom(t *testing.T) {
|
||
setupAuditTestDB(t)
|
||
uid := id.UserID("@zone-cmd-adv:example")
|
||
zoneCmdTestCharacter(t, uid, 1)
|
||
defer cleanupZoneRuns(uid)
|
||
p := &AdventurePlugin{}
|
||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "enter goblin_warrens"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
before, _ := getActiveZoneRun(uid)
|
||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "advance"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
after, _ := getActiveZoneRun(uid)
|
||
if after == nil {
|
||
t.Fatal("run vanished after advance")
|
||
}
|
||
if after.CurrentRoom != before.CurrentRoom+1 {
|
||
t.Errorf("current room: before=%d after=%d", before.CurrentRoom, after.CurrentRoom)
|
||
}
|
||
if len(after.RoomsCleared) != 1 {
|
||
t.Errorf("rooms cleared = %d, want 1", len(after.RoomsCleared))
|
||
}
|
||
}
|
||
|
||
func TestZoneCmd_AbandonClearsActive(t *testing.T) {
|
||
setupAuditTestDB(t)
|
||
uid := id.UserID("@zone-cmd-aban:example")
|
||
zoneCmdTestCharacter(t, uid, 1)
|
||
defer cleanupZoneRuns(uid)
|
||
p := &AdventurePlugin{}
|
||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "enter goblin_warrens"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "abandon"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if run, _ := getActiveZoneRun(uid); run != nil {
|
||
t.Error("active run after abandon")
|
||
}
|
||
// Second abandon should be a no-op (handler swallows ErrNoActiveRun
|
||
// and DM-replies "no run to abandon" — no error returned).
|
||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "abandon"); err != nil {
|
||
t.Fatalf("second abandon: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestResolveZoneInput_MatchesIDIndexAndDisplay(t *testing.T) {
|
||
avail := zonesForLevel(1)
|
||
if len(avail) < 2 {
|
||
t.Skip("need at least 2 zones registered")
|
||
}
|
||
cases := []struct {
|
||
in string
|
||
want ZoneID
|
||
}{
|
||
{"goblin_warrens", ZoneGoblinWarrens},
|
||
{"GOBLIN_WARRENS", ZoneGoblinWarrens},
|
||
{"Goblin Warrens", ZoneGoblinWarrens},
|
||
{"crypt of valdris", ZoneCryptValdris},
|
||
{"1", avail[0].ID},
|
||
{"2", avail[1].ID},
|
||
}
|
||
for _, c := range cases {
|
||
got, ok := resolveZoneInput(c.in, avail)
|
||
if !ok {
|
||
t.Errorf("resolve(%q): not found", c.in)
|
||
continue
|
||
}
|
||
if got != c.want {
|
||
t.Errorf("resolve(%q) = %s, want %s", c.in, got, c.want)
|
||
}
|
||
}
|
||
if _, ok := resolveZoneInput("nope", avail); ok {
|
||
t.Error("expected resolveZoneInput(nope) to fail")
|
||
}
|
||
if _, ok := resolveZoneInput("999", avail); ok {
|
||
t.Error("expected out-of-range index to fail")
|
||
}
|
||
}
|
||
|
||
func TestGMMoodLabel_Bands(t *testing.T) {
|
||
cases := map[int]string{
|
||
100: "effusive",
|
||
80: "effusive",
|
||
79: "friendly",
|
||
60: "friendly",
|
||
59: "neutral",
|
||
40: "neutral",
|
||
39: "grumpy",
|
||
20: "grumpy",
|
||
19: "hostile",
|
||
0: "hostile",
|
||
}
|
||
for mood, want := range cases {
|
||
if got := dmMoodLabel(mood); got != want {
|
||
t.Errorf("dmMoodLabel(%d) = %s, want %s", mood, got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Phase 11 D7 — !zone taunt / !zone compliment.
|
||
|
||
func TestZoneCmd_TauntAppliesMoodPenalty(t *testing.T) {
|
||
setupAuditTestDB(t)
|
||
uid := id.UserID("@zone-cmd-taunt:example")
|
||
zoneCmdTestCharacter(t, uid, 1)
|
||
defer cleanupZoneRuns(uid)
|
||
p := &AdventurePlugin{}
|
||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "enter goblin_warrens"); err != nil {
|
||
t.Fatalf("enter: %v", err)
|
||
}
|
||
before, err := getActiveZoneRun(uid)
|
||
if err != nil || before == nil {
|
||
t.Fatalf("active run after enter: %v", err)
|
||
}
|
||
startMood := before.DMMood
|
||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "taunt"); err != nil {
|
||
t.Fatalf("taunt: %v", err)
|
||
}
|
||
after, err := getActiveZoneRun(uid)
|
||
if err != nil || after == nil {
|
||
t.Fatalf("active run after taunt: %v", err)
|
||
}
|
||
want := clampMood(startMood + MoodEventDelta(MoodEventTaunt))
|
||
if after.DMMood != want {
|
||
t.Errorf("mood after taunt = %d, want %d (start %d, delta %d)",
|
||
after.DMMood, want, startMood, MoodEventDelta(MoodEventTaunt))
|
||
}
|
||
}
|
||
|
||
func TestZoneCmd_ComplimentAppliesMoodBonus(t *testing.T) {
|
||
setupAuditTestDB(t)
|
||
uid := id.UserID("@zone-cmd-compliment:example")
|
||
zoneCmdTestCharacter(t, uid, 1)
|
||
defer cleanupZoneRuns(uid)
|
||
p := &AdventurePlugin{}
|
||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "enter goblin_warrens"); err != nil {
|
||
t.Fatalf("enter: %v", err)
|
||
}
|
||
before, err := getActiveZoneRun(uid)
|
||
if err != nil || before == nil {
|
||
t.Fatalf("active run after enter: %v", err)
|
||
}
|
||
startMood := before.DMMood
|
||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "compliment"); err != nil {
|
||
t.Fatalf("compliment: %v", err)
|
||
}
|
||
after, err := getActiveZoneRun(uid)
|
||
if err != nil || after == nil {
|
||
t.Fatalf("active run after compliment: %v", err)
|
||
}
|
||
want := clampMood(startMood + MoodEventDelta(MoodEventCompliment))
|
||
if after.DMMood != want {
|
||
t.Errorf("mood after compliment = %d, want %d (start %d, delta %d)",
|
||
after.DMMood, want, startMood, MoodEventDelta(MoodEventCompliment))
|
||
}
|
||
}
|
||
|
||
func TestZoneCmd_TauntWithoutRunNoCrash(t *testing.T) {
|
||
setupAuditTestDB(t)
|
||
uid := id.UserID("@zone-cmd-taunt-norun:example")
|
||
zoneCmdTestCharacter(t, uid, 1)
|
||
defer cleanupZoneRuns(uid)
|
||
p := &AdventurePlugin{}
|
||
// No active run — should return cleanly with the no-run nudge.
|
||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "taunt"); err != nil {
|
||
t.Fatalf("taunt no-run: %v", err)
|
||
}
|
||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "compliment"); err != nil {
|
||
t.Fatalf("compliment no-run: %v", err)
|
||
}
|
||
}
|
||
|
||
// Phase 11 D9 — !zone lore.
|
||
|
||
func TestZoneCmd_LoreWithoutRunNoCrash(t *testing.T) {
|
||
setupAuditTestDB(t)
|
||
uid := id.UserID("@zone-cmd-lore-norun:example")
|
||
zoneCmdTestCharacter(t, uid, 1)
|
||
defer cleanupZoneRuns(uid)
|
||
p := &AdventurePlugin{}
|
||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "lore"); err != nil {
|
||
t.Fatalf("lore no-run: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestZoneCmd_LoreWithActiveRunNoSideEffects(t *testing.T) {
|
||
setupAuditTestDB(t)
|
||
uid := id.UserID("@zone-cmd-lore-run:example")
|
||
zoneCmdTestCharacter(t, uid, 1)
|
||
defer cleanupZoneRuns(uid)
|
||
p := &AdventurePlugin{}
|
||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "enter goblin_warrens"); err != nil {
|
||
t.Fatalf("enter: %v", err)
|
||
}
|
||
before, err := getActiveZoneRun(uid)
|
||
if err != nil || before == nil {
|
||
t.Fatalf("active run after enter: %v", err)
|
||
}
|
||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "lore"); err != nil {
|
||
t.Fatalf("lore: %v", err)
|
||
}
|
||
after, err := getActiveZoneRun(uid)
|
||
if err != nil || after == nil {
|
||
t.Fatalf("active run after lore: %v", err)
|
||
}
|
||
if after.DMMood != before.DMMood {
|
||
t.Errorf("lore should not move mood: before %d, after %d", before.DMMood, after.DMMood)
|
||
}
|
||
if after.CurrentRoom != before.CurrentRoom {
|
||
t.Errorf("lore should not advance room: before %d, after %d", before.CurrentRoom, after.CurrentRoom)
|
||
}
|
||
}
|
||
|
||
func TestTauntComplimentResponseLines_Deterministic(t *testing.T) {
|
||
const runID = "run-d7-test"
|
||
t1 := tauntResponseLine(runID, 0)
|
||
t2 := tauntResponseLine(runID, 0)
|
||
if t1 == "" || t1 != t2 {
|
||
t.Errorf("taunt lines not deterministic: %q vs %q", t1, t2)
|
||
}
|
||
c1 := complimentResponseLine(runID, 0)
|
||
c2 := complimentResponseLine(runID, 0)
|
||
if c1 == "" || c1 != c2 {
|
||
t.Errorf("compliment lines not deterministic: %q vs %q", c1, c2)
|
||
}
|
||
if t1 == c1 {
|
||
t.Errorf("taunt and compliment shouldn't collide on same key: %q", t1)
|
||
}
|
||
}
|
||
|
||
func TestRenderZoneMap_LayoutAndMarkers(t *testing.T) {
|
||
seq := []RoomType{RoomEntry, RoomExploration, RoomTrap, RoomElite, RoomBoss}
|
||
cleared := map[int]bool{0: true, 1: true}
|
||
got := renderZoneMap(seq, 2, cleared)
|
||
want := "E──?──T──★──☠\n✓ ✓ ▶ · ·"
|
||
if got != want {
|
||
t.Errorf("renderZoneMap mismatch:\n got: %q\nwant: %q", got, want)
|
||
}
|
||
}
|
||
|
||
func TestRenderZoneMap_Empty(t *testing.T) {
|
||
if got := renderZoneMap(nil, 0, nil); got != "(no rooms)" {
|
||
t.Errorf("empty seq: got %q", got)
|
||
}
|
||
}
|
||
|
||
// TestNarrationCoverage_AllZonesAllTypes — D6 polish guarantee. Locks in
|
||
// that every DMNarrationType resolves to a non-empty pool for every
|
||
// registered zone. Adding a new zone or new DMNarrationType without
|
||
// wiring its pool will fail this test.
|
||
func TestNarrationCoverage_AllZonesAllTypes(t *testing.T) {
|
||
types := []DMNarrationType{
|
||
DMRoomEntry, DMCombatStart, DMCombatEnd, DMNat20, DMNat1,
|
||
DMBossEntry, DMBossDeath, DMPlayerDeath, DMZoneComplete,
|
||
DMTrapDetected, DMTrapTripped, DMLore,
|
||
}
|
||
for _, z := range allZones() {
|
||
for _, k := range types {
|
||
pool := pickPool(z.ID, k)
|
||
if len(pool) == 0 {
|
||
t.Errorf("zone %s × %s: empty pool — every (zone, type) must route to prewritten flavor", z.ID, k)
|
||
}
|
||
}
|
||
}
|
||
}
|