Files
gogobee/internal/plugin/dnd_zone_traps.go
prosolis dfa7beeb96 Adv 2.0 D&D polish: GM→DM rename, streamed zone combat, end-to-end scenario test
GM→DM rename across docs and code (GMNarrationType→DMNarrationType,
GMState→DMState, narration constants, comments) so the system reads as
"Dungeon Master" everywhere. Player-visible "GM mood" wording stays
where it appears in flavor.

Streamed zone/expedition combat: zone advance now stages patrol →
patrol play-by-play → patrol resolution → room intro → room play-by-play
→ final outcome through sendZoneCombatMessages with 2–3s pacing
(arena keeps its 5–8s window). Combat narrative lines pick up a compact
d20-vs-AC roll annotation for hit/crit/miss/block events.

Combat outcome polish: dndHPSnapshot lets narration show sheet HP
rather than legacy combat-engine HP, and markAdventureDead clears the
zombie state where hp_current was 0 but the legacy alive flag stayed
true after a D&D-layer KO.

Adv 2.0 announcement (ADVENTURE_2.0_ANNOUNCEMENT.md), README rewrite
covering the new layer, and adv2_scenario_test.go — a full
zone-run + expedition + harvest playthrough against a copy of the prod
DB asserting persisted state end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00

304 lines
9.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
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
// (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
SuppressesMagic bool // antimagic-class: 0 damage, narrative suppression
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,
},
}
// 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,
},
}
// tier3TrapCatalog — design doc §6 Tier 35 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 35 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
}
// 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.SuppressesMagic || 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)
}
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)
// Lead with the trap's own mechanism-specific Trigger so the narrative
// matches the trap kind (e.g. a Collapsing Ceiling no longer reads as
// a dart in the air). Trigger is required on every catalog entry.
return fmt.Sprintf("💢 **%s** — %s. (%s %s) — **%d HP** (%d/%d remaining).",
tr.Display, tr.Trigger, 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()
}