Adv 2.0 D&D Phase 12 E4c: !region command + inter-region travel

Spec §11.3. New !region command surface:
  !region          — list regions w/ status (▶ current, ✓ cleared,
                     · visited, ★ zone boss,  base camp)
  !region travel   — move to next region in order

Travel burns one day of supplies via applyDailyBurn (so harsh / siege
multipliers stack normally), advances current_day +1, fires one
transit wandering check (resolveTransitWanderingCheck — same buckets
as the night check but campMod = 0 since you're not bedded down),
then writes region-transition narration on both ends.

Two new flavor pools (RegionTransitDeparture / RegionTransitArrival)
with [REGION_NEXT] interpolation. Travel rejected when camped, and
when already in the final (zone-boss) region.

Tests cover the campMod=0 invariant, the day/supply/region delta on
travel, and the marker rendering in renderRegionList.
This commit is contained in:
prosolis
2026-05-08 16:19:33 -07:00
parent c2bed4282d
commit e392cf7d8f
4 changed files with 401 additions and 0 deletions

View File

@@ -226,6 +226,22 @@ var AbyssPortalCollapse = []string{
"The portal collapses. TwinBee watches it happen and does what TwinBee does in situations with no good options: moves. 'Out,' TwinBee says, and means it completely. 'Now. Everything you have, we move now.' The expedition ends here, not in defeat, but in physics. What you took is yours. What's left in there is the portal's problem. Come back when it isn't.", "The portal collapses. TwinBee watches it happen and does what TwinBee does in situations with no good options: moves. 'Out,' TwinBee says, and means it completely. 'Now. Everything you have, we move now.' The expedition ends here, not in defeat, but in physics. What you took is yours. What's left in there is the portal's problem. Come back when it isn't.",
} }
// ─────────────────────────────────────────────────────────────────────────────
// REGION TRANSITION (multi-region zones; §11.3)
// ─────────────────────────────────────────────────────────────────────────────
var RegionTransitDeparture = []string{
"TwinBee marks the boundary on the internal map and crosses it. 'New region,' TwinBee says, with the careful attention of someone who knows boundaries in dungeons aren't always the same kind of boundaries you'd find on the surface. The route is set. The day is committed. We move.",
"Crossing into [REGION_NEXT]. TwinBee folds the previous region's notes into the satchel and unfolds new ones. The light changes. The air changes. The rules of how to be careful change a little. TwinBee adjusts.",
"The transit between regions is its own kind of room. TwinBee narrates it that way — sightlines, footing, what's behind, what's ahead — because the only way the in-between part stops feeling exposed is to treat it like the rest of the dungeon. Treated. Moving.",
}
var RegionTransitArrival = []string{
"You arrive in [REGION_NEXT]. TwinBee surveys, takes in the new geometry, and updates the working assumptions. 'Different shape,' TwinBee says. 'Same general principle. We learn what wants to kill us here, and we get there first.'",
"[REGION_NEXT] receives you. TwinBee notes the temperature, the sound, the things-not-said-by-the-room-but-implied. A region is not just a place. It's a posture. TwinBee adopts the new one and suggests you do as well.",
"Boundary crossed. TwinBee stamps the day in the log — one full day spent in transit, supplies adjusted, the wandering that happened on the way handled and filed. We are here now. The next stretch is what it is.",
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// VOLUNTARY EXTRACTION // VOLUNTARY EXTRACTION
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────

View File

@@ -261,6 +261,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
if p.IsCommand(ctx.Body, "threat") { if p.IsCommand(ctx.Body, "threat") {
return p.handleThreatCmd(ctx) return p.handleThreatCmd(ctx)
} }
if p.IsCommand(ctx.Body, "region") {
return p.handleRegionCmd(ctx, p.GetArgs(ctx.Body, "region"))
}
// 1. Arena commands (work in rooms and DMs) // 1. Arena commands (work in rooms and DMs)
if p.IsCommand(ctx.Body, "bail") { if p.IsCommand(ctx.Body, "bail") {

View File

@@ -0,0 +1,257 @@
package plugin
import (
"fmt"
"math/rand/v2"
"strings"
"gogobee/internal/flavor"
)
// 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, 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. `!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 45 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":
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))
}
// Burn one day of supplies (transit day).
siege := exp.SiegeMode
harsh := exp.ThreatLevel > 60 || zoneTemporalHarsh(exp)
newSupplies, burned := applyDailyBurn(exp.Supplies, harsh, siege)
exp.Supplies = newSupplies
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
return p.SendDM(ctx.Sender, "Couldn't apply transit supply burn: "+err.Error())
}
if err := advanceExpeditionDay(exp.ID); err != nil {
return p.SendDM(ctx.Sender, "Couldn't advance the expedition day: "+err.Error())
}
exp.CurrentDay++
// Transit wandering check (no camp protection — campMod = 0).
c, _ := LoadDnDCharacter(ctx.Sender)
var charClass DnDClass
if c != nil {
charClass = c.Class
}
tc := resolveTransitWanderingCheck(exp, charClass, nil)
_ = processTransitWanderingCheck(exp, tc)
// Update CurrentRegion + visited list.
if err := setCurrentRegion(exp, next.ID); err != nil {
return p.SendDM(ctx.Sender, "Couldn't update current region: "+err.Error())
}
if _, err := MarkRegionVisited(exp, next.ID); err != nil {
return p.SendDM(ctx.Sender, "Couldn't mark region visited: "+err.Error())
}
// 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 p.SendDM(ctx.Sender, b.String())
}
// 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()
}

