Files
gogobee/internal/plugin/dnd_expedition_camp_e2d_test.go
prosolis f844068660 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>
2026-05-09 14:25:22 -07:00

146 lines
4.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}