mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +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") {
|
if p.IsCommand(ctx.Body, "camp") {
|
||||||
return p.handleCampCmd(ctx, p.GetArgs(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)
|
// 1. Arena commands (work in rooms and DMs)
|
||||||
if p.IsCommand(ctx.Body, "bail") {
|
if p.IsCommand(ctx.Body, "bail") {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package plugin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gogobee/internal/flavor"
|
"gogobee/internal/flavor"
|
||||||
)
|
)
|
||||||
@@ -171,6 +172,74 @@ func appendThreatTransitionLog(e *Expedition, band ThreatBand) error {
|
|||||||
return appendExpeditionLog(e.ID, e.CurrentDay, "threat", summary, line)
|
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
|
// applyBossDefeatThreat records the -20 threat drop when a zone boss is
|
||||||
// killed (§8.1). Caller wires it from the combat completion path once
|
// killed (§8.1). Caller wires it from the combat completion path once
|
||||||
// expedition combat is hooked up.
|
// 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) {
|
func TestApplyBossDefeatThreat_DropsLevel(t *testing.T) {
|
||||||
setupZoneRunTestDB(t)
|
setupZoneRunTestDB(t)
|
||||||
uid := id.UserID("@exp-boss-threat:example")
|
uid := id.UserID("@exp-boss-threat:example")
|
||||||
|
|||||||
Reference in New Issue
Block a user