Files
gogobee/internal/plugin/dnd_expedition_threat.go
prosolis 3369d7d8fe gofmt: bring internal/ and cmd/ back to gofmt -l clean
Mechanical `gofmt -w ./internal ./cmd`. Mostly struct-field realignment that
had drifted, plus a few trailing-newline fixes. No behaviour change — gofmt is
semantics-preserving, and build/vet/test are green either side.

Split out from the code-review fixes that follow so those stay reviewable
instead of hiding inside a wall of realignment.
2026-07-10 07:18:07 -07:00

280 lines
9.3 KiB
Go
Raw Permalink 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"
"strings"
"gogobee/internal/flavor"
"maunium.net/go/mautrix/id"
)
// Phase 12 E2a — Threat Clock state machine.
// Spec: gogobee_expedition_system.md §8.
//
// E1a already shipped the persistence (applyThreatDelta, ThreatEvent,
// threat_level / threat_siege columns). This file layers the threshold
// model on top: labels, per-band combat effects, threshold-crossing log
// flavor, and the daily threat drift applied at briefing rollover.
//
// Combat-driven modifiers (combat-in-new-room +5, escape +10, loud-ability
// +8) require the combat engine to know about expeditions, which it does
// not yet. They land alongside the !advance/combat hookup in a later phase;
// this file is wired into the bits we *can* drive: day rollover + boss
// defeat + manual threat events from camp/extraction logic.
// ThreatBand is the bracketed state of the threat clock.
type ThreatBand int
const (
ThreatBandQuiet ThreatBand = iota
ThreatBandStirring
ThreatBandAlert
ThreatBandHostile
ThreatBandSiege
)
// ThreatBandInfo bundles the per-band display + combat-side knobs.
// AC/Init/Perception adjustments are applied by the combat engine when
// it reads the active expedition's threat band; SupplyMult feeds into
// currentBurn() (already wired); ShortRest disabled is checked by the
// rest plumbing once expedition combat is wired.
type ThreatBandInfo struct {
Band ThreatBand
Label string
Perception int // enemy passive perception bonus
AC int // enemy AC bonus
InitAdv bool // enemy initiative advantage
TrapsArm bool // re-arm cleared traps each day
SupplyMult float32 // multiplier on daily supply burn
NoShortRest bool // siege-mode only
}
// threatBandFor returns the band for a given level, honouring siege override.
func threatBandFor(level int, siege bool) ThreatBand {
if siege || level >= 81 {
return ThreatBandSiege
}
switch {
case level >= 61:
return ThreatBandHostile
case level >= 41:
return ThreatBandAlert
case level >= 21:
return ThreatBandStirring
default:
return ThreatBandQuiet
}
}
// threatBandInfo returns the descriptor for a band.
func threatBandInfo(b ThreatBand) ThreatBandInfo {
switch b {
case ThreatBandStirring:
return ThreatBandInfo{Band: b, Label: "Stirring", Perception: 1, SupplyMult: 1}
case ThreatBandAlert:
return ThreatBandInfo{Band: b, Label: "Alert", AC: 2, Perception: 2, SupplyMult: 1}
case ThreatBandHostile:
return ThreatBandInfo{Band: b, Label: "Hostile", AC: 2, Perception: 2, InitAdv: true, TrapsArm: true, SupplyMult: 1}
case ThreatBandSiege:
return ThreatBandInfo{Band: b, Label: "Siege", AC: 2, Perception: 2, InitAdv: true, TrapsArm: true, SupplyMult: 2, NoShortRest: true}
}
return ThreatBandInfo{Band: ThreatBandQuiet, Label: "Quiet", SupplyMult: 1}
}
// dailyThreatDrift is the +3/day base (§8.1) plus the DMMood-driven mod
// (Wrathful +5, Elated -3). Mood bands map: effusive (≥80) → Elated,
// hostile (<20) → Wrathful, the middle three are neutral.
func dailyThreatDrift(dmMood int) (int, string) {
// Base drift was 3; lowered to 1 in Phase 5-B alongside the player
// power floor + supply-burn cut. Slower threat climb gives players
// the days they need to extract before threat tips zones into
// elite-bracket frequency. See gogobee_expedition_difficulty.md
// Phase 3-A (proved the harness lift) + Phase 5-B (shipped).
base := 1
mod := 0
tag := ""
switch {
case dmMood >= 80:
mod = -3
tag = "elated"
case dmMood < 20:
mod = 5
tag = "wrathful"
}
delta := base + mod
reason := "day passes in zone"
if tag != "" {
reason = fmt.Sprintf("day passes in zone (%s mood mod %+d)", tag, mod)
}
return delta, reason
}
// applyDailyThreatDrift applies the per-day threat increment at briefing
// rollover. No-op once the boss has been defeated (the zone has nothing
// left to escalate). Returns the delta actually applied.
func applyDailyThreatDrift(e *Expedition) (int, string, error) {
if e.BossDefeated {
return 0, "", nil
}
delta, reason := dailyThreatDrift(e.DMMood)
if delta == 0 {
return 0, reason, nil
}
prevLevel := e.ThreatLevel
prevBand := threatBandFor(e.ThreatLevel, e.SiegeMode)
if err := applyThreatDelta(e.ID, delta, reason); err != nil {
return 0, reason, err
}
// Mirror the change in-memory so the caller can render it.
e.ThreatLevel += delta
if e.ThreatLevel > 100 {
e.ThreatLevel = 100
}
if e.ThreatLevel < 0 {
e.ThreatLevel = 0
}
if e.ThreatLevel >= 100 {
e.SiegeMode = true
}
newBand := threatBandFor(e.ThreatLevel, e.SiegeMode)
if newBand != prevBand && newBand > prevBand {
_ = appendThreatTransitionLog(e, newBand)
}
// E2c: §8.3 — one-time-per-expedition warning when crossing 70.
// Track in RegionState so a drop-and-recross (e.g. fortified-camp
// -5 followed by daily +3 drift) doesn't re-fire the beat.
if prevLevel < 70 && e.ThreatLevel >= 70 {
warned, _ := e.RegionState["siege_warning_fired"].(bool)
if !warned {
_ = appendApproachingSiegeLog(e)
if e.RegionState == nil {
e.RegionState = map[string]any{}
}
e.RegionState["siege_warning_fired"] = true
_ = persistRegionState(e)
}
}
return delta, reason, nil
}
// appendApproachingSiegeLog records the §8.3 "begin warning at 70" beat
// once per expedition. Caller is responsible for only firing it on the
// crossing edge (see applyDailyThreatDrift).
func appendApproachingSiegeLog(e *Expedition) error {
line := flavor.Pick(flavor.ThreatClockApproachingSiege)
return appendExpeditionLog(e.ID, e.CurrentDay, "threat",
"threat clock past 70 — siege approaches", line)
}
// appendThreatTransitionLog records a band-crossing in the expedition log
// with the prewritten TwinBee narration for that band.
func appendThreatTransitionLog(e *Expedition, band ThreatBand) error {
var pool []string
switch band {
case ThreatBandStirring:
pool = flavor.ThreatClockStirring
case ThreatBandAlert:
pool = flavor.ThreatClockAlert
case ThreatBandHostile:
pool = flavor.ThreatClockHostile
case ThreatBandSiege:
pool = flavor.ThreatClockSiege
default:
return nil
}
line := flavor.Pick(pool)
info := threatBandInfo(band)
summary := fmt.Sprintf("threat clock crossed into %s (%d/100)", info.Label, e.ThreatLevel)
return appendExpeditionLog(e.ID, e.CurrentDay, "threat", summary, line)
}
// handleThreatCmd surfaces the current threat clock state — level, band,
// per-band combat effects, and the last few ThreatEvent log entries.
func (p *AdventurePlugin) handleThreatCmd(ctx MessageContext) error {
exp, _, err := activeExpeditionFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
}
if exp == nil {
return p.SendDM(ctx.Sender,
"No active expedition. Threat clock only ticks during expeditions.")
}
band := threatBandFor(exp.ThreatLevel, exp.SiegeMode)
info := threatBandInfo(band)
var b strings.Builder
b.WriteString(fmt.Sprintf("⏰ **Threat Clock — %d / 100 — %s**\n\n", exp.ThreatLevel, info.Label))
b.WriteString(threatBandEffectsBlock(info))
if len(exp.ThreatEvents) > 0 {
b.WriteString("\n**Recent threat events:**\n")
// Show last 5 newest-last for chronological reading.
start := 0
if len(exp.ThreatEvents) > 5 {
start = len(exp.ThreatEvents) - 5
}
for _, ev := range exp.ThreatEvents[start:] {
sign := "+"
if ev.Delta < 0 {
sign = ""
}
b.WriteString(fmt.Sprintf(" • %s%d — %s _(at %s)_\n",
sign, ev.Delta, ev.Reason, ev.Timestamp.Format("2006-01-02 15:04")))
}
}
if exp.SiegeMode {
b.WriteString("\n_Siege Mode is active. The dungeon's final form. It cannot be reduced — only finished or escaped._\n")
} else if exp.ThreatLevel >= 70 {
b.WriteString("\n_Past 70: the window for stealth has closed. Finish or extract._\n")
}
return p.SendDM(ctx.Sender, b.String())
}
// threatBandEffectsBlock renders the band's combat/supply effects.
func threatBandEffectsBlock(info ThreatBandInfo) string {
if info.Band == ThreatBandQuiet {
return "_The zone is unaware of you. No modifications in effect._\n"
}
var b strings.Builder
b.WriteString("**Effects:**\n")
if info.Perception > 0 {
b.WriteString(fmt.Sprintf(" • Enemy perception +%d\n", info.Perception))
}
if info.AC > 0 {
b.WriteString(fmt.Sprintf(" • Enemy AC +%d\n", info.AC))
}
if info.InitAdv {
b.WriteString(" • Enemies have initiative advantage\n")
}
if info.TrapsArm {
b.WriteString(" • Cleared traps re-arm overnight\n")
}
if info.SupplyMult > 1 {
b.WriteString(fmt.Sprintf(" • Supply burn ×%.1f\n", info.SupplyMult))
}
if info.NoShortRest {
b.WriteString(" • Short rests blocked (siege)\n")
}
return b.String()
}
// applyBossDefeatThreat records the -20 threat drop when a zone boss is
// killed (§8.1). Caller wires it from the combat completion path once
// expedition combat is hooked up.
func applyBossDefeatThreat(expID string) error {
return applyThreatDelta(expID, -20, "boss defeated")
}
// applyRoomCombatThreat records the +5 threat bump per §8.1 when the
// player resolves combat in a new (non-boss) room. Silent no-op for
// standalone zone runs (no active expedition).
func applyRoomCombatThreatForUser(userID id.UserID, elite bool) {
exp, err := getActiveExpedition(userID)
if err != nil || exp == nil {
return
}
delta := 5
if elite {
delta = 8 // elite encounters are louder
}
_ = applyThreatDelta(exp.ID, delta, "combat in new room")
}