diff --git a/internal/plugin/dnd_expedition_camp.go b/internal/plugin/dnd_expedition_camp.go index bf46b24..be478f8 100644 --- a/internal/plugin/dnd_expedition_camp.go +++ b/internal/plugin/dnd_expedition_camp.go @@ -2,10 +2,12 @@ package plugin import ( "fmt" + "math/rand/v2" "strings" "time" "gogobee/internal/flavor" + "maunium.net/go/mautrix/id" ) // Phase 12 E1e — basic camp system (rough + standard). @@ -77,8 +79,13 @@ func (p *AdventurePlugin) handleCampCmd(ctx MessageContext, args string) error { case "rough", "standard": // allowed in E1e case "fortified": - 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`.)") + // 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, + "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.)") @@ -96,6 +103,7 @@ func campHelpText(exp *Expedition) string { b.WriteString("**!camp ** — 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 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") if exp.Camp != nil && exp.Camp.Active { 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 != "" { 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._") - } 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._") } 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 { if exp.Camp == nil || !exp.Camp.Active { return p.SendDM(ctx.Sender, "No camp to break.") diff --git a/internal/plugin/dnd_expedition_camp_e2d_test.go b/internal/plugin/dnd_expedition_camp_e2d_test.go new file mode 100644 index 0000000..3af85d7 --- /dev/null +++ b/internal/plugin/dnd_expedition_camp_e2d_test.go @@ -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) + } +} diff --git a/internal/plugin/dnd_expedition_cycle.go b/internal/plugin/dnd_expedition_cycle.go index 05210b5..ea12449 100644 --- a/internal/plugin/dnd_expedition_cycle.go +++ b/internal/plugin/dnd_expedition_cycle.go @@ -162,6 +162,19 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error { e.Supplies = newSupplies 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 // boss kill. May cross a threshold and append a flavor-bearing log. if _, _, err := applyDailyThreatDrift(e); err != nil { @@ -170,6 +183,9 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error { line := pickMorningBriefing(e.CurrentDay) body := renderMorningBriefing(e, line, burn) + if restSummary != "" { + body += "\n💤 _" + restSummary + "_\n" + } if uid := id.UserID(e.UserID); uid != "" { if err := p.SendDM(uid, body); err != nil {