mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Every ownership lookup in the adventure module keys on a user id, and a party member owns neither the expedition row nor the zone run. So each player-facing read quietly told them they were not playing. Rewire them. Reads a member should see resolve through activeExpeditionFor / activeZoneRunFor. Leader-only actions answer with copy that names the leader instead of denying the expedition. Three busy-guards had to start refusing a member outright: !zone enter, !expedition start and !sell all keyed on the sender's own row, so a seated member could open a private dungeon, outfit a rival expedition, or run a shop from the boss room. Four things the rewire itself exposed: !resources looks like a read but seed-persists harvest nodes, and saveHarvestNodes rewrites the entire region_state blob — kills, event gates, temporal stack — last-write-wins. Reaching it as a member would revert the leader's walk from a stale snapshot. Persist only for the owner; seedRoomNodes is pure, so a member re-derives the same nodes. !zone taunt moves the party's shared mood, which is intended and safe: applyMoodEvent lands an atomic delta. Its neighbour applyMoodDecayIfStale writes an absolute gm_mood from the caller's snapshot, and every command takes the *sender's* lock — a member running it against the leader's run holds the wrong mutex. The owner check now lives on that function. A seat outlives status='active'. releaseParty deliberately skips the seven-day 'extracting' limbo, so the roster persists while activeExpeditionFor goes blind — long enough for a member to open a run that wins every lookup once the leader !resumes. seatedExpeditionFor spans both statuses; it is what the busy-guards ask. !expedition run was still member-blind. It is the same walk as !zone advance, reached by its other name. isPartyMember replaces `run != nil && !isLeader`: activeZoneRunFor reports isLeader=false for a player with no run anywhere, so the bare test sends a solo player to go ask their leader. Golden byte-identical; solo T1 expedition clears end-to-end.
148 lines
4.6 KiB
Go
148 lines
4.6 KiB
Go
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 := activeExpeditionFor(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, _, _ := activeZoneRunFor(ctx.Sender)
|
||
if run != nil {
|
||
if g, ok := loadZoneGraph(run.ZoneID); ok {
|
||
b.WriteString("```\n")
|
||
b.WriteString(renderZoneGraphMap(g, run))
|
||
b.WriteString("\n```\n")
|
||
b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss ⚿=Secret · ✓ cleared ▶ here · pending ╳ locked_")
|
||
if path := renderVisitedPath(g, run); path != "" {
|
||
b.WriteString("\n**Path:** " + path)
|
||
}
|
||
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_")
|
||
}
|
||
}
|
||
} 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())
|
||
}
|