Adv 2.0 D&D Phase 12 E6a: Multi-region expedition map (!map)

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) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 16:40:18 -07:00
parent d179ca27a7
commit 157455a3fe
4 changed files with 226 additions and 1 deletions

View File

@@ -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>`.")
}
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())
}