mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
176
internal/plugin/dnd_zone_traps.go
Normal file
176
internal/plugin/dnd_zone_traps.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user