mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
Mechanical `gofmt -w ./internal ./cmd`. Mostly struct-field realignment that had drifted, plus a few trailing-newline fixes. No behaviour change — gofmt is semantics-preserving, and build/vet/test are green either side. Split out from the code-review fixes that follow so those stay reviewable instead of hiding inside a wall of realignment.
304 lines
9.9 KiB
Go
304 lines
9.9 KiB
Go
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 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
|
||
}
|
||
|
||
// 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()
|
||
}
|