From 9cb66d46ac3a5cbc6a937ba7346ee2531c90049f Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 8 May 2026 15:45:14 -0700 Subject: [PATCH] Adv 2.0 D&D Phase 12 E2e: !threat command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §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) --- internal/plugin/adventure.go | 3 + internal/plugin/dnd_expedition_threat.go | 69 +++++++++++++++++++ internal/plugin/dnd_expedition_threat_test.go | 43 ++++++++++++ 3 files changed, 115 insertions(+) diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 8a4307f..cf89dab 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -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") { diff --git a/internal/plugin/dnd_expedition_threat.go b/internal/plugin/dnd_expedition_threat.go index 816df63..351f844 100644 --- a/internal/plugin/dnd_expedition_threat.go +++ b/internal/plugin/dnd_expedition_threat.go @@ -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. diff --git a/internal/plugin/dnd_expedition_threat_test.go b/internal/plugin/dnd_expedition_threat_test.go index fd226f6..75e605b 100644 --- a/internal/plugin/dnd_expedition_threat_test.go +++ b/internal/plugin/dnd_expedition_threat_test.go @@ -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")