mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
Adv 2.0 D&D Phase 12 E2d: Fortified Camp branch + overnight rest effects
§5.1 fortified camp unlocked in !camp, gated on exp.BossDefeated for
now (cache-site waypoints land in E3+). +2 SU cost was already in the
E1e cost table; this commit just stops rejecting the type.
§3 / §5.1 / §8.1 overnight rest applied at briefing rollover via
processOvernightCamp:
- rough: HP floor of HPMax/2 (no spell refresh)
- standard: full HP, exhaustion -1, refreshAllResources +
refreshSpellSlots
- fortified / base: standard rest + 1d6 HP bonus on wake +
threat-clock -5 (§8.1 modifier)
The camp auto-breaks once the rest applies — staying camped multi-day
isn't a thing in the spec; the player can re-pitch on the next night
if they want to. Briefing body now includes a 💤 line summarizing the
HP/threat delta from the night's rest.
Combat-driven side of fortified ('+1d6 HP on wake' as a rare flavor
event) currently surfaces as the rest summary; a peaceful-night TwinBee
narration variant could come later if we add a dedicated pool — for
now reusing the existing CampEstablished ambience suffices.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,10 +2,12 @@ package plugin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/flavor"
|
"gogobee/internal/flavor"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Phase 12 E1e — basic camp system (rough + standard).
|
// Phase 12 E1e — basic camp system (rough + standard).
|
||||||
@@ -77,8 +79,13 @@ func (p *AdventurePlugin) handleCampCmd(ctx MessageContext, args string) error {
|
|||||||
case "rough", "standard":
|
case "rough", "standard":
|
||||||
// allowed in E1e
|
// allowed in E1e
|
||||||
case "fortified":
|
case "fortified":
|
||||||
|
// E2d: §5.1 — boss-cleared room or cache site. Cache sites
|
||||||
|
// are zone-specific waypoints (E3+), so for now we require the
|
||||||
|
// expedition's boss to have been defeated.
|
||||||
|
if !exp.BossDefeated {
|
||||||
return p.SendDM(ctx.Sender,
|
return p.SendDM(ctx.Sender,
|
||||||
"Fortified camps require a boss-cleared room or cache site. (Wires up in a later phase — for now, `rough` or `standard`.)")
|
"Fortified camps require a boss-cleared room or cache site. Defeat the zone boss (or find a cache) first.")
|
||||||
|
}
|
||||||
case "base":
|
case "base":
|
||||||
return p.SendDM(ctx.Sender,
|
return p.SendDM(ctx.Sender,
|
||||||
"Base camps unlock in Tier 4–5 zones from Day 3+. (Wires up in a later phase.)")
|
"Base camps unlock in Tier 4–5 zones from Day 3+. (Wires up in a later phase.)")
|
||||||
@@ -96,6 +103,7 @@ func campHelpText(exp *Expedition) string {
|
|||||||
b.WriteString("**!camp <type>** — establish camp during an expedition.\n\n")
|
b.WriteString("**!camp <type>** — establish camp during an expedition.\n\n")
|
||||||
b.WriteString("`!camp rough` — partial rest (any location, +0.5 SU, high night risk)\n")
|
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 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 break` — break camp\n\n")
|
b.WriteString("`!camp break` — break camp\n\n")
|
||||||
if exp.Camp != nil && exp.Camp.Active {
|
if exp.Camp != nil && exp.Camp.Active {
|
||||||
b.WriteString(fmt.Sprintf("_Currently camped: **%s** (room %d)._",
|
b.WriteString(fmt.Sprintf("_Currently camped: **%s** (room %d)._",
|
||||||
@@ -160,14 +168,99 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st
|
|||||||
if line != "" {
|
if line != "" {
|
||||||
b.WriteString("\n" + line + "\n")
|
b.WriteString("\n" + line + "\n")
|
||||||
}
|
}
|
||||||
if kind == CampTypeRough {
|
switch kind {
|
||||||
|
case CampTypeRough:
|
||||||
b.WriteString("\n_Rough camp — partial rest. HP recovers to 50%, no spell slots restored._")
|
b.WriteString("\n_Rough camp — partial rest. HP recovers to 50%, no spell slots restored._")
|
||||||
} else {
|
case CampTypeFortified:
|
||||||
|
b.WriteString("\n_Fortified camp — long rest + 1d6 HP bonus on wake; threat clock −5; wandering rolls −4._")
|
||||||
|
default:
|
||||||
b.WriteString("\n_Standard camp — full long rest at the next morning briefing._")
|
b.WriteString("\n_Standard camp — full long rest at the next morning briefing._")
|
||||||
}
|
}
|
||||||
return p.SendDM(ctx.Sender, b.String())
|
return p.SendDM(ctx.Sender, b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// processOvernightCamp applies the overnight long-rest effects of an
|
||||||
|
// active camp at briefing time (§3, §5.1, §8.1). Auto-breaks the camp
|
||||||
|
// after the rest. Returns a one-line summary for the briefing body.
|
||||||
|
//
|
||||||
|
// Effects:
|
||||||
|
// - rough: HP recovered to at least 50% of max.
|
||||||
|
// - standard: HP fully restored, spell slots refreshed, exhaustion -1.
|
||||||
|
// - fortified: standard + 1d6 HP bonus on top, threat -5.
|
||||||
|
// - base: same as fortified for the rest itself; persistent waypoint
|
||||||
|
// mechanics land in E4.
|
||||||
|
//
|
||||||
|
// Returns "" if the expedition wasn't camped overnight.
|
||||||
|
func processOvernightCamp(e *Expedition) string {
|
||||||
|
if e.Camp == nil || !e.Camp.Active {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
uid := id.UserID(e.UserID)
|
||||||
|
c, _ := LoadDnDCharacter(uid)
|
||||||
|
if c == nil {
|
||||||
|
// No character to apply HP/spells to; just break the camp.
|
||||||
|
_ = updateCamp(e.ID, nil)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
kind := e.Camp.Type
|
||||||
|
prevHP := c.HPCurrent
|
||||||
|
bonusHP := 0
|
||||||
|
|
||||||
|
switch kind {
|
||||||
|
case CampTypeRough:
|
||||||
|
half := c.HPMax / 2
|
||||||
|
if c.HPCurrent < half {
|
||||||
|
c.HPCurrent = half
|
||||||
|
}
|
||||||
|
case CampTypeStandard:
|
||||||
|
c.HPCurrent = c.HPMax
|
||||||
|
c.TempHP = 0
|
||||||
|
if c.Exhaustion > 0 {
|
||||||
|
c.Exhaustion--
|
||||||
|
}
|
||||||
|
case CampTypeFortified, CampTypeBase:
|
||||||
|
c.HPCurrent = c.HPMax
|
||||||
|
c.TempHP = 0
|
||||||
|
if c.Exhaustion > 0 {
|
||||||
|
c.Exhaustion--
|
||||||
|
}
|
||||||
|
bonusHP = 1 + rand.IntN(6)
|
||||||
|
c.HPCurrent += bonusHP
|
||||||
|
if c.HPCurrent > c.HPMax {
|
||||||
|
c.HPCurrent = c.HPMax
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = SaveDnDCharacter(c)
|
||||||
|
if kind == CampTypeStandard || kind == CampTypeFortified || kind == CampTypeBase {
|
||||||
|
_ = refreshAllResources(uid)
|
||||||
|
_ = refreshSpellSlots(uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Threat reduction (§8.1: -5 for fortified long rest).
|
||||||
|
if kind == CampTypeFortified || kind == CampTypeBase {
|
||||||
|
_ = applyThreatDelta(e.ID, -5, "long rest in fortified camp")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-break the camp now that the rest has been applied.
|
||||||
|
_ = updateCamp(e.ID, nil)
|
||||||
|
e.Camp = nil
|
||||||
|
|
||||||
|
// Pretty summary for the briefing body.
|
||||||
|
switch kind {
|
||||||
|
case CampTypeRough:
|
||||||
|
if c.HPCurrent > prevHP {
|
||||||
|
return fmt.Sprintf("Rough rest: HP %d → %d.", prevHP, c.HPCurrent)
|
||||||
|
}
|
||||||
|
return "Rough rest: no HP gain (already above the half-HP floor)."
|
||||||
|
case CampTypeStandard:
|
||||||
|
return fmt.Sprintf("Long rest: HP %d → %d, spell slots & resources refreshed.", prevHP, c.HPCurrent)
|
||||||
|
case CampTypeFortified, CampTypeBase:
|
||||||
|
return fmt.Sprintf("Fortified rest: HP %d → %d (+1d6 = %d bonus); threat clock −5; resources refreshed.",
|
||||||
|
prevHP, c.HPCurrent, bonusHP)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (p *AdventurePlugin) campBreak(ctx MessageContext, exp *Expedition) error {
|
func (p *AdventurePlugin) campBreak(ctx MessageContext, exp *Expedition) error {
|
||||||
if exp.Camp == nil || !exp.Camp.Active {
|
if exp.Camp == nil || !exp.Camp.Active {
|
||||||
return p.SendDM(ctx.Sender, "No camp to break.")
|
return p.SendDM(ctx.Sender, "No camp to break.")
|
||||||
|
|||||||
145
internal/plugin/dnd_expedition_camp_e2d_test.go
Normal file
145
internal/plugin/dnd_expedition_camp_e2d_test.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Phase 12 E2d — Fortified camp branch.
|
||||||
|
|
||||||
|
func TestCampCmd_FortifiedRequiresBossDefeated(t *testing.T) {
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
uid := id.UserID("@camp-fortified-pre:example")
|
||||||
|
campTestCharacter(t, uid, 1)
|
||||||
|
defer cleanupExpeditions(uid)
|
||||||
|
|
||||||
|
if _, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||||
|
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
if err := p.handleCampCmd(MessageContext{Sender: uid}, "fortified"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := getActiveExpedition(uid)
|
||||||
|
if got.Camp != nil && got.Camp.Active {
|
||||||
|
t.Error("fortified camp should be rejected before boss defeated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCampCmd_FortifiedAfterBossSucceeds(t *testing.T) {
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
uid := id.UserID("@camp-fortified-post:example")
|
||||||
|
campTestCharacter(t, uid, 1)
|
||||||
|
defer cleanupExpeditions(uid)
|
||||||
|
|
||||||
|
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||||
|
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := db.Get().Exec(`UPDATE dnd_expedition SET boss_defeated = 1 WHERE expedition_id = ?`, exp.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
if err := p.handleCampCmd(MessageContext{Sender: uid}, "fortified"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := getActiveExpedition(uid)
|
||||||
|
if got.Camp == nil || !got.Camp.Active || got.Camp.Type != CampTypeFortified {
|
||||||
|
t.Errorf("expected active fortified camp, got %+v", got.Camp)
|
||||||
|
}
|
||||||
|
if got.Supplies.Current != 3 { // 5 − 2 SU
|
||||||
|
t.Errorf("supplies after fortified pitch = %v, want 3", got.Supplies.Current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessOvernightCamp_StandardRefillsHP(t *testing.T) {
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
uid := id.UserID("@camp-rest-std:example")
|
||||||
|
campTestCharacter(t, uid, 1)
|
||||||
|
defer cleanupExpeditions(uid)
|
||||||
|
|
||||||
|
c, _ := LoadDnDCharacter(uid)
|
||||||
|
c.HPCurrent = 5
|
||||||
|
_ = SaveDnDCharacter(c)
|
||||||
|
|
||||||
|
exp, _ := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||||
|
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1})
|
||||||
|
exp.Camp = &CampState{Active: true, Type: CampTypeStandard, EstablishedAt: time.Now().UTC()}
|
||||||
|
_ = updateCamp(exp.ID, exp.Camp)
|
||||||
|
|
||||||
|
summary := processOvernightCamp(exp)
|
||||||
|
if !strings.Contains(strings.ToLower(summary), "long rest") {
|
||||||
|
t.Errorf("standard rest summary = %q", summary)
|
||||||
|
}
|
||||||
|
got, _ := LoadDnDCharacter(uid)
|
||||||
|
if got.HPCurrent != got.HPMax {
|
||||||
|
t.Errorf("HP after standard rest = %d, want max %d", got.HPCurrent, got.HPMax)
|
||||||
|
}
|
||||||
|
postExp, _ := getExpedition(exp.ID)
|
||||||
|
if postExp.Camp != nil {
|
||||||
|
t.Error("camp should auto-break after rest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessOvernightCamp_FortifiedReducesThreatAndAddsBonus(t *testing.T) {
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
uid := id.UserID("@camp-rest-fort:example")
|
||||||
|
campTestCharacter(t, uid, 1)
|
||||||
|
defer cleanupExpeditions(uid)
|
||||||
|
|
||||||
|
c, _ := LoadDnDCharacter(uid)
|
||||||
|
c.HPCurrent = c.HPMax // already full; bonus would clip at max
|
||||||
|
c.HPMax = 30
|
||||||
|
c.HPCurrent = 30
|
||||||
|
_ = SaveDnDCharacter(c)
|
||||||
|
|
||||||
|
exp, _ := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||||
|
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1})
|
||||||
|
if err := applyThreatDelta(exp.ID, 40, "seed"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
exp, _ = getExpedition(exp.ID)
|
||||||
|
exp.Camp = &CampState{Active: true, Type: CampTypeFortified, EstablishedAt: time.Now().UTC()}
|
||||||
|
_ = updateCamp(exp.ID, exp.Camp)
|
||||||
|
|
||||||
|
summary := processOvernightCamp(exp)
|
||||||
|
if !strings.Contains(strings.ToLower(summary), "fortified rest") {
|
||||||
|
t.Errorf("fortified rest summary = %q", summary)
|
||||||
|
}
|
||||||
|
postExp, _ := getExpedition(exp.ID)
|
||||||
|
if postExp.ThreatLevel != 35 {
|
||||||
|
t.Errorf("threat after fortified rest = %d, want 35", postExp.ThreatLevel)
|
||||||
|
}
|
||||||
|
if postExp.Camp != nil {
|
||||||
|
t.Error("camp should auto-break after rest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessOvernightCamp_RoughHalfHPFloor(t *testing.T) {
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
uid := id.UserID("@camp-rest-rough:example")
|
||||||
|
campTestCharacter(t, uid, 1)
|
||||||
|
defer cleanupExpeditions(uid)
|
||||||
|
|
||||||
|
c, _ := LoadDnDCharacter(uid)
|
||||||
|
c.HPMax = 20
|
||||||
|
c.HPCurrent = 4 // way under half
|
||||||
|
_ = SaveDnDCharacter(c)
|
||||||
|
|
||||||
|
exp, _ := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||||
|
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1})
|
||||||
|
exp.Camp = &CampState{Active: true, Type: CampTypeRough, EstablishedAt: time.Now().UTC()}
|
||||||
|
_ = updateCamp(exp.ID, exp.Camp)
|
||||||
|
|
||||||
|
_ = processOvernightCamp(exp)
|
||||||
|
got, _ := LoadDnDCharacter(uid)
|
||||||
|
if got.HPCurrent != 10 {
|
||||||
|
t.Errorf("rough rest HP = %d, want 10 (half of 20)", got.HPCurrent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -162,6 +162,19 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
|||||||
e.Supplies = newSupplies
|
e.Supplies = newSupplies
|
||||||
e.CurrentDay++
|
e.CurrentDay++
|
||||||
|
|
||||||
|
// E2d: overnight camp rest effects (HP/spells/threat). Auto-breaks
|
||||||
|
// the camp after applying. Run before threat drift so a fortified
|
||||||
|
// camp's −5 lands first and a same-day +3 drift can't push back over
|
||||||
|
// a threshold the rest just dropped.
|
||||||
|
restSummary := processOvernightCamp(e)
|
||||||
|
if restSummary != "" {
|
||||||
|
if fresh, err := getExpedition(e.ID); err == nil && fresh != nil {
|
||||||
|
e.ThreatLevel = fresh.ThreatLevel
|
||||||
|
e.SiegeMode = fresh.SiegeMode
|
||||||
|
e.Camp = fresh.Camp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// E2a: daily threat drift (+3 base, GM-mood modifier). No-op after
|
// E2a: daily threat drift (+3 base, GM-mood modifier). No-op after
|
||||||
// boss kill. May cross a threshold and append a flavor-bearing log.
|
// boss kill. May cross a threshold and append a flavor-bearing log.
|
||||||
if _, _, err := applyDailyThreatDrift(e); err != nil {
|
if _, _, err := applyDailyThreatDrift(e); err != nil {
|
||||||
@@ -170,6 +183,9 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
|||||||
|
|
||||||
line := pickMorningBriefing(e.CurrentDay)
|
line := pickMorningBriefing(e.CurrentDay)
|
||||||
body := renderMorningBriefing(e, line, burn)
|
body := renderMorningBriefing(e, line, burn)
|
||||||
|
if restSummary != "" {
|
||||||
|
body += "\n💤 _" + restSummary + "_\n"
|
||||||
|
}
|
||||||
|
|
||||||
if uid := id.UserID(e.UserID); uid != "" {
|
if uid := id.UserID(e.UserID); uid != "" {
|
||||||
if err := p.SendDM(uid, body); err != nil {
|
if err := p.SendDM(uid, body); err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user