mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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") {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
139
internal/plugin/dnd_expedition_map.go
Normal file
139
internal/plugin/dnd_expedition_map.go
Normal 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())
|
||||
}
|
||||
80
internal/plugin/dnd_expedition_map_test.go
Normal file
80
internal/plugin/dnd_expedition_map_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user