Files
gogobee/internal/plugin/dnd_zone_combat_test.go
prosolis 98a9c930fa 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>
2026-05-09 14:25:22 -07:00

357 lines
10 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 (
"hash/fnv"
"math/rand/v2"
"strings"
"testing"
"maunium.net/go/mautrix/id"
)
func newDeterministicRNG(t *testing.T, seed string) *rand.Rand {
t.Helper()
h := fnv.New64a()
h.Write([]byte(seed))
return rand.New(rand.NewPCG(h.Sum64(), 0xC0FFEE))
}
// Phase 11 D1e — combat / trap / boss resolution tests.
//
// These exercise the pure helpers in dnd_zone_combat.go (deterministic
// enemy pick, coin-pattern parsing, loot rolling) and the integrated
// !zone advance path (trap nick + boss combat with a beefed-up char).
func TestPickZoneEnemy_NonElite_RespectsRoster(t *testing.T) {
zone, _ := getZone(ZoneGoblinWarrens)
seen := map[string]bool{}
for room := 0; room < 32; room++ {
m, ok := pickZoneEnemy(zone, "deterministic-run", room, false)
if !ok {
t.Fatalf("pickZoneEnemy returned !ok at room %d", room)
}
// Non-elite picks must never include elite-flagged entries.
for _, e := range zone.Enemies {
if e.IsElite && e.BestiaryID == m.ID {
t.Errorf("non-elite pick returned elite roster entry: %s", m.ID)
}
}
seen[m.ID] = true
}
if len(seen) < 2 {
t.Errorf("expected variety across 32 rooms, only saw %d unique enemies: %v", len(seen), seen)
}
}
func TestPickZoneEnemy_Elite_FiltersToEliteRoster(t *testing.T) {
zone, _ := getZone(ZoneGoblinWarrens)
for room := 0; room < 16; room++ {
m, ok := pickZoneEnemy(zone, "elite-run", room, true)
if !ok {
t.Fatalf("elite pickZoneEnemy returned !ok at room %d", room)
}
// Confirm the picked monster's ID is one of the roster's elite-flagged entries.
isElite := false
for _, e := range zone.Enemies {
if e.BestiaryID == m.ID && e.IsElite {
isElite = true
break
}
}
if !isElite {
t.Errorf("elite pick picked non-elite roster entry: %s", m.ID)
}
}
}
func TestPickZoneEnemy_Deterministic(t *testing.T) {
zone, _ := getZone(ZoneCryptValdris)
a, _ := pickZoneEnemy(zone, "stable", 3, false)
b, _ := pickZoneEnemy(zone, "stable", 3, false)
if a.ID != b.ID {
t.Errorf("deterministic pick diverged: %s vs %s", a.ID, b.ID)
}
}
func TestRollCoinPattern_Parses(t *testing.T) {
rng := newDeterministicRNG(t, "coins")
v := rollCoinPattern("coins_2d10x5", rng)
// 2d10×5: min 10, max 100.
if v < 10 || v > 100 {
t.Errorf("2d10x5 = %d, out of [10,100]", v)
}
}
func TestRollCoinPattern_BadInputFallsBack(t *testing.T) {
rng := newDeterministicRNG(t, "bad-coins")
if got := rollCoinPattern("coins_garbage", rng); got != 25 {
t.Errorf("bad coin pattern fallback = %d, want 25", got)
}
}
func TestZoneCombatXP_LossFraction(t *testing.T) {
loss := zoneCombatXP(CombatResult{PlayerWon: false}, 1.0, 1)
win := zoneCombatXP(CombatResult{PlayerWon: true}, 1.0, 1)
if loss >= win {
t.Errorf("loss (%d) should be < win (%d)", loss, win)
}
}
func TestZoneCombatXP_NearDeathBonus(t *testing.T) {
plain := zoneCombatXP(CombatResult{PlayerWon: true}, 1.0, 1)
nd := zoneCombatXP(CombatResult{PlayerWon: true, NearDeath: true}, 1.0, 1)
if nd <= plain {
t.Errorf("near-death (%d) should beat plain win (%d)", nd, plain)
}
}
func TestZoneCombatXP_TierFloor(t *testing.T) {
// CR 0.25 at T5 should hit the tier floor, not 25 raw.
got := zoneCombatXP(CombatResult{PlayerWon: true}, 0.25, 5)
if got <= int(0.25*100) {
t.Errorf("expected tier floor to lift CR 0.25 at T5 above 25, got %d", got)
}
}
func TestTitleCaseUnderscored(t *testing.T) {
cases := map[string]string{
"wpn_handaxe_+1": "Wpn Handaxe +1",
"valdris_phylactery": "Valdris Phylactery",
"": "",
"single": "Single",
}
for in, want := range cases {
if got := titleCaseUnderscored(in); got != want {
t.Errorf("titleCaseUnderscored(%q) = %q, want %q", in, got, want)
}
}
}
// 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-pit:example")
zoneCmdTestCharacter(t, uid, 1)
defer cleanupZoneRuns(uid)
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
if err != nil {
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.applyTrapEffectWithDetect(uid, run, zone, dndChar, pit,
failedDetect(pit.DetectSkill, pit.DetectDC))
if dmg <= 0 {
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)
}
c, _ := LoadDnDCharacter(uid)
if c.HPCurrent >= c.HPMax {
t.Errorf("trap did not persist HP loss: cur=%d max=%d", c.HPCurrent, c.HPMax)
}
}
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")
zoneCmdTestCharacter(t, uid, 1)
defer cleanupZoneRuns(uid)
c, _ := LoadDnDCharacter(uid)
c.HPCurrent = 2
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
run, _ := startZoneRun(uid, ZoneCryptValdris, 1, nil)
zone, _ := getZone(ZoneCryptValdris)
dndChar, _ := LoadDnDCharacter(uid)
pit := tier1TrapCatalog[0]
p := &AdventurePlugin{}
_, _ = 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")
zoneCmdTestCharacter(t, uid, 1)
defer cleanupZoneRuns(uid)
run, _ := startZoneRun(uid, ZoneCryptValdris, 1, nil)
zone, _ := getZone(ZoneCryptValdris)
p := &AdventurePlugin{}
granted := p.rollZoneLoot(uid, run, zone)
// Crypt of Valdris has UniqueAlways "valdris_phylactery_shard" + always-drop coins.
foundShard := false
foundCoins := false
for _, id := range granted {
if id == "valdris_phylactery_shard" {
foundShard = true
}
if strings.HasPrefix(id, "coins_") {
foundCoins = true
}
}
if !foundShard {
t.Errorf("UniqueAlways quest item missing from drops: %v", granted)
}
if !foundCoins {
t.Errorf("100%%-drop coins missing from drops: %v", granted)
}
}