Adv 2.0 D&D Phase 11 D1c: !zone command surface

dnd_zone_cmd.go ships handleDnDZoneCmd with subdispatch over the D1b
state machine: list, enter <id|#|name>, status, map, advance, abandon.
Bare !zone (or !zone list) shows zones available at the player's level
with !zone enter hints; an active run is flagged inline so the player
can't lose track of it.

Enter resolves zone input by ID, 1-based list index, or display name
(case/whitespace-insensitive, "the " prefix tolerated), and rejects
unknown / tier-locked zones plus duplicate concurrent runs without
clobbering the active run. Status reports current room / cleared /
loot / GM mood with a band label (effusive/friendly/neutral/grumpy/
hostile per design doc §3.2). Map prints an ASCII layout with ✓ for
cleared rooms and ▶ for the current room. Advance is a deliberate D1c
stub that calls markRoomCleared and reports the next room — combat
resolution per room wires in D1e. Abandon delegates to abandonZoneRun
and is idempotent (no-active surfaces a friendly DM, not an error).

Wired into adventure.go OnMessage right after the existing D&D
commands. 10 new tests cover dispatch (no-char nudge, list with char,
enter by ID and by index, unknown-zone rejection, duplicate-run guard,
status/map with no run, advance moves the room cursor, abandon clears
active and is idempotent), resolveZoneInput across ID/display/index/
case forms, and gmMoodLabel band edges. Full repo test suite green
(modulo a pre-existing RNG flake in
TestSimulateCombat_FirstAttackBonusImprovesEarlyHits unrelated to this
change).

TwinBee narration + mood triggers land in D1d.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 12:06:55 -07:00
parent ee3b2977aa
commit a1738e4a22
3 changed files with 583 additions and 0 deletions

View File

