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

@@ -3,6 +3,8 @@ package plugin
import (
"fmt"
"strings"
"maunium.net/go/mautrix/id"
)
// !zone — Phase 11 D1c. The command surface for the dungeon-zone state
@@ -20,9 +22,8 @@ import (
// !zone advance → resolve the current room and move on
// !zone abandon → end the active run (no rewards)
//
// Combat resolution per room arrives in D1e; advance currently just
// records the room as cleared and reports the next room type.
// TwinBee narration / mood triggers arrive in D1d.
// TwinBee narration / mood triggers arrive in D1d. Combat / trap / boss
// resolution per room is wired in D1e via dnd_zone_combat.go.
func (p *AdventurePlugin) handleDnDZoneCmd(ctx MessageContext, args string) error {
userMu := p.advUserLock(ctx.Sender)
@@ -286,9 +287,11 @@ func (p *AdventurePlugin) zoneCmdMap(ctx MessageContext) error {
// ── advance ─────────────────────────────────────────────────────────────────
// zoneCmdAdvance is the D1c stub: it records the current room cleared and
// reports the next room. Real combat / trap / boss resolution wires in
// D1e+. This is intentional — D1c ships the *surface*.
// zoneCmdAdvance resolves the room the player is currently standing in,
// then moves them to the next. Resolution branches on RoomType — combat
// for Exploration/Elite/Boss, a trap nick for Trap, narration-only for
// Entry. Player loss aborts the run with a mood penalty and player-death
// flavor; boss win drops the zone Loot table.
func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
run, err := getActiveZoneRun(ctx.Sender)
if err != nil {
@@ -301,24 +304,49 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
zone, _ := getZone(run.ZoneID)
prev := run.CurrentRoomType()
prevIdx := run.CurrentRoom
// Resolve the current room *before* clearing it, so combat results
// can decide whether the player advances or the run ends.
resolution, ended, err := p.resolveRoom(ctx.Sender, run, zone)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't resolve room: "+err.Error())
}
if ended {
return p.SendDM(ctx.Sender, resolution)
}
next, err := markRoomCleared(run.RunID)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't advance: "+err.Error())
}
if next == "" {
// Boss room cleared → zone complete. Bump mood per design §3.2.
_, _ = applyMoodEvent(run.RunID, MoodEventZoneComplete)
var b strings.Builder
b.WriteString(fmt.Sprintf("🏆 **Cleared %s.** Boss defeated. Run complete.\n\n", zone.Display))
if resolution != "" {
b.WriteString(resolution)
b.WriteString("\n\n")
}
b.WriteString(fmt.Sprintf("🏆 **Cleared %s.** Run complete.\n\n", zone.Display))
if line := twinBeeLine(zone.ID, GMZoneComplete, run.RunID, prevIdx); line != "" {
b.WriteString(line)
b.WriteString("\n\n")
}
b.WriteString("_(Combat resolution + loot rolls land in D1e — for now this is a clean state-machine win.)_")
// Drop the zone loot table on boss kill.
if granted := p.rollZoneLoot(ctx.Sender, run, zone); len(granted) > 0 {
b.WriteString("**Loot:**\n")
for _, id := range granted {
b.WriteString("• " + id + "\n")
}
}
return p.SendDM(ctx.Sender, b.String())
}
nextIdx := run.CurrentRoom + 1 // markRoomCleared already advanced; reflect for narration salt
nextIdx := run.CurrentRoom + 1
var b strings.Builder
if resolution != "" {
b.WriteString(resolution)
b.WriteString("\n\n")
}
b.WriteString(fmt.Sprintf("✓ Cleared room %d (%s).\n\n", prevIdx+1, prettyRoomType(prev)))
if next == RoomBoss {
if line := twinBeeLine(zone.ID, GMBossEntry, run.RunID, nextIdx); line != "" {
@@ -336,6 +364,102 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
return p.SendDM(ctx.Sender, b.String())
}
// resolveRoom dispatches to the per-room-type resolver. Returns the
// resolution narration, an `ended` flag (true when the run ended due to
// player death — caller should send the narration and stop), and any
// error encountered. Entry rooms are pure flavor and resolve trivially.
func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition) (string, bool, error) {
switch run.CurrentRoomType() {
case RoomEntry:
return "", false, nil
case RoomTrap:
_, narration := p.resolveTrapRoom(userID, run, zone)
return narration, false, nil
case RoomExploration:
return p.resolveCombatRoom(userID, run, zone, false)
case RoomElite:
return p.resolveCombatRoom(userID, run, zone, true)
case RoomBoss:
return p.resolveBossRoom(userID, run, zone)
}
return "", false, nil
}
// resolveCombatRoom spawns one roster enemy (elite filter optional),
// runs combat, persists side effects, fires nat-1/nat-20 mood deltas,
// and renders the narration block. Returns ended=true on player loss.
func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition, elite bool) (string, bool, error) {
monster, ok := pickZoneEnemy(zone, run.RunID, run.CurrentRoom, elite)
if !ok {
return fmt.Sprintf("_(No %s roster entry — skipping.)_", map[bool]string{true: "elite", false: "exploration"}[elite]), false, nil
}
result, err := p.runZoneCombat(userID, monster, int(zone.Tier))
if err != nil {
return "", false, err
}
scanMoodEventsFromCombat(run.RunID, result)
var b strings.Builder
if line := twinBeeLine(zone.ID, GMCombatStart, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
}
if elite {
b.WriteString(fmt.Sprintf("⚔️ **Elite encounter — %s** (HP %d, AC %d)\n", monster.Name, monster.HP, monster.AC))
} else {
b.WriteString(fmt.Sprintf("⚔️ **%s** (HP %d, AC %d)\n", monster.Name, monster.HP, monster.AC))
}
if !result.PlayerWon {
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
_ = abandonZoneRun(userID)
if line := twinBeeLine(zone.ID, GMPlayerDeath, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
}
b.WriteString(fmt.Sprintf("💀 You fell to **%s**. Run ended.", monster.Name))
return b.String(), true, nil
}
if line := twinBeeLine(zone.ID, GMCombatEnd, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
}
b.WriteString(fmt.Sprintf("✅ **%s** down (HP %d→%d).", monster.Name, result.PlayerStartHP, result.PlayerEndHP))
return b.String(), false, nil
}
// resolveBossRoom runs the zone-boss bestiary entry through the same
// combat path as room combat. Win → caller drops zone loot.
func (p *AdventurePlugin) resolveBossRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition) (string, bool, error) {
monster, ok := dndBestiary[zone.Boss.BestiaryID]
if !ok {
return fmt.Sprintf("_(Boss %s not in bestiary — skipping.)_", zone.Boss.Name), false, nil
}
result, err := p.runZoneCombat(userID, monster, int(zone.Tier))
if err != nil {
return "", false, err
}
scanMoodEventsFromCombat(run.RunID, result)
var b strings.Builder
b.WriteString(fmt.Sprintf("👑 **Boss — %s** (HP %d, AC %d)\n", monster.Name, monster.HP, monster.AC))
if !result.PlayerWon {
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
_ = abandonZoneRun(userID)
if line := twinBeeLine(zone.ID, GMPlayerDeath, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
}
b.WriteString(fmt.Sprintf("💀 **%s** stands over your body. Run ended.", monster.Name))
return b.String(), true, nil
}
if line := twinBeeLine(zone.ID, GMBossDeath, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
}
b.WriteString(fmt.Sprintf("🏆 **%s** falls (HP %d→%d).", monster.Name, result.PlayerStartHP, result.PlayerEndHP))
return b.String(), false, nil
}
// ── abandon ─────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) zoneCmdAbandon(ctx MessageContext) error {

