Files
gogobee/internal/plugin/dnd_expedition_threat.go
prosolis a063e0ccd0 N3/P6d: a party where every member can see the dungeon
Every ownership lookup in the adventure module keys on a user id, and a
party member owns neither the expedition row nor the zone run. So each
player-facing read quietly told them they were not playing.

Rewire them. Reads a member should see resolve through activeExpeditionFor
/ activeZoneRunFor. Leader-only actions answer with copy that names the
leader instead of denying the expedition. Three busy-guards had to start
refusing a member outright: !zone enter, !expedition start and !sell all
keyed on the sender's own row, so a seated member could open a private
dungeon, outfit a rival expedition, or run a shop from the boss room.

Four things the rewire itself exposed:

!resources looks like a read but seed-persists harvest nodes, and
saveHarvestNodes rewrites the entire region_state blob — kills, event
gates, temporal stack — last-write-wins. Reaching it as a member would
revert the leader's walk from a stale snapshot. Persist only for the owner;
seedRoomNodes is pure, so a member re-derives the same nodes.

!zone taunt moves the party's shared mood, which is intended and safe:
applyMoodEvent lands an atomic delta. Its neighbour applyMoodDecayIfStale
writes an absolute gm_mood from the caller's snapshot, and every command
takes the *sender's* lock — a member running it against the leader's run
holds the wrong mutex. The owner check now lives on that function.

A seat outlives status='active'. releaseParty deliberately skips the
seven-day 'extracting' limbo, so the roster persists while
activeExpeditionFor goes blind — long enough for a member to open a run
that wins every lookup once the leader !resumes. seatedExpeditionFor spans
both statuses; it is what the busy-guards ask.

!expedition run was still member-blind. It is the same walk as !zone
advance, reached by its other name.

isPartyMember replaces `run != nil && !isLeader`: activeZoneRunFor reports
isLeader=false for a player with no run anywhere, so the bare test sends a
solo player to go ask their leader.

Golden byte-identical; solo T1 expedition clears end-to-end.
2026-07-09 23:56:16 -07:00

280 lines
9.3 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"
"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")
}