diff --git a/internal/plugin/dnd_expedition_temporal.go b/internal/plugin/dnd_expedition_temporal.go index 09328d5..15ae2f1 100644 --- a/internal/plugin/dnd_expedition_temporal.go +++ b/internal/plugin/dnd_expedition_temporal.go @@ -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. diff --git a/internal/plugin/dnd_expedition_temporal_test.go b/internal/plugin/dnd_expedition_temporal_test.go index 1b63513..8778e03 100644 --- a/internal/plugin/dnd_expedition_temporal_test.go +++ b/internal/plugin/dnd_expedition_temporal_test.go @@ -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")