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:
prosolis
2026-05-08 15:36:29 -07:00
parent b520b0ce2d
commit 9b48dda79e
3 changed files with 513 additions and 0 deletions

View 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.16.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("12 %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
}