Files
gogobee/internal/plugin/dnd_sheet.go
prosolis adcc62112b N2/B1+B4+C4: tempering, the expedition achievement wing, arena seasons
B1 — tempering. `!adventure temper` pushes an owned magic item one rung up
the rarity ladder at the blacksmith, capped at Legendary. Rarity lives on
the registry definition, so an upgraded instance records its progress as a
`temper` step count on its own row (adventure_inventory, magic_item_equipped)
and effective rarity is derived on read. The stored base rarity is never
written back, so an item cannot double-bump across a load/save round-trip.
Effects flow through the existing rarity scalars — no new combat math, and
the Legendary cap means this accelerates reaching the current ceiling rather
than raising it.

Costs mirror the crafting ladder (10k/25k/60k/150k, Foraging 15/20/25/30).
Only the Legendary rung consumes a T5 material; gating the lower rungs on
one would make tempering unreachable until a player already clears T5.

Two plan anchors were wrong: thyraks_core and portal_fragment are not
loot-note strings needing promotion — they are UniqueAlways slate entries
that already materialize as inventory rows on every T5 clear.

B4 — expedition achievement wing: first-clear and all-zones-cleared per tier
(10), a quiet-clear for keeping peak threat under 50, and temper_legendary.
The quiet-clear requires max_threat_seen to be *present*, not merely low:
recordMaxThreat samples only on day rollover and never stores a zero, so an
absent key means no threat history, not low threat. The plan's no-death-T4
achievement is not shipped — no per-expedition death counter exists — and
pet-saved-my-life already ships as combat_pet_save.

C4 — arena seasons. Standings are derived from arena_history.created_at per
calendar quarter rather than wiping arena_stats: same visible reset, but
lifetime totals survive for `!arena stats` and no destructive job can fire
twice. Crowns archive to arena_season_titles rather than player_meta.title,
which already carries the Survivalist milestone. Rollover rides the existing
midnight ticker and self-dedups on the season key, so a bot that was down on
the boundary catches up on its next wake.
2026-07-09 19:22:10 -07:00

222 lines
7.5 KiB
Go

