mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 00:52:40 +00:00
Ship-audit P0/P1 sweep on open5e: prose, combat math, UX hygiene
P0 — actual bugs: - eldritch_blast: SRD-imported "Whatever" placeholder leaked to players; overlay added in dnd_spells_data.go. - Battle Master disarming_attack + parry: math.Max on multiplicative DamageReduct was a no-op vs the 1.0 default. Switched to *= and added regression tests covering the stacking case too. - Extended TestSpellDescriptionsAreJargonFree to catch "Whatever", trailing ellipses, mid-word truncation, and empty-size phrases. This surfaced 4 more genuinely-broken SRD descriptions (find_familiar, clairvoyance, glyph_of_warding, teleportation_circle); all now overlayed in dnd_spells_data.go. P1 — UX hygiene: - Setup completion now nudges new casters toward !spells/!cast. - Magic-item attunement vocab unified to "bond" everywhere user-facing; renamed the Treasures sheet section to avoid clashing with the bond vocab. - Ambient DM footers stripped of "Threat +N" / "Supplies −N SU" hidden- meter leakage; verb-form footers instead. - !expedition status defaults to a days-left + threat-label summary; raw SU / threat-out-of-100 / zone-stack / roll-modifier moved behind `!expedition status --debug`. - !zone taunt/compliment help text dropped the "(mood −10)" / "(mood +5)" numerics that turned the hidden TwinBee-mood mechanic into a min/max knob. - TwinBee pronoun pass: switched she/her to singular they on the two surfaces I touched plus achievements.bj_beat_twinbee. P1 — correctness/scale: - adventure_shop curio purchase now guards the euro.Debit bool return before inventory grant + pot cut; the GetBalance preflight isn't enough since a concurrent blackjack/lottery debit can slip the balance across BLACKJACK_DEBT_LIMIT between read and write. - equipMagicItem reordered: remove inventory item BEFORE equip, with a best-effort rollback if equip fails. The prior order left a dup window if the inventory delete tripped on a transient DB error. - magicItemsByRarityCache promoted to sync.Once — concurrent cold-start loot rolls otherwise raced on the lazy assignment. Stale-audit verifications (no code change needed): - light / druidcraft / water_walk overlays already exist and win the SRD merge — the broken raw text never reaches players. - bootstrapPhase5BHPRefresh is already wired at adventure.go:228. Deferred: - combatantsForSession per-turn 6-load DB chatter (perf P1-A): touches the hot combat path; needs its own test pass before shipping. Tracked in memory at project_combat_session_cache_deferred. go vet clean; go test -race ./... green (229s). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"math/rand/v2"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
@@ -28,25 +29,29 @@ import (
|
||||
|
||||
// ── Rarity / loot indexing ──────────────────────────────────────────────────
|
||||
|
||||
// magicItemsByRarity buckets the registry by rarity. Built once, lazily.
|
||||
var magicItemsByRarityCache map[DnDRarity][]MagicItem
|
||||
// magicItemsByRarity buckets the registry by rarity. Built once via
|
||||
// sync.Once — concurrent loot rolls on cold start would otherwise race
|
||||
// on the cache assignment below.
|
||||
var (
|
||||
magicItemsByRarityOnce sync.Once
|
||||
magicItemsByRarityCache map[DnDRarity][]MagicItem
|
||||
)
|
||||
|
||||
func magicItemsByRarity() map[DnDRarity][]MagicItem {
|
||||
if magicItemsByRarityCache != nil {
|
||||
return magicItemsByRarityCache
|
||||
}
|
||||
idx := make(map[DnDRarity][]MagicItem)
|
||||
ids := make([]string, 0, len(magicItemRegistry))
|
||||
for id := range magicItemRegistry {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids) // deterministic bucket order
|
||||
for _, id := range ids {
|
||||
mi := magicItemRegistry[id]
|
||||
idx[mi.Rarity] = append(idx[mi.Rarity], mi)
|
||||
}
|
||||
magicItemsByRarityCache = idx
|
||||
return idx
|
||||
magicItemsByRarityOnce.Do(func() {
|
||||
idx := make(map[DnDRarity][]MagicItem)
|
||||
ids := make([]string, 0, len(magicItemRegistry))
|
||||
for id := range magicItemRegistry {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids) // deterministic bucket order
|
||||
for _, id := range ids {
|
||||
mi := magicItemRegistry[id]
|
||||
idx[mi.Rarity] = append(idx[mi.Rarity], mi)
|
||||
}
|
||||
magicItemsByRarityCache = idx
|
||||
})
|
||||
return magicItemsByRarityCache
|
||||
}
|
||||
|
||||
// pickMagicItemForRarity returns a uniform-random registry item of the given
|
||||
@@ -541,12 +546,26 @@ func (p *AdventurePlugin) resolveMagicEquipReply(ctx MessageContext, interaction
|
||||
attune = true
|
||||
}
|
||||
}
|
||||
if err := equipMagicItem(ctx.Sender, mi.Slot, mi.ID, attune); err != nil {
|
||||
// Remove the inventory row FIRST, then equip. If equip fails after the
|
||||
// remove succeeded, restore inventory. Doing it in the other order
|
||||
// meant a transient DB error on remove left the item both equipped
|
||||
// *and* still in inventory — a free duplication.
|
||||
if err := removeAdvInventoryItem(it.ID); err != nil {
|
||||
slog.Error("magic-item: failed to remove from inventory before equip",
|
||||
"user", ctx.Sender, "item", mi.ID, "err", err)
|
||||
return p.SendDM(ctx.Sender, "Failed to equip that item.")
|
||||
}
|
||||
if err := removeAdvInventoryItem(it.ID); err != nil {
|
||||
slog.Error("magic-item: failed to remove equipped item from inventory",
|
||||
"user", ctx.Sender, "item", mi.ID, "err", err)
|
||||
if err := equipMagicItem(ctx.Sender, mi.Slot, mi.ID, attune); err != nil {
|
||||
// Roll back: try to put the item back in inventory so the player
|
||||
// doesn't lose it. Best-effort; log if the rollback also fails.
|
||||
restored := magicItemSell(mi)
|
||||
restored.Value = it.Value
|
||||
restored.SkillSource = "magic_item:" + mi.ID
|
||||
if rbErr := addAdvInventoryItem(ctx.Sender, restored); rbErr != nil {
|
||||
slog.Error("magic-item: equip failed AND inventory rollback failed",
|
||||
"user", ctx.Sender, "item", mi.ID, "equip_err", err, "rollback_err", rbErr)
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "Failed to equip that item.")
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
@@ -554,10 +573,10 @@ func (p *AdventurePlugin) resolveMagicEquipReply(ctx MessageContext, interaction
|
||||
mi.Name, mi.Slot, magicItemEffectSummary(mi)))
|
||||
switch {
|
||||
case mi.Attunement && attune:
|
||||
sb.WriteString(fmt.Sprintf("\nAttuned (%d/%d attunement slots used).",
|
||||
sb.WriteString(fmt.Sprintf("\nBonded (%d/%d bond slots used).",
|
||||
countAttunedMagicItems(equipped)+1, dndMagicItemAttuneLimit))
|
||||
case mi.Attunement && atCap:
|
||||
sb.WriteString(fmt.Sprintf("\n⚠️ All %d attunement slots are full — it's worn but **inert** until you free one (`!adventure equip-magic` to swap).",
|
||||
sb.WriteString(fmt.Sprintf("\n⚠️ All %d bond slots are full — it's worn but **inert** until you free one (`!adventure equip-magic` to swap).",
|
||||
dndMagicItemAttuneLimit))
|
||||
}
|
||||
return p.SendDM(ctx.Sender, sb.String())
|
||||
|
||||
Reference in New Issue
Block a user