mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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>
207 lines
5.7 KiB
Go
207 lines
5.7 KiB
Go
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)
|
||
}
|
||
}
|