Files
gogobee/internal/plugin/dnd_expedition_threat.go
prosolis aaa45eab14 N7/B3: the Omen — one rotating world modifier per ISO week
activeOmen() is a pure function of the UTC ISO (year, week): omenTable
indexed by (year*53+week)%len, so it advances weekly with no schema, no
ticker state, no persistence. Five non-combat seams read it — harvest yield,
supply freebie, expedition start mood, arena payout (scales gross earnings
before the pot tax), and ingredient drop chance. TwinBee reveals the active
omen in the existing morning DM (no net-new scheduled message).

Launch set is buffs-with-texture on non-combat levers only: Bountiful
Harvest, Quartermaster's Blessing, Golden Purse, Overflowing Satchels, Still
Waters. Nothing touches SimulateCombat or the turn engine — the omen is keyed
on the real clock, so a combat mutator would make the golden and the balance
corpus week-dependent. The plan's "elites +2 ATK" is deliberately dropped for
that reason.

The balance sim drives the real expedition loop and would otherwise traverse
all five seams, making corpus sweeps depend on the wall-clock week. NewSimRunner
sets simOmenDisabled (mirrors simAutoArmEnabled), so activeOmen returns a
no-effect omen under the sim. Still Waters subtracts from the daily threat
*rise* only, floored at hold-steady — it never forces active decay.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 20:09:30 -07:00

288 lines
9.6 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)
// N7/B3 the Omen — a "still waters" week subtracts from the daily threat
// *rise* only (guarded on delta > 0), floored so the worst case is threat
// holding steady for the day; it never turns a rise into active decay.
if r := activeOmen().ThreatDriftReduce; r > 0 && delta > 0 {
if delta -= r; delta < 0 {
delta = 0
}
}
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")
}