From 9b48dda79ef357fefeb20cfba3ef6430e64d7878 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 8 May 2026 15:36:29 -0700 Subject: [PATCH] Adv 2.0 D&D Phase 12 E2b: Wandering monster table + night phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §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) --- internal/plugin/dnd_expedition_cycle.go | 24 ++ internal/plugin/dnd_expedition_night.go | 289 +++++++++++++++++++ internal/plugin/dnd_expedition_night_test.go | 200 +++++++++++++ 3 files changed, 513 insertions(+) create mode 100644 internal/plugin/dnd_expedition_night.go create mode 100644 internal/plugin/dnd_expedition_night_test.go diff --git a/internal/plugin/dnd_expedition_cycle.go b/internal/plugin/dnd_expedition_cycle.go index 7cdde36..7f46f11 100644 --- a/internal/plugin/dnd_expedition_cycle.go +++ b/internal/plugin/dnd_expedition_cycle.go @@ -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 { diff --git a/internal/plugin/dnd_expedition_night.go b/internal/plugin/dnd_expedition_night.go new file mode 100644 index 0000000..1a98114 --- /dev/null +++ b/internal/plugin/dnd_expedition_night.go @@ -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 +} diff --git a/internal/plugin/dnd_expedition_night_test.go b/internal/plugin/dnd_expedition_night_test.go new file mode 100644 index 0000000..6159c08 --- /dev/null +++ b/internal/plugin/dnd_expedition_night_test.go @@ -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) + } + } +}