View File

@@ -0,0 +1,125 @@
package plugin
import (
"strings"
"testing"
"maunium.net/go/mautrix/id"
)
func TestResolveTransitWanderingCheck_NoCampMod(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@transit-noncamp:example.org")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneUnderdark, "", ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1})
if err != nil {
t.Fatal(err)
}
// Force d20 = 14 with no threat mod and no class mod → outcome
// should be Minor (total=14 → 11..14 bucket).
nc := resolveTransitWanderingCheck(exp, "", func() int { return 14 })
if nc.CampMod != 0 {
t.Errorf("transit campMod = %d, want 0", nc.CampMod)
}
if nc.Outcome != NightOutcomeMinor {
t.Errorf("outcome = %v, want Minor", nc.Outcome)
}
// Ranger in wilderness zone gets -2.
nc = resolveTransitWanderingCheck(exp, ClassRanger, func() int { return 14 })
if nc.ClassMod != -2 {
t.Errorf("ranger classMod = %d, want -2", nc.ClassMod)
}
}
func TestRegionTravel_AdvancesDayBurnsSuppliesAndUpdatesRegion(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@region-travel:example.org")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneUnderdark, "",
ExpeditionSupplies{Max: 20, Current: 20, DailyBurn: 2, HarshMod: 1.5})
if err != nil {
t.Fatal(err)
}
startDay := exp.CurrentDay
startSupplies := exp.Supplies.Current
// Drive the travel manually (avoid command surface for unit isolation).
if err := setCurrentRegion(exp, "underdark_surface_tunnels"); err != nil {
t.Fatal(err)
}
cur, _ := CurrentRegion(exp)
next, _ := nextRegion(exp.ZoneID, cur.ID)
if next.ID != "underdark_drow_outpost" {
t.Fatalf("next = %s", next.ID)
}
// Mimic regionCmdTravel core: burn one day, advance, mark visited.
newSupplies, burned := applyDailyBurn(exp.Supplies, false, false)
if burned != 2 {
t.Errorf("burned = %v, want 2", burned)
}
exp.Supplies = newSupplies
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
t.Fatal(err)
}
if err := advanceExpeditionDay(exp.ID); err != nil {
t.Fatal(err)
}
if err := setCurrentRegion(exp, next.ID); err != nil {
t.Fatal(err)
}
if _, err := MarkRegionVisited(exp, next.ID); err != nil {
t.Fatal(err)
}
loaded, _ := getActiveExpedition(uid)
if loaded.CurrentDay != startDay+1 {
t.Errorf("day did not advance: %d → %d", startDay, loaded.CurrentDay)
}
if loaded.Supplies.Current != startSupplies-2 {
t.Errorf("supplies after burn = %v, want %v", loaded.Supplies.Current, startSupplies-2)
}
if loaded.CurrentRegion != "underdark_drow_outpost" {
t.Errorf("CurrentRegion = %q", loaded.CurrentRegion)
}
if !IsRegionVisited(loaded, "underdark_drow_outpost") {
t.Error("next region not marked visited")
}
}
func TestRenderRegionList_MarksCurrentAndCleared(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@region-render:example.org")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneAbyssPortal, "", ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1})
if err != nil {
t.Fatal(err)
}
if _, err := MarkRegionBossDefeated(exp, "abyss_outer_rift"); err != nil {
t.Fatal(err)
}
if err := setCurrentRegion(exp, "abyss_demon_assembly"); err != nil {
t.Fatal(err)
}
if _, err := MarkRegionVisited(exp, "abyss_demon_assembly"); err != nil {
t.Fatal(err)
}
out := renderRegionList(exp)
if !strings.Contains(out, "✓ ") {
t.Errorf("expected ✓ marker for cleared region; got:\n%s", out)
}
if !strings.Contains(out, "▶ ") {
t.Errorf("expected ▶ marker for current region; got:\n%s", out)
}
if !strings.Contains(out, "Demon Assembly") || !strings.Contains(out, "Outer Rift") {
t.Errorf("region names missing in render:\n%s", out)
}
if !strings.Contains(out, "★") {
t.Errorf("expected ★ marker on zone-boss region:\n%s", out)
}
}