From 8eaada5c1bed8fa260f7f64810ee3d5a0daecfcb Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 8 May 2026 13:26:22 -0700 Subject: [PATCH] Adv 2.0 D&D Phase 11 D4b: Tier 3-5 trap catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds tier3TrapCatalog (Antimagic Field, Necrotic Miasma, Symbol of Death, Planar Rift) per design doc §6 Tier 3-5 table. Tiers 3, 4, and 5 share this pool — the doc lumps them together. Antimagic Field uses a new SuppressesMagic flag (no damage, narrative suppression beat); the other three deliver dice damage and skip the unmodelled secondary effects (max-HP debuff, drop-to-0, teleport) for a later phase. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/plugin/dnd_zone_combat_test.go | 119 ++++++++++++++++++++++++ internal/plugin/dnd_zone_traps.go | 91 ++++++++++++++++-- 2 files changed, 203 insertions(+), 7 deletions(-) diff --git a/internal/plugin/dnd_zone_combat_test.go b/internal/plugin/dnd_zone_combat_test.go index d646918..3ffdace 100644 --- a/internal/plugin/dnd_zone_combat_test.go +++ b/internal/plugin/dnd_zone_combat_test.go @@ -388,6 +388,125 @@ func TestRollTrapDamage_TripwireZero(t *testing.T) { } } +// ── Phase 11 D4b — Tier 3 trap catalog ────────────────────────────────────── + +func TestPickZoneTrap_Tier3UsesTier3Catalog(t *testing.T) { + zone, ok := getZone(ZoneManorBlackspire) + if !ok { + t.Fatal("manor_blackspire zone missing") + } + if zone.Tier != ZoneTierJourneyman { + t.Fatalf("manor_blackspire expected tier 3, got %d", zone.Tier) + } + seen := map[ZoneTrapKind]bool{} + for room := 0; room < 64; room++ { + tr, ok := pickZoneTrap(zone, "t3-run", room) + if !ok { + t.Fatalf("pickZoneTrap !ok at room %d", room) + } + if tr.Tier != ZoneTierJourneyman { + t.Errorf("tier 3 zone picked tier %d trap (%s)", tr.Tier, tr.Kind) + } + seen[tr.Kind] = true + } + if len(seen) < 2 { + t.Errorf("expected variety across 64 rooms in tier 3, only saw %d kinds: %v", len(seen), seen) + } +} + +func TestTier3TrapCatalog_AllValid(t *testing.T) { + if len(tier3TrapCatalog) < 4 { + t.Fatalf("tier 3 catalog should have at least 4 traps, got %d", len(tier3TrapCatalog)) + } + for _, tr := range tier3TrapCatalog { + if tr.Tier != ZoneTierJourneyman { + t.Errorf("trap %s tagged tier %d, expected 3", tr.Kind, tr.Tier) + } + if tr.Display == "" || tr.Trigger == "" { + t.Errorf("trap %s missing display/trigger text", tr.Kind) + } + nonDamaging := tr.AlertsEnemies || tr.SuppressesMagic + if !nonDamaging && (tr.DamageDiceN == 0 || tr.DamageDiceD == 0) { + t.Errorf("trap %s deals no damage and is not alarm/suppression", tr.Kind) + } + if tr.DetectDC < 16 { + t.Errorf("trap %s detect DC %d looks tier 2; tier 3 should be 16+", tr.Kind, tr.DetectDC) + } + } +} + +func TestRollTrapDamage_NecroticMiasmaIn6d6Range(t *testing.T) { + var miasma zoneTrapDef + for _, tr := range tier3TrapCatalog { + if tr.Kind == TrapNecroticMiasma { + miasma = tr + break + } + } + if miasma.Kind != TrapNecroticMiasma { + t.Fatal("necrotic miasma not in tier 3 catalog") + } + for room := 0; room < 64; room++ { + dmg := rollTrapDamage(miasma, "miasma-run", room) + if dmg < 6 || dmg > 36 { // 6d6 range + t.Errorf("miasma damage out of 6..36 range at room %d: %d", room, dmg) + } + } +} + +func TestRollTrapDamage_AntimagicFieldZero(t *testing.T) { + var anti zoneTrapDef + for _, tr := range tier3TrapCatalog { + if tr.Kind == TrapAntimagicField { + anti = tr + break + } + } + if anti.Kind != TrapAntimagicField { + t.Fatal("antimagic field not in tier 3 catalog") + } + if dmg := rollTrapDamage(anti, "any-run", 0); dmg != 0 { + t.Errorf("antimagic field should deal 0 damage, got %d", dmg) + } +} + +func TestZoneTrapRoom_AntimagicFieldNoHPLoss(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@zone-trap-antimagic:example") + zoneCmdTestCharacter(t, uid, 5) + defer cleanupZoneRuns(uid) + + run, err := startZoneRun(uid, ZoneManorBlackspire, 5, nil) + if err != nil { + t.Fatal(err) + } + zone, _ := getZone(ZoneManorBlackspire) + dndChar, _ := LoadDnDCharacter(uid) + startHP := dndChar.HPCurrent + + var anti zoneTrapDef + for _, tr := range tier3TrapCatalog { + if tr.Kind == TrapAntimagicField { + anti = tr + break + } + } + + p := &AdventurePlugin{} + dmg, narration := p.applyTrapEffectWithDetect(uid, run, zone, dndChar, anti, + failedDetect(anti.DetectSkill, anti.DetectDC)) + if dmg != 0 { + t.Errorf("antimagic field should deal 0 damage even on fail, got %d", dmg) + } + if !strings.Contains(narration, "Antimagic") { + t.Errorf("antimagic narration missing trap name: %q", narration) + } + c, _ := LoadDnDCharacter(uid) + if c.HPCurrent != startHP { + t.Errorf("antimagic field should not change HP: cur=%d, want=%d", c.HPCurrent, startHP) + } +} + func TestRollZoneLoot_BossLootDrops(t *testing.T) { setupAuditTestDB(t) uid := id.UserID("@zone-loot:example") diff --git a/internal/plugin/dnd_zone_traps.go b/internal/plugin/dnd_zone_traps.go index 1cc28f4..ed0fb06 100644 --- a/internal/plugin/dnd_zone_traps.go +++ b/internal/plugin/dnd_zone_traps.go @@ -27,6 +27,10 @@ const ( TrapFlameJet ZoneTrapKind = "flame_jet" TrapCollapsingCeil ZoneTrapKind = "collapsing_ceiling" TrapGlyphOfWarding ZoneTrapKind = "glyph_of_warding" + TrapAntimagicField ZoneTrapKind = "antimagic_field" + TrapNecroticMiasma ZoneTrapKind = "necrotic_miasma" + TrapSymbolOfDeath ZoneTrapKind = "symbol_of_death" + TrapPlanarRift ZoneTrapKind = "planar_rift" ) // zoneTrapDef — static definition of a zone trap. Damage dice are in @@ -40,10 +44,11 @@ type zoneTrapDef struct { DetectDC int DamageDiceN int // 0 if no direct damage DamageDiceD int // sides - DamageType string // "bludgeoning", "piercing", "poison", ... - AlertsEnemies bool // tripwire-class: 0 damage, raises alarm - Tier ZoneTier - Weight int // 1..10, default 5 + DamageType string // "bludgeoning", "piercing", "poison", ... + AlertsEnemies bool // tripwire-class: 0 damage, raises alarm + SuppressesMagic bool // antimagic-class: 0 damage, narrative suppression + Tier ZoneTier + Weight int // 1..10, default 5 } // tier1TrapCatalog — design doc §6 Tier 1 table. @@ -126,14 +131,83 @@ var tier2TrapCatalog = []zoneTrapDef{ }, } -// trapsByTier returns all traps registered at the given tier. Tier 3+ -// catalogs append to this switch in later sub-phases (D4a). +// tier3TrapCatalog — design doc §6 Tier 3–5 table. The doc lumps all +// high-tier traps into a single list, so tiers 3, 4, and 5 share this +// pool. Two of the four entries (Antimagic Field, Symbol of Death) have +// effects that don't fit the dice-only damage model: +// +// - Antimagic Field: no damage; SuppressesMagic flag flips the +// narration to a non-damaging suppression beat. The actual +// spell-suppression mechanic is left to a later phase — for now +// the line tells the player magic is choked in this room. +// - Necrotic Miasma: design doc says "6d6 + max HP -10 until long +// rest". The max-HP debuff isn't modeled here; trap delivers the +// 6d6 necrotic damage only. +// - Symbol of Death: doc reads "INT DC 20 or drop to 0 HP". We +// approximate with 8d10 psychic; the existing near-KO cap in +// applyTrapEffectWithDetect prevents an outright kill. +// - Planar Rift: 4d10 psychic. The teleport-to-random-room mechanic +// isn't modeled — trap just deals psychic damage for now. +var tier3TrapCatalog = []zoneTrapDef{ + { + Kind: TrapAntimagicField, + Display: "Antimagic Field", + Trigger: "the air thickens and every charm at your belt goes cold", + DetectSkill: SkillArcana, + DetectDC: 18, + SuppressesMagic: true, + Tier: ZoneTierJourneyman, + Weight: 3, + }, + { + Kind: TrapNecroticMiasma, + Display: "Necrotic Miasma", + Trigger: "the sarcophagus lid shifts and a green-black fume pours out", + DetectSkill: SkillPerception, + DetectDC: 16, + DamageDiceN: 6, + DamageDiceD: 6, + DamageType: "necrotic", + Tier: ZoneTierJourneyman, + Weight: 5, + }, + { + Kind: TrapSymbolOfDeath, + Display: "Symbol of Death", + Trigger: "an inscribed sigil flares and your vision blanks to white", + DetectSkill: SkillInvestigation, + DetectDC: 20, + DamageDiceN: 8, + DamageDiceD: 10, + DamageType: "psychic", + Tier: ZoneTierJourneyman, + Weight: 2, + }, + { + Kind: TrapPlanarRift, + Display: "Planar Rift", + Trigger: "the floor folds wrong and a tear of starless dark grasps at you", + DetectSkill: SkillArcana, + DetectDC: 20, + DamageDiceN: 4, + DamageDiceD: 10, + DamageType: "psychic", + Tier: ZoneTierJourneyman, + Weight: 3, + }, +} + +// trapsByTier returns all traps registered at the given tier. Tiers 3, +// 4, and 5 share the Tier 3 catalog per the design doc's combined +// "Tier 3–5 Traps" table; later phases may split them. func trapsByTier(t ZoneTier) []zoneTrapDef { switch t { case ZoneTierBeginner: return tier1TrapCatalog case ZoneTierApprentice: return tier2TrapCatalog + case ZoneTierJourneyman, ZoneTierVeteran, ZoneTierLegendary: + return tier3TrapCatalog } return nil } @@ -190,7 +264,7 @@ func zoneTrapSelectorHash(runID string, roomIdx int) uint64 { // here; the design-doc CON DC 11 save is folded into the same dice pool // as a flattened average. func rollTrapDamage(tr zoneTrapDef, runID string, roomIdx int) int { - if tr.AlertsEnemies || tr.DamageDiceN == 0 || tr.DamageDiceD == 0 { + if tr.AlertsEnemies || tr.SuppressesMagic || tr.DamageDiceN == 0 || tr.DamageDiceD == 0 { return 0 } rng := rand.New(rand.NewPCG(zoneTrapSelectorHash(runID, roomIdx), 0x712D2A)) @@ -207,6 +281,9 @@ func trapDamageHeader(tr zoneTrapDef, dmg int, hpCur, hpMax int) string { if tr.AlertsEnemies { return fmt.Sprintf("🔔 **%s** — %s. The next room knows you're here.", tr.Display, tr.Trigger) } + if tr.SuppressesMagic { + return fmt.Sprintf("🚫 **%s** — %s. Spells fizzle and enchanted gear sleeps until you leave the room.", tr.Display, tr.Trigger) + } dice := fmt.Sprintf("%dd%d", tr.DamageDiceN, tr.DamageDiceD) return fmt.Sprintf("💢 **%s** (%s %s) — **%d HP** (%d/%d remaining).", tr.Display, dice, tr.DamageType, dmg, hpCur, hpMax)