mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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") {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user