Adv 2.0 D&D Phase 12 E2e: !threat command

§14 surfaces the threat clock as a first-class command. !threat shows
level/100, current band, the per-band combat & supply effects (built
from ThreatBandInfo so display tracks any future band re-tuning), and
the last 5 ThreatEvent log entries with deltas + reasons. Adds a
"siege is active" / "past 70" footer when those gates have triggered.

Active-enemy camp guard and close-call evening recap stay deferred —
both need combat state inside an expedition, which doesn't exist yet
(expeditions still don't host their own combat loop). They land in the
combat-link phase along with the §8.1 combat-driven threat modifiers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 15:45:14 -07:00
parent f844068660
commit 9cb66d46ac
3 changed files with 115 additions and 0 deletions

View File

@@ -258,6 +258,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
if p.IsCommand(ctx.Body, "camp") {
return p.handleCampCmd(ctx, p.GetArgs(ctx.Body, "camp"))
}
if p.IsCommand(ctx.Body, "threat") {
return p.handleThreatCmd(ctx)
}
// 1. Arena commands (work in rooms and DMs)
if p.IsCommand(ctx.Body, "bail") {

View File

@@ -2,6 +2,7 @@ package plugin
import (
"fmt"
"strings"
"gogobee/internal/flavor"
)
@@ -171,6 +172,74 @@ func appendThreatTransitionLog(e *Expedition, band ThreatBand) error {
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 := getActiveExpedition(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.

View File

@@ -201,6 +201,49 @@ func TestApplyDailyThreatDrift_LogsApproachingSiegeOnceAt70(t *testing.T) {
}
}
func TestThreatBandEffectsBlock_Bands(t *testing.T) {
q := threatBandEffectsBlock(threatBandInfo(ThreatBandQuiet))
if !strings.Contains(strings.ToLower(q), "unaware") {
t.Errorf("Quiet block should mention unaware, got %q", q)
}
s := threatBandEffectsBlock(threatBandInfo(ThreatBandSiege))
if !strings.Contains(strings.ToLower(s), "short rests blocked") {
t.Errorf("Siege block should call out blocked short rests, got %q", s)
}
if !strings.Contains(s, "×2") {
t.Errorf("Siege block should show ×2 supply mult, got %q", s)
}
}
func TestHandleThreatCmd_NoExpeditionDoesNotPanic(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@threat-cmd-noexp:example")
defer cleanupExpeditions(uid)
p := &AdventurePlugin{}
if err := p.handleThreatCmd(MessageContext{Sender: uid}); err != nil {
t.Fatal(err)
}
}
func TestHandleThreatCmd_ActiveExpeditionLogsEvents(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@threat-cmd-active:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
if err := applyThreatDelta(exp.ID, 45, "test seed"); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
if err := p.handleThreatCmd(MessageContext{Sender: uid}); err != nil {
t.Fatal(err)
}
}
func TestApplyBossDefeatThreat_DropsLevel(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-boss-threat:example")