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