mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -117,6 +117,13 @@ func applyZoneTemporalPreBurn(e *Expedition, nextDay int) TemporalBurnOverride {
|
||||
}
|
||||
case ZoneFeywildCrossing:
|
||||
return feywildTemporalPreBurn(e, nextDay)
|
||||
case ZoneAbyssPortal:
|
||||
// §7.6 81–99: "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: 0–20
|
||||
// mild: 21–40 (-1 WIS)
|
||||
// warp: 41–60 (rooms shift order)
|
||||
// surges: 61–80 (12h wandering checks)
|
||||
// unravel: 81–99 (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.
|
||||
|
||||
@@ -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) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@exp-tidal-other:example")
|
||||
|
||||
Reference in New Issue
Block a user