Files
gogobee/internal/plugin/dnd_expedition_night.go
prosolis 1953eec3b5 Adv 2.0 Phase L1: Babysit pivot + legacy resolver retire
Babysit pivots from harvest service to pet-care + safe-rest perk:
- runBabysitDaily/runAutoBabysitDay/runBabysitAction deleted; replaced
  by runBabysitDailyTrickle (3 pet XP/day while subscribed).
- BabysitSafeRest predicate promotes Standard camps to Fortified-tier
  rest (HP+1d6, threat -5, resources refresh) and reduces wandering
  monster check campMod from 0 to -4. Hooks live in
  dnd_expedition_camp.go and dnd_expedition_night.go.
- !adventure babysit auto/focus subcommands removed; status/purchase
  DMs reflect the new perks.

Scheduler drops the auto-babysit fallback block (no more silent
debits + harvest-day for missed logins). Babysit subscribers still
skip the morning DM; trickle runs in the background.

TwinBee daily simulator self-contained: simulateTwinBeeOutcome +
simulateTwinBeeLoot replace the resolveAdvAction call against a
fake CombatLevel=35 character. Outcome distribution and reward
shape preserved; gold-share to active players unchanged.

Legacy activity resolver retired: resolveAdvAction,
advEligibleLocations, AdvEligibleLocation, resolveAdvEmptyOutcome
deleted from adventure_activities.go (zero production callers
post-babysit/twinbee). AdvActionResult kept — combat_bridge still
produces it for arena/dungeon flows; dies in L2/L3.

Migration plan doc gogobee_legacy_migration.md added: five-phase
L1-L5 plan covering arena (Adv 2.0 boss flow rebuild), co-op
deletion, perk-gate sweep, and final teardown of AdventureCharacter
+ CombatLevel + combat_engine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00

298 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"fmt"
"math/rand/v2"
"strings"
"gogobee/internal/flavor"
"maunium.net/go/mautrix/id"
)
// 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
}
// Babysit safe-rest: an active subscription gives Standard camps the
// fortified-tier night-risk reduction (the babysitter is watching).
effectiveCamp := e.Camp.Type
if effectiveCamp == CampTypeStandard && BabysitSafeRest(id.UserID(e.UserID)) {
effectiveCamp = CampTypeFortified
}
campMod := 0
switch effectiveCamp {
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
}