Adv 2.0 D&D Phase 12 E3f: Abyss Portal destabilization

§7.6 instability accumulator: +5/day (cap 100), persisted via
TemporalStack. Bands:
  • 0–20   normal
  • 21–40  mild (-1 WIS)
  • 41–60  reality warps (rooms shift order)
  • 61–80  demon surges (12h wandering)
  • 81–99  unraveling — forces 2× supply burn override + -2 all rolls
  • 100    collapse — forced extraction (status = failed)

Pre-burn override fires at 81–99 so the supply doubling lands the same
day the band activates. Collapse at 100 calls completeExpedition
with ExpeditionStatusFailed and emits the AbyssPortalCollapse line.

Adds ReduceAbyssInstability(e, n) for the §7.6 -10 per major story
objective; combat-link wires the call when objectives complete.
Combat-side knobs (WIS/all-roll penalties, room-shift, surge cadence)
are exposed via AbyssInstabilityBandFor — combat-link reads on tick.

Reuses flavor.AbyssPortalDestabilizationMid / Critical / Collapse per
feedback_reuse_existing_flavor.

Closes Phase 12 E3 (Zone Temporal Events). Next session = E4
(Multi-Region Zones).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 16:07:48 -07:00
parent ff7d55f33b
commit 10d398fd9a
2 changed files with 255 additions and 0 deletions

View File

