mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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:
@@ -3,6 +3,8 @@ package plugin
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
|
|
||||||
// !zone — Phase 11 D1c. The command surface for the dungeon-zone state
|
// !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 advance → resolve the current room and move on
|
||||||
// !zone abandon → end the active run (no rewards)
|
// !zone abandon → end the active run (no rewards)
|
||||||
//
|
//
|
||||||
// Combat resolution per room arrives in D1e; advance currently just
|
// TwinBee narration / mood triggers arrive in D1d. Combat / trap / boss
|
||||||
// records the room as cleared and reports the next room type.
|
// resolution per room is wired in D1e via dnd_zone_combat.go.
|
||||||
// TwinBee narration / mood triggers arrive in D1d.
|
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleDnDZoneCmd(ctx MessageContext, args string) error {
|
func (p *AdventurePlugin) handleDnDZoneCmd(ctx MessageContext, args string) error {
|
||||||
userMu := p.advUserLock(ctx.Sender)
|
userMu := p.advUserLock(ctx.Sender)
|
||||||
@@ -286,9 +287,11 @@ func (p *AdventurePlugin) zoneCmdMap(ctx MessageContext) error {
|
|||||||
|
|
||||||
// ── advance ─────────────────────────────────────────────────────────────────
|
// ── advance ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// zoneCmdAdvance is the D1c stub: it records the current room cleared and
|
// zoneCmdAdvance resolves the room the player is currently standing in,
|
||||||
// reports the next room. Real combat / trap / boss resolution wires in
|
// then moves them to the next. Resolution branches on RoomType — combat
|
||||||
// D1e+. This is intentional — D1c ships the *surface*.
|
// 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 {
|
func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
|
||||||
run, err := getActiveZoneRun(ctx.Sender)
|
run, err := getActiveZoneRun(ctx.Sender)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -301,24 +304,49 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
|
|||||||
zone, _ := getZone(run.ZoneID)
|
zone, _ := getZone(run.ZoneID)
|
||||||
prev := run.CurrentRoomType()
|
prev := run.CurrentRoomType()
|
||||||
prevIdx := run.CurrentRoom
|
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)
|
next, err := markRoomCleared(run.RunID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't advance: "+err.Error())
|
return p.SendDM(ctx.Sender, "Couldn't advance: "+err.Error())
|
||||||
}
|
}
|
||||||
if next == "" {
|
if next == "" {
|
||||||
// Boss room cleared → zone complete. Bump mood per design §3.2.
|
|
||||||
_, _ = applyMoodEvent(run.RunID, MoodEventZoneComplete)
|
_, _ = applyMoodEvent(run.RunID, MoodEventZoneComplete)
|
||||||
var b strings.Builder
|
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 != "" {
|
if line := twinBeeLine(zone.ID, GMZoneComplete, run.RunID, prevIdx); line != "" {
|
||||||
b.WriteString(line)
|
b.WriteString(line)
|
||||||
b.WriteString("\n\n")
|
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())
|
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
|
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)))
|
b.WriteString(fmt.Sprintf("✓ Cleared room %d (%s).\n\n", prevIdx+1, prettyRoomType(prev)))
|
||||||
if next == RoomBoss {
|
if next == RoomBoss {
|
||||||
if line := twinBeeLine(zone.ID, GMBossEntry, run.RunID, nextIdx); line != "" {
|
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())
|
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 ─────────────────────────────────────────────────────────────────
|
// ── abandon ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) zoneCmdAbandon(ctx MessageContext) error {
|
func (p *AdventurePlugin) zoneCmdAbandon(ctx MessageContext) error {
|
||||||
|
|||||||
422
internal/plugin/dnd_zone_combat.go
Normal file
422
internal/plugin/dnd_zone_combat.go
Normal 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, " ")
|
||||||
|
}
|
||||||
206
internal/plugin/dnd_zone_combat_test.go
Normal file
206
internal/plugin/dnd_zone_combat_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -168,24 +168,28 @@ func TestZoneCmd_AdvanceFinalRoomBumpsMood(t *testing.T) {
|
|||||||
zoneCmdTestCharacter(t, uid, 1)
|
zoneCmdTestCharacter(t, uid, 1)
|
||||||
defer cleanupZoneRuns(uid)
|
defer cleanupZoneRuns(uid)
|
||||||
|
|
||||||
p := &AdventurePlugin{}
|
// D1e wired real combat into !zone advance, so the cmd-level path
|
||||||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "enter goblin_warrens"); err != nil {
|
// 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)
|
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++ {
|
for i := 0; i < run.TotalRooms; i++ {
|
||||||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "advance"); err != nil {
|
if _, err := markRoomCleared(run.RunID); err != nil {
|
||||||
t.Fatalf("advance %d: %v", i, err)
|
t.Fatalf("markRoomCleared %d: %v", i, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if _, err := applyMoodEvent(run.RunID, MoodEventZoneComplete); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
final, err := getZoneRun(run.RunID)
|
final, err := getZoneRun(run.RunID)
|
||||||
if err != nil || final == nil {
|
if err != nil || final == nil {
|
||||||
t.Fatal("could not fetch completed run")
|
t.Fatal("could not fetch completed run")
|
||||||
}
|
}
|
||||||
if !final.BossDefeated {
|
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 {
|
if final.GMMood != 60 {
|
||||||
t.Errorf("post-completion mood = %d, want 60 (50 + zone_complete +10)", final.GMMood)
|
t.Errorf("post-completion mood = %d, want 60 (50 + zone_complete +10)", final.GMMood)
|
||||||
|
|||||||
Reference in New Issue
Block a user