mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Adv 2.0 D&D Phase 11 D4b: Tier 3-5 trap catalog
Adds tier3TrapCatalog (Antimagic Field, Necrotic Miasma, Symbol of Death, Planar Rift) per design doc §6 Tier 3-5 table. Tiers 3, 4, and 5 share this pool — the doc lumps them together. Antimagic Field uses a new SuppressesMagic flag (no damage, narrative suppression beat); the other three deliver dice damage and skip the unmodelled secondary effects (max-HP debuff, drop-to-0, teleport) for a later phase. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -388,6 +388,125 @@ func TestRollTrapDamage_TripwireZero(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Phase 11 D4b — Tier 3 trap catalog ──────────────────────────────────────
|
||||||
|
|
||||||
|
func TestPickZoneTrap_Tier3UsesTier3Catalog(t *testing.T) {
|
||||||
|
zone, ok := getZone(ZoneManorBlackspire)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("manor_blackspire zone missing")
|
||||||
|
}
|
||||||
|
if zone.Tier != ZoneTierJourneyman {
|
||||||
|
t.Fatalf("manor_blackspire expected tier 3, got %d", zone.Tier)
|
||||||
|
}
|
||||||
|
seen := map[ZoneTrapKind]bool{}
|
||||||
|
for room := 0; room < 64; room++ {
|
||||||
|
tr, ok := pickZoneTrap(zone, "t3-run", room)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("pickZoneTrap !ok at room %d", room)
|
||||||
|
}
|
||||||
|
if tr.Tier != ZoneTierJourneyman {
|
||||||
|
t.Errorf("tier 3 zone picked tier %d trap (%s)", tr.Tier, tr.Kind)
|
||||||
|
}
|
||||||
|
seen[tr.Kind] = true
|
||||||
|
}
|
||||||
|
if len(seen) < 2 {
|
||||||
|
t.Errorf("expected variety across 64 rooms in tier 3, only saw %d kinds: %v", len(seen), seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTier3TrapCatalog_AllValid(t *testing.T) {
|
||||||
|
if len(tier3TrapCatalog) < 4 {
|
||||||
|
t.Fatalf("tier 3 catalog should have at least 4 traps, got %d", len(tier3TrapCatalog))
|
||||||
|
}
|
||||||
|
for _, tr := range tier3TrapCatalog {
|
||||||
|
if tr.Tier != ZoneTierJourneyman {
|
||||||
|
t.Errorf("trap %s tagged tier %d, expected 3", tr.Kind, tr.Tier)
|
||||||
|
}
|
||||||
|
if tr.Display == "" || tr.Trigger == "" {
|
||||||
|
t.Errorf("trap %s missing display/trigger text", tr.Kind)
|
||||||
|
}
|
||||||
|
nonDamaging := tr.AlertsEnemies || tr.SuppressesMagic
|
||||||
|
if !nonDamaging && (tr.DamageDiceN == 0 || tr.DamageDiceD == 0) {
|
||||||
|
t.Errorf("trap %s deals no damage and is not alarm/suppression", tr.Kind)
|
||||||
|
}
|
||||||
|
if tr.DetectDC < 16 {
|
||||||
|
t.Errorf("trap %s detect DC %d looks tier 2; tier 3 should be 16+", tr.Kind, tr.DetectDC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRollTrapDamage_NecroticMiasmaIn6d6Range(t *testing.T) {
|
||||||
|
var miasma zoneTrapDef
|
||||||
|
for _, tr := range tier3TrapCatalog {
|
||||||
|
if tr.Kind == TrapNecroticMiasma {
|
||||||
|
miasma = tr
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if miasma.Kind != TrapNecroticMiasma {
|
||||||
|
t.Fatal("necrotic miasma not in tier 3 catalog")
|
||||||
|
}
|
||||||
|
for room := 0; room < 64; room++ {
|
||||||
|
dmg := rollTrapDamage(miasma, "miasma-run", room)
|
||||||
|
if dmg < 6 || dmg > 36 { // 6d6 range
|
||||||
|
t.Errorf("miasma damage out of 6..36 range at room %d: %d", room, dmg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRollTrapDamage_AntimagicFieldZero(t *testing.T) {
|
||||||
|
var anti zoneTrapDef
|
||||||
|
for _, tr := range tier3TrapCatalog {
|
||||||
|
if tr.Kind == TrapAntimagicField {
|
||||||
|
anti = tr
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if anti.Kind != TrapAntimagicField {
|
||||||
|
t.Fatal("antimagic field not in tier 3 catalog")
|
||||||
|
}
|
||||||
|
if dmg := rollTrapDamage(anti, "any-run", 0); dmg != 0 {
|
||||||
|
t.Errorf("antimagic field should deal 0 damage, got %d", dmg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZoneTrapRoom_AntimagicFieldNoHPLoss(t *testing.T) {
|
||||||
|
setupAuditTestDB(t)
|
||||||
|
uid := id.UserID("@zone-trap-antimagic:example")
|
||||||
|
zoneCmdTestCharacter(t, uid, 5)
|
||||||
|
defer cleanupZoneRuns(uid)
|
||||||
|
|
||||||
|
run, err := startZoneRun(uid, ZoneManorBlackspire, 5, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
zone, _ := getZone(ZoneManorBlackspire)
|
||||||
|
dndChar, _ := LoadDnDCharacter(uid)
|
||||||
|
startHP := dndChar.HPCurrent
|
||||||
|
|
||||||
|
var anti zoneTrapDef
|
||||||
|
for _, tr := range tier3TrapCatalog {
|
||||||
|
if tr.Kind == TrapAntimagicField {
|
||||||
|
anti = tr
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &AdventurePlugin{}
|
||||||
|
dmg, narration := p.applyTrapEffectWithDetect(uid, run, zone, dndChar, anti,
|
||||||
|
failedDetect(anti.DetectSkill, anti.DetectDC))
|
||||||
|
if dmg != 0 {
|
||||||
|
t.Errorf("antimagic field should deal 0 damage even on fail, got %d", dmg)
|
||||||
|
}
|
||||||
|
if !strings.Contains(narration, "Antimagic") {
|
||||||
|
t.Errorf("antimagic narration missing trap name: %q", narration)
|
||||||
|
}
|
||||||
|
c, _ := LoadDnDCharacter(uid)
|
||||||
|
if c.HPCurrent != startHP {
|
||||||
|
t.Errorf("antimagic field should not change HP: cur=%d, want=%d", c.HPCurrent, startHP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRollZoneLoot_BossLootDrops(t *testing.T) {
|
func TestRollZoneLoot_BossLootDrops(t *testing.T) {
|
||||||
setupAuditTestDB(t)
|
setupAuditTestDB(t)
|
||||||
uid := id.UserID("@zone-loot:example")
|
uid := id.UserID("@zone-loot:example")
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ const (
|
|||||||
TrapFlameJet ZoneTrapKind = "flame_jet"
|
TrapFlameJet ZoneTrapKind = "flame_jet"
|
||||||
TrapCollapsingCeil ZoneTrapKind = "collapsing_ceiling"
|
TrapCollapsingCeil ZoneTrapKind = "collapsing_ceiling"
|
||||||
TrapGlyphOfWarding ZoneTrapKind = "glyph_of_warding"
|
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
|
// zoneTrapDef — static definition of a zone trap. Damage dice are in
|
||||||
@@ -40,10 +44,11 @@ type zoneTrapDef struct {
|
|||||||
DetectDC int
|
DetectDC int
|
||||||
DamageDiceN int // 0 if no direct damage
|
DamageDiceN int // 0 if no direct damage
|
||||||
DamageDiceD int // sides
|
DamageDiceD int // sides
|
||||||
DamageType string // "bludgeoning", "piercing", "poison", ...
|
DamageType string // "bludgeoning", "piercing", "poison", ...
|
||||||
AlertsEnemies bool // tripwire-class: 0 damage, raises alarm
|
AlertsEnemies bool // tripwire-class: 0 damage, raises alarm
|
||||||
Tier ZoneTier
|
SuppressesMagic bool // antimagic-class: 0 damage, narrative suppression
|
||||||
Weight int // 1..10, default 5
|
Tier ZoneTier
|
||||||
|
Weight int // 1..10, default 5
|
||||||
}
|
}
|
||||||
|
|
||||||
// tier1TrapCatalog — design doc §6 Tier 1 table.
|
// tier1TrapCatalog — design doc §6 Tier 1 table.
|
||||||
@@ -126,14 +131,83 @@ var tier2TrapCatalog = []zoneTrapDef{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// trapsByTier returns all traps registered at the given tier. Tier 3+
|
// tier3TrapCatalog — design doc §6 Tier 3–5 table. The doc lumps all
|
||||||
// catalogs append to this switch in later sub-phases (D4a).
|
// 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 {
|
func trapsByTier(t ZoneTier) []zoneTrapDef {
|
||||||
switch t {
|
switch t {
|
||||||
case ZoneTierBeginner:
|
case ZoneTierBeginner:
|
||||||
return tier1TrapCatalog
|
return tier1TrapCatalog
|
||||||
case ZoneTierApprentice:
|
case ZoneTierApprentice:
|
||||||
return tier2TrapCatalog
|
return tier2TrapCatalog
|
||||||
|
case ZoneTierJourneyman, ZoneTierVeteran, ZoneTierLegendary:
|
||||||
|
return tier3TrapCatalog
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -190,7 +264,7 @@ func zoneTrapSelectorHash(runID string, roomIdx int) uint64 {
|
|||||||
// here; the design-doc CON DC 11 save is folded into the same dice pool
|
// here; the design-doc CON DC 11 save is folded into the same dice pool
|
||||||
// as a flattened average.
|
// as a flattened average.
|
||||||
func rollTrapDamage(tr zoneTrapDef, runID string, roomIdx int) int {
|
func rollTrapDamage(tr zoneTrapDef, runID string, roomIdx int) int {
|
||||||
if tr.AlertsEnemies || tr.DamageDiceN == 0 || tr.DamageDiceD == 0 {
|
if tr.AlertsEnemies || tr.SuppressesMagic || tr.DamageDiceN == 0 || tr.DamageDiceD == 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
rng := rand.New(rand.NewPCG(zoneTrapSelectorHash(runID, roomIdx), 0x712D2A))
|
rng := rand.New(rand.NewPCG(zoneTrapSelectorHash(runID, roomIdx), 0x712D2A))
|
||||||
@@ -207,6 +281,9 @@ func trapDamageHeader(tr zoneTrapDef, dmg int, hpCur, hpMax int) string {
|
|||||||
if tr.AlertsEnemies {
|
if tr.AlertsEnemies {
|
||||||
return fmt.Sprintf("🔔 **%s** — %s. The next room knows you're here.", tr.Display, tr.Trigger)
|
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)
|
dice := fmt.Sprintf("%dd%d", tr.DamageDiceN, tr.DamageDiceD)
|
||||||
return fmt.Sprintf("💢 **%s** (%s %s) — **%d HP** (%d/%d remaining).",
|
return fmt.Sprintf("💢 **%s** (%s %s) — **%d HP** (%d/%d remaining).",
|
||||||
tr.Display, dice, tr.DamageType, dmg, hpCur, hpMax)
|
tr.Display, dice, tr.DamageType, dmg, hpCur, hpMax)
|
||||||
|
|||||||
Reference in New Issue
Block a user