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:
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{}
}
@@ -140,6 +147,8 @@ func applyZoneTemporalPostRollover(e *Expedition) []string {
return feywildTemporalPostRollover(e)
case ZoneDragonsLair:
return dragonsLairTemporalPostRollover(e)
case ZoneAbyssPortal:
return abyssPortalTemporalPostRollover(e)
}
return nil
}
@@ -427,6 +436,119 @@ func dragonsLairTemporalPostRollover(e *Expedition) []string {
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
// active camp is fortified (or base) in the Underforge — long rest
// drops heat stacks by 2 per §7.3. Returns the new stack count.