Adv 2.0 D&D layer: Phases 1-8 + Phase 9 SP1+SP2

Combines all uncommitted D&D work into one checkpoint:

Phases 1-8 (per gogobee_dnd_session_summary.md):
- Race/class/stats system, !setup flow, !sheet, !respec
- d20-vs-AC combat with race/class passives, auto-migration
- XP/level-up, skill checks, NPC refund hooks
- Equipment slot mapping, rarity, !rest short/long
- Pre-arm active abilities, resource pools
- Bestiary, !roll/!stats/!level, onboarding DM
- Audit fixes A-I, flavor wiring under internal/flavor/
- Phase 8 weapon dice + armor AC tables (gogobee_equipment_appendix)

Phase 9 SP1 — spell system foundations:
- 79 SpellDefinitions (76 in-scope + 3 reaction-deferred)
- dnd_known_spells, dnd_spell_slots tables
- pending_cast / concentration_* columns on dnd_character
- Slot tables for Mage/Cleric/Ranger, DC + attack-bonus math
- Long-rest refreshes slots; respec wipes spell state
- Auto-grant default known list on !setup confirm and auto-migration

Phase 9 SP2 — !cast / !spells / !prepare commands:
- Out-of-combat HEAL and UTILITY resolve immediately
- Damage/control/buff queue as pending_cast for next combat
- Audit-style save-then-debit, slot refund on save failure
- !cast --drop, concentration supersession, prep-cap enforcement
- Reaction spells refused with Phase 11 note

SP3 (combat-time resolution of pending_cast) and SP4 (full prep/learn
tests) are next. Build clean, full test suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 08:21:44 -07:00
parent 8e0fe0230c
commit 9e1a1f606c
80 changed files with 19088 additions and 210 deletions

View File

@@ -2,6 +2,7 @@ package plugin
import (
"math/rand/v2"
"strings"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
@@ -241,20 +242,25 @@ var advAllTreasures = map[int][]AdvTreasureDef{
// ── Treasure Drop Logic ──────────────────────────────────────────────────────
func rollAdvTreasureDrop(tier int, userID id.UserID, chatLevel int) *AdvTreasureDrop {
rate, ok := advTreasureDropRates[tier]
// rollAdvTreasureDropDetailed returns the drop (or nil) plus the random roll
// and the effective drop rate, so callers can surface near-miss feedback
// ("rolled 1.8% vs 1.5% chance — just missed"). Players never see treasure
// math otherwise, which makes rare drops feel mythical.
func rollAdvTreasureDropDetailed(tier int, userID id.UserID, chatLevel int) (drop *AdvTreasureDrop, roll, rate float64) {
r, ok := advTreasureDropRates[tier]
if !ok {
return nil
return nil, 0, 0
}
rate += chatLevelRareBonus(chatLevel)
rate = r + chatLevelRareBonus(chatLevel)
roll = rand.Float64()
if rand.Float64() >= rate {
return nil
if roll >= rate {
return nil, roll, rate
}
pool, ok := advAllTreasures[tier]
if !ok || len(pool) == 0 {
return nil
return nil, roll, rate
}
// Pick random treasure
@@ -267,11 +273,68 @@ func rollAdvTreasureDrop(tier int, userID id.UserID, chatLevel int) *AdvTreasure
def = &pool[rand.IntN(len(pool))]
owns, err = advUserOwnsTreasure(userID, def.Key)
if err != nil || owns {
return nil // both rolls duplicated
return nil, roll, rate // both rolls duplicated
}
}
return &AdvTreasureDrop{Def: def}
return &AdvTreasureDrop{Def: def}, roll, rate
}
// rollAdvTreasureDrop is the legacy single-return variant used by call sites
// that don't surface near-miss feedback (auto-babysit, twinbee shares).
func rollAdvTreasureDrop(tier int, userID id.UserID, chatLevel int) *AdvTreasureDrop {
d, _, _ := rollAdvTreasureDropDetailed(tier, userID, chatLevel)
return d
}
// ── Treasure Comparison ─────────────────────────────────────────────────────
// advTreasureIrreplaceable reports whether a treasure carries a bonus type
// that can't be replicated by another drop (e.g. monthly death bypass).
// Such treasures are excluded from auto-swap and fall back to the manual
// discard prompt so the player consciously chooses to give one up.
func advTreasureIrreplaceable(def *AdvTreasureDef) bool {
if def == nil {
return false
}
for _, b := range def.Bonuses {
if strings.HasPrefix(b.Type, "special_") {
return true
}
}
return false
}
// advTreasureRank produces a comparable triple (tier, bonusCount, key) for
// auto-swap decisions. Bonuses are heterogeneous and there's no honest
// scalar comparator — we use tier first, then bonus count as a tiebreaker,
// then the deterministic key so equal-rank ties prefer the existing item
// (no churn). Higher tuple = better treasure.
type advTreasureRankKey struct {
Tier int
BonusCount int
Key string
}
func advTreasureRank(def *AdvTreasureDef) advTreasureRankKey {
if def == nil {
return advTreasureRankKey{}
}
return advTreasureRankKey{Tier: def.Tier, BonusCount: len(def.Bonuses), Key: def.Key}
}
// advTreasureRankBetter reports whether a is strictly better than b.
// Equal-rank returns false (caller treats this as "keep existing").
func advTreasureRankBetter(a, b advTreasureRankKey) bool {
if a.Tier != b.Tier {
return a.Tier > b.Tier
}
if a.BonusCount != b.BonusCount {
return a.BonusCount > b.BonusCount
}
// Deterministic but neutral tiebreak — different keys aren't "better"
// than each other, so equal Tier+BonusCount means no swap.
return false
}
// ── Treasure DB Operations ───────────────────────────────────────────────────