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

207 lines
5.7 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)
}
}
}
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)
}
}