diff --git a/internal/plugin/dnd_zone_combat.go b/internal/plugin/dnd_zone_combat.go index f179ba2..e4bed72 100644 --- a/internal/plugin/dnd_zone_combat.go +++ b/internal/plugin/dnd_zone_combat.go @@ -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() } diff --git a/internal/plugin/dnd_zone_combat_test.go b/internal/plugin/dnd_zone_combat_test.go index fe049f1..a244473 100644 --- a/internal/plugin/dnd_zone_combat_test.go +++ b/internal/plugin/dnd_zone_combat_test.go @@ -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") diff --git a/internal/plugin/dnd_zone_traps.go b/internal/plugin/dnd_zone_traps.go new file mode 100644 index 0000000..ccbaf1e --- /dev/null +++ b/internal/plugin/dnd_zone_traps.go @@ -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() +}