@@ -117,6 +117,13 @@ func applyZoneTemporalPreBurn(e *Expedition, nextDay int) TemporalBurnOverride {
} }
case ZoneFeywildCrossing: case ZoneFeywildCrossing:
return feywildTemporalPreBurn(e, nextDay) return feywildTemporalPreBurn(e, nextDay)
case ZoneAbyssPortal:
// §7.6 8199: "Supply burn doubles." Hit unraveling band before
// today's burn fires so the multiplier applies on the same day
// the band activates.
if !e.BossDefeated && e.TemporalStack >= 81 && e.TemporalStack < 100 {
return TemporalBurnOverride{Multiplier: 2.0, Reason: "abyss_unraveling"}
}
} }
return TemporalBurnOverride{} return TemporalBurnOverride{}
} }
@@ -140,6 +147,8 @@ func applyZoneTemporalPostRollover(e *Expedition) []string {
return feywildTemporalPostRollover(e) return feywildTemporalPostRollover(e)
case ZoneDragonsLair: case ZoneDragonsLair:
return dragonsLairTemporalPostRollover(e) return dragonsLairTemporalPostRollover(e)
case ZoneAbyssPortal:
return abyssPortalTemporalPostRollover(e)
} }
return nil return nil
} }
@@ -427,6 +436,119 @@ func dragonsLairTemporalPostRollover(e *Expedition) []string {
return lines return lines
} }
// ─────────────────────────────────────────────────────────────────────
// §7.6 — Abyss Portal, Destabilization
// ─────────────────────────────────────────────────────────────────────
// AbyssInstabilityPerDay is the §7.6 increment.
const AbyssInstabilityPerDay = 5
// AbyssMaxInstability — at 100 the portal collapses and forces
// extraction.
const AbyssMaxInstability = 100
// AbyssInstabilityBandFor classifies the §7.6 instability tiers.
// normal: 020
// mild: 2140 (-1 WIS)
// warp: 4160 (rooms shift order)
// surges: 6180 (12h wandering checks)
// unravel: 8199 (supply ×2, -2 all rolls)
// collapse: 100
func AbyssInstabilityBandFor(stack int) string {
switch {
case stack <= 20:
return "normal"
case stack <= 40:
return "mild"
case stack <= 60:
return "warp"
case stack <= 80:
return "surges"
case stack < 100:
return "unravel"
default:
return "collapse"
}
}
// abyssPortalTemporalPostRollover increments instability +5/day,
// emits narration on band-crossing into "unravel" (81+) and on
// collapse at 100, and triggers forced extraction at 100. Boss-
// defeated suppresses further accumulation.
func abyssPortalTemporalPostRollover(e *Expedition) []string {
if e.BossDefeated {
return nil
}
prev := e.TemporalStack
if prev >= AbyssMaxInstability {
return nil
}
next := prev + AbyssInstabilityPerDay
if next > AbyssMaxInstability {
next = AbyssMaxInstability
}
e.TemporalStack = next
if err := updateTemporalStack(e.ID, next); err != nil {
return nil
}
prevBand := AbyssInstabilityBandFor(prev)
newBand := AbyssInstabilityBandFor(next)
if prevBand == newBand {
return nil
}
switch newBand {
case "warp":
line := flavor.Pick(flavor.AbyssPortalDestabilizationMid)
line = strings.ReplaceAll(line, "[N]", fmt.Sprintf("%d", next))
_ = appendExpeditionLog(e.ID, e.CurrentDay, "temporal",
fmt.Sprintf("portal instability %d — reality warps (rooms shift)", next),
line)
return []string{line}
case "surges":
line := flavor.Pick(flavor.AbyssPortalDestabilizationMid)
line = strings.ReplaceAll(line, "[N]", fmt.Sprintf("%d", next))
_ = appendExpeditionLog(e.ID, e.CurrentDay, "temporal",
fmt.Sprintf("portal instability %d — demon surges (12h wandering)", next),
line)
return []string{line}
case "unravel":
line := flavor.Pick(flavor.AbyssPortalDestabilizationCritical)
_ = appendExpeditionLog(e.ID, e.CurrentDay, "temporal",
fmt.Sprintf("portal instability %d — unraveling: supply ×2, -2 all rolls", next),
line)
return []string{line}
case "collapse":
line := flavor.Pick(flavor.AbyssPortalCollapse)
_ = appendExpeditionLog(e.ID, e.CurrentDay, "temporal",
"portal collapsed — expedition forcibly extracted", line)
// Forced extraction: spec §7.6 marks the run failed.
_ = completeExpedition(e.ID, ExpeditionStatusFailed)
return []string{line}
}
return nil
}
// ReduceAbyssInstability drops instability by N for a major story
// objective completion (§7.6: -10 per objective). Combat-link wires
// this when objectives are recorded; exposed here for symmetry with
// reduceUnderforgeHeat.
func ReduceAbyssInstability(e *Expedition, amount int) int {
if e.ZoneID != ZoneAbyssPortal || amount <= 0 {
return e.TemporalStack
}
next := e.TemporalStack - amount
if next < 0 {
next = 0
}
if next == e.TemporalStack {
return next
}
e.TemporalStack = next
_ = updateTemporalStack(e.ID, next)
return next
}
// reduceUnderforgeHeat is called by processOvernightCamp when the // reduceUnderforgeHeat is called by processOvernightCamp when the
// active camp is fortified (or base) in the Underforge — long rest // active camp is fortified (or base) in the Underforge — long rest
// drops heat stacks by 2 per §7.3. Returns the new stack count. // drops heat stacks by 2 per §7.3. Returns the new stack count.

View File

