From a199efd77325b4ed451ce9ddbffb27f71f1833e7 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 8 May 2026 16:21:47 -0700 Subject: [PATCH] Adv 2.0 D&D Phase 12 E4d: Base camp as persistent waypoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- internal/plugin/dnd_expedition_camp.go | 46 ++++++++++- internal/plugin/dnd_expedition_camp_test.go | 85 +++++++++++++++++++++ 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/internal/plugin/dnd_expedition_camp.go b/internal/plugin/dnd_expedition_camp.go index 010b7f0..00367a2 100644 --- a/internal/plugin/dnd_expedition_camp.go +++ b/internal/plugin/dnd_expedition_camp.go @@ -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": - return p.SendDM(ctx.Sender, - "Base camps unlock in Tier 4–5 zones from Day 3+. (Wires up in a later phase.)") + // 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 are a multi-region waypoint feature — only available in Tier 4–5 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._") } diff --git a/internal/plugin/dnd_expedition_camp_test.go b/internal/plugin/dnd_expedition_camp_test.go index 1bf8827..3b7aacb 100644 --- a/internal/plugin/dnd_expedition_camp_test.go +++ b/internal/plugin/dnd_expedition_camp_test.go @@ -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")