From 157455a3feb7939f5b6071ce976f58ffab3b9433 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 8 May 2026 16:40:18 -0700 Subject: [PATCH] Adv 2.0 D&D Phase 12 E6a: Multi-region expedition map (!map) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds renderRegionChain — for tier 4-5 multi-region zones, renders the 4-region progression (e.g. ST──DO──IW──TDT for Underdark) with ✓ cleared / ▶ here / · pending markers, and ⛺ overlay where a base camp has been pitched. Single-region zones skip the chain and fall through to the existing renderZoneMap room layout. !map (top-level) and !expedition map both invoke handleExpeditionMapCmd which composes: region chain → current region label → per-run room map → legend. Region abbreviations are decoded in a "Regions:" footer so the glyph row stays compact. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/plugin/adventure.go | 3 + internal/plugin/dnd_expedition_cmd.go | 5 +- internal/plugin/dnd_expedition_map.go | 139 +++++++++++++++++++++ internal/plugin/dnd_expedition_map_test.go | 80 ++++++++++++ 4 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 internal/plugin/dnd_expedition_map.go create mode 100644 internal/plugin/dnd_expedition_map_test.go diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index be21da6..fc26556 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -270,6 +270,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error { if p.IsCommand(ctx.Body, "resume") { return p.handleResumeCmd(ctx, p.GetArgs(ctx.Body, "resume")) } + if p.IsCommand(ctx.Body, "map") { + return p.handleExpeditionMapCmd(ctx, p.GetArgs(ctx.Body, "map")) + } // 1. Arena commands (work in rooms and DMs) if p.IsCommand(ctx.Body, "bail") { diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index f766434..67c6e0f 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -68,6 +68,8 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string return p.handleExtractCmd(ctx, "") case "resume": return p.handleResumeCmd(ctx, rest) + case "map", "m": + return p.handleExpeditionMapCmd(ctx, "") default: return p.SendDM(ctx.Sender, expeditionHelpText()) } @@ -85,7 +87,8 @@ func expeditionHelpText() string { b.WriteString("`!expedition log` — last 5 log entries\n") b.WriteString("`!expedition abandon` — end the expedition (no rewards)\n") b.WriteString("`!extract` — voluntary extraction (1 day, resumable for 7 days)\n") - b.WriteString("`!resume [Ns] [Md]` — resume an extracted expedition") + b.WriteString("`!resume [Ns] [Md]` — resume an extracted expedition\n") + b.WriteString("`!map` — region/room ASCII map") return b.String() } diff --git a/internal/plugin/dnd_expedition_map.go b/internal/plugin/dnd_expedition_map.go new file mode 100644 index 0000000..7ca652f --- /dev/null +++ b/internal/plugin/dnd_expedition_map.go @@ -0,0 +1,139 @@ +package plugin + +import ( + "fmt" + "strings" +) + +// Phase 12 E6 — Expedition map renderer. +// Spec: gogobee_expedition_system.md §14 (`!map` — "Display expedition +// progress as ASCII region/room map"). +// +// For multi-region zones (Underdark / Dragon's Lair / Abyss Portal) the +// output is a two-row region chain on top of the existing per-run room +// map. Single-region zones render just the room map. The room map is +// rendered by renderZoneMap (dnd_zone_cmd.go) — this file only adds the +// region chain layer and the !map / !expedition map command wiring. + +// renderRegionChain renders the multi-region progression as +// Region1──Region2──Region3──Region4 with a status row underneath +// (✓ cleared, ▶ here, · pending). Returns "" for single-region zones. +func renderRegionChain(e *Expedition) string { + regions := regionsForZone(e.ZoneID) + if len(regions) == 0 { + return "" + } + cur, _ := CurrentRegion(e) + cleared := regionListFromState(e, regionStateClearedKey) + camps := regionListFromState(e, regionStateBaseCampKey) + + var top, bot strings.Builder + for i, r := range regions { + if i > 0 { + top.WriteString("──") + bot.WriteString(" ") + } + // Top row: short region label (first letters of each word, max 3). + top.WriteString(regionGlyph(r.Name)) + // Bottom row: status marker, with ⛺ overlay when a base camp + // has been pitched there. + var marker string + switch { + case regionListContains(cleared, r.ID): + marker = "✓" + case r.ID == cur.ID: + marker = "▶" + default: + marker = "·" + } + if regionListContains(camps, r.ID) { + marker = "⛺" + } + bot.WriteString(marker) + } + return top.String() + "\n" + bot.String() +} + +// regionGlyph builds a 2-3 char abbreviation for a region name. Initials +// of each word, capped at 3 chars. "Surface Tunnels" → "ST", +// "The Deep Throne" → "TDT", "Drow Outpost" → "DO". +func regionGlyph(name string) string { + parts := strings.Fields(name) + var b strings.Builder + for _, p := range parts { + if len(p) == 0 { + continue + } + b.WriteByte(p[0]) + if b.Len() >= 3 { + break + } + } + return strings.ToUpper(b.String()) +} + +// renderRegionLegend lists the abbreviations used in renderRegionChain so +// the player can decode the glyphs. Empty for single-region zones. +func renderRegionLegend(e *Expedition) string { + regions := regionsForZone(e.ZoneID) + if len(regions) == 0 { + return "" + } + parts := make([]string, 0, len(regions)) + for _, r := range regions { + parts = append(parts, fmt.Sprintf("%s=%s", regionGlyph(r.Name), r.Name)) + } + return strings.Join(parts, " ") +} + +// ── !map command ──────────────────────────────────────────────────────────── + +func (p *AdventurePlugin) handleExpeditionMapCmd(ctx MessageContext, _ string) error { + exp, err := getActiveExpedition(ctx.Sender) + if err != nil { + return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error()) + } + if exp == nil { + return p.SendDM(ctx.Sender, "No active expedition. Use `!expedition start `.") + } + zone, _ := getZone(exp.ZoneID) + + var b strings.Builder + b.WriteString(fmt.Sprintf("🗺 **%s — Expedition Map (Day %d)**\n", zone.Display, exp.CurrentDay)) + + chain := renderRegionChain(exp) + if chain != "" { + cur, _ := CurrentRegion(exp) + b.WriteString("```\n") + b.WriteString(chain) + b.WriteString("\n```\n") + b.WriteString(fmt.Sprintf("_Current region: **%s**_\n\n", cur.Name)) + } + + // Per-run room map. The expedition's zone run is the per-region + // dungeon instance; for single-region zones it's the only run. + run, _ := getActiveZoneRun(ctx.Sender) + if run != nil && len(run.RoomSeq) > 0 { + cleared := map[int]bool{} + for _, r := range run.RoomsCleared { + cleared[r] = true + } + b.WriteString("```\n") + b.WriteString(renderZoneMap(run.RoomSeq, run.CurrentRoom, cleared)) + b.WriteString("\n```\n") + b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss · ✓ cleared ▶ here · pending_") + if chain != "" { + b.WriteString("\n_Regions: ") + b.WriteString(renderRegionLegend(exp)) + b.WriteString(" · ⛺ base camp pitched_") + } + } else { + b.WriteString("_(no room layout — between rooms or pre-entry)_") + if chain != "" { + b.WriteString("\n_Regions: ") + b.WriteString(renderRegionLegend(exp)) + b.WriteString(" · ⛺ base camp pitched_") + } + } + return p.SendDM(ctx.Sender, b.String()) +} diff --git a/internal/plugin/dnd_expedition_map_test.go b/internal/plugin/dnd_expedition_map_test.go new file mode 100644 index 0000000..18ee5db --- /dev/null +++ b/internal/plugin/dnd_expedition_map_test.go @@ -0,0 +1,80 @@ +package plugin + +import ( + "strings" + "testing" +) + +func TestRenderRegionChain_SingleRegionZone(t *testing.T) { + e := &Expedition{ZoneID: ZoneSunkenTemple} + if got := renderRegionChain(e); got != "" { + t.Errorf("expected empty chain for single-region zone, got %q", got) + } +} + +func TestRenderRegionChain_MultiRegionMarkers(t *testing.T) { + e := &Expedition{ + ZoneID: ZoneUnderdark, + CurrentRegion: "underdark_drow_outpost", + RegionState: map[string]any{ + regionStateClearedKey: []string{"underdark_surface_tunnels"}, + regionStateBaseCampKey: []string{"underdark_drow_outpost"}, + }, + } + out := renderRegionChain(e) + if out == "" { + t.Fatalf("expected non-empty chain for multi-region zone") + } + lines := strings.Split(out, "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d (%q)", len(lines), out) + } + bot := lines[1] + // 4 regions: cleared / camp(at-current) / pending / pending + // Camp marker overrides the ▶ (here) per renderRegionChain. + if !strings.Contains(bot, "✓") { + t.Errorf("expected ✓ for cleared region: %q", bot) + } + if !strings.Contains(bot, "⛺") { + t.Errorf("expected ⛺ at base-camp region: %q", bot) + } + if !strings.Contains(bot, "·") { + t.Errorf("expected · for pending region: %q", bot) + } +} + +func TestRenderRegionChain_CurrentMarker(t *testing.T) { + e := &Expedition{ + ZoneID: ZoneUnderdark, + CurrentRegion: "underdark_drow_outpost", + } + out := renderRegionChain(e) + if !strings.Contains(out, "▶") { + t.Errorf("expected ▶ at current region (no camps yet): %q", out) + } +} + +func TestRegionGlyph(t *testing.T) { + cases := map[string]string{ + "Surface Tunnels": "ST", + "The Deep Throne": "TDT", + "Drow Outpost": "DO", + "Illithid Warren": "IW", + "Kobold Warrens": "KW", + } + for name, want := range cases { + if got := regionGlyph(name); got != want { + t.Errorf("regionGlyph(%q) = %q, want %q", name, got, want) + } + } +} + +func TestRenderRegionLegend_HasAllRegions(t *testing.T) { + e := &Expedition{ZoneID: ZoneUnderdark} + got := renderRegionLegend(e) + for _, want := range []string{"Surface Tunnels", "Drow Outpost", "Illithid Warren", "The Deep Throne"} { + if !strings.Contains(got, want) { + t.Errorf("legend missing %q: %s", want, got) + } + } +}