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 (
"fmt"
"hash/fnv"
"math/rand/v2"
"strings"
"sync"
@@ -131,22 +132,29 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
eqScore := advEquipmentScore(equip)
for _, slot := range allSlots {
eq := equip[slot]
if eq == nil {
continue
}
marker := ""
if eq.Masterwork {
marker = " ⭐"
} else if eq.ArenaTier > 0 {
marker = " ⚔️"
}
// Mastery segment is appended as a third inline field after
// condition only when there's progress — keeps rows tight for
// freshly-equipped gear and grows with use.
mastery := ""
if eq != nil && eq.ActionsUsed >= 20 {
mastery = " ✦"
}
if eq != nil {
marker := ""
if eq.Masterwork {
marker = " ⭐"
} else if eq.ArenaTier > 0 {
marker = " ⚔️"
}
sb.WriteString(fmt.Sprintf(" %s %s: %s%s (Tier %d | %d%% condition%s)\n",
slotEmoji(slot), slotTitle(slot), eq.Name, marker, eq.Tier, eq.Condition, mastery))
if seg := advMasteryRowSegment(eq.ActionsUsed); seg != "" {
mastery = " | " + seg
}
sb.WriteString(fmt.Sprintf(" %s %s: %s%s (Tier %d | %d%% cond%s)\n",
slotEmoji(slot), slotTitle(slot), eq.Name, marker, eq.Tier, eq.Condition, mastery))
}
sb.WriteString(fmt.Sprintf(" Equipment Score: %.1f\n", eqScore))
if line := renderAdvMasteryAggregate(equip); line != "" {
sb.WriteString(" " + line + "\n")
}
// Treasures
if len(treasures) > 0 {
@@ -157,15 +165,8 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
continue
}
seen[t.TreasureKey] = true
// Find def for inventory desc
for tier, defs := range advAllTreasures {
_ = tier
for _, def := range defs {
if def.Key == t.TreasureKey {
sb.WriteString(fmt.Sprintf(" %s\n", def.InventoryDesc))
break
}
}
if def := lookupAdvTreasureDef(t.TreasureKey); def != nil {
sb.WriteString(fmt.Sprintf(" %s\n", def.InventoryDesc))
}
}
}
@@ -234,6 +235,138 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
// ── Morning DM ───────────────────────────────────────────────────────────────
// renderCoopTeaser surfaces an open co-op run the player could join, so the
// system isn't a footnote in help text. Returns "" when there's nothing to
// surface (no open runs, or the player is already locked into one). When
// runs exist that the player is under-levelled for, we still hint at them
// as a stretch goal — joining as a liability is a real choice.
func renderCoopTeaser(char *AdventureCharacter) string {
runs, err := loadOpenCoopRuns()
if err != nil || len(runs) == 0 {
return ""
}
pick, stretch := pickCoopTeaserCandidate(runs, char)
tag := ""
if pick == nil {
pick = stretch
tag = " *(below recommended level — would join as liability)*"
}
if pick == nil {
return ""
}
leaderName := string(pick.LeaderID)
if c, err := loadAdvCharacter(pick.LeaderID); err == nil && c != nil && c.DisplayName != "" {
leaderName = c.DisplayName
}
def := coopTierTable[pick.Tier]
return fmt.Sprintf("🛡️ **Open Co-op #%d** — Tier %d (%s) led by %s · `!coop join %d`%s",
pick.ID, pick.Tier, def.difficulty, leaderName, pick.ID, tag)
}
// pickCoopTeaserCandidate scans the open coop runs and returns up to two
// picks: a primary (where the player meets the level gate) and a stretch
// (any other run, joined as a liability). Skips runs the player already
// leads. Pure function — testable without DB.
func pickCoopTeaserCandidate(runs []*CoopRun, char *AdventureCharacter) (match, stretch *CoopRun) {
for _, r := range runs {
if r.LeaderID == char.UserID {
continue
}
def := coopTierTable[r.Tier]
if char.CombatLevel >= def.minLevel && match == nil {
match = r
} else if stretch == nil {
stretch = r
}
}
return match, stretch
}
// renderCraftingTeaser surfaces the crafting system before and just after
// the Foraging-10 auto-unlock, so it doesn't stay invisible to players who
// never type !adventure recipes. Returns "" when the teaser shouldn't fire
// today (out of pre-unlock window, or already deep into post-unlock).
func renderCraftingTeaser(char *AdventureCharacter) string {
const unlock = 10
if char.ForagingSkill < unlock {
// Pre-unlock teaser: only when within 3 levels.
if char.ForagingSkill < unlock-3 {
return ""
}
levelsToGo := unlock - char.ForagingSkill
return fmt.Sprintf("🧪 **Crafting unlocks in %d Foraging level%s** — auto-craft consumables from gathered ingredients (Berry Poultice, Herb Salve, etc.).",
levelsToGo, plural(levelsToGo))
}
// Post-unlock: nudge when no successful crafts yet (gentle), or once a
// week (loose periodicity via day-of-year %).
if char.CraftsSucceeded == 0 {
return "🧪 **Crafting is unlocked.** Gather a couple of matching ingredients and TwinBee will auto-craft consumables — try `!adventure recipes` to see what your level supports."
}
// Per-player weekly reminder: hash UserID + ISO week to pick a stable
// weekday for this player. Spreads the cohort across the week instead
// of all firing on the same global day.
if int(time.Now().UTC().Weekday()) == craftingReminderWeekday(char.UserID, time.Now().UTC()) {
return "🧪 *Crafting reminder* — `!adventure recipes` shows what's available at Foraging Lv." + fmt.Sprintf("%d.", char.ForagingSkill)
}
return ""
}
// craftingReminderWeekday picks the day-of-week (0=Sunday..6=Saturday) on
// which a given player gets the weekly crafting nudge during the given
// week. Stable within an ISO week (predictable, not annoying), rotates
// across weeks (not always the same day long-term).
func craftingReminderWeekday(userID id.UserID, now time.Time) int {
year, week := now.ISOWeek()
h := fnv.New32a()
fmt.Fprintf(h, "%s|%d|%d", string(userID), year, week)
return int(h.Sum32() % 7)
}
func plural(n int) string {
if n == 1 {
return ""
}
return "s"
}
// renderRivalNudge surfaces a pending rival challenge in the morning DM so
// players who missed or ignored the dramatic challenge DM see a reminder
// before it expires. Returns "" if there's no pending challenge against
// this player.
func renderRivalNudge(char *AdventureCharacter) string {
c := pendingRivalChallengeForChallenged(char.UserID)
if c == nil {
return ""
}
hours := int(time.Until(c.ExpiresAt).Hours())
if hours < 1 {
hours = 1 // floor — "<1h" feels worse than "1h"
}
rivalName := string(c.ChallengerID)
if rc, err := loadAdvCharacter(c.ChallengerID); err == nil && rc != nil && rc.DisplayName != "" {
rivalName = rc.DisplayName
}
return fmt.Sprintf("⚔️ **Rival challenge open** — %s, round %d/3, €%d on the line · expires in %dh · reply **rock**, **paper**, or **scissors**",
rivalName, c.Round, c.Stake, hours)
}
// writeAdvLocationLines renders the eligible-location bullets shown in the
// morning DM. Each line surfaces both the downside (death %) and upside
// (exceptional %) so the menu teaches risk/reward, not just risk.
func writeAdvLocationLines(sb *strings.Builder, locs []AdvEligibleLocation) {
for _, el := range locs {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death · ~%.0f%% exceptional%s)\n",
el.Location.Name, el.Location.Tier, el.DeathPct, el.ExceptionalPct, warn))
}
}
func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, balance float64, bonuses *AdvBonusSummary, holidayName string) string {
var sb strings.Builder
@@ -303,51 +436,45 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
coopRun.ID, coopRun.CurrentDay, coopRun.TotalDays, harvestLeft, harvestMax))
} else {
sb.WriteString(fmt.Sprintf("📋 **Actions:** %d/%d combat · %d/%d harvest\n\n", combatLeft, combatMax, harvestLeft, harvestMax))
// Co-op teaser — show open runs the player could join.
if line := renderCoopTeaser(char); line != "" {
sb.WriteString(line)
sb.WriteString("\n")
}
}
// Rival nudge — a pending challenge waits for action.
if line := renderRivalNudge(char); line != "" {
sb.WriteString(line)
sb.WriteString("\n")
}
// Crafting teaser — surface the system when it's near unlock or post-unlock.
if line := renderCraftingTeaser(char); line != "" {
sb.WriteString(line)
sb.WriteString("\n")
}
// Location choices
if inCoop {
sb.WriteString(fmt.Sprintf("**1⃣ Dungeon:** _(off in the Co-op, no solo combat — `!coop status` for run state)_\n"))
sb.WriteString("**1⃣ Dungeon:** _(off in the Co-op, no solo combat — `!coop status` for run state)_\n")
} else if char.CanDoCombat(isHol) {
sb.WriteString("**1⃣ Dungeon:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityDungeon, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
writeAdvLocationLines(&sb, advEligibleLocations(char, equip, AdvActivityDungeon, bonuses))
} else {
sb.WriteString("**1⃣ Dungeon:** _(no combat actions remaining)_\n")
}
if char.CanDoHarvest(isHol) {
sb.WriteString("**2⃣ Mine:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityMining, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
writeAdvLocationLines(&sb, advEligibleLocations(char, equip, AdvActivityMining, bonuses))
sb.WriteString("**3⃣ Forage:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityForaging, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
writeAdvLocationLines(&sb, advEligibleLocations(char, equip, AdvActivityForaging, bonuses))
sb.WriteString("**4⃣ Fish:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityFishing, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
writeAdvLocationLines(&sb, advEligibleLocations(char, equip, AdvActivityFishing, bonuses))
} else {
sb.WriteString("**2⃣ Mine:** _(no harvest actions remaining)_\n")
sb.WriteString("**3⃣ Forage:** _(no harvest actions remaining)_\n")
@@ -505,6 +632,183 @@ func renderAdvRespawnDM(char *AdventureCharacter) string {
})
}
// ── Treasure List ───────────────────────────────────────────────────────────
// lookupAdvTreasureDef returns the canonical definition for a treasure key,
// or nil if unknown. Walks the tiered table; cheap because the total count
// is small.
func lookupAdvTreasureDef(key string) *AdvTreasureDef {
for _, defs := range advAllTreasures {
for i := range defs {
if defs[i].Key == key {
d := defs[i]
return &d
}
}
}
return nil
}
// renderAdvTreasureList renders the standalone treasure listing for
// `!adventure treasures`. Shows each treasure, marks irreplaceable ones,
// and surfaces the lock state so players know whether new drops will
// auto-swap or be refused.
func renderAdvTreasureList(treasures []AdvTreasureDef, locked bool) string {
var sb strings.Builder
sb.WriteString("💎 **Your Treasures**")
if locked {
sb.WriteString(" 🔒 _(locked — drops at cap refused)_")
}
sb.WriteString("\n\n")
if len(treasures) == 0 {
sb.WriteString("_No treasures yet. Higher-tier locations have higher drop rates._")
return sb.String()
}
for _, t := range treasures {
t := t
marker := ""
if advTreasureIrreplaceable(&t) {
marker = " 🛡️"
}
sb.WriteString(fmt.Sprintf(" • Tier %d · %s%s\n _%s_\n", t.Tier, t.Name, marker, t.InventoryDesc))
}
if locked {
sb.WriteString("\n_Unlock with `!adventure treasures unlock` to allow higher-tier auto-swaps._")
} else {
sb.WriteString("\n_Higher-tier drops auto-swap your lowest replaceable treasure (10-min undo). 🛡️ = irreplaceable, never auto-discarded._\n_Lock with `!adventure treasures lock` to refuse drops at cap._")
}
return sb.String()
}
// renderAdvMasteryAggregate produces the one-line summary shown under the
// equipment block on the character sheet. Three states:
// - Bonus active: shows percent, threshold count, cap-reached when at +10%.
// - No bonus but some progress: hints that a threshold is approaching.
// - No progress: empty (don't bloat sheets for new players).
func renderAdvMasteryAggregate(equip map[EquipmentSlot]*AdvEquipment) string {
r := advMasteryRollup(equip)
if !r.AnyProgress {
return ""
}
if r.Bonus > 0 {
base := fmt.Sprintf("🎯 Mastery: +%.0f%% loot quality · %d threshold%s crossed",
r.Bonus, r.ThresholdsCrossed, plural(r.ThresholdsCrossed))
if r.MaxedSlots > 0 {
base += fmt.Sprintf(" · %d slot%s maxed", r.MaxedSlots, plural(r.MaxedSlots))
}
// Cap is +10%; the raw count keeps rising past it. Flag when the
// player is paying threshold tax for nothing.
if r.Bonus >= 10 {
base += " (cap reached)"
}
base += " · `!adventure mastery`"
return base
}
// Some progress, no thresholds crossed yet — surface the next gate.
return "🎯 Mastery: building up — first threshold at 50 actions/slot · `!adventure mastery`"
}
// ── Equipment Mastery View ──────────────────────────────────────────────────
// renderAdvMasteryView renders a per-slot mastery readout: progress bar,
// tier marker, and next threshold so players can see "where am I" between
// the discrete celebration DMs that fire at threshold crossings.
func renderAdvMasteryView(equip map[EquipmentSlot]*AdvEquipment) string {
var sb strings.Builder
sb.WriteString("🎯 **Equipment Mastery**\n\n")
any := false
for _, slot := range allSlots {
eq := equip[slot]
if eq == nil {
sb.WriteString(fmt.Sprintf(" %s %s — _empty_\n", slotEmoji(slot), slotTitle(slot)))
continue
}
any = true
tier, next := advMasteryTier(eq.ActionsUsed)
marker := advMasteryMarker(eq.ActionsUsed)
if marker == "" {
marker = "·"
}
// Bar against the next threshold (or against 250 when at max so the
// bar visually maxes out rather than disappearing).
var barTotal int
if next > 0 {
barTotal = next
} else {
barTotal = advMasteryThresholds[len(advMasteryThresholds)-1]
}
bar := masteryBar(eq.ActionsUsed, barTotal)
_ = tier
if next > 0 {
sb.WriteString(fmt.Sprintf(" %s %s · %s · %d/%d %s %s\n",
slotEmoji(slot), slotTitle(slot), eq.Name, eq.ActionsUsed, next, bar, marker))
} else {
sb.WriteString(fmt.Sprintf(" %s %s · %s · %d (max) %s %s\n",
slotEmoji(slot), slotTitle(slot), eq.Name, eq.ActionsUsed, bar, marker))
}
}
bonus := advEquipmentMasteryBonus(equip)
sb.WriteString("\n")
if !any {
sb.WriteString("_No equipment yet — gear up first._")
return sb.String()
}
if bonus > 0 {
sb.WriteString(fmt.Sprintf("**Active mastery bonus: +%.0f%% loot quality** (cap +10%%)\n", bonus))
} else {
sb.WriteString("_No mastery bonus yet — first threshold at 50 actions per slot._\n")
}
sb.WriteString("Thresholds: 50 ✦ · 100 ✦✦ · 250 ✦✦✦. Each crossed threshold per slot adds +1% loot quality.")
return sb.String()
}
// masteryBar builds a 10-cell progress bar for the mastery view.
func masteryBar(value, total int) string {
if total <= 0 {
return "▱▱▱▱▱▱▱▱▱▱"
}
filled := value * 10 / total
if filled > 10 {
filled = 10
}
if filled < 0 {
filled = 0
}
return strings.Repeat("▰", filled) + strings.Repeat("▱", 10-filled)
}
// ── Auto-Babysit DM ─────────────────────────────────────────────────────────
// renderAutoBabysitDM builds the morning notification when auto-babysit
// covered an idle day. Surfaces what the babysitter actually accomplished
// (skill focus, gold/XP, items) plus a flavor highlight on lucky days, so
// the system feels like an active companion instead of a silent debit.
func renderAutoBabysitDM(daily int, streak int, res AutoBabysitDayResult) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🍼 **Auto-babysit activated** — €%d deducted. Your streak is safe at %d days.\n", daily, streak))
if res.Skill != "" {
sb.WriteString(fmt.Sprintf("Focus: %s · €%d earned · %d XP", res.Skill, res.Gold, res.XP))
if len(res.Items) > 0 {
sb.WriteString(fmt.Sprintf(" · items: %s", strings.Join(res.Items, ", ")))
}
sb.WriteString("\n")
}
if res.Highlight && res.Skill != "" {
line := pickBabysitFlavor(babysitHighlightLines)
if line != "" {
sb.WriteString("\n_" + fmt.Sprintf(line, res.Skill) + "_")
}
}
return strings.TrimRight(sb.String(), "\n")
}
// ── Idle Shame DM ────────────────────────────────────────────────────────────
func renderAdvIdleShameDM(char *AdventureCharacter) string {