Adv 2.0 D&D Phase 12 E2c: Siege Mode effects + threat-70 warning

§8.3 siege economics:
- applyDailyBurn now takes an explicit siege flag and enforces a 2× floor
  on supply burn even when HarshMod is below 2 (tier-1 zones still get
  starved out per spec).
- currentBurn mirrors the same precedence so status/briefing readouts
  stay consistent.
- Briefing rollover passes e.SiegeMode through, decoupling siege from
  the harsh-conditions composite.

§8.3 threat-70 warning: applyDailyThreatDrift emits a one-time
appendApproachingSiegeLog when the level crosses 70 (prevLevel<70 and
new level≥70). Pulls from a new flavor.ThreatClockApproachingSiege pool
seeded with the spec's verbatim warning beat plus two voice-matched
alternates.

Other siege effects (boss +20 HP / Legendary Resistance, cleared-room
respawn, no-short-rest enforcement) stay deferred to the combat-link
phase — ThreatBandInfo already exposes the flags that engine will read.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 15:39:31 -07:00
parent 9b48dda79e
commit 5b8ef740f8
7 changed files with 116 additions and 12 deletions

View File

@@ -155,6 +155,16 @@ var ThreatClockSiege = []string{
"Siege Mode. TwinBee delivers this without decoration because decoration would be dishonest. The dungeon is fully active, fully aware, and fully committed to ending this expedition. So is TwinBee — to ending it on your terms, not theirs. What happens next is a race. TwinBee is already running.", "Siege Mode. TwinBee delivers this without decoration because decoration would be dishonest. The dungeon is fully active, fully aware, and fully committed to ending this expedition. So is TwinBee — to ending it on your terms, not theirs. What happens next is a race. TwinBee is already running.",
} }
// ThreatClockApproachingSiege fires once when the threat clock crosses 70 —
// the spec's "begin warning" line (§8.3). Distinct from the Hostile-band
// flavor because this is the dungeon-design moment of telling the player
// they're past the point where stealth is recoverable.
var ThreatClockApproachingSiege = []string{
"They know you're here. Not a suspicion anymore. A certainty. The question now is whether you finish before they organize. TwinBee says this clearly so it doesn't have to be said again.",
"Threat at seventy. TwinBee marks this on the internal ledger and underlines it. The window for quiet operations has closed. The window for finishing is still open — narrower, but open. TwinBee suggests using it.",
"The dungeon's posture has shifted from 'searching' to 'hunting.' TwinBee tracks the difference precisely: before, they were looking for evidence; now they are looking for you. The plan, accordingly, simplifies. Finish or extract. Middle paths have closed.",
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// ZONE TEMPORAL EVENTS // ZONE TEMPORAL EVENTS
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────

View File

@@ -257,8 +257,17 @@ func (p *AdventurePlugin) expeditionCmdStatus(ctx MessageContext) error {
func currentBurn(exp *Expedition) float32 { func currentBurn(exp *Expedition) float32 {
burn := exp.Supplies.DailyBurn burn := exp.Supplies.DailyBurn
if exp.ThreatLevel > 60 || exp.SiegeMode { if exp.SiegeMode {
// Harsh conditions: §4.1 / §8.3. // §8.3: siege doubles supply burn outright, overriding HarshMod
// (which can be < 2 at low tiers).
mult := exp.Supplies.HarshMod
if mult < 2 {
mult = 2
}
return burn * mult
}
if exp.ThreatLevel > 60 {
// Harsh conditions: §4.1.
mult := exp.Supplies.HarshMod mult := exp.Supplies.HarshMod
if mult <= 0 { if mult <= 0 {
mult = 1 mult = 1

View File

@@ -151,8 +151,8 @@ func scanExpeditionRows(rows *sql.Rows) ([]*Expedition, error) {
// morning briefing DM, appends a log entry, and stamps last_briefing_at. // morning briefing DM, appends a log entry, and stamps last_briefing_at.
func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error { func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
// Advance day + supply burn happen together at the morning rollover. // Advance day + supply burn happen together at the morning rollover.
harsh := e.ThreatLevel > 60 || e.SiegeMode || e.TemporalStack > 0 harsh := e.ThreatLevel > 60 || e.TemporalStack > 0
newSupplies, burn := applyDailyBurn(e.Supplies, harsh) newSupplies, burn := applyDailyBurn(e.Supplies, harsh, e.SiegeMode)
if err := updateSupplies(e.ID, newSupplies); err != nil { if err := updateSupplies(e.ID, newSupplies); err != nil {
return err return err
} }

View File

@@ -158,13 +158,26 @@ func makeSupplies(tier ZoneTier, p SupplyPurchase) ExpeditionSupplies {
// applyDailyBurn deducts one day's supplies from the snapshot (caller // applyDailyBurn deducts one day's supplies from the snapshot (caller
// persists). Returns the new snapshot and the SU consumed. // persists). Returns the new snapshot and the SU consumed.
func applyDailyBurn(s ExpeditionSupplies, harshActive bool) (ExpeditionSupplies, float32) { //
// Multiplier precedence (§4.1, §8.3):
// - siege overrides everything with a hard 2× floor (even for tier 1
// where HarshMod is 1×) — the dungeon is actively starving you out.
// - otherwise, harshActive applies HarshMod (zone-tier scaled).
func applyDailyBurn(s ExpeditionSupplies, harshActive, siege bool) (ExpeditionSupplies, float32) {
burn := s.DailyBurn burn := s.DailyBurn
if harshActive { switch {
burn *= s.HarshMod case siege:
if burn == s.DailyBurn { // HarshMod was 0 / unset mult := s.HarshMod
burn = s.DailyBurn if mult < 2 {
mult = 2
} }
burn *= mult
case harshActive:
mult := s.HarshMod
if mult <= 0 {
mult = 1
}
burn *= mult
} }
s.Current -= burn s.Current -= burn
if s.Current < 0 { if s.Current < 0 {

View File

@@ -135,7 +135,7 @@ func TestMakeSupplies_FillsFromTier(t *testing.T) {
func TestApplyDailyBurn_DrainsAndClamps(t *testing.T) { func TestApplyDailyBurn_DrainsAndClamps(t *testing.T) {
s := ExpeditionSupplies{Current: 5, Max: 10, DailyBurn: 2, HarshMod: 1.5, ForagedToday: true} s := ExpeditionSupplies{Current: 5, Max: 10, DailyBurn: 2, HarshMod: 1.5, ForagedToday: true}
s, burn := applyDailyBurn(s, false) s, burn := applyDailyBurn(s, false, false)
if burn != 2 { if burn != 2 {
t.Errorf("burn = %v, want 2", burn) t.Errorf("burn = %v, want 2", burn)
} }
@@ -146,13 +146,25 @@ func TestApplyDailyBurn_DrainsAndClamps(t *testing.T) {
t.Error("forage flag should reset on day rollover") t.Error("forage flag should reset on day rollover")
} }
// Harsh active doubles via mult. // Harsh active doubles via mult.
s, burn = applyDailyBurn(s, true) s, burn = applyDailyBurn(s, true, false)
if burn != 3 { // 2 × 1.5 if burn != 3 { // 2 × 1.5
t.Errorf("harsh burn = %v, want 3", burn) t.Errorf("harsh burn = %v, want 3", burn)
} }
// Siege forces a 2× floor even when HarshMod is below 2 (tier 1).
t1 := ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}
_, sb := applyDailyBurn(t1, false, true)
if sb != 2 {
t.Errorf("siege tier1 burn = %v, want 2 (forced floor)", sb)
}
// Siege at higher tier still uses HarshMod when it exceeds 2.
t5 := ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 3}
_, sb5 := applyDailyBurn(t5, false, true)
if sb5 != 3 {
t.Errorf("siege tier5 burn = %v, want 3 (HarshMod)", sb5)
}
// Drain to floor. // Drain to floor.
for i := 0; i < 5; i++ { for i := 0; i < 5; i++ {
s, _ = applyDailyBurn(s, false) s, _ = applyDailyBurn(s, false, false)
} }
if s.Current != 0 { if s.Current != 0 {
t.Errorf("expected current clamped to 0, got %v", s.Current) t.Errorf("expected current clamped to 0, got %v", s.Current)

View File

@@ -113,6 +113,7 @@ func applyDailyThreatDrift(e *Expedition) (int, string, error) {
if delta == 0 { if delta == 0 {
return 0, reason, nil return 0, reason, nil
} }
prevLevel := e.ThreatLevel
prevBand := threatBandFor(e.ThreatLevel, e.SiegeMode) prevBand := threatBandFor(e.ThreatLevel, e.SiegeMode)
if err := applyThreatDelta(e.ID, delta, reason); err != nil { if err := applyThreatDelta(e.ID, delta, reason); err != nil {
return 0, reason, err return 0, reason, err
@@ -132,9 +133,22 @@ func applyDailyThreatDrift(e *Expedition) (int, string, error) {
if newBand != prevBand && newBand > prevBand { if newBand != prevBand && newBand > prevBand {
_ = appendThreatTransitionLog(e, newBand) _ = appendThreatTransitionLog(e, newBand)
} }
// E2c: §8.3 — one-time warning when crossing 70.
if prevLevel < 70 && e.ThreatLevel >= 70 {
_ = appendApproachingSiegeLog(e)
}
return delta, reason, nil 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 // appendThreatTransitionLog records a band-crossing in the expedition log
// with the prewritten TwinBee narration for that band. // with the prewritten TwinBee narration for that band.
func appendThreatTransitionLog(e *Expedition, band ThreatBand) error { func appendThreatTransitionLog(e *Expedition, band ThreatBand) error {

View File

@@ -155,6 +155,52 @@ func TestDeliverBriefing_AppliesThreatDrift(t *testing.T) {
} }
} }
func TestApplyDailyThreatDrift_LogsApproachingSiegeOnceAt70(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-threat-70warn:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
// Seed to 68 — a +3 drift crosses 70.
if err := applyThreatDelta(exp.ID, 68, "seed"); err != nil {
t.Fatal(err)
}
exp, _ = getExpedition(exp.ID)
if _, _, err := applyDailyThreatDrift(exp); err != nil {
t.Fatal(err)
}
entries, _ := recentExpeditionLog(exp.ID, 20)
count := 0
for _, e := range entries {
if e.Type == "threat" && strings.Contains(e.Summary, "past 70") {
count++
}
}
if count != 1 {
t.Errorf("expected exactly one approaching-siege log, got %d", count)
}
// A subsequent drift past 70 should not re-emit the warning.
exp, _ = getExpedition(exp.ID)
if _, _, err := applyDailyThreatDrift(exp); err != nil {
t.Fatal(err)
}
entries, _ = recentExpeditionLog(exp.ID, 20)
count = 0
for _, e := range entries {
if e.Type == "threat" && strings.Contains(e.Summary, "past 70") {
count++
}
}
if count != 1 {
t.Errorf("expected approaching-siege log still 1 after second drift, got %d", count)
}
}
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")