Adv 2.0 D&D Phase 12 E4d: Base camp as persistent waypoint

Replaces the deferred-branch rejection in handleCampCmd: !camp base
now pitches when (a) the zone is multi-region, (b) the player's
current region's BaseCampSite is true, and (c) the region boss is
cleared. Otherwise the surface returns specific reasons (wrong zone,
wrong site, boss not down).

First pitch records the region in RegionState["base_camps"] via
addRegionListEntry — that list is what !region renders the  marker
from, and the waypoint is persistent for the rest of the expedition
even after the camp is broken. Reuses the existing
flavor.BaseCampEstablished pool with [N] day interpolation.

Tests cover non-base-site rejection, uncleared-region rejection, and
the happy path through pitch + waypoint persistence (3 SU cost,
HasBaseCampAt true after).
This commit is contained in:
prosolis
2026-05-08 16:21:47 -07:00
parent e392cf7d8f
commit a199efd773
2 changed files with 128 additions and 3 deletions

View File

@@ -87,8 +87,27 @@ func (p *AdventurePlugin) handleCampCmd(ctx MessageContext, args string) error {
"Fortified camps require a boss-cleared room or cache site. Defeat the zone boss (or find a cache) first.")
}
case "base":
// E4d: §11.1 — base camps unlock per region after the region
// boss is defeated, and only at base-camp-eligible sites.
if !IsMultiRegionZone(exp.ZoneID) {
return p.SendDM(ctx.Sender,
"Base camps unlock in Tier 45 zones from Day 3+. (Wires up in a later phase.)")
"Base camps are a multi-region waypoint feature — only available in Tier 45 zones (Underdark / Dragon's Lair / Abyss Portal).")
}
region, ok := CurrentRegion(exp)
if !ok {
return p.SendDM(ctx.Sender,
"Couldn't resolve your current region — try `!region` first.")
}
if !region.BaseCampSite {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"**%s** isn't a base-camp-eligible site. Look for a region whose boss has fallen and the geography is defensible.",
region.Name))
}
if !IsRegionCleared(exp, region.ID) {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You can pitch a base camp in **%s** only after defeating its region boss (%s).",
region.Name, region.RegionBoss))
}
default:
return p.SendDM(ctx.Sender,
"Unknown camp type. Try `rough` (any location) or `standard` (cleared rooms).")
@@ -104,6 +123,7 @@ func campHelpText(exp *Expedition) string {
b.WriteString("`!camp rough` — partial rest (any location, +0.5 SU, high night risk)\n")
b.WriteString("`!camp standard` — full long rest (cleared room, +1 SU, medium risk)\n")
b.WriteString("`!camp fortified` — long rest + bonus (boss-cleared room, +2 SU, low risk)\n")
b.WriteString("`!camp base` — persistent waypoint (region-boss-cleared base-camp site, +3 SU, very low risk)\n")
b.WriteString("`!camp break` — break camp\n\n")
if exp.Camp != nil && exp.Camp.Active {
b.WriteString(fmt.Sprintf("_Currently camped: **%s** (room %d)._",
@@ -157,10 +177,28 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st
return p.SendDM(ctx.Sender, "Couldn't pitch camp: "+err.Error())
}
line := flavor.Pick(flavor.CampEstablished)
// E4d: pick the BaseCampEstablished pool for base camps; otherwise
// the generic camp pool. Both already handle [N] day interpolation
// for the base-camp first-pitch message.
var line string
if kind == CampTypeBase {
line = flavor.Pick(flavor.BaseCampEstablished)
line = strings.ReplaceAll(line, "[N]", fmt.Sprintf("%d", exp.CurrentDay))
} else {
line = flavor.Pick(flavor.CampEstablished)
}
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "rest",
fmt.Sprintf("camp pitched (%s) — %.1f SU consumed", kind, cost), line)
// Mark the region as a persistent base-camp waypoint on first pitch.
if kind == CampTypeBase {
if region, ok := CurrentRegion(exp); ok {
if _, err := addRegionListEntry(exp, regionStateBaseCampKey, region.ID); err != nil {
return p.SendDM(ctx.Sender, "Couldn't record base camp waypoint: "+err.Error())
}
}
}
var b strings.Builder
b.WriteString(fmt.Sprintf("⛺ **Camp established — %s.**\n", kind))
b.WriteString(fmt.Sprintf("Supplies: %.1f / %.1f SU (%.1f).\n",
@@ -173,6 +211,8 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st
b.WriteString("\n_Rough camp — partial rest. HP recovers to 50%, no spell slots restored._")
case CampTypeFortified:
b.WriteString("\n_Fortified camp — long rest + 1d6 HP bonus on wake; threat clock 5; wandering rolls 4._")
case CampTypeBase:
b.WriteString("\n_Base camp — long rest + 1d6 HP bonus; threat clock 5; wandering rolls 6. **Waypoint persisted** — camp here again at no eligibility cost on later returns._")
default:
b.WriteString("\n_Standard camp — full long rest at the next morning briefing._")
}

View File

@@ -156,6 +156,91 @@ func TestCampCmd_RejectsFortifiedAndBase(t *testing.T) {
}
}
func TestCampCmd_BaseRejectsNonBaseCampSiteRegion(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@camp-base-bad-site:example")
campTestCharacter(t, uid, 12)
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneUnderdark, "",
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
// Surface Tunnels has BaseCampSite=false. Even if we mark it
// cleared, !camp base should reject.
if _, err := MarkRegionBossDefeated(exp, "underdark_surface_tunnels"); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
if err := p.handleCampCmd(MessageContext{Sender: uid}, "base"); err != nil {
t.Fatal(err)
}
loaded, _ := getActiveExpedition(uid)
if loaded.Camp != nil {
t.Error("base camp should not pitch in non-base-camp-site region")
}
}
func TestCampCmd_BaseRejectsUnclearedRegion(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@camp-base-uncleared:example")
campTestCharacter(t, uid, 12)
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneUnderdark, "",
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
// Move to Drow Outpost (BaseCampSite=true) but don't clear it.
if err := setCurrentRegion(exp, "underdark_drow_outpost"); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
if err := p.handleCampCmd(MessageContext{Sender: uid}, "base"); err != nil {
t.Fatal(err)
}
loaded, _ := getActiveExpedition(uid)
if loaded.Camp != nil {
t.Error("base camp should not pitch before region boss is cleared")
}
}
func TestCampCmd_BasePitchPersistsWaypoint(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@camp-base-pitch:example")
campTestCharacter(t, uid, 12)
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneUnderdark, "",
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
if err := setCurrentRegion(exp, "underdark_drow_outpost"); err != nil {
t.Fatal(err)
}
if _, err := MarkRegionBossDefeated(exp, "underdark_drow_outpost"); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
if err := p.handleCampCmd(MessageContext{Sender: uid}, "base"); err != nil {
t.Fatal(err)
}
loaded, _ := getActiveExpedition(uid)
if loaded.Camp == nil || loaded.Camp.Type != CampTypeBase {
t.Fatalf("expected base camp pitched; got %+v", loaded.Camp)
}
if loaded.Supplies.Current != 7 { // 10 - 3 SU base cost
t.Errorf("supplies after base pitch = %v, want 7", loaded.Supplies.Current)
}
if !HasBaseCampAt(loaded, "underdark_drow_outpost") {
t.Error("base camp waypoint not persisted to RegionState")
}
}
func TestCampCmd_InsufficientSuppliesRejected(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@camp-broke:example")