mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 19:01:09 +00:00
The engine narrates every fight, trap and haul to one Matrix DM and then discards the shape underneath it. This records that shape as it happens so Pete can retell the run to somebody who wasn't in the room. Beats ride the roster ticker (one extra request per two minutes, not one per room) but on their own table, never pete_emit_queue: they are high-volume and low-stakes, and a chatty run must not be able to spend the retry budget a death dispatch depends on. Unlike the snapshots they ARE retried — a dropped beat is a hole in a story, not a stale number the next tick corrects. Recording is a leaf everywhere it is called. If a beat can't be written the walk carries on exactly as it did before this existed. Privacy is stricter here than on the board. The board omits an opted-out player from a snapshot; a liveblog would be an account of where they are and what is happening to them, so their beats never leave the box at all — and a run whose owner can't be resolved is refused rather than published. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
304 lines
10 KiB
Go
304 lines
10 KiB
Go
package plugin
|
||
|
||
import (
|
||
"fmt"
|
||
"math/rand/v2"
|
||
"strings"
|
||
|
||
"gogobee/internal/flavor"
|
||
|
||
"maunium.net/go/mautrix/id"
|
||
)
|
||
|
||
// Phase 12 E4c — !region command surface and inter-region travel.
|
||
// Spec: gogobee_expedition_system.md §11.3.
|
||
//
|
||
// Subcommands:
|
||
//
|
||
// !region → list regions w/ status; current first
|
||
// !region list → same as bare
|
||
// !region travel [<id>|next] → travel to the next region (linear)
|
||
//
|
||
// Travel rules (§11.3):
|
||
// - 1 in-game day burned (current_day += 1, daily-burn applied)
|
||
// - 1 wandering monster check fires during transit (no camp protection)
|
||
// - Region-transition narration logged to the expedition log
|
||
//
|
||
// Travel is linear (Order N → Order N+1) in E4c — branching transitions
|
||
// are out of scope for the design doc as written. Players cannot travel
|
||
// past the last region (the zone-boss region).
|
||
|
||
func (p *AdventurePlugin) handleRegionCmd(ctx MessageContext, args string) error {
|
||
userMu := p.advUserLock(ctx.Sender)
|
||
userMu.Lock()
|
||
defer userMu.Unlock()
|
||
|
||
exp, isLeader, 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. `!region` only works during an expedition.")
|
||
}
|
||
if !IsMultiRegionZone(exp.ZoneID) {
|
||
return p.SendDM(ctx.Sender,
|
||
"This zone has no regions — it's a single sub-dungeon. `!region` only applies to Tier 4–5 multi-region zones.")
|
||
}
|
||
|
||
args = strings.TrimSpace(strings.ToLower(args))
|
||
sub, _ := splitFirstWord(args)
|
||
switch sub {
|
||
case "", "list", "ls", "status":
|
||
return p.SendDM(ctx.Sender, renderRegionList(exp))
|
||
case "travel", "advance", "go", "next":
|
||
if !isLeader {
|
||
// Transit burns the shared supply pool and moves everyone.
|
||
return p.SendDM(ctx.Sender,
|
||
"Your party leader calls the march. `!region` to see where you are.")
|
||
}
|
||
return p.regionCmdTravel(ctx, exp)
|
||
case "help", "?":
|
||
return p.SendDM(ctx.Sender, regionHelpText())
|
||
default:
|
||
return p.SendDM(ctx.Sender, regionHelpText())
|
||
}
|
||
}
|
||
|
||
func regionHelpText() string {
|
||
var b strings.Builder
|
||
b.WriteString("**!region** — multi-region zone navigation.\n\n")
|
||
b.WriteString("`!region` — list regions and progress\n")
|
||
b.WriteString("`!region travel` — move to the next region (1 in-game day, supply burn, transit wandering check)\n")
|
||
return b.String()
|
||
}
|
||
|
||
func renderRegionList(exp *Expedition) string {
|
||
rs := regionsForZone(exp.ZoneID)
|
||
zone, _ := getZone(exp.ZoneID)
|
||
var b strings.Builder
|
||
b.WriteString(fmt.Sprintf("🗺 **%s — Regions**\n\n", zone.Display))
|
||
for _, r := range rs {
|
||
marker := " "
|
||
switch {
|
||
case r.ID == exp.CurrentRegion:
|
||
marker = "▶ "
|
||
case IsRegionCleared(exp, r.ID):
|
||
marker = "✓ "
|
||
case IsRegionVisited(exp, r.ID):
|
||
marker = "· "
|
||
}
|
||
bossNote := r.RegionBoss
|
||
if r.IsZoneBoss {
|
||
bossNote += " ★"
|
||
}
|
||
baseTag := ""
|
||
if r.BaseCampSite {
|
||
if HasBaseCampAt(exp, r.ID) {
|
||
baseTag = " ⛺"
|
||
} else if IsRegionCleared(exp, r.ID) {
|
||
baseTag = " ⛺ (eligible)"
|
||
}
|
||
}
|
||
b.WriteString(fmt.Sprintf("%s**%d. %s** — _%s_%s\n",
|
||
marker, r.Order, r.Name, bossNote, baseTag))
|
||
}
|
||
b.WriteString("\n_▶ current ✓ cleared · visited ★ zone boss ⛺ base camp_\n")
|
||
if next, ok := nextRegion(exp.ZoneID, exp.CurrentRegion); ok {
|
||
b.WriteString(fmt.Sprintf("\nNext: **%s** — `!region travel`", next.Name))
|
||
} else {
|
||
b.WriteString("\n_End of the line — the zone boss waits in this region._")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func (p *AdventurePlugin) regionCmdTravel(ctx MessageContext, exp *Expedition) error {
|
||
if exp.Camp != nil && exp.Camp.Active {
|
||
return p.SendDM(ctx.Sender,
|
||
"You can't travel between regions while camped. `!camp break` first.")
|
||
}
|
||
cur, ok := CurrentRegion(exp)
|
||
if !ok {
|
||
return p.SendDM(ctx.Sender, "Current region is not set — this expedition's state is unusual; try `!region` to refresh.")
|
||
}
|
||
next, ok := nextRegion(exp.ZoneID, cur.ID)
|
||
if !ok {
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||
"You're already in **%s** — the final region of this zone. Defeat the zone boss to complete the expedition.",
|
||
cur.Name))
|
||
}
|
||
narrative, err := p.advanceToNextRegion(ctx.Sender, exp, cur, next)
|
||
if err != nil {
|
||
return p.SendDM(ctx.Sender, "Region transit failed: "+err.Error())
|
||
}
|
||
return p.SendDM(ctx.Sender, narrative)
|
||
}
|
||
|
||
// advanceToNextRegion performs an inter-region transition: burns the transit
|
||
// day + supplies, fires the transit wandering check, retires the outgoing
|
||
// region's DungeonRun, bumps CurrentRegion + the visited list, and spawns the
|
||
// incoming region's run (pinning exp.RunID). Returns the rendered transit
|
||
// narrative block. Shared by the manual `!region travel` command and the
|
||
// autopilot walk's mid-zone auto-advance — keeping the transit cost identical
|
||
// on both paths.
|
||
func (p *AdventurePlugin) advanceToNextRegion(userID id.UserID, exp *Expedition, cur, next ExpeditionRegion) (string, error) {
|
||
// Burn one day of supplies (transit day).
|
||
var burned float32
|
||
newSupplies, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
||
harsh := fresh.ThreatLevel > 60 || zoneTemporalHarsh(fresh)
|
||
next, b := applyExpeditionDailyBurn(fresh, harsh, fresh.SiegeMode)
|
||
burned = b
|
||
return next, nil
|
||
})
|
||
if err != nil {
|
||
return "", fmt.Errorf("apply transit supply burn: %w", err)
|
||
}
|
||
exp.Supplies = newSupplies
|
||
if err := advanceExpeditionDay(exp.ID); err != nil {
|
||
return "", fmt.Errorf("advance expedition day: %w", err)
|
||
}
|
||
exp.CurrentDay++
|
||
|
||
// Transit wandering check (no camp protection — campMod = 0).
|
||
c, _ := LoadDnDCharacter(userID)
|
||
var charClass DnDClass
|
||
charLevel := 1
|
||
if c != nil {
|
||
charClass = c.Class
|
||
charLevel = c.Level
|
||
}
|
||
tc := resolveTransitWanderingCheck(exp, charClass, nil)
|
||
_ = processTransitWanderingCheck(exp, tc)
|
||
|
||
// The liveblog beat goes on the *outgoing* run, and it has to be filed before
|
||
// the run is retired — a region crossing is the last thing that happens in
|
||
// the region being left, and it is what explains why that run's log stops.
|
||
if outgoing, _ := getZoneRun(exp.RunID); outgoing != nil {
|
||
beatRegion(outgoing, cur.Name, next.Name)
|
||
}
|
||
|
||
// R2 — retire the outgoing region's DungeonRun before mutating
|
||
// CurrentRegion so retireRegionRun keys the right region.
|
||
if err := retireRegionRun(exp, cur.ID); err != nil {
|
||
return "", fmt.Errorf("retire previous region run: %w", err)
|
||
}
|
||
|
||
// Update CurrentRegion + visited list.
|
||
if err := setCurrentRegion(exp, next.ID); err != nil {
|
||
return "", fmt.Errorf("update current region: %w", err)
|
||
}
|
||
if _, err := MarkRegionVisited(exp, next.ID); err != nil {
|
||
return "", fmt.Errorf("mark region visited: %w", err)
|
||
}
|
||
|
||
// Spawn the new region's DungeonRun and pin exp.RunID.
|
||
if _, err := ensureRegionRun(exp, charLevel); err != nil {
|
||
return "", fmt.Errorf("outfit the new region: %w", err)
|
||
}
|
||
|
||
// Log + flavor.
|
||
departure := strings.ReplaceAll(flavor.Pick(flavor.RegionTransitDeparture),
|
||
"[REGION_NEXT]", next.Name)
|
||
arrival := strings.ReplaceAll(flavor.Pick(flavor.RegionTransitArrival),
|
||
"[REGION_NEXT]", next.Name)
|
||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
|
||
fmt.Sprintf("region transit: %s → %s (Day %d, %.1f SU burned)",
|
||
cur.Name, next.Name, exp.CurrentDay, burned),
|
||
departure)
|
||
|
||
var b strings.Builder
|
||
b.WriteString(fmt.Sprintf("🗺 **Region transit — %s → %s**\n\n", cur.Name, next.Name))
|
||
b.WriteString(fmt.Sprintf("**Day:** %d → %d **Supplies:** −%.1f SU (now %.1f / %.1f)\n",
|
||
exp.CurrentDay-1, exp.CurrentDay, burned, exp.Supplies.Current, exp.Supplies.Max))
|
||
if departure != "" {
|
||
b.WriteString("\n" + departure + "\n")
|
||
}
|
||
b.WriteString("\n" + renderTransitWanderingCheck(tc) + "\n")
|
||
if arrival != "" {
|
||
b.WriteString("\n" + arrival + "\n")
|
||
}
|
||
if next.IsZoneBoss {
|
||
b.WriteString("\n_★ Final region — the zone boss is here._")
|
||
}
|
||
return b.String(), nil
|
||
}
|
||
|
||
// resolveTransitWanderingCheck mirrors resolveWanderingCheck (§6.1) but
|
||
// operates without an active camp (campMod = 0). The check fires once
|
||
// per region transition per §11.3.
|
||
func resolveTransitWanderingCheck(e *Expedition, charClass DnDClass, roll1d20 func() int) NightCheck {
|
||
if roll1d20 == nil {
|
||
roll1d20 = func() int { return rand.IntN(20) + 1 }
|
||
}
|
||
r := roll1d20()
|
||
threatMod := 0
|
||
if e.ThreatLevel > 30 {
|
||
threatMod = (e.ThreatLevel - 30) / 10
|
||
}
|
||
classMod := 0
|
||
if charClass == ClassRanger && wildernessZones[e.ZoneID] {
|
||
classMod = -2
|
||
}
|
||
total := r + threatMod + classMod
|
||
out := wanderingOutcome(total)
|
||
|
||
nc := NightCheck{
|
||
Outcome: out,
|
||
Roll: r,
|
||
Total: total,
|
||
ThreatMod: threatMod,
|
||
CampMod: 0,
|
||
ClassMod: classMod,
|
||
}
|
||
switch out {
|
||
case NightOutcomePeaceful:
|
||
nc.Summary = "Quiet transit."
|
||
case NightOutcomePassage:
|
||
nc.Summary = "Signs of pursuit along the route; no encounter."
|
||
nc.ThreatBumped = true
|
||
case NightOutcomeMinor, NightOutcomeStandard, NightOutcomeElite, NightOutcomeAmbush:
|
||
mid, mname := pickWanderingMonster(e.ZoneID, out)
|
||
nc.MonsterID = mid
|
||
nc.MonsterName = mname
|
||
nc.Summary = fmt.Sprintf("%s during transit — %s.", outcomeLabel(out), describeMonsterCount(out, mname))
|
||
}
|
||
nc.Flavor = wanderingFlavor(out, e.ThreatLevel, e.SiegeMode)
|
||
return nc
|
||
}
|
||
|
||
// processTransitWanderingCheck applies side effects of a transit
|
||
// encounter: log entry + threat bump on signs-of-pursuit. Combat
|
||
// resolution itself defers to the combat-link phase, identical to the
|
||
// night-phase pattern (§6).
|
||
func processTransitWanderingCheck(e *Expedition, nc NightCheck) error {
|
||
summary := nc.Summary
|
||
if nc.MonsterName != "" {
|
||
summary = fmt.Sprintf("%s [transit d20=%d, total=%d, mods: threat%+d class%+d]",
|
||
summary, nc.Roll, nc.Total, nc.ThreatMod, nc.ClassMod)
|
||
} else {
|
||
summary = fmt.Sprintf("%s [transit d20=%d, total=%d]", summary, nc.Roll, nc.Total)
|
||
}
|
||
if err := appendExpeditionLog(e.ID, e.CurrentDay, "transit", summary, nc.Flavor); err != nil {
|
||
return err
|
||
}
|
||
if nc.ThreatBumped {
|
||
_ = applyThreatDelta(e.ID, 2, "signs of pursuit during transit")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func renderTransitWanderingCheck(nc NightCheck) string {
|
||
var b strings.Builder
|
||
b.WriteString(fmt.Sprintf("🐾 **Transit check — %s**\n", outcomeLabel(nc.Outcome)))
|
||
if nc.Summary != "" {
|
||
b.WriteString(nc.Summary + "\n")
|
||
}
|
||
b.WriteString(fmt.Sprintf("_d20=%d, total=%d (threat%+d, class%+d)_",
|
||
nc.Roll, nc.Total, nc.ThreatMod, nc.ClassMod))
|
||
if nc.Outcome == NightOutcomeMinor || nc.Outcome == NightOutcomeStandard ||
|
||
nc.Outcome == NightOutcomeElite || nc.Outcome == NightOutcomeAmbush {
|
||
b.WriteString("\n_Encounter pending — resolves at your next `!advance`._")
|
||
}
|
||
return b.String()
|
||
}
|