View File

@@ -0,0 +1,422 @@
package plugin
import (
"fmt"
"hash/fnv"
"log/slog"
"math/rand/v2"
"strings"
"maunium.net/go/mautrix/id"
)
// Phase 11 D1e — combat / trap / boss resolution per zone room.
//
// D1c shipped the !zone command surface and D1d wired TwinBee narration
// + mood. D1e fills in the "what actually happens when you !zone advance
// into a fight" — spawning enemies from the zone roster, running them
// through the existing combat engine with the player's full D&D layer,
// awarding XP per kill and zone loot on boss defeat, and ending the run
// (mood penalty + GMPlayerDeath narration) if the player goes down.
//
// Per-room behavior:
// Entry → no combat (already narrated on enter)
// Exploration → SpawnWeight-biased pick from non-elite roster, combat
// Trap → flat % MaxHP nick + trap_detected/trap_tripped flavor
// Elite → pick from elite-only roster (falls back to non-elite),
// combat with +tier scaling on stats
// Boss → zone.Boss bestiary entry, combat + zone Loot table on win
//
// Boss fights remain one-shot SimulateCombat in D1e — the per-turn
// !attack/!cast/!dodge interface from gogobee_dungeon_zones.md §4 is
// scoped to a later sub-phase. The roster, stat blocks, and mood/loot
// scaffolding land here so the run loop is end-to-end playable.
// ── Enemy selection ─────────────────────────────────────────────────────────
// pickZoneEnemy picks one enemy from the zone roster for the given room
// kind. Selection is deterministic on (run, room) so re-running the same
// room (idempotent reads of !zone advance after it's already resolved)
// yields the same line of fate. isElite filters to entries flagged elite,
// falling back to the full roster if no elite entries exist.
//
// SpawnWeight (1..10) biases the cumulative wheel; weight 0 falls back to 5.
func pickZoneEnemy(zone ZoneDefinition, runID string, roomIdx int, isElite bool) (DnDMonsterTemplate, bool) {
pool := make([]ZoneEnemy, 0, len(zone.Enemies))
if isElite {
for _, e := range zone.Enemies {
if e.IsElite {
pool = append(pool, e)
}
}
}
if len(pool) == 0 {
for _, e := range zone.Enemies {
if !isElite && e.IsElite {
continue
}
pool = append(pool, e)
}
}
if len(pool) == 0 {
return DnDMonsterTemplate{}, false
}
total := 0
for _, e := range pool {
w := e.SpawnWeight
if w <= 0 {
w = 5
}
total += w
}
roll := int(zoneSelectorHash(runID, roomIdx) % uint64(total))
cum := 0
var chosen ZoneEnemy
for _, e := range pool {
w := e.SpawnWeight
if w <= 0 {
w = 5
}
cum += w
if roll < cum {
chosen = e
break
}
}
tmpl, ok := dndBestiary[chosen.BestiaryID]
return tmpl, ok
}
// zoneSelectorHash — same family as pickLineDeterministic; kept separate
// so a roster pick and a narration pick on the same (run, room) don't
// collide on the same hash output.
func zoneSelectorHash(runID string, roomIdx int) uint64 {
h := fnv.New64a()
h.Write([]byte("enemy:"))
h.Write([]byte(runID))
var sb [8]byte
for i := 0; i < 8; i++ {
sb[i] = byte(roomIdx >> (8 * i))
}
h.Write(sb[:])
return h.Sum64()
}
// ── Combat runner ───────────────────────────────────────────────────────────
// runZoneCombat sets up a Combatant pair from the player's full D&D
// layer + a bestiary monster, runs SimulateCombat with dungeonCombatPhases,
// and persists post-combat side effects (HP, subclass state, XP).
//
// Returns the engine result so the caller can branch on PlayerWon and
// fold combat events into the per-room narration.
func (p *AdventurePlugin) runZoneCombat(
userID id.UserID,
monster DnDMonsterTemplate,
tier int,
) (CombatResult, error) {
char, err := loadAdvCharacter(userID)
if err != nil || char == nil {
return CombatResult{}, fmt.Errorf("load adv character: %w", err)
}
equip, err := loadAdvEquipment(userID)
if err != nil {
return CombatResult{}, fmt.Errorf("load equipment: %w", err)
}
bonuses := p.loadCombatBonuses(userID, char)
chatLvl := p.chatLevel(userID)
playerStats, playerMods := DerivePlayerStats(char, equip, bonuses, chatLvl, char.CurrentStreak, false)
dndChar, freshMigrate, err := ensureDnDCharacterForCombat(userID, char)
if err != nil {
return CombatResult{}, fmt.Errorf("ensure dnd character: %w", err)
}
if freshMigrate {
p.maybeSendDnDOnboarding(userID, char, dndChar)
}
applyDnDPlayerLayer(&playerStats, dndChar)
applyDnDEquipmentLayer(&playerStats, dndChar, equip)
applyDnDHPScaling(&playerStats, dndChar)
applyClassPassives(&playerStats, &playerMods, dndChar)
applyRacePassives(&playerStats, &playerMods, dndChar)
applySubclassPassives(&playerStats, &playerMods, dndChar)
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
slog.Info("dnd: armed ability fired (zone)", "user", userID, "ability", firedName)
}
enemyStats, enemyMods := monster.toCombatStats()
// Tier scaling matches applyDnDDungeonMonsterLayer's intent: keep AC
// floor reasonable for higher-tier zones, but only bump if the bestiary
// stat block is below the floor — boss/elite stat blocks already encode
// their challenge, so we don't double-scale them.
if tier > 1 {
floorAC := dndDungeonACBase + tier
if enemyStats.AC < floorAC {
enemyStats.AC = floorAC
}
floorAB := dndDungeonAtkBase + tier
if enemyStats.AttackBonus < floorAB {
enemyStats.AttackBonus = floorAB
}
}
applyPendingCast(userID, dndChar, &playerStats, &playerMods, &enemyStats)
player := Combatant{
Name: char.DisplayName,
Stats: playerStats,
Mods: playerMods,
IsPlayer: true,
}
enemy := Combatant{
Name: monster.Name,
Stats: enemyStats,
Mods: enemyMods,
Ability: monster.Ability,
}
result := SimulateCombat(player, enemy, dungeonCombatPhases)
p.grantCombatAchievements(userID, result)
persistDnDHPAfterCombat(userID, result.PlayerStartHP, result.PlayerEndHP)
if err := persistDnDPostCombatSubclass(dndChar, playerMods.BerserkerRage, result, playerMods); err != nil {
slog.Error("dnd: post-combat subclass persist (zone)", "user", userID, "err", err)
}
if xp := zoneCombatXP(result, monster.CR, tier); xp > 0 {
if _, err := p.grantDnDXP(userID, xp); err != nil {
slog.Error("dnd: grantDnDXP zone", "user", userID, "err", err)
}
}
return result, nil
}
// zoneCombatXP — CR-weighted XP per kill, with a tier-based floor so
// low-CR roster fillers in a high-tier zone still feel worthwhile.
// Loss grants the standard dndLossXPFraction.
func zoneCombatXP(result CombatResult, cr float32, tier int) int {
if tier < 1 {
tier = 1
}
// CR 0.25 → 25, CR 1 → 100, CR 3 → 300, CR 5 → 500. Caps at CR 20.
base := int(cr * 100)
floor := dndDungeonXPPerTier * tier // T1=15, T5=75
if base < floor {
base = floor
}
if !result.PlayerWon {
return int(float64(base) * dndLossXPFraction)
}
if result.NearDeath {
return int(float64(base) * dndNearDeathXPBonus)
}
return base
}
// ── Mood event scanning ─────────────────────────────────────────────────────
// scanMoodEventsFromCombat walks combat events and applies §3.2 mood
// triggers for nat-20s and nat-1s. Returns the count of each so the
// caller can include the deltas in the rendered status line.
func scanMoodEventsFromCombat(runID string, result CombatResult) (nat20s, nat1s int) {
for _, ev := range result.Events {
if ev.Roll == 20 && ev.Actor == "player" {
nat20s++
}
if ev.Roll == 1 && ev.Actor == "player" {
nat1s++
}
}
for i := 0; i < nat20s; i++ {
if _, err := applyMoodEvent(runID, MoodEventNat20); err != nil {
slog.Error("zone: applyMoodEvent nat20", "run", runID, "err", err)
break
}
}
for i := 0; i < nat1s; i++ {
if _, err := applyMoodEvent(runID, MoodEventNat1); err != nil {
slog.Error("zone: applyMoodEvent nat1", "run", runID, "err", err)
break
}
}
return nat20s, nat1s
}
// ── Trap rooms ──────────────────────────────────────────────────────────────
// resolveTrapRoom applies a small flat HP nick scaled by zone tier and
// returns a narration block. D1e ships the simple version: no DEX save,
// no detection roll. Tier 1 traps shave ~10% of MaxHP; higher tiers
// proportionally more. Damage is capped so a trap can't itself kill —
// players still need to make it to the trap with low HP for that to
// matter, in which case the game design intentionally lets it bite.
func (p *AdventurePlugin) resolveTrapRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition) (damage int, narration string) {
dndChar, _ := LoadDnDCharacter(userID)
if dndChar == nil {
return 0, ""
}
tier := int(zone.Tier)
pct := 0.08 + 0.03*float64(tier-1) // T1=8%, T5=20%
dmg := int(float64(dndChar.HPMax) * pct)
if dmg < 1 {
dmg = 1
}
// Don't outright KO a player from a single trap.
if dmg >= dndChar.HPCurrent {
dmg = dndChar.HPCurrent - 1
if dmg < 0 {
dmg = 0
}
}
dndChar.HPCurrent -= dmg
if err := SaveDnDCharacter(dndChar); err != nil {
slog.Error("zone: trap HP persist", "user", userID, "err", err)
}
var b strings.Builder
if line := twinBeeLine(zone.ID, GMTrapDetected, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
}
if dmg > 0 {
if line := twinBeeLine(zone.ID, GMTrapTripped, run.RunID, run.CurrentRoom); line != "" {
b.WriteString(line)
b.WriteString("\n")
}
b.WriteString(fmt.Sprintf("💢 The trap takes **%d HP** (%d/%d remaining).", dmg, dndChar.HPCurrent, dndChar.HPMax))
} else {
b.WriteString("You spot it in time. The trap clatters harmlessly.")
}
return dmg, b.String()
}
// ── Loot ─────────────────────────────────────────────────────────────────────
// rollZoneLoot rolls every entry in zone.Loot and returns the IDs that
// dropped. UniqueAlways entries always drop. Probabilistic entries roll
// independently. Coin-pattern IDs ("coins_<dice>x<mult>") are expanded
// to a single "coins" item with rolled value; everything else becomes a
// treasure-tier inventory item with a tier-derived placeholder value
// (zone equipment registry wiring is a later content phase).
func (p *AdventurePlugin) rollZoneLoot(userID id.UserID, run *DungeonRun, zone ZoneDefinition) []string {
rng := rand.New(rand.NewPCG(uint64(zoneSelectorHash(run.RunID, run.CurrentRoom)), 0x100712))
var granted []string
for _, entry := range zone.Loot {
if !entry.UniqueAlways && rng.Float64() > entry.DropChance {
continue
}
item, ok := zoneLootToInventory(entry, zone, rng)
if !ok {
continue
}
if err := addAdvInventoryItem(userID, item); err != nil {
slog.Error("zone: addAdvInventoryItem", "user", userID, "item", item.Name, "err", err)
continue
}
if err := addLoot(run.RunID, entry.ItemID); err != nil {
slog.Error("zone: addLoot audit", "user", userID, "item", entry.ItemID, "err", err)
}
granted = append(granted, entry.ItemID)
}
return granted
}
// zoneLootToInventory converts a ZoneLootEntry into an AdvItem. Coin
// entries get a rolled gold value; named items become treasure with a
// tier-scaled placeholder value. Returns ok=false for entries we should
// silently skip (none today, but the contract leaves room for future
// quest-only entries that don't materialize as inventory rows).
func zoneLootToInventory(entry ZoneLootEntry, zone ZoneDefinition, rng *rand.Rand) (AdvItem, bool) {
if strings.HasPrefix(entry.ItemID, "coins_") {
val := rollCoinPattern(entry.ItemID, rng)
return AdvItem{
Name: "Coin Pouch",
Type: "treasure",
Tier: int(zone.Tier),
Value: int64(val),
}, true
}
tier := int(zone.Tier)
displayName := titleCaseUnderscored(entry.ItemID)
value := int64(50 * tier * tier) // T1=50, T2=200, T3=450, T4=800, T5=1250
if entry.UniqueAlways {
value = int64(100 * tier * tier)
}
return AdvItem{
Name: displayName,
Type: "treasure",
Tier: tier,
Value: value,
}, true
}
// rollCoinPattern parses "coins_<n>d<sides>x<mult>" — e.g.
// "coins_2d10x5" → 2d10 × 5. Bad input falls back to a tier-agnostic
// 25-coin floor so the player still gets *something* and the bug is
// loud in logs but not run-ending.
func rollCoinPattern(id string, rng *rand.Rand) int {
body := strings.TrimPrefix(id, "coins_")
xParts := strings.SplitN(body, "x", 2)
if len(xParts) != 2 {
slog.Warn("zone: bad coin pattern (no x)", "id", id)
return 25
}
mult, err := atoi(xParts[1])
if err != nil || mult <= 0 {
slog.Warn("zone: bad coin pattern mult", "id", id)
return 25
}
dice := strings.SplitN(xParts[0], "d", 2)
if len(dice) != 2 {
slog.Warn("zone: bad coin pattern dice", "id", id)
return 25
}
n, err := atoi(dice[0])
if err != nil || n <= 0 {
slog.Warn("zone: bad coin pattern n", "id", id)
return 25
}
sides, err := atoi(dice[1])
if err != nil || sides <= 0 {
slog.Warn("zone: bad coin pattern sides", "id", id)
return 25
}
roll := 0
for i := 0; i < n; i++ {
roll += rng.IntN(sides) + 1
}
return roll * mult
}
// atoi — local strconv-free parser to avoid a single-call import.
func atoi(s string) (int, error) {
if s == "" {
return 0, fmt.Errorf("empty")
}
n := 0
for _, r := range s {
if r < '0' || r > '9' {
return 0, fmt.Errorf("non-digit %q", r)
}
n = n*10 + int(r-'0')
}
return n, nil
}
// titleCaseUnderscored renders "wpn_handaxe_+1" → "Wpn Handaxe +1".
// Pure ASCII; no Unicode handling needed for our zone-loot ID space.
func titleCaseUnderscored(id string) string {
parts := strings.Split(id, "_")
for i, p := range parts {
if p == "" {
continue
}
c0 := p[0]
if c0 >= 'a' && c0 <= 'z' {
parts[i] = string(c0-32) + p[1:]
}
}
return strings.Join(parts, " ")
}

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)
}
}

