mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 00:52:40 +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 (
|
||||
"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 {
|
||||
|
||||
Reference in New Issue
Block a user