Adv 2.0 D&D Phase 11 D2a: Tier 1 trap catalog

Replaces the flat-percent HP nick from D1e with the design doc's three
Tier 1 traps (Pit, Tripwire Alarm, Poison Dart). Each trap defines its
own detect skill + DC and damage dice; selection is deterministic per
(runID, roomIdx).

resolveTrapRoom now picks a trap, rolls Perception/Investigation, and
either narrates a clean spot (zero damage) or rolls the trap's dice and
applies HP loss with the existing KO-protection clamp. Tripwire deals
no damage but raises the alarm narratively.

Higher-tier zones still fall through to resolveTrapRoomLegacy until
D3a/D4a fill in their catalogs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 12:38:29 -07:00
parent d2a1b79ee3
commit 98a9c930fa
3 changed files with 406 additions and 23 deletions

View File

@@ -246,24 +246,86 @@ func scanMoodEventsFromCombat(runID string, result CombatResult) (nat20s, nat1s
// ── Trap rooms ──────────────────────────────────────────────────────────────
// resolveTrapRoom applies a small flat HP nick scaled by zone tier and
// returns a narration block. D1e ships the simple version: no DEX save,
// no detection roll. Tier 1 traps shave ~10% of MaxHP; higher tiers
// proportionally more. Damage is capped so a trap can't itself kill —
// players still need to make it to the trap with low HP for that to
// matter, in which case the game design intentionally lets it bite.
// resolveTrapRoom picks a trap from the zone's tier catalog (D2a),
// rolls a detection skill check (Perception/Investigation, per trap),
// and either narrates a clean spot or applies the trap's damage dice.
// Damage is capped so a single trap can't outright KO the player.
//
// Higher-tier zones currently fall through to the pre-D2a flat-percent
// nick — D3a/D4a will add Tier 2+ trap catalogs.
func (p *AdventurePlugin) resolveTrapRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition) (damage int, narration string) {
dndChar, _ := LoadDnDCharacter(userID)
if dndChar == nil {
return 0, ""
}
trap, ok := pickZoneTrap(zone, run.RunID, run.CurrentRoom)
if !ok {
return p.resolveTrapRoomLegacy(userID, run, zone, dndChar)
}
return p.applyTrapEffect(userID, run, zone, dndChar, trap)
}
// applyTrapEffect runs the detect roll and damage application for a
// chosen trap. Split out from resolveTrapRoom so tests can target a
// specific trap kind without depending on the room-seed selector.
func (p *AdventurePlugin) applyTrapEffect(userID id.UserID, run *DungeonRun, zone ZoneDefinition, dndChar *DnDCharacter, trap zoneTrapDef) (int, string) {
detect := performSkillCheck(dndChar, trap.DetectSkill, trap.DetectDC)
return p.applyTrapEffectWithDetect(userID, run, zone, dndChar, trap, detect)
}
// applyTrapEffectWithDetect is the deterministic core of trap resolution:
// given a precomputed detection check result, it produces the narration
// and persists HP loss on a failed detect. Used by applyTrapEffect (which
// rolls the d20) and by tests (which inject a fixed result).
func (p *AdventurePlugin) applyTrapEffectWithDetect(
userID id.UserID,
run *DungeonRun,
zone ZoneDefinition,
dndChar *DnDCharacter,
trap zoneTrapDef,
detect SkillCheckResult,
) (int, string) {
var b strings.Builder
if line := twinBeeLine(zone.ID, GMTrapDetected, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
}
if detect.Success {
b.WriteString(trapSpottedHeader(trap, detect))
return 0, b.String()
}
dmg := rollTrapDamage(trap, run.RunID, run.CurrentRoom)
if dmg >= dndChar.HPCurrent {
dmg = dndChar.HPCurrent - 1
if dmg < 0 {
dmg = 0
}
}
if dmg > 0 {
dndChar.HPCurrent -= dmg
if err := SaveDnDCharacter(dndChar); err != nil {
slog.Error("zone: trap HP persist", "user", userID, "err", err)
}
}
if line := twinBeeLine(zone.ID, GMTrapTripped, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
}
b.WriteString(trapDamageHeader(trap, dmg, dndChar.HPCurrent, dndChar.HPMax))
return dmg, b.String()
}
// resolveTrapRoomLegacy is the pre-D2a flat-percent fallback used when a
// zone tier has no entries in the trap catalog yet (Tier 2+). Removed
// once D3a/D4a fill in the higher-tier catalogs.
func (p *AdventurePlugin) resolveTrapRoomLegacy(userID id.UserID, run *DungeonRun, zone ZoneDefinition, dndChar *DnDCharacter) (int, string) {
tier := int(zone.Tier)
pct := 0.08 + 0.03*float64(tier-1) // T1=8%, T5=20%
pct := 0.08 + 0.03*float64(tier-1)
dmg := int(float64(dndChar.HPMax) * pct)
if dmg < 1 {
dmg = 1
}
// Don't outright KO a player from a single trap.
if dmg >= dndChar.HPCurrent {
dmg = dndChar.HPCurrent - 1
if dmg < 0 {
@@ -272,23 +334,18 @@ func (p *AdventurePlugin) resolveTrapRoom(userID id.UserID, run *DungeonRun, zon
}
dndChar.HPCurrent -= dmg
if err := SaveDnDCharacter(dndChar); err != nil {
slog.Error("zone: trap HP persist", "user", userID, "err", err)
slog.Error("zone: trap HP persist (legacy)", "user", userID, "err", err)
}
var b strings.Builder
if line := twinBeeLine(zone.ID, GMTrapDetected, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
}
if dmg > 0 {
if line := twinBeeLine(zone.ID, GMTrapTripped, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
}
b.WriteString(fmt.Sprintf("💢 The trap takes **%d HP** (%d/%d remaining).", dmg, dndChar.HPCurrent, dndChar.HPMax))
} else {
b.WriteString("You spot it in time. The trap clatters harmlessly.")
if line := twinBeeLine(zone.ID, GMTrapTripped, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
}
b.WriteString(fmt.Sprintf("💢 The trap takes **%d HP** (%d/%d remaining).", dmg, dndChar.HPCurrent, dndChar.HPMax))
return dmg, b.String()
}

View File

@@ -127,9 +127,20 @@ func TestTitleCaseUnderscored(t *testing.T) {
}
}
func TestZoneTrapRoom_DamagesAndPersists(t *testing.T) {
// failedDetect — fabricated SkillCheckResult that always reads as a
// missed detection. Used by trap-room tests to avoid d20 flake.
func failedDetect(skill DnDSkill, dc int) SkillCheckResult {
return SkillCheckResult{Skill: skill, DC: dc, Roll: 5, Mod: 0, Total: 5, Success: false}
}
// passedDetect — fabricated successful detection.
func passedDetect(skill DnDSkill, dc int) SkillCheckResult {
return SkillCheckResult{Skill: skill, DC: dc, Roll: 18, Mod: 0, Total: 18, Success: true}
}
func TestZoneTrapRoom_PitDamagesAndPersists(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@zone-trap:example")
uid := id.UserID("@zone-trap-pit:example")
zoneCmdTestCharacter(t, uid, 1)
defer cleanupZoneRuns(uid)
@@ -138,11 +149,21 @@ func TestZoneTrapRoom_DamagesAndPersists(t *testing.T) {
t.Fatal(err)
}
zone, _ := getZone(ZoneGoblinWarrens)
dndChar, _ := LoadDnDCharacter(uid)
pit := tier1TrapCatalog[0]
if pit.Kind != TrapPit {
t.Fatalf("expected pit at index 0, got %s", pit.Kind)
}
p := &AdventurePlugin{}
dmg, narration := p.resolveTrapRoom(uid, run, zone)
dmg, narration := p.applyTrapEffectWithDetect(uid, run, zone, dndChar, pit,
failedDetect(pit.DetectSkill, pit.DetectDC))
if dmg <= 0 {
t.Errorf("trap dealt no damage: %d", dmg)
t.Errorf("pit trap dealt no damage on failed detect: %d", dmg)
}
if dmg > 12 {
t.Errorf("pit trap exceeded 2d6 max: %d", dmg)
}
if !strings.Contains(narration, "HP") {
t.Errorf("trap narration missing HP line: %q", narration)
@@ -153,6 +174,70 @@ func TestZoneTrapRoom_DamagesAndPersists(t *testing.T) {
}
}
func TestZoneTrapRoom_DetectedSpotsHarmless(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@zone-trap-spot:example")
zoneCmdTestCharacter(t, uid, 1)
defer cleanupZoneRuns(uid)
run, _ := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
zone, _ := getZone(ZoneGoblinWarrens)
dndChar, _ := LoadDnDCharacter(uid)
startHP := dndChar.HPCurrent
pit := tier1TrapCatalog[0]
p := &AdventurePlugin{}
dmg, narration := p.applyTrapEffectWithDetect(uid, run, zone, dndChar, pit,
passedDetect(pit.DetectSkill, pit.DetectDC))
if dmg != 0 {
t.Errorf("detected trap should deal 0 damage, got %d", dmg)
}
if !strings.Contains(narration, "spotted") {
t.Errorf("detected trap narration missing 'spotted': %q", narration)
}
c, _ := LoadDnDCharacter(uid)
if c.HPCurrent != startHP {
t.Errorf("detected trap should not persist HP change: cur=%d, want=%d", c.HPCurrent, startHP)
}
}
func TestZoneTrapRoom_TripwireZeroDamageButAlerts(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@zone-trap-tripwire:example")
zoneCmdTestCharacter(t, uid, 1)
defer cleanupZoneRuns(uid)
run, _ := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
zone, _ := getZone(ZoneGoblinWarrens)
dndChar, _ := LoadDnDCharacter(uid)
startHP := dndChar.HPCurrent
var tripwire zoneTrapDef
for _, tr := range tier1TrapCatalog {
if tr.Kind == TrapTripwireAlarm {
tripwire = tr
break
}
}
if tripwire.Kind != TrapTripwireAlarm {
t.Fatal("tripwire not present in catalog")
}
p := &AdventurePlugin{}
dmg, narration := p.applyTrapEffectWithDetect(uid, run, zone, dndChar, tripwire,
failedDetect(tripwire.DetectSkill, tripwire.DetectDC))
if dmg != 0 {
t.Errorf("tripwire should deal 0 damage even on fail, got %d", dmg)
}
if !strings.Contains(narration, "Tripwire") {
t.Errorf("tripwire narration missing trap name: %q", narration)
}
c, _ := LoadDnDCharacter(uid)
if c.HPCurrent != startHP {
t.Errorf("tripwire should not change HP: cur=%d, want=%d", c.HPCurrent, startHP)
}
}
func TestZoneTrapRoom_DoesNotKO(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@zone-trap-low-hp:example")
@@ -166,15 +251,80 @@ func TestZoneTrapRoom_DoesNotKO(t *testing.T) {
}
run, _ := startZoneRun(uid, ZoneCryptValdris, 1, nil)
zone, _ := getZone(ZoneCryptValdris)
dndChar, _ := LoadDnDCharacter(uid)
pit := tier1TrapCatalog[0]
p := &AdventurePlugin{}
_, _ = p.resolveTrapRoom(uid, run, zone)
_, _ = p.applyTrapEffectWithDetect(uid, run, zone, dndChar, pit,
failedDetect(pit.DetectSkill, pit.DetectDC))
c, _ = LoadDnDCharacter(uid)
if c.HPCurrent < 1 {
t.Errorf("trap KO'd a player below 1 HP: %d", c.HPCurrent)
}
}
func TestPickZoneTrap_DeterministicAndTier1Only(t *testing.T) {
zone, _ := getZone(ZoneGoblinWarrens)
a, ok := pickZoneTrap(zone, "fixed-run", 3)
if !ok {
t.Fatal("pickZoneTrap returned !ok for tier 1 zone")
}
b, _ := pickZoneTrap(zone, "fixed-run", 3)
if a.Kind != b.Kind {
t.Errorf("pickZoneTrap not deterministic: %s vs %s", a.Kind, b.Kind)
}
if a.Tier != ZoneTierBeginner {
t.Errorf("tier 1 zone picked tier %d trap", a.Tier)
}
}
func TestPickZoneTrap_VariesAcrossRooms(t *testing.T) {
zone, _ := getZone(ZoneGoblinWarrens)
seen := map[ZoneTrapKind]bool{}
for room := 0; room < 64; room++ {
tr, ok := pickZoneTrap(zone, "varied-run", room)
if !ok {
t.Fatalf("pickZoneTrap !ok at room %d", room)
}
seen[tr.Kind] = true
}
if len(seen) < 2 {
t.Errorf("expected variety across 64 rooms, only saw %d kinds: %v", len(seen), seen)
}
}
func TestRollTrapDamage_PoisonDartIn2d4Range(t *testing.T) {
var dart zoneTrapDef
for _, tr := range tier1TrapCatalog {
if tr.Kind == TrapPoisonDart {
dart = tr
break
}
}
if dart.Kind != TrapPoisonDart {
t.Fatal("poison dart not in catalog")
}
for room := 0; room < 64; room++ {
dmg := rollTrapDamage(dart, "dart-run", room)
if dmg < 2 || dmg > 8 { // 2d4 range
t.Errorf("poison dart damage out of 2..8 range at room %d: %d", room, dmg)
}
}
}
func TestRollTrapDamage_TripwireZero(t *testing.T) {
var tripwire zoneTrapDef
for _, tr := range tier1TrapCatalog {
if tr.Kind == TrapTripwireAlarm {
tripwire = tr
break
}
}
if dmg := rollTrapDamage(tripwire, "any-run", 0); dmg != 0 {
t.Errorf("tripwire damage should be 0, got %d", dmg)
}
}
func TestRollZoneLoot_BossLootDrops(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@zone-loot:example")

View File

@@ -0,0 +1,176 @@
package plugin
import (
"fmt"
"hash/fnv"
"math/rand/v2"
"strings"
)
// Phase 11 D2a — Tier 1 trap catalog. Implements `gogobee_dungeon_zones.md`
// §6 (Environmental Traps) for tier 1, replacing the flat-percent HP nick
// shipped in D1e with proper trap kinds.
//
// Each trap defines a detect skill + DC, optional damage dice, and an
// "alerts" flag for tripwire-style hazards that don't deal damage. Higher
// tiers add their own trap pools in later sub-phases (D3a/D4a).
//
// Selection is deterministic on (runID, roomIdx, "trap") so re-reading
// !zone status after a trap room resolves yields the same trap.
type ZoneTrapKind string
const (
TrapPit ZoneTrapKind = "pit"
TrapTripwireAlarm ZoneTrapKind = "tripwire_alarm"
TrapPoisonDart ZoneTrapKind = "poison_dart"
)
// zoneTrapDef — static definition of a zone trap. Damage dice are in
// (count, sides) form; AlertsEnemies flags traps that have no damage
// but raise the alarm. Weight biases the room-level random pick.
type zoneTrapDef struct {
Kind ZoneTrapKind
Display string
Trigger string // short flavor descriptor for the trip narration
DetectSkill DnDSkill
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
}
// tier1TrapCatalog — design doc §6 Tier 1 table.
var tier1TrapCatalog = []zoneTrapDef{
{
Kind: TrapPit,
Display: "Pit Trap",
Trigger: "the floor gives way",
DetectSkill: SkillPerception,
DetectDC: 12,
DamageDiceN: 2,
DamageDiceD: 6,
DamageType: "bludgeoning",
Tier: ZoneTierBeginner,
Weight: 5,
},
{
Kind: TrapTripwireAlarm,
Display: "Tripwire Alarm",
Trigger: "a wire snags your ankle and a clatter of bells erupts",
DetectSkill: SkillPerception,
DetectDC: 10,
AlertsEnemies: true,
Tier: ZoneTierBeginner,
Weight: 4,
},
{
Kind: TrapPoisonDart,
Display: "Poison Dart",
Trigger: "a dart hisses out of a hole you didn't see",
DetectSkill: SkillInvestigation,
DetectDC: 12,
DamageDiceN: 2, // 1d4 piercing + 1d4 simulated poison flattened to 2d4
DamageDiceD: 4,
DamageType: "piercing+poison",
Tier: ZoneTierBeginner,
Weight: 3,
},
}
// trapsByTier returns all traps registered at the given tier. Currently
// only Tier 1 ships; higher tiers append to this switch in later phases.
func trapsByTier(t ZoneTier) []zoneTrapDef {
switch t {
case ZoneTierBeginner:
return tier1TrapCatalog
}
return nil
}
// pickZoneTrap selects a trap deterministically from the zone's tier
// catalog based on (runID, roomIdx). Returns ok=false if no traps are
// registered for the tier (defensive — Tier 1 always has entries).
func pickZoneTrap(zone ZoneDefinition, runID string, roomIdx int) (zoneTrapDef, bool) {
pool := trapsByTier(zone.Tier)
if len(pool) == 0 {
return zoneTrapDef{}, false
}
total := 0
for _, tr := range pool {
w := tr.Weight
if w <= 0 {
w = 5
}
total += w
}
roll := int(zoneTrapSelectorHash(runID, roomIdx) % uint64(total))
cum := 0
for _, tr := range pool {
w := tr.Weight
if w <= 0 {
w = 5
}
cum += w
if roll < cum {
return tr, true
}
}
return pool[len(pool)-1], true
}
// zoneTrapSelectorHash — separate namespace from enemy/loot hashes so a
// trap pick on the same (run, room) doesn't collide with their seeds.
func zoneTrapSelectorHash(runID string, roomIdx int) uint64 {
h := fnv.New64a()
h.Write([]byte("trap:"))
h.Write([]byte(runID))
var sb [8]byte
for i := 0; i < 8; i++ {
sb[i] = byte(roomIdx >> (8 * i))
}
h.Write(sb[:])
return h.Sum64()
}
// rollTrapDamage rolls a trap's damage dice with a seeded RNG so the
// same (run, room) yields the same number each read. Returns 0 for
// alert-only traps (Tripwire). DamageType "piercing+poison" is treated
// as a single combined dice pool — we don't model the Poisoned condition
// 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 {
return 0
}
rng := rand.New(rand.NewPCG(zoneTrapSelectorHash(runID, roomIdx), 0x712D2A))
total := 0
for i := 0; i < tr.DamageDiceN; i++ {
total += rng.IntN(tr.DamageDiceD) + 1
}
return total
}
// trapDamageHeader builds the user-facing one-liner for a damaging trap
// trip — "💢 Pit Trap (2d6 bludgeoning) — 9 HP". Pure formatter.
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)
}
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)
}
// trapSpottedHeader — narration for a successful detection roll. No
// damage; the player gets a beat of competence flavor.
func trapSpottedHeader(tr zoneTrapDef, res SkillCheckResult) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("👁️ **%s spotted** — ", tr.Display))
info, _ := skillInfo(tr.DetectSkill)
b.WriteString(fmt.Sprintf("%s check %d vs DC %d.", info.Display, res.Total, tr.DetectDC))
return b.String()
}