diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index f276277..6368806 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -60,6 +60,8 @@ func (p *AdventurePlugin) handleDnDZoneCmd(ctx MessageContext, args string) erro return p.zoneCmdTaunt(ctx) case "compliment": return p.zoneCmdCompliment(ctx) + case "lore", "history": + return p.zoneCmdLore(ctx) default: return p.SendDM(ctx.Sender, zoneHelpText()) } @@ -75,7 +77,8 @@ func zoneHelpText() string { b.WriteString("`!zone advance` — resolve the current room and move on\n") b.WriteString("`!zone abandon` — end the active run (no rewards)\n") b.WriteString("`!zone taunt` — poke TwinBee (mood −10)\n") - b.WriteString("`!zone compliment` — flatter TwinBee (mood +5)") + b.WriteString("`!zone compliment` — flatter TwinBee (mood +5)\n") + b.WriteString("`!zone lore` — TwinBee shares zone history") return b.String() } @@ -541,6 +544,30 @@ func (p *AdventurePlugin) zoneMoodInteraction( return p.SendDM(ctx.Sender, b.String()) } +// ── lore ──────────────────────────────────────────────────────────────────── + +// zoneCmdLore renders a deterministic TwinBee lore line for the active +// run's current room. Pulls from the zone-specific LoreLines* pool when +// one exists (Tier 1–5 ship dedicated lore), falls back to the generic +// LoreLines pool otherwise. roomIdx is folded into the selector so +// repeated `!zone lore` in the same room returns the same prose, but +// cross-room calls vary. +func (p *AdventurePlugin) zoneCmdLore(ctx MessageContext) error { + run, err := getActiveZoneRun(ctx.Sender) + if err != nil { + return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error()) + } + if run == nil { + return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter `.") + } + zone, _ := getZone(run.ZoneID) + line := twinBeeLine(zone.ID, GMLore, run.RunID, run.CurrentRoom) + if line == "" { + return p.SendDM(ctx.Sender, "TwinBee has nothing to say about this place.") + } + return p.SendDM(ctx.Sender, fmt.Sprintf("📖 **%s — Lore**\n\n%s", zone.Display, line)) +} + // ── abandon ───────────────────────────────────────────────────────────────── func (p *AdventurePlugin) zoneCmdAbandon(ctx MessageContext) error { diff --git a/internal/plugin/dnd_zone_cmd_test.go b/internal/plugin/dnd_zone_cmd_test.go index fca0b5b..6fbd092 100644 --- a/internal/plugin/dnd_zone_cmd_test.go +++ b/internal/plugin/dnd_zone_cmd_test.go @@ -318,6 +318,47 @@ func TestZoneCmd_TauntWithoutRunNoCrash(t *testing.T) { } } +// 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.GMMood != before.GMMood { + t.Errorf("lore should not move mood: before %d, after %d", before.GMMood, after.GMMood) + } + 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)