@@ -247,6 +247,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
if p.IsCommand(ctx.Body, "level") { if p.IsCommand(ctx.Body, "level") {
return p.handleDnDLevelCmd(ctx) return p.handleDnDLevelCmd(ctx)
} }
if p.IsCommand(ctx.Body, "zone") {
return p.handleDnDZoneCmd(ctx, p.GetArgs(ctx.Body, "zone"))
}
// 1. Arena commands (work in rooms and DMs) // 1. Arena commands (work in rooms and DMs)
if p.IsCommand(ctx.Body, "bail") { if p.IsCommand(ctx.Body, "bail") {

View File

@@ -0,0 +1,334 @@
package plugin
import (
"fmt"
"strings"
)
// !zone — Phase 11 D1c. The command surface for the dungeon-zone state
// machine landed in D1b. This file is presentation + dispatch only; the
// actual run/state plumbing lives in dnd_zone_run.go and the registry in
// dnd_zone.go.
//
// Subcommands:
//
// !zone → list zones available at player level
// !zone list → same
// !zone enter <id|number|name> → start a run
// !zone status → current room / cleared count / mood / loot
// !zone map → ASCII layout of the run with current marker
// !zone advance → resolve the current room and move on
// !zone abandon → end the active run (no rewards)
//
// Combat resolution per room arrives in D1e; advance currently just
// records the room as cleared and reports the next room type.
// TwinBee narration / mood triggers arrive in D1d.
func (p *AdventurePlugin) handleDnDZoneCmd(ctx MessageContext, args string) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
c, err := LoadDnDCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't load your character: "+err.Error())
}
if c == nil || c.PendingSetup {
return p.SendDM(ctx.Sender,
"No Adv 2.0 character yet — run `!setup` (or just enter combat and we'll auto-build one).")
}
args = strings.TrimSpace(args)
sub, rest := splitFirstWord(args)
switch strings.ToLower(sub) {
case "", "list", "ls":
return p.zoneCmdList(ctx, c)
case "enter", "go", "start":
return p.zoneCmdEnter(ctx, c, rest)
case "status", "info":
return p.zoneCmdStatus(ctx)
case "map", "m":
return p.zoneCmdMap(ctx)
case "advance", "next", "a":
return p.zoneCmdAdvance(ctx)
case "abandon", "leave", "quit":
return p.zoneCmdAbandon(ctx)
default:
return p.SendDM(ctx.Sender, zoneHelpText())
}
}
func zoneHelpText() string {
var b strings.Builder
b.WriteString("**!zone** — dungeon zone runs.\n\n")
b.WriteString("`!zone` or `!zone list` — show zones available at your level\n")
b.WriteString("`!zone enter <id|#>` — start a new run\n")
b.WriteString("`!zone status` — show your current run\n")
b.WriteString("`!zone map` — show the room layout\n")
b.WriteString("`!zone advance` — resolve the current room and move on\n")
b.WriteString("`!zone abandon` — end the active run (no rewards)")
return b.String()
}
// splitFirstWord returns (firstWord, rest) with rest trimmed.
func splitFirstWord(s string) (string, string) {
s = strings.TrimSpace(s)
if s == "" {
return "", ""
}
i := strings.IndexAny(s, " \t")
if i < 0 {
return s, ""
}
return s[:i], strings.TrimSpace(s[i+1:])
}
// ── list ────────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) zoneCmdList(ctx MessageContext, c *DnDCharacter) error {
zones := zonesForLevel(c.Level)
if len(zones) == 0 {
return p.SendDM(ctx.Sender, "No zones available at your level. (This shouldn't happen — file a bug.)")
}
var b strings.Builder
b.WriteString(fmt.Sprintf("**Zones available at L%d** (you can enter zones up to 2 tiers above your current tier):\n\n", c.Level))
for i, z := range zones {
b.WriteString(fmt.Sprintf("**%d.** %s — _T%d, L%d%d_ `!zone enter %s`\n",
i+1, z.Display, int(z.Tier), z.LevelMin, z.LevelMax, z.ID))
b.WriteString(fmt.Sprintf(" %s\n", z.Atmosphere))
}
if active, _ := getActiveZoneRun(ctx.Sender); active != nil {
zone, _ := getZone(active.ZoneID)
b.WriteString(fmt.Sprintf("\n_⚠ Active run: **%s**, room %d/%d. Use `!zone status` or `!zone abandon`._",
zone.Display, active.CurrentRoom+1, active.TotalRooms))
}
return p.SendDM(ctx.Sender, b.String())
}
// ── enter ───────────────────────────────────────────────────────────────────
// resolveZoneInput maps a user-typed token (id, display name, or 1-based
// list index from `!zone list`) to a ZoneID at-or-below the player's
// allowed tier ceiling. Matching is case-insensitive and forgiving on
// spaces/underscores.
func resolveZoneInput(input string, available []ZoneDefinition) (ZoneID, bool) {
in := strings.ToLower(strings.TrimSpace(input))
if in == "" {
return "", false
}
// Numeric index into the available list (1-based).
if n := atoiSafe(in); n >= 1 && n <= len(available) {
return available[n-1].ID, true
}
normIn := strings.ReplaceAll(in, " ", "_")
for _, z := range available {
if strings.EqualFold(string(z.ID), in) || strings.EqualFold(string(z.ID), normIn) {
return z.ID, true
}
if strings.EqualFold(z.Display, input) {
return z.ID, true
}
// Loose match: ignore "the " prefix on display name.
disp := strings.TrimPrefix(strings.ToLower(z.Display), "the ")
if disp == in || disp == normIn {
return z.ID, true
}
}
return "", false
}
// atoiSafe — returns -1 on parse failure.
func atoiSafe(s string) int {
n := 0
if s == "" {
return -1
}
for _, r := range s {
if r < '0' || r > '9' {
return -1
}
n = n*10 + int(r-'0')
}
return n
}
func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest string) error {
if rest == "" {
return p.SendDM(ctx.Sender,
"`!zone enter <id|#>` — pick from `!zone list`. Example: `!zone enter goblin_warrens` or `!zone enter 1`.")
}
available := zonesForLevel(c.Level)
id, ok := resolveZoneInput(rest, available)
if !ok {
return p.SendDM(ctx.Sender,
"Unknown zone for your level. Try `!zone list` to see what's available.")
}
run, err := startZoneRun(ctx.Sender, id, c.Level, nil)
if err != nil {
switch err {
case ErrRunAlreadyActive:
active, _ := getActiveZoneRun(ctx.Sender)
zone, _ := getZone(active.ZoneID)
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You're already in **%s** (room %d/%d). Finish it or `!zone abandon` first.",
zone.Display, active.CurrentRoom+1, active.TotalRooms))
case ErrZoneTierLocked:
return p.SendDM(ctx.Sender,
"That zone is too far above your level. (Cap: 2 tiers above your current.)")
case ErrUnknownZone:
return p.SendDM(ctx.Sender, "Unknown zone.")
default:
return p.SendDM(ctx.Sender, "Couldn't start zone run: "+err.Error())
}
}
zone, _ := getZone(run.ZoneID)
var b strings.Builder
b.WriteString(fmt.Sprintf("🗝 **Entering %s** _(T%d, %d rooms)_\n\n", zone.Display, int(zone.Tier), run.TotalRooms))
b.WriteString("_" + zone.Hook + "_\n\n")
b.WriteString(fmt.Sprintf("**Room 1/%d — %s.** Use `!zone advance` to proceed, `!zone map` for layout.",
run.TotalRooms, prettyRoomType(run.CurrentRoomType())))
return p.SendDM(ctx.Sender, b.String())
}
// ── status ──────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) zoneCmdStatus(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 list` then `!zone enter <id>`.")
}
zone, _ := getZone(run.ZoneID)
var b strings.Builder
b.WriteString(fmt.Sprintf("**%s** — room %d/%d (%s)\n",
zone.Display, run.CurrentRoom+1, run.TotalRooms, prettyRoomType(run.CurrentRoomType())))
b.WriteString(fmt.Sprintf("Cleared: %d Loot: %d GM mood: %d/100 (%s)\n",
len(run.RoomsCleared), len(run.LootCollected), run.GMMood, gmMoodLabel(run.GMMood)))
b.WriteString(fmt.Sprintf("Started: %s Last action: %s",
run.StartedAt.Format("2006-01-02 15:04"),
run.LastActionAt.Format("2006-01-02 15:04")))
return p.SendDM(ctx.Sender, b.String())
}
// gmMoodLabel returns a human-friendly label for the mood gauge per
// design doc §3.2 mood bands (≥80 effusive, 6079 friendly, 4059 neutral,
// 2039 grumpy, <20 hostile).
func gmMoodLabel(mood int) string {
switch {
case mood >= 80:
return "effusive"
case mood >= 60:
return "friendly"
case mood >= 40:
return "neutral"
case mood >= 20:
return "grumpy"
default:
return "hostile"
}
}
// prettyRoomType formats a RoomType for display strings.
func prettyRoomType(rt RoomType) string {
switch rt {
case RoomEntry:
return "Entry"
case RoomExploration:
return "Exploration"
case RoomTrap:
return "Trap"
case RoomElite:
return "Elite"
case RoomBoss:
return "Boss"
default:
return "?"
}
}
// ── map ─────────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) zoneCmdMap(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 <id>`.")
}
zone, _ := getZone(run.ZoneID)
cleared := map[int]bool{}
for _, r := range run.RoomsCleared {
cleared[r] = true
}
var b strings.Builder
b.WriteString(fmt.Sprintf("**%s — Map**\n```\n", zone.Display))
for i, rt := range run.RoomSeq {
marker := " "
switch {
case cleared[i]:
marker = "✓ "
case i == run.CurrentRoom:
marker = "▶ "
}
b.WriteString(fmt.Sprintf("%s[%d] %s\n", marker, i+1, prettyRoomType(rt)))
}
b.WriteString("```")
return p.SendDM(ctx.Sender, b.String())
}
// ── advance ─────────────────────────────────────────────────────────────────
// zoneCmdAdvance is the D1c stub: it records the current room cleared and
// reports the next room. Real combat / trap / boss resolution wires in
// D1e+. This is intentional — D1c ships the *surface*.
func (p *AdventurePlugin) zoneCmdAdvance(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 <id>`.")
}
zone, _ := getZone(run.ZoneID)
prev := run.CurrentRoomType()
prevIdx := run.CurrentRoom
next, err := markRoomCleared(run.RunID)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't advance: "+err.Error())
}
if next == "" {
// Run completed via boss room.
var b strings.Builder
b.WriteString(fmt.Sprintf("🏆 **Cleared %s.** Boss defeated. Run complete.\n\n", zone.Display))
b.WriteString("_(Combat resolution + loot rolls land in D1e — for now this is a clean state-machine win.)_")
return p.SendDM(ctx.Sender, b.String())
}
var b strings.Builder
b.WriteString(fmt.Sprintf("✓ Cleared room %d (%s).\n", prevIdx+1, prettyRoomType(prev)))
b.WriteString(fmt.Sprintf("**Room %d/%d — %s.** ", run.CurrentRoom+2, run.TotalRooms, prettyRoomType(next)))
b.WriteString("`!zone advance` to continue.")
return p.SendDM(ctx.Sender, b.String())
}
// ── abandon ─────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) zoneCmdAbandon(ctx MessageContext) error {
run, _ := getActiveZoneRun(ctx.Sender)
if err := abandonZoneRun(ctx.Sender); err != nil {
if err == ErrNoActiveRun {
return p.SendDM(ctx.Sender, "No active run to abandon.")
}
return p.SendDM(ctx.Sender, "Couldn't abandon run: "+err.Error())
}
if run == nil {
return p.SendDM(ctx.Sender, "Run abandoned.")
}
zone, _ := getZone(run.ZoneID)
return p.SendDM(ctx.Sender, fmt.Sprintf(
"🚪 Abandoned **%s** at room %d/%d. No rewards.",
zone.Display, run.CurrentRoom+1, run.TotalRooms))
}

View File

@@ -0,0 +1,246 @@
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 := gmMoodLabel(mood); got != want {
t.Errorf("gmMoodLabel(%d) = %s, want %s", mood, got, want)
}
}
}