mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Secret rooms were dead content: every NodeKindSecret node silently collapsed to a normal exploration fight and its authored LootBias (1.5-3.0) was never read at runtime. D4 makes them what they read as — no-combat treasure caches. resolveRoom now diverts a secret node (keyed off the graph node, since CurrentRoomType has already lost the kind) to resolveSecretRoom before the RoomType switch — shared by manual !zone advance, !expedition run autopilot, and the sim. Each secret pays a guaranteed journal page (the D1a grant hook built "for secret rooms"), a LootBias-weighted treasure roll (floored at elite weight), and a guaranteed zone-tier consumable cache, with bespoke in-world discovery flavor. Underdark was the only T2+ zone with no secret; added at throne_gallery on the universal R4 tail (Lost Reliquary, Perception DC 17), merging to throne_steps so it's length-neutral. Two cross-zone keys: the Sunken Temple's Coral Reliquary grants a Sunken Sigil that opens a Sealed Reliquary in Manor Blackspire; the Underforge's Forge Vault grants an Underforge Seal that opens a Sealed Vault in the Underdark. Keys are persistent inventory items matched against LockKey key_id; grants are idempotent. Graph validator + no-soft-lock pass on both touched graphs; combat golden byte-identical; go build/vet/test green repo-wide. Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
489 lines
17 KiB
Go
489 lines
17 KiB
Go
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 + DMPlayerDeath 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 the given phase
|
||
// budget (nil = dungeonCombatPhases), and persists post-combat side
|
||
// effects (HP, subclass state, XP).
|
||
//
|
||
// dmMood (0–100) drives the DM's combat tilt: Effusive softens monster
|
||
// Attack and gives the player +InitiativeBias; Hostile sharpens monster
|
||
// Attack and slows the player. Pass 50 (neutral) when no run context.
|
||
//
|
||
// Returns the engine result so the caller can branch on PlayerWon and
|
||
// fold combat events into the per-room narration.
|
||
// runZoneCombat resolves a zone encounter for exactly one player. It is the
|
||
// one-seat case of runZoneCombatRoster, and the entry point for every fight
|
||
// that is not on an expedition — the arena's encounter bouts included, which is
|
||
// why the roster is not resolved here: an arena fighter must never drag their
|
||
// party into the ring.
|
||
func (p *AdventurePlugin) runZoneCombat(
|
||
userID id.UserID,
|
||
monster DnDMonsterTemplate,
|
||
tier int,
|
||
phases []CombatPhase,
|
||
dmMood int,
|
||
) (CombatResult, error) {
|
||
res, _, err := p.runZoneCombatRoster([]id.UserID{userID}, monster, tier, phases, dmMood)
|
||
if err != nil {
|
||
return CombatResult{}, err
|
||
}
|
||
return res.Seats[0], 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) {
|
||
return scanMoodEventsFromEvents(runID, result.Events)
|
||
}
|
||
|
||
// scanMoodEventsFromEvents is the event-slice core of scanMoodEventsFromCombat
|
||
// — used by the turn-based close-out, which has a CombatEvent log (the
|
||
// session's accumulated TurnLog) rather than a one-shot CombatResult.
|
||
func scanMoodEventsFromEvents(runID string, events []CombatEvent) (nat20s, nat1s int) {
|
||
for _, ev := range 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 picks a trap from the zone's tier catalog (D2a),
|
||
// rolls a detection skill check (Perception/Investigation, per trap),
|
||
// and either narrates a clean spot or applies the trap's damage dice.
|
||
// Damage is capped so a single trap can't outright KO the player.
|
||
//
|
||
// Higher-tier zones currently fall through to the pre-D2a flat-percent
|
||
// nick — D3a/D4a will add Tier 2+ trap catalogs.
|
||
// resolveSecretRoom resolves a NodeKindSecret room as a no-combat treasure
|
||
// cache (N5/D4). Finding the secret — via the perception/stat check on the fork
|
||
// that hangs it, or a cross-zone key — is the reward: a guaranteed journal page
|
||
// (the Hollow King's fragments gather in hidden rooms), a LootBias-weighted
|
||
// treasure roll, a guaranteed zone-tier consumable cache, and, for the two
|
||
// key-bearing secrets, a cross-zone key. No enemy, no threat.
|
||
//
|
||
// LootBias has been authored on every secret node since the branching-zones
|
||
// phase and was dead code until now; it finally scales the treasure weight here.
|
||
func (p *AdventurePlugin) resolveSecretRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition, node ZoneNode) string {
|
||
var b strings.Builder
|
||
b.WriteString(secretRoomDiscoveryLine(node))
|
||
|
||
var rewards []string
|
||
if line := p.grantJournalPage(userID, nil); line != "" {
|
||
rewards = append(rewards, line)
|
||
}
|
||
if line := p.grantSecretRoomKey(userID, node); line != "" {
|
||
rewards = append(rewards, line)
|
||
}
|
||
|
||
// LootBias-weighted treasure roll. Floors at the elite weight so even the
|
||
// leanest secret out-rolls a standard kill; the richest (abyss 3.0) still
|
||
// rolls below a boss. checkTreasureDrop DMs the discovery itself on a hit,
|
||
// exactly as a combat-room drop does, so it isn't folded into the outcome.
|
||
weight := node.Content.LootBias
|
||
if weight < advTreasureWeightElite {
|
||
weight = advTreasureWeightElite
|
||
}
|
||
p.rollZoneTreasure(userID, zone.ID, weight)
|
||
|
||
// Guaranteed cache so the room always pays out something visible.
|
||
for _, item := range consumableCache(int(zone.Tier), secretRoomCacheCount) {
|
||
it := item
|
||
if line := p.grantZoneItem(userID, &it, "🧪"); line != "" {
|
||
rewards = append(rewards, line)
|
||
}
|
||
}
|
||
|
||
if len(rewards) > 0 {
|
||
b.WriteString("\n\n")
|
||
b.WriteString(strings.Join(rewards, "\n"))
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func (p *AdventurePlugin) resolveTrapRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition) (damage int, narration string) {
|
||
dndChar, _ := LoadDnDCharacter(userID)
|
||
if dndChar == nil {
|
||
return 0, ""
|
||
}
|
||
trap, ok := pickZoneTrap(zone, run.RunID, run.CurrentRoom)
|
||
if !ok {
|
||
return p.resolveTrapRoomLegacy(userID, run, zone, dndChar)
|
||
}
|
||
return p.applyTrapEffect(userID, run, zone, dndChar, trap)
|
||
}
|
||
|
||
// applyTrapEffect runs the detect roll and damage application for a
|
||
// chosen trap. Split out from resolveTrapRoom so tests can target a
|
||
// specific trap kind without depending on the room-seed selector.
|
||
func (p *AdventurePlugin) applyTrapEffect(userID id.UserID, run *DungeonRun, zone ZoneDefinition, dndChar *DnDCharacter, trap zoneTrapDef) (int, string) {
|
||
detect := performSkillCheck(dndChar, trap.DetectSkill, trap.DetectDC)
|
||
return p.applyTrapEffectWithDetect(userID, run, zone, dndChar, trap, detect)
|
||
}
|
||
|
||
// applyTrapEffectWithDetect is the deterministic core of trap resolution:
|
||
// given a precomputed detection check result, it produces the narration
|
||
// and persists HP loss on a failed detect. Used by applyTrapEffect (which
|
||
// rolls the d20) and by tests (which inject a fixed result).
|
||
func (p *AdventurePlugin) applyTrapEffectWithDetect(
|
||
userID id.UserID,
|
||
run *DungeonRun,
|
||
zone ZoneDefinition,
|
||
dndChar *DnDCharacter,
|
||
trap zoneTrapDef,
|
||
detect SkillCheckResult,
|
||
) (int, string) {
|
||
var b strings.Builder
|
||
if detect.Success {
|
||
// Detected: the TwinBee "you spotted it" line only fires here, so a
|
||
// failed detection no longer contradicts the next line. The mismatch
|
||
// where "Perception roll pays off" preceded a tripped trap was
|
||
// caused by this line firing unconditionally.
|
||
if line := twinBeeLine(zone.ID, DMTrapDetected, run.RunID, narrationCadence(run)); line != "" {
|
||
b.WriteString(line)
|
||
b.WriteString("\n")
|
||
}
|
||
b.WriteString(trapSpottedHeader(trap, detect))
|
||
return 0, b.String()
|
||
}
|
||
|
||
dmg := rollTrapDamage(trap, run.RunID, run.CurrentRoom)
|
||
// Tiefling fiendish heritage — fire traps deal half damage.
|
||
if trap.DamageType == "fire" && dndChar.Race == RaceTiefling {
|
||
dmg = max(1, dmg/2)
|
||
}
|
||
if dmg >= dndChar.HPCurrent {
|
||
dmg = dndChar.HPCurrent - 1
|
||
if dmg < 0 {
|
||
dmg = 0
|
||
}
|
||
}
|
||
if dmg > 0 {
|
||
dndChar.HPCurrent -= dmg
|
||
if err := SaveDnDCharacter(dndChar); err != nil {
|
||
slog.Error("zone: trap HP persist", "user", userID, "err", err)
|
||
}
|
||
}
|
||
// Trip flavor lives in trap.Trigger (mechanism-specific) which the
|
||
// damage header now surfaces. The generic DMTrapTripped pool is no
|
||
// longer pulled here — it mixed dart/ceiling/pit/glyph lines that
|
||
// frequently mismatched the actual trap.
|
||
b.WriteString(trapDamageHeader(trap, dmg, dndChar.HPCurrent, dndChar.HPMax))
|
||
return dmg, b.String()
|
||
}
|
||
|
||
// resolveTrapRoomLegacy is the pre-D2a flat-percent fallback used when a
|
||
// zone tier has no entries in the trap catalog yet (Tier 2+). Removed
|
||
// once D3a/D4a fill in the higher-tier catalogs.
|
||
func (p *AdventurePlugin) resolveTrapRoomLegacy(userID id.UserID, run *DungeonRun, zone ZoneDefinition, dndChar *DnDCharacter) (int, string) {
|
||
tier := int(zone.Tier)
|
||
pct := 0.08 + 0.03*float64(tier-1)
|
||
dmg := int(float64(dndChar.HPMax) * pct)
|
||
if dmg < 1 {
|
||
dmg = 1
|
||
}
|
||
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 (legacy)", "user", userID, "err", err)
|
||
}
|
||
// Legacy path always trips — no detection check. Drop the TwinBee pool
|
||
// picks here for the same reason as applyTrapEffectWithDetect: the
|
||
// detected line shouldn't fire when nothing was detected, and the
|
||
// generic tripped pool routinely mismatches the actual trap. Plain
|
||
// damage line carries the result.
|
||
var b strings.Builder
|
||
b.WriteString(fmt.Sprintf("💢 The trap takes **%d HP** (%d/%d remaining).", dmg, dndChar.HPCurrent, dndChar.HPMax))
|
||
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))
|
||
// DM mood tilts probabilistic drop chances. UniqueAlways entries are
|
||
// untouched — those are story drops, not flavor for the DM to gate.
|
||
moodMult := dmMoodCombatTilt(run.DMMood).LootQualityMod
|
||
if moodMult == 0 {
|
||
moodMult = 1.0
|
||
}
|
||
var granted []string
|
||
for _, entry := range zone.Loot {
|
||
if !entry.UniqueAlways {
|
||
chance := entry.DropChance * moodMult
|
||
if rng.Float64() > chance {
|
||
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, " ")
|
||
}
|