@@ -669,6 +669,139 @@ func TestDragonsLair_BossDefeatedSilencesPulses(t *testing.T) {
} }
} }
func TestAbyssInstabilityBandFor(t *testing.T) {
cases := []struct {
stack int
want string
}{
{0, "normal"}, {20, "normal"},
{21, "mild"}, {40, "mild"},
{41, "warp"}, {60, "warp"},
{61, "surges"}, {80, "surges"},
{81, "unravel"}, {99, "unravel"},
{100, "collapse"},
}
for _, c := range cases {
if got := AbyssInstabilityBandFor(c.stack); got != c.want {
t.Errorf("Abyss band(%d) = %q, want %q", c.stack, got, c.want)
}
}
}
func TestAbyss_DailyInstabilityIncrements(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-abyss-tick:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneAbyssPortal, "",
ExpeditionSupplies{Current: 100, Max: 100, DailyBurn: 4, HarshMod: 3})
if err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
// Three briefings → instability should hit 15.
for i := 0; i < 3; i++ {
fresh, _ := getExpedition(exp.ID)
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET last_briefing_at = NULL, supplies_json = ? WHERE expedition_id = ?`,
`{"current":99,"max":99,"daily_burn":4,"harsh_mod":3,"foraged_today":false,"packs_standard":3,"packs_deluxe":0}`,
exp.ID); err != nil {
t.Fatal(err)
}
fresh, _ = getExpedition(exp.ID)
if err := p.deliverBriefing(fresh, time.Now().UTC()); err != nil {
t.Fatal(err)
}
}
got, _ := getExpedition(exp.ID)
if got.TemporalStack != 15 {
t.Errorf("instability = %d, want 15", got.TemporalStack)
}
}
func TestAbyss_UnravelingDoublesBurn(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-abyss-unravel:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneAbyssPortal, "",
ExpeditionSupplies{Current: 100, Max: 100, DailyBurn: 4, HarshMod: 3})
if err != nil {
t.Fatal(err)
}
// Set instability to 85 (unravel band).
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET temporal_stack = 85 WHERE expedition_id = ?`,
exp.ID); err != nil {
t.Fatal(err)
}
fresh, _ := getExpedition(exp.ID)
startSU := fresh.Supplies.Current
if err := (&AdventurePlugin{}).deliverBriefing(fresh, time.Now().UTC()); err != nil {
t.Fatal(err)
}
got, _ := getExpedition(exp.ID)
wantBurn := float32(8.0) // 4 * 2 (unraveling override)
if startSU-got.Supplies.Current != wantBurn {
t.Errorf("burn = %v, want %v (unraveling 2×)", startSU-got.Supplies.Current, wantBurn)
}
}
func TestAbyss_CollapseFailsExpedition(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-abyss-collapse:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneAbyssPortal, "",
ExpeditionSupplies{Current: 100, Max: 100, DailyBurn: 4, HarshMod: 3})
if err != nil {
t.Fatal(err)
}
// Set instability to 95 — next daily +5 lands on 100 (collapse).
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET temporal_stack = 95 WHERE expedition_id = ?`,
exp.ID); err != nil {
t.Fatal(err)
}
fresh, _ := getExpedition(exp.ID)
if err := (&AdventurePlugin{}).deliverBriefing(fresh, time.Now().UTC()); err != nil {
t.Fatal(err)
}
got, _ := getExpedition(exp.ID)
if got.TemporalStack != 100 {
t.Errorf("instability = %d, want 100", got.TemporalStack)
}
if got.Status != ExpeditionStatusFailed {
t.Errorf("status = %q, want %q after collapse", got.Status, ExpeditionStatusFailed)
}
}
func TestReduceAbyssInstability(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-abyss-reduce:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneAbyssPortal, "",
ExpeditionSupplies{Current: 100, Max: 100, DailyBurn: 4, HarshMod: 3})
if err != nil {
t.Fatal(err)
}
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET temporal_stack = 25 WHERE expedition_id = ?`,
exp.ID); err != nil {
t.Fatal(err)
}
fresh, _ := getExpedition(exp.ID)
if got := ReduceAbyssInstability(fresh, 10); got != 15 {
t.Errorf("ReduceAbyssInstability(25, 10) = %d, want 15", got)
}
// Wrong zone — no-op.
other := &Expedition{ZoneID: ZoneGoblinWarrens, TemporalStack: 50}
if ReduceAbyssInstability(other, 10) != 50 {
t.Error("ReduceAbyssInstability on non-Abyss zone should be no-op")
}
}
func TestSunkenTemple_NoTidalOnNonZone(t *testing.T) { func TestSunkenTemple_NoTidalOnNonZone(t *testing.T) {
setupZoneRunTestDB(t) setupZoneRunTestDB(t)
uid := id.UserID("@exp-tidal-other:example") uid := id.UserID("@exp-tidal-other:example")