package plugin
import (
"fmt"
"sort"
"strings"
)
func (p *AdventurePlugin) handleDnDAbilitiesCmd(ctx MessageContext) error {
c, err := LoadDnDCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't load your character: "+err.Error())
}
if c == nil || c.PendingSetup {
return p.SendDM(ctx.Sender, "No Adv 2.0 character yet — run `!setup` (or just enter combat and we'll auto-build one).")
}
ri, _ := raceInfo(c.Race)
ci, _ := classInfo(c.Class)
ab := dndClassAbilities[c.Class]
var b strings.Builder
b.WriteString(fmt.Sprintf("**%s %s — Abilities**\n\n", ri.Display, ci.Display))
b.WriteString(fmt.Sprintf("**Class passive — %s**\n %s\n\n", ab.Name, ab.Description))
b.WriteString(fmt.Sprintf("**Race trait**\n %s\n", ri.Passive))
// Active abilities (Phase 6 + Phase 10 subclass-gated)
actives := characterActiveAbilities(c)
if len(actives) > 0 {
resType, _ := classResourceMax(c.Class)
cur, max, _ := getResource(c.UserID, resType)
b.WriteString(fmt.Sprintf("\n**Active abilities** (%s %d/%d)\n", resType, cur, max))
for _, a := range actives {
b.WriteString(fmt.Sprintf(" • **%s** (1 %s) — %s\n", a.Name, a.Resource, a.Description))
}
b.WriteString("\nUse `!arm <ability>` to ready one for your next combat. Refreshes on long rest.\n")
if c.ArmedAbility != "" {
b.WriteString(fmt.Sprintf("\n_Currently armed: **%s**_\n", displayAbility(c.ArmedAbility)))
}
}
return p.SendDM(ctx.Sender, b.String())
}
// !sheet — read-only D&D character sheet renderer.
//
// Sources:
// dnd_character (D&D layer)
// player_meta (skills, pet, housing — for at-a-glance context)
// adventure_equipment (current gear)
// adventure_treasures (= attunement substrate per v1.1 §7.4)
func (p *AdventurePlugin) handleDnDSheetCmd(ctx MessageContext) error {
c, err := LoadDnDCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't load your sheet: "+err.Error())
}
if c == nil || c.PendingSetup {
return p.SendDM(ctx.Sender,
"You don't have an Adv 2.0 character yet.\n\nRun `!setup` to begin character creation. "+
"Your existing adventure progress (skills, pets, coins, arena streak) will be preserved.")
}
advChar, _ := loadAdvCharacter(ctx.Sender)
equip, _ := loadAdvEquipment(ctx.Sender)
treasures, _ := loadAdvTreasureBonuses(ctx.Sender)
meta, _ := loadPlayerMeta(ctx.Sender)
house, _ := loadHouseState(ctx.Sender)
magicEquip, _ := loadEquippedMagicItems(ctx.Sender)
return p.SendDM(ctx.Sender, renderDnDSheet(c, advChar, meta, house, equip, treasures, magicEquip))
}
func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta, house HouseState, equip map[EquipmentSlot]*AdvEquipment, treasures []AdvTreasureBonus, magicEquip map[DnDSlot]EquippedMagicItem) string {
ri, _ := raceInfo(c.Race)
ci, _ := classInfo(c.Class)
mods := c.Modifiers()
var b strings.Builder
name := string(c.UserID)
if adv != nil && adv.DisplayName != "" {
name = adv.DisplayName
}
b.WriteString(fmt.Sprintf("⚔️ **%s** — Level %d %s %s\n", name, c.Level, ri.Display, ci.Display))
if c.Subclass != "" {
if si, ok := subclassInfo(c.Subclass); ok {
b.WriteString(fmt.Sprintf(" _%s_\n", si.Display))
}
} else if c.Level >= dndSubclassMinLevel {
b.WriteString(" _Subclass: unchosen — run `!subclass`_\n")
}
b.WriteString(strings.Repeat("─", 36) + "\n")
// Vitals
b.WriteString(fmt.Sprintf("**HP** %d/%d", c.HPCurrent, c.HPMax))
if c.TempHP > 0 {
b.WriteString(fmt.Sprintf(" (+%d temp)", c.TempHP))
}
b.WriteString(fmt.Sprintf(" **AC** %d", c.ArmorClass))
if c.Level >= dndMaxLevel {
b.WriteString(" **XP** capped (L20)\n")
} else {
b.WriteString(fmt.Sprintf(" **XP** %d / %d (next L%d)\n",
c.XP, dndXPToNextLevel(c.Level), c.Level+1))
}
// Ability scores with modifiers
b.WriteString(fmt.Sprintf("STR %2d (%+d) DEX %2d (%+d) CON %2d (%+d)\n",
c.STR, mods[0], c.DEX, mods[1], c.CON, mods[2]))
b.WriteString(fmt.Sprintf("INT %2d (%+d) WIS %2d (%+d) CHA %2d (%+d)\n",
c.INT, mods[3], c.WIS, mods[4], c.CHA, mods[5]))
b.WriteString(fmt.Sprintf("\n_%s_\n", ri.Passive))
// Equipment — D&D 10-slot view, with rarity inferred from legacy fields.
b.WriteString("\n**Equipment**\n")
dndEquip := map[DnDSlot]*AdvEquipment{}
for _, slot := range allSlots {
eq, ok := equip[slot]
if !ok || eq == nil {
continue
}
dndEquip[mapLegacySlot(slot)] = eq
}
anyEquipped := false
for _, ds := range dndSlotOrder {
eq := dndEquip[ds]
if eq == nil {
continue
}
anyEquipped = true
rarity := equipmentRarity(eq)
tag := ""
if eq.Masterwork {
tag = " ★"
}
b.WriteString(fmt.Sprintf(" %s %-9s T%d %d%%%s %s _(%s)_\n",
rarityIcon(rarity), string(ds), eq.Tier, eq.Condition, tag, eq.Name, rarity))
}
if !anyEquipped {
b.WriteString(" _(none equipped)_\n")
}
// Magic items — registry items worn in the D&D slots (separate from the
// legacy tier-gear above). Attunement items mark themselves "(inactive)"
// when worn but not bonded — they grant nothing until the player frees
// an attunement slot via `!adventure equip-magic`.
if len(magicEquip) > 0 {
attunedNow := countAttunedMagicItems(magicEquip)
b.WriteString("\n**Magic Items**\n")
for _, ds := range dndSlotOrder {
e, ok := magicEquip[ds]
if !ok || e.Item.ID == "" {
continue
}
status := ""
if e.Item.Attunement {
switch {
case e.Attuned:
status = fmt.Sprintf(" — bonded (%d/%d)", attunedNow, dndMagicItemAttuneLimit)
case attunedNow >= dndMagicItemAttuneLimit:
status = " — **(inactive)** _bond cap full_"
default:
status = " — **(inactive)** _unbonded_"
}
}
mi := e.Effective()
temperMark := ""
if e.Temper > 0 {
temperMark = " 🔨"
}
b.WriteString(fmt.Sprintf(" %s %-9s %s%s _(%s)_%s\n %s\n",
rarityIcon(mi.Rarity), string(ds), mi.Name, temperMark, mi.Rarity,
status, magicItemEffectSummary(mi)))
}
}
// Treasure bonuses (re-using adventure_treasures per v1.1 §7.4).
// Player-facing label kept distinct from the magic-item "bond" vocab
// so the sheet doesn't read as two competing attunement systems.
if len(treasures) > 0 {
b.WriteString("\n**Treasures**\n")
// Group by treasure_key — one treasure can have multiple bonuses.
byKey := map[string][]AdvTreasureBonus{}
var keys []string
for _, t := range treasures {
if _, seen := byKey[t.TreasureKey]; !seen {
keys = append(keys, t.TreasureKey)
}
byKey[t.TreasureKey] = append(byKey[t.TreasureKey], t)
}
sort.Strings(keys)
for _, k := range keys {
bonuses := byKey[k]
name := bonuses[0].Name
parts := make([]string, 0, len(bonuses))
for _, bn := range bonuses {
parts = append(parts, fmt.Sprintf("%s %+.1f", bn.BonusType, bn.BonusValue))
}
b.WriteString(fmt.Sprintf(" • %s — %s\n", name, strings.Join(parts, ", ")))
}
}
// Legacy adventure context — preserved progress at a glance
if adv != nil {
b.WriteString("\n**Adventure progress** _(preserved)_\n")
b.WriteString(fmt.Sprintf(" Mining %d Foraging %d Fishing %d\n",
adv.MiningSkill, adv.ForagingSkill, adv.FishingSkill))
wins, losses := adv.ArenaWins, adv.ArenaLosses
if meta != nil {
wins, losses = meta.ArenaWins, meta.ArenaLosses
}
b.WriteString(fmt.Sprintf(" Arena %dW/%dL Streak %d (best %d)\n",
wins, losses, adv.CurrentStreak, adv.BestStreak))
if adv.PetName != "" {
b.WriteString(fmt.Sprintf(" Pet: %s the %s (lv %d)\n", adv.PetName, adv.PetType, adv.PetLevel))
}
if house.Tier > 0 {
b.WriteString(fmt.Sprintf(" Housing: tier %d\n", house.Tier))
}
}
return b.String()
}