View File

@@ -168,24 +168,28 @@ func TestZoneCmd_AdvanceFinalRoomBumpsMood(t *testing.T) {
zoneCmdTestCharacter(t, uid, 1)
defer cleanupZoneRuns(uid)
p := &AdventurePlugin{}
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "enter goblin_warrens"); err != nil {
// D1e wired real combat into !zone advance, so the cmd-level path
// is no longer deterministic for an L1 fighter against the Goblin
// Warrens roster. Drive the persistence layer directly to assert
// the mood-on-completion contract.
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
if err != nil {
t.Fatal(err)
}
run, _ := getActiveZoneRun(uid)
// Advance through every room. zone_complete fires on the boss-room
// advance and bumps mood by +10 (50 → 60).
for i := 0; i < run.TotalRooms; i++ {
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "advance"); err != nil {
t.Fatalf("advance %d: %v", i, err)
if _, err := markRoomCleared(run.RunID); err != nil {
t.Fatalf("markRoomCleared %d: %v", i, err)
}
}
if _, err := applyMoodEvent(run.RunID, MoodEventZoneComplete); err != nil {
t.Fatal(err)
}
final, err := getZoneRun(run.RunID)
if err != nil || final == nil {
t.Fatal("could not fetch completed run")
}
if !final.BossDefeated {
t.Error("boss should be defeated after final advance")
t.Error("boss should be defeated after final markRoomCleared")
}
if final.GMMood != 60 {
t.Errorf("post-completion mood = %d, want 60 (50 + zone_complete +10)", final.GMMood)