mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 00:52:40 +00:00
Adv 2.0 D&D Phase 12 E2b: Wandering monster table + night phase
§6 night-phase resolution. Fires once per recap when the player has an
active camp. resolveWanderingCheck rolls 1d20 + threat mod (+1 per full
10 above 30) + camp mod (rough +3, fortified -4, base -6) + class mod
(ranger -2 in wilderness zones), then buckets per §6.1 into Peaceful /
Passage / Minor / Standard / Elite / Ambush.
Encounter side picks a roster entry from the zone (weighted by
SpawnWeight, biased to elite for Elite/Ambush) and persists the result
to camp.NightEvents + a "night"-typed log entry. Signs-of-passage adds
+2 threat per spec. Combat resolution itself is deferred — expeditions
don't host their own combat loop yet — so encounter outcomes log as
pending and surface in the recap with an "encounter pending" hint;
the combat-link phase will read these on next !advance.
Flavor reuse: encounter narration pulls from the existing ThreatClock
{Stirring,Alert,Hostile,Siege} pools, matched to the current band so
voice tracks the dungeon's awareness level.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -191,12 +191,36 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
|||||||
// deliverRecap posts the evening recap DM, appends a log entry, and stamps
|
// deliverRecap posts the evening recap DM, appends a log entry, and stamps
|
||||||
// last_recap_at. No supply burn here — that's the briefing's job.
|
// last_recap_at. No supply burn here — that's the briefing's job.
|
||||||
func (p *AdventurePlugin) deliverRecap(e *Expedition, now time.Time) error {
|
func (p *AdventurePlugin) deliverRecap(e *Expedition, now time.Time) error {
|
||||||
|
// E2b: night phase wandering check fires before the recap so its
|
||||||
|
// outcome is part of today's log when the recap renders.
|
||||||
|
var night *NightCheck
|
||||||
|
if e.Camp != nil && e.Camp.Active {
|
||||||
|
c, _ := LoadDnDCharacter(id.UserID(e.UserID))
|
||||||
|
var charClass DnDClass
|
||||||
|
if c != nil {
|
||||||
|
charClass = c.Class
|
||||||
|
}
|
||||||
|
nc := resolveWanderingCheck(e, charClass, nil)
|
||||||
|
if err := processNightCheck(e, nc); err != nil {
|
||||||
|
slog.Warn("expedition: night check", "expedition", e.ID, "err", err)
|
||||||
|
}
|
||||||
|
night = &nc
|
||||||
|
// Refresh in-memory threat after possible signs-of-passage bump.
|
||||||
|
if fresh, err := getExpedition(e.ID); err == nil && fresh != nil {
|
||||||
|
e.ThreatLevel = fresh.ThreatLevel
|
||||||
|
e.SiegeMode = fresh.SiegeMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dayEntries, err := dayLogEntries(e.ID, e.CurrentDay)
|
dayEntries, err := dayLogEntries(e.ID, e.CurrentDay)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
line := pickEveningRecap(e, dayEntries)
|
line := pickEveningRecap(e, dayEntries)
|
||||||
body := renderEveningRecap(e, line, dayEntries)
|
body := renderEveningRecap(e, line, dayEntries)
|
||||||
|
if night != nil {
|
||||||
|
body += "\n" + renderNightCheck(*night)
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
289
internal/plugin/dnd_expedition_night.go
Normal file
289
internal/plugin/dnd_expedition_night.go
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gogobee/internal/flavor"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Phase 12 E2b — Wandering monster system & night phase resolution.
|
||||||
|
// Spec: gogobee_expedition_system.md §6.
|
||||||
|
//
|
||||||
|
// The check fires once per night during the 21:00 recap when the player
|
||||||
|
// has an active camp. Result is logged with a TwinBee narration and
|
||||||
|
// surfaced in the recap body. Combat resolution itself ("player wakes
|
||||||
|
// to combat") is deferred — expeditions don't yet host their own combat
|
||||||
|
// loop, so for E2b a triggered encounter persists as a NightEvents
|
||||||
|
// entry on the camp and a flavored "night" log line. The combat-link
|
||||||
|
// phase will read pending night encounters at !advance time.
|
||||||
|
|
||||||
|
// NightOutcome enumerates the d20 result buckets per §6.1.
|
||||||
|
type NightOutcome int
|
||||||
|
|
||||||
|
const (
|
||||||
|
NightOutcomePeaceful NightOutcome = iota
|
||||||
|
NightOutcomePassage
|
||||||
|
NightOutcomeMinor
|
||||||
|
NightOutcomeStandard
|
||||||
|
NightOutcomeElite
|
||||||
|
NightOutcomeAmbush
|
||||||
|
)
|
||||||
|
|
||||||
|
// NightCheck is the resolved roll + chosen monster (if any).
|
||||||
|
type NightCheck struct {
|
||||||
|
Outcome NightOutcome
|
||||||
|
Roll int // raw d20
|
||||||
|
Total int // roll + modifiers
|
||||||
|
ThreatMod int // §6.1 threat modifier
|
||||||
|
CampMod int // rough +3, fortified -4
|
||||||
|
ClassMod int // ranger -2 (wilderness)
|
||||||
|
MonsterID string // bestiary id of the encounter, if any
|
||||||
|
MonsterName string
|
||||||
|
Summary string // short factual line
|
||||||
|
Flavor string // TwinBee voice line, may be empty
|
||||||
|
ThreatBumped bool // signs-of-passage adds +2 threat
|
||||||
|
}
|
||||||
|
|
||||||
|
// Class-tag for ranger discount: §6.3 "while in a wilderness zone".
|
||||||
|
// We treat outdoor / forest / underdark / feywild zones as wilderness.
|
||||||
|
var wildernessZones = map[ZoneID]bool{
|
||||||
|
ZoneForestShadows: true,
|
||||||
|
ZoneSunkenTemple: true, // tidal exterior
|
||||||
|
ZoneUnderdark: true,
|
||||||
|
ZoneFeywildCrossing: true,
|
||||||
|
ZoneDragonsLair: true, // mountain
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveWanderingCheck rolls the d20 and applies modifiers per §6.1–6.3.
|
||||||
|
// charClass is the active character's class (used for ranger/rogue mods);
|
||||||
|
// pass "" if unknown. roll1d20 is injectable for testing.
|
||||||
|
func resolveWanderingCheck(e *Expedition, charClass DnDClass, roll1d20 func() int) NightCheck {
|
||||||
|
if e.Camp == nil || !e.Camp.Active {
|
||||||
|
return NightCheck{Outcome: NightOutcomePeaceful, Summary: "no camp — no night check"}
|
||||||
|
}
|
||||||
|
if roll1d20 == nil {
|
||||||
|
roll1d20 = func() int { return rand.IntN(20) + 1 }
|
||||||
|
}
|
||||||
|
r := roll1d20()
|
||||||
|
|
||||||
|
threatMod := 0
|
||||||
|
if e.ThreatLevel > 30 {
|
||||||
|
// §6.1: +1 per full 10 points above 30. Level 40 → +1; 35 → +0.
|
||||||
|
threatMod = (e.ThreatLevel - 30) / 10
|
||||||
|
}
|
||||||
|
|
||||||
|
campMod := 0
|
||||||
|
switch e.Camp.Type {
|
||||||
|
case CampTypeRough:
|
||||||
|
campMod = 3
|
||||||
|
case CampTypeFortified:
|
||||||
|
campMod = -4
|
||||||
|
case CampTypeBase:
|
||||||
|
campMod = -6
|
||||||
|
}
|
||||||
|
|
||||||
|
classMod := 0
|
||||||
|
if charClass == ClassRanger && wildernessZones[e.ZoneID] {
|
||||||
|
classMod = -2
|
||||||
|
}
|
||||||
|
|
||||||
|
total := r + threatMod + campMod + classMod
|
||||||
|
out := wanderingOutcome(total)
|
||||||
|
|
||||||
|
nc := NightCheck{
|
||||||
|
Outcome: out,
|
||||||
|
Roll: r,
|
||||||
|
Total: total,
|
||||||
|
ThreatMod: threatMod,
|
||||||
|
CampMod: campMod,
|
||||||
|
ClassMod: classMod,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch out {
|
||||||
|
case NightOutcomePeaceful:
|
||||||
|
nc.Summary = "Peaceful night."
|
||||||
|
case NightOutcomePassage:
|
||||||
|
nc.Summary = "Signs of passage near camp; no encounter."
|
||||||
|
nc.ThreatBumped = true
|
||||||
|
case NightOutcomeMinor, NightOutcomeStandard, NightOutcomeElite, NightOutcomeAmbush:
|
||||||
|
mid, mname := pickWanderingMonster(e.ZoneID, out)
|
||||||
|
nc.MonsterID = mid
|
||||||
|
nc.MonsterName = mname
|
||||||
|
nc.Summary = fmt.Sprintf("%s — %s.", outcomeLabel(out), describeMonsterCount(out, mname))
|
||||||
|
}
|
||||||
|
|
||||||
|
nc.Flavor = wanderingFlavor(out, e.ThreatLevel, e.SiegeMode)
|
||||||
|
return nc
|
||||||
|
}
|
||||||
|
|
||||||
|
// wanderingOutcome buckets the modified total per §6.1.
|
||||||
|
func wanderingOutcome(total int) NightOutcome {
|
||||||
|
switch {
|
||||||
|
case total >= 20:
|
||||||
|
return NightOutcomeAmbush
|
||||||
|
case total >= 18:
|
||||||
|
return NightOutcomeElite
|
||||||
|
case total >= 15:
|
||||||
|
return NightOutcomeStandard
|
||||||
|
case total >= 11:
|
||||||
|
return NightOutcomeMinor
|
||||||
|
case total >= 6:
|
||||||
|
return NightOutcomePassage
|
||||||
|
default:
|
||||||
|
return NightOutcomePeaceful
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func outcomeLabel(o NightOutcome) string {
|
||||||
|
switch o {
|
||||||
|
case NightOutcomePeaceful:
|
||||||
|
return "Peaceful night"
|
||||||
|
case NightOutcomePassage:
|
||||||
|
return "Signs of passage"
|
||||||
|
case NightOutcomeMinor:
|
||||||
|
return "Minor encounter"
|
||||||
|
case NightOutcomeStandard:
|
||||||
|
return "Standard encounter"
|
||||||
|
case NightOutcomeElite:
|
||||||
|
return "Elite encounter"
|
||||||
|
case NightOutcomeAmbush:
|
||||||
|
return "Ambush"
|
||||||
|
}
|
||||||
|
return "Night check"
|
||||||
|
}
|
||||||
|
|
||||||
|
func describeMonsterCount(o NightOutcome, name string) string {
|
||||||
|
switch o {
|
||||||
|
case NightOutcomeMinor:
|
||||||
|
return fmt.Sprintf("1–2 %s wake you", pluralize(name))
|
||||||
|
case NightOutcomeStandard:
|
||||||
|
return fmt.Sprintf("a group of %s closes in", pluralize(name))
|
||||||
|
case NightOutcomeElite:
|
||||||
|
return fmt.Sprintf("an elite %s steps out of the dark", name)
|
||||||
|
case NightOutcomeAmbush:
|
||||||
|
return fmt.Sprintf("an elite %s catches you mid-sleep — surprise round", name)
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func pluralize(name string) string {
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(lower, "s"), strings.HasSuffix(lower, "x"), strings.HasSuffix(lower, "z"):
|
||||||
|
return name + "es"
|
||||||
|
default:
|
||||||
|
return name + "s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickWanderingMonster pulls a roster entry from the zone, biased by elite
|
||||||
|
// flag for elite/ambush outcomes. Returns "", "" if the zone has no roster.
|
||||||
|
func pickWanderingMonster(zoneID ZoneID, o NightOutcome) (string, string) {
|
||||||
|
zone, ok := getZone(zoneID)
|
||||||
|
if !ok || len(zone.Enemies) == 0 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
wantElite := o == NightOutcomeElite || o == NightOutcomeAmbush
|
||||||
|
var pool []ZoneEnemy
|
||||||
|
for _, en := range zone.Enemies {
|
||||||
|
if en.IsElite == wantElite {
|
||||||
|
pool = append(pool, en)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(pool) == 0 {
|
||||||
|
// Fallback: any enemy regardless of elite flag.
|
||||||
|
pool = zone.Enemies
|
||||||
|
}
|
||||||
|
// Weighted pick by SpawnWeight; default 5 if zero.
|
||||||
|
totalW := 0
|
||||||
|
for _, en := range pool {
|
||||||
|
w := en.SpawnWeight
|
||||||
|
if w <= 0 {
|
||||||
|
w = 5
|
||||||
|
}
|
||||||
|
totalW += w
|
||||||
|
}
|
||||||
|
pickW := rand.IntN(totalW) + 1
|
||||||
|
cum := 0
|
||||||
|
for _, en := range pool {
|
||||||
|
w := en.SpawnWeight
|
||||||
|
if w <= 0 {
|
||||||
|
w = 5
|
||||||
|
}
|
||||||
|
cum += w
|
||||||
|
if pickW <= cum {
|
||||||
|
if m, ok := dndBestiary[en.BestiaryID]; ok {
|
||||||
|
return en.BestiaryID, m.Name
|
||||||
|
}
|
||||||
|
return en.BestiaryID, en.BestiaryID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// wanderingFlavor picks a TwinBee line from the existing ThreatClock pools
|
||||||
|
// when the band's voice fits the outcome; falls back to "" so the recap
|
||||||
|
// renders just the factual summary.
|
||||||
|
func wanderingFlavor(o NightOutcome, threat int, siege bool) string {
|
||||||
|
if o == NightOutcomePeaceful || o == NightOutcomePassage {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
band := threatBandFor(threat, siege)
|
||||||
|
switch band {
|
||||||
|
case ThreatBandSiege:
|
||||||
|
return flavor.Pick(flavor.ThreatClockSiege)
|
||||||
|
case ThreatBandHostile:
|
||||||
|
return flavor.Pick(flavor.ThreatClockHostile)
|
||||||
|
case ThreatBandAlert:
|
||||||
|
return flavor.Pick(flavor.ThreatClockAlert)
|
||||||
|
case ThreatBandStirring:
|
||||||
|
return flavor.Pick(flavor.ThreatClockStirring)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderNightCheck formats a recap-bottom block for the wandering result.
|
||||||
|
func renderNightCheck(nc NightCheck) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(fmt.Sprintf("🌙 **Night phase — %s**\n", outcomeLabel(nc.Outcome)))
|
||||||
|
if nc.Summary != "" {
|
||||||
|
b.WriteString(nc.Summary + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("_d20=%d, total=%d (threat%+d, camp%+d, class%+d)_\n",
|
||||||
|
nc.Roll, nc.Total, nc.ThreatMod, nc.CampMod, nc.ClassMod))
|
||||||
|
if nc.Flavor != "" {
|
||||||
|
b.WriteString("\n" + nc.Flavor + "\n")
|
||||||
|
}
|
||||||
|
if nc.Outcome == NightOutcomeMinor || nc.Outcome == NightOutcomeStandard ||
|
||||||
|
nc.Outcome == NightOutcomeElite || nc.Outcome == NightOutcomeAmbush {
|
||||||
|
b.WriteString("\n_Encounter pending — resolves at your next `!advance`._\n")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// processNightCheck applies side-effects: log entry, threat bump for
|
||||||
|
// signs-of-passage, persist NightEvents on the camp.
|
||||||
|
func processNightCheck(e *Expedition, nc NightCheck) error {
|
||||||
|
summary := nc.Summary
|
||||||
|
if nc.MonsterName != "" {
|
||||||
|
summary = fmt.Sprintf("%s [d20=%d, total=%d, mods: threat%+d camp%+d class%+d]",
|
||||||
|
summary, nc.Roll, nc.Total, nc.ThreatMod, nc.CampMod, nc.ClassMod)
|
||||||
|
} else {
|
||||||
|
summary = fmt.Sprintf("%s [d20=%d, total=%d]", summary, nc.Roll, nc.Total)
|
||||||
|
}
|
||||||
|
if err := appendExpeditionLog(e.ID, e.CurrentDay, "night", summary, nc.Flavor); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if nc.ThreatBumped {
|
||||||
|
_ = applyThreatDelta(e.ID, 2, "signs of passage near camp")
|
||||||
|
}
|
||||||
|
if e.Camp != nil {
|
||||||
|
e.Camp.NightEvents = append(e.Camp.NightEvents, summary)
|
||||||
|
if err := updateCamp(e.ID, e.Camp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
200
internal/plugin/dnd_expedition_night_test.go
Normal file
200
internal/plugin/dnd_expedition_night_test.go
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Phase 12 E2b — wandering monster / night phase tests.
|
||||||
|
|
||||||
|
func TestWanderingOutcome_BucketBoundaries(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
total int
|
||||||
|
want NightOutcome
|
||||||
|
}{
|
||||||
|
{1, NightOutcomePeaceful},
|
||||||
|
{5, NightOutcomePeaceful},
|
||||||
|
{6, NightOutcomePassage},
|
||||||
|
{10, NightOutcomePassage},
|
||||||
|
{11, NightOutcomeMinor},
|
||||||
|
{14, NightOutcomeMinor},
|
||||||
|
{15, NightOutcomeStandard},
|
||||||
|
{17, NightOutcomeStandard},
|
||||||
|
{18, NightOutcomeElite},
|
||||||
|
{19, NightOutcomeElite},
|
||||||
|
{20, NightOutcomeAmbush},
|
||||||
|
{25, NightOutcomeAmbush},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := wanderingOutcome(c.total)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("total %d → %v, want %v", c.total, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWanderingCheck_RoughCampBumpsRoll(t *testing.T) {
|
||||||
|
exp := &Expedition{
|
||||||
|
ZoneID: ZoneGoblinWarrens,
|
||||||
|
ThreatLevel: 0,
|
||||||
|
Camp: &CampState{Active: true, Type: CampTypeRough},
|
||||||
|
}
|
||||||
|
// roll 8 → +3 rough → total 11 → Minor (would have been Passage at 8).
|
||||||
|
nc := resolveWanderingCheck(exp, ClassFighter, func() int { return 8 })
|
||||||
|
if nc.Outcome != NightOutcomeMinor {
|
||||||
|
t.Errorf("rough+8 → %v, want Minor", nc.Outcome)
|
||||||
|
}
|
||||||
|
if nc.CampMod != 3 {
|
||||||
|
t.Errorf("camp mod = %d, want 3", nc.CampMod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWanderingCheck_FortifiedCampReducesRoll(t *testing.T) {
|
||||||
|
exp := &Expedition{
|
||||||
|
ZoneID: ZoneGoblinWarrens,
|
||||||
|
ThreatLevel: 0,
|
||||||
|
Camp: &CampState{Active: true, Type: CampTypeFortified},
|
||||||
|
}
|
||||||
|
// roll 14 → -4 fortified → total 10 → Passage.
|
||||||
|
nc := resolveWanderingCheck(exp, ClassFighter, func() int { return 14 })
|
||||||
|
if nc.Outcome != NightOutcomePassage {
|
||||||
|
t.Errorf("fortified+14 → %v, want Passage", nc.Outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWanderingCheck_ThreatModAbove30(t *testing.T) {
|
||||||
|
exp := &Expedition{
|
||||||
|
ZoneID: ZoneGoblinWarrens,
|
||||||
|
ThreatLevel: 60,
|
||||||
|
Camp: &CampState{Active: true, Type: CampTypeStandard},
|
||||||
|
}
|
||||||
|
// (60-30)/10 = 3 mod. roll 12 → +3 → total 15 → Standard.
|
||||||
|
nc := resolveWanderingCheck(exp, ClassFighter, func() int { return 12 })
|
||||||
|
if nc.ThreatMod != 3 {
|
||||||
|
t.Errorf("threat mod = %d, want 3", nc.ThreatMod)
|
||||||
|
}
|
||||||
|
if nc.Outcome != NightOutcomeStandard {
|
||||||
|
t.Errorf("outcome = %v, want Standard", nc.Outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWanderingCheck_RangerWildernessDiscount(t *testing.T) {
|
||||||
|
exp := &Expedition{
|
||||||
|
ZoneID: ZoneForestShadows,
|
||||||
|
ThreatLevel: 0,
|
||||||
|
Camp: &CampState{Active: true, Type: CampTypeStandard},
|
||||||
|
}
|
||||||
|
nc := resolveWanderingCheck(exp, ClassRanger, func() int { return 12 })
|
||||||
|
if nc.ClassMod != -2 {
|
||||||
|
t.Errorf("ranger wilderness mod = %d, want -2", nc.ClassMod)
|
||||||
|
}
|
||||||
|
// In a non-wilderness zone, no class mod.
|
||||||
|
exp2 := *exp
|
||||||
|
exp2.ZoneID = ZoneGoblinWarrens
|
||||||
|
nc2 := resolveWanderingCheck(&exp2, ClassRanger, func() int { return 12 })
|
||||||
|
if nc2.ClassMod != 0 {
|
||||||
|
t.Errorf("ranger non-wilderness mod = %d, want 0", nc2.ClassMod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWanderingCheck_NoCampNoEncounter(t *testing.T) {
|
||||||
|
exp := &Expedition{ZoneID: ZoneGoblinWarrens}
|
||||||
|
nc := resolveWanderingCheck(exp, ClassFighter, func() int { return 20 })
|
||||||
|
if nc.Outcome != NightOutcomePeaceful {
|
||||||
|
t.Errorf("no camp should yield peaceful, got %v", nc.Outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessNightCheck_LogsAndBumpsThreatOnPassage(t *testing.T) {
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
uid := id.UserID("@exp-night-passage:example")
|
||||||
|
defer cleanupExpeditions(uid)
|
||||||
|
|
||||||
|
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||||
|
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
exp.Camp = &CampState{Active: true, Type: CampTypeStandard, EstablishedAt: time.Now().UTC()}
|
||||||
|
if err := updateCamp(exp.ID, exp.Camp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
nc := NightCheck{
|
||||||
|
Outcome: NightOutcomePassage, Roll: 8, Total: 8,
|
||||||
|
Summary: "Signs of passage near camp; no encounter.",
|
||||||
|
ThreatBumped: true,
|
||||||
|
}
|
||||||
|
if err := processNightCheck(exp, nc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := getExpedition(exp.ID)
|
||||||
|
if got.ThreatLevel != 2 {
|
||||||
|
t.Errorf("threat after signs-of-passage = %d, want 2", got.ThreatLevel)
|
||||||
|
}
|
||||||
|
entries, _ := recentExpeditionLog(exp.ID, 5)
|
||||||
|
foundNight := false
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Type == "night" && strings.Contains(e.Summary, "Signs of passage") {
|
||||||
|
foundNight = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundNight {
|
||||||
|
t.Error("night log entry missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessNightCheck_PersistsCampNightEvents(t *testing.T) {
|
||||||
|
setupZoneRunTestDB(t)
|
||||||
|
uid := id.UserID("@exp-night-campevents:example")
|
||||||
|
defer cleanupExpeditions(uid)
|
||||||
|
|
||||||
|
exp, _ := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||||
|
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
||||||
|
exp.Camp = &CampState{Active: true, Type: CampTypeStandard, EstablishedAt: time.Now().UTC(), NightEvents: []string{}}
|
||||||
|
_ = updateCamp(exp.ID, exp.Camp)
|
||||||
|
|
||||||
|
nc := NightCheck{Outcome: NightOutcomePeaceful, Roll: 3, Total: 3, Summary: "Peaceful night."}
|
||||||
|
if err := processNightCheck(exp, nc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := getExpedition(exp.ID)
|
||||||
|
if got.Camp == nil || len(got.Camp.NightEvents) != 1 {
|
||||||
|
t.Errorf("camp.NightEvents not appended: %+v", got.Camp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickWanderingMonster_RespectsEliteFlag(t *testing.T) {
|
||||||
|
// Run several picks; elite outcomes should never return non-elite when
|
||||||
|
// the zone has elite entries. Goblin Warrens should have at least one
|
||||||
|
// IsElite enemy in its roster.
|
||||||
|
zone, _ := getZone(ZoneGoblinWarrens)
|
||||||
|
hasElite := false
|
||||||
|
for _, e := range zone.Enemies {
|
||||||
|
if e.IsElite {
|
||||||
|
hasElite = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasElite {
|
||||||
|
t.Skip("goblin_warrens roster has no elite — pick test would be vacuous")
|
||||||
|
}
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
id, _ := pickWanderingMonster(ZoneGoblinWarrens, NightOutcomeElite)
|
||||||
|
if id == "" {
|
||||||
|
t.Fatalf("expected an elite pick, got empty")
|
||||||
|
}
|
||||||
|
isElite := false
|
||||||
|
for _, e := range zone.Enemies {
|
||||||
|
if e.BestiaryID == id && e.IsElite {
|
||||||
|
isElite = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isElite {
|
||||||
|
t.Errorf("elite outcome picked non-elite %q", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user