From 5b8ef740f875b7279b3b3728641dd6525f292926 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 8 May 2026 15:39:31 -0700 Subject: [PATCH] Adv 2.0 D&D Phase 12 E2c: Siege Mode effects + threat-70 warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §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) --- internal/flavor/twinbee_expedition_flavor.go | 10 ++++ internal/plugin/dnd_expedition_cmd.go | 13 +++++- internal/plugin/dnd_expedition_cycle.go | 4 +- internal/plugin/dnd_expedition_supplies.go | 23 ++++++++-- .../plugin/dnd_expedition_supplies_test.go | 18 ++++++-- internal/plugin/dnd_expedition_threat.go | 14 ++++++ internal/plugin/dnd_expedition_threat_test.go | 46 +++++++++++++++++++ 7 files changed, 116 insertions(+), 12 deletions(-) diff --git a/internal/flavor/twinbee_expedition_flavor.go b/internal/flavor/twinbee_expedition_flavor.go index fc85027..1dc1dde 100644 --- a/internal/flavor/twinbee_expedition_flavor.go +++ b/internal/flavor/twinbee_expedition_flavor.go @@ -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.", } +// 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 // ───────────────────────────────────────────────────────────────────────────── diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index c0c95a8..ea86db4 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -257,8 +257,17 @@ func (p *AdventurePlugin) expeditionCmdStatus(ctx MessageContext) error { func currentBurn(exp *Expedition) float32 { burn := exp.Supplies.DailyBurn - if exp.ThreatLevel > 60 || exp.SiegeMode { - // Harsh conditions: §4.1 / §8.3. + if exp.SiegeMode { + // §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 if mult <= 0 { mult = 1 diff --git a/internal/plugin/dnd_expedition_cycle.go b/internal/plugin/dnd_expedition_cycle.go index 7f46f11..05210b5 100644 --- a/internal/plugin/dnd_expedition_cycle.go +++ b/internal/plugin/dnd_expedition_cycle.go @@ -151,8 +151,8 @@ func scanExpeditionRows(rows *sql.Rows) ([]*Expedition, error) { // morning briefing DM, appends a log entry, and stamps last_briefing_at. func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error { // Advance day + supply burn happen together at the morning rollover. - harsh := e.ThreatLevel > 60 || e.SiegeMode || e.TemporalStack > 0 - newSupplies, burn := applyDailyBurn(e.Supplies, harsh) + harsh := e.ThreatLevel > 60 || e.TemporalStack > 0 + newSupplies, burn := applyDailyBurn(e.Supplies, harsh, e.SiegeMode) if err := updateSupplies(e.ID, newSupplies); err != nil { return err } diff --git a/internal/plugin/dnd_expedition_supplies.go b/internal/plugin/dnd_expedition_supplies.go index 2d3ac7f..c0b4c45 100644 --- a/internal/plugin/dnd_expedition_supplies.go +++ b/internal/plugin/dnd_expedition_supplies.go @@ -158,13 +158,26 @@ func makeSupplies(tier ZoneTier, p SupplyPurchase) ExpeditionSupplies { // applyDailyBurn deducts one day's supplies from the snapshot (caller // 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 - if harshActive { - burn *= s.HarshMod - if burn == s.DailyBurn { // HarshMod was 0 / unset - burn = s.DailyBurn + switch { + case siege: + mult := s.HarshMod + if mult < 2 { + mult = 2 } + burn *= mult + case harshActive: + mult := s.HarshMod + if mult <= 0 { + mult = 1 + } + burn *= mult } s.Current -= burn if s.Current < 0 { diff --git a/internal/plugin/dnd_expedition_supplies_test.go b/internal/plugin/dnd_expedition_supplies_test.go index 55d3afa..bedfd88 100644 --- a/internal/plugin/dnd_expedition_supplies_test.go +++ b/internal/plugin/dnd_expedition_supplies_test.go @@ -135,7 +135,7 @@ func TestMakeSupplies_FillsFromTier(t *testing.T) { func TestApplyDailyBurn_DrainsAndClamps(t *testing.T) { 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 { 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") } // Harsh active doubles via mult. - s, burn = applyDailyBurn(s, true) + s, burn = applyDailyBurn(s, true, false) if burn != 3 { // 2 × 1.5 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. for i := 0; i < 5; i++ { - s, _ = applyDailyBurn(s, false) + s, _ = applyDailyBurn(s, false, false) } if s.Current != 0 { t.Errorf("expected current clamped to 0, got %v", s.Current) diff --git a/internal/plugin/dnd_expedition_threat.go b/internal/plugin/dnd_expedition_threat.go index 4e88ac7..816df63 100644 --- a/internal/plugin/dnd_expedition_threat.go +++ b/internal/plugin/dnd_expedition_threat.go @@ -113,6 +113,7 @@ func applyDailyThreatDrift(e *Expedition) (int, string, error) { if delta == 0 { return 0, reason, nil } + prevLevel := e.ThreatLevel prevBand := threatBandFor(e.ThreatLevel, e.SiegeMode) if err := applyThreatDelta(e.ID, delta, reason); err != nil { return 0, reason, err @@ -132,9 +133,22 @@ func applyDailyThreatDrift(e *Expedition) (int, string, error) { if newBand != prevBand && newBand > prevBand { _ = appendThreatTransitionLog(e, newBand) } + // E2c: §8.3 — one-time warning when crossing 70. + if prevLevel < 70 && e.ThreatLevel >= 70 { + _ = appendApproachingSiegeLog(e) + } 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 // with the prewritten TwinBee narration for that band. func appendThreatTransitionLog(e *Expedition, band ThreatBand) error { diff --git a/internal/plugin/dnd_expedition_threat_test.go b/internal/plugin/dnd_expedition_threat_test.go index bc215dd..fd226f6 100644 --- a/internal/plugin/dnd_expedition_threat_test.go +++ b/internal/plugin/dnd_expedition_threat_test.go @@ -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) { setupZoneRunTestDB(t) uid := id.UserID("@exp-boss-threat:example")