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

@@ -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
// last_recap_at. No supply burn here — that's the briefing's job.
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)
if err != nil {
return err
}
line := pickEveningRecap(e, dayEntries)
body := renderEveningRecap(e, line, dayEntries)
if night != nil {
body += "\n" + renderNightCheck(*night)
}
if uid := id.UserID(e.UserID); uid != "" {
if err := p.SendDM(uid, body); err != nil {