Adv 2.0 D&D Phase 11 D1e: zone combat + trap + boss resolution

Wires real combat into !zone advance. Each room now resolves through
its own path: Entry is pure flavor, Exploration spawns a SpawnWeight-
biased non-elite from the zone roster, Elite filters to elite-flagged
entries, Trap nicks 8–20% MaxHP scaled by tier (KO-protected), Boss
runs the bestiary entry from zone.Boss with the zone Loot table on
victory. Combat reuses the existing dungeonCombatPhases pipeline with
the player's full D&D layer (class/race/subclass passives, equipment,
HP scaling, armed abilities, pending casts) and persists HP, subclass
state, and CR-weighted XP after each kill.

Mood event triggers fold in: nat-20s/nat-1s scanned from CombatResult
events apply +3/-2 deltas, player_death applies -5 + abandons the run,
zone_complete (already wired in D1d) lands when the boss falls. Loot
drops materialize into adventure_inventory with coin patterns
("coins_2d10x5") expanded into rolled gold-pouch treasure rows and
named items rendered as tier-scaled placeholder treasure (real
equipment-registry wiring is a later content phase).

Updates the D1d mood-on-completion test to drive the persistence layer
directly, since real combat against the L1 Goblin Warrens roster is
non-deterministic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 12:29:17 -07:00
parent 9dc0e37340
commit d2a1b79ee3
4 changed files with 774 additions and 18 deletions

View File

@@ -0,0 +1,206 @@
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)
}
}
}
func TestZoneTrapRoom_DamagesAndPersists(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@zone-trap:example")
zoneCmdTestCharacter(t, uid, 1)
defer cleanupZoneRuns(uid)
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
if err != nil {
t.Fatal(err)
}
zone, _ := getZone(ZoneGoblinWarrens)
p := &AdventurePlugin{}
dmg, narration := p.resolveTrapRoom(uid, run, zone)
if dmg <= 0 {
t.Errorf("trap dealt no damage: %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_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)
p := &AdventurePlugin{}
_, _ = p.resolveTrapRoom(uid, run, zone)
c, _ = LoadDnDCharacter(uid)
if c.HPCurrent < 1 {
t.Errorf("trap KO'd a player below 1 HP: %d", c.HPCurrent)
}
}
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)
}
}