From 4ef152fbe605d03472f9bf2af5bb8490539f46ef Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 8 May 2026 13:05:13 -0700 Subject: [PATCH] Adv 2.0 D&D Phase 11 D3b: Tier 2 trap catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Flame Jet (3d6 fire), Collapsing Ceiling (4d6 bludgeoning), and Glyph of Warding (5d8 lightning) per design doc §6 Tier 2. Detect DCs match the table (Perception/Investigation/Arcana 14). Save mechanics ("DEX DC X half") are not modeled — full dice resolve on a failed detect, matching the Tier 1 convention from D2a. trapsByTier now dispatches Apprentice → tier2TrapCatalog. Added deterministic-pick + variety + range tests for the new tier. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/plugin/dnd_zone_combat_test.go | 63 +++++++++++++++++++++++++ internal/plugin/dnd_zone_traps.go | 51 +++++++++++++++++++- 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/internal/plugin/dnd_zone_combat_test.go b/internal/plugin/dnd_zone_combat_test.go index a244473..d646918 100644 --- a/internal/plugin/dnd_zone_combat_test.go +++ b/internal/plugin/dnd_zone_combat_test.go @@ -278,6 +278,69 @@ func TestPickZoneTrap_DeterministicAndTier1Only(t *testing.T) { } } +func TestPickZoneTrap_Tier2UsesTier2Catalog(t *testing.T) { + zone, ok := getZone(ZoneForestShadows) + if !ok { + t.Fatal("forest_shadows zone missing") + } + if zone.Tier != ZoneTierApprentice { + t.Fatalf("forest_shadows expected tier 2, got %d", zone.Tier) + } + seen := map[ZoneTrapKind]bool{} + for room := 0; room < 64; room++ { + tr, ok := pickZoneTrap(zone, "t2-run", room) + if !ok { + t.Fatalf("pickZoneTrap !ok at room %d", room) + } + if tr.Tier != ZoneTierApprentice { + t.Errorf("tier 2 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 2, only saw %d kinds: %v", len(seen), seen) + } +} + +func TestRollTrapDamage_GlyphOfWardingIn5d8Range(t *testing.T) { + var glyph zoneTrapDef + for _, tr := range tier2TrapCatalog { + if tr.Kind == TrapGlyphOfWarding { + glyph = tr + break + } + } + if glyph.Kind != TrapGlyphOfWarding { + t.Fatal("glyph of warding not in tier 2 catalog") + } + for room := 0; room < 64; room++ { + dmg := rollTrapDamage(glyph, "glyph-run", room) + if dmg < 5 || dmg > 40 { + t.Errorf("glyph damage out of 5..40 range at room %d: %d", room, dmg) + } + } +} + +func TestTier2TrapCatalog_AllValid(t *testing.T) { + if len(tier2TrapCatalog) < 3 { + t.Fatalf("tier 2 catalog should have at least 3 traps, got %d", len(tier2TrapCatalog)) + } + for _, tr := range tier2TrapCatalog { + if tr.Tier != ZoneTierApprentice { + t.Errorf("trap %s tagged tier %d, expected 2", tr.Kind, tr.Tier) + } + if tr.Display == "" || tr.Trigger == "" { + t.Errorf("trap %s missing display/trigger text", tr.Kind) + } + if !tr.AlertsEnemies && (tr.DamageDiceN == 0 || tr.DamageDiceD == 0) { + t.Errorf("trap %s deals no damage and is not an alarm", tr.Kind) + } + if tr.DetectDC <= 12 { + t.Errorf("trap %s detect DC %d looks tier 1; tier 2 should be 13+", tr.Kind, tr.DetectDC) + } + } +} + func TestPickZoneTrap_VariesAcrossRooms(t *testing.T) { zone, _ := getZone(ZoneGoblinWarrens) seen := map[ZoneTrapKind]bool{} diff --git a/internal/plugin/dnd_zone_traps.go b/internal/plugin/dnd_zone_traps.go index ccbaf1e..1cc28f4 100644 --- a/internal/plugin/dnd_zone_traps.go +++ b/internal/plugin/dnd_zone_traps.go @@ -24,6 +24,9 @@ const ( TrapPit ZoneTrapKind = "pit" TrapTripwireAlarm ZoneTrapKind = "tripwire_alarm" TrapPoisonDart ZoneTrapKind = "poison_dart" + TrapFlameJet ZoneTrapKind = "flame_jet" + TrapCollapsingCeil ZoneTrapKind = "collapsing_ceiling" + TrapGlyphOfWarding ZoneTrapKind = "glyph_of_warding" ) // zoneTrapDef — static definition of a zone trap. Damage dice are in @@ -81,12 +84,56 @@ var tier1TrapCatalog = []zoneTrapDef{ }, } -// trapsByTier returns all traps registered at the given tier. Currently -// only Tier 1 ships; higher tiers append to this switch in later phases. +// tier2TrapCatalog — design doc §6 Tier 2 table. Save mechanics ("DEX DC X +// half") are not modeled at this layer; full dice resolve on a failed +// detect, matching the Tier 1 convention. +var tier2TrapCatalog = []zoneTrapDef{ + { + Kind: TrapFlameJet, + Display: "Flame Jet", + Trigger: "a pressure plate sinks under your boot and gouts of flame roar up", + DetectSkill: SkillPerception, + DetectDC: 14, + DamageDiceN: 3, + DamageDiceD: 6, + DamageType: "fire", + Tier: ZoneTierApprentice, + Weight: 5, + }, + { + Kind: TrapCollapsingCeil, + Display: "Collapsing Ceiling", + Trigger: "a rigged support snaps and a slab of ceiling crashes down", + DetectSkill: SkillInvestigation, + DetectDC: 14, + DamageDiceN: 4, + DamageDiceD: 6, + DamageType: "bludgeoning", + Tier: ZoneTierApprentice, + Weight: 4, + }, + { + Kind: TrapGlyphOfWarding, + Display: "Glyph of Warding", + Trigger: "a sigil flares on the doorframe and arcs of lightning lash out", + DetectSkill: SkillArcana, + DetectDC: 14, + DamageDiceN: 5, + DamageDiceD: 8, + DamageType: "lightning", + Tier: ZoneTierApprentice, + Weight: 3, + }, +} + +// trapsByTier returns all traps registered at the given tier. Tier 3+ +// catalogs append to this switch in later sub-phases (D4a). func trapsByTier(t ZoneTier) []zoneTrapDef { switch t { case ZoneTierBeginner: return tier1TrapCatalog + case ZoneTierApprentice: + return tier2TrapCatalog } return nil }