mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 17:02:42 +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:
@@ -556,7 +556,7 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "bj_beat_twinbee", Name: "Gotcha 🐝", Description: "She is furious and will not forget this.",
|
ID: "bj_beat_twinbee", Name: "Gotcha 🐝", Description: "They are furious and will not forget this.",
|
||||||
Emoji: "🐝",
|
Emoji: "🐝",
|
||||||
Check: func(d *sql.DB, u id.UserID) bool {
|
Check: func(d *sql.DB, u id.UserID) bool {
|
||||||
// Granted by blackjack plugin when beating the bot
|
// Granted by blackjack plugin when beating the bot
|
||||||
|
|||||||
@@ -1301,7 +1301,17 @@ func (p *AdventurePlugin) resolveShopCurioChoice(ctx MessageContext, interaction
|
|||||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You need €%.0f for %s but only have €%.0f.", price, match.Name, balance))
|
return p.SendDM(ctx.Sender, fmt.Sprintf("You need €%.0f for %s but only have €%.0f.", price, match.Name, balance))
|
||||||
}
|
}
|
||||||
|
|
||||||
p.euro.Debit(ctx.Sender, price, "shop_curio")
|
// Guard the Debit return: GetBalance above is a snapshot read, but
|
||||||
|
// the debt-limit check inside Debit (BLACKJACK_DEBT_LIMIT) can refuse
|
||||||
|
// the write if a concurrent debit (blackjack, lottery, etc.) has
|
||||||
|
// slipped the balance across the limit between read and write.
|
||||||
|
// Without this guard the curio would be granted on a refused debit.
|
||||||
|
if !p.euro.Debit(ctx.Sender, price, "shop_curio") {
|
||||||
|
p.pending.Store(string(ctx.Sender), interaction)
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
|
"Tried to charge €%.0f for %s, but the purse refused — your balance moved out from under us. Try again.",
|
||||||
|
price, match.Name))
|
||||||
|
}
|
||||||
if potCut := int(math.Round(price * 0.05)); potCut > 0 {
|
if potCut := int(math.Round(price * 0.05)); potCut > 0 {
|
||||||
communityPotAdd(potCut)
|
communityPotAdd(potCut)
|
||||||
trackTaxPaid(ctx.Sender, potCut)
|
trackTaxPaid(ctx.Sender, potCut)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
|
|||||||
case "":
|
case "":
|
||||||
// If active, show status; otherwise help.
|
// If active, show status; otherwise help.
|
||||||
if exp, _ := getActiveExpedition(ctx.Sender); exp != nil {
|
if exp, _ := getActiveExpedition(ctx.Sender); exp != nil {
|
||||||
return p.expeditionCmdStatus(ctx)
|
return p.expeditionCmdStatus(ctx, "")
|
||||||
}
|
}
|
||||||
return p.SendDM(ctx.Sender, expeditionHelpText())
|
return p.SendDM(ctx.Sender, expeditionHelpText())
|
||||||
case "help", "?":
|
case "help", "?":
|
||||||
@@ -59,7 +59,7 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
|
|||||||
case "start", "begin", "go":
|
case "start", "begin", "go":
|
||||||
return p.expeditionCmdStart(ctx, c, rest)
|
return p.expeditionCmdStart(ctx, c, rest)
|
||||||
case "status", "info":
|
case "status", "info":
|
||||||
return p.expeditionCmdStatus(ctx)
|
return p.expeditionCmdStatus(ctx, rest)
|
||||||
case "log", "history":
|
case "log", "history":
|
||||||
return p.expeditionCmdLog(ctx)
|
return p.expeditionCmdLog(ctx)
|
||||||
case "abandon", "quit":
|
case "abandon", "quit":
|
||||||
@@ -271,7 +271,18 @@ func estimateDays(maxSU, dailyBurn float32) int {
|
|||||||
|
|
||||||
// ── status ──────────────────────────────────────────────────────────────────
|
// ── status ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) expeditionCmdStatus(ctx MessageContext) error {
|
func (p *AdventurePlugin) expeditionCmdStatus(ctx MessageContext, args string) error {
|
||||||
|
debug := false
|
||||||
|
for _, tok := range strings.Fields(args) {
|
||||||
|
switch strings.ToLower(tok) {
|
||||||
|
case "--debug", "-d", "debug", "raw":
|
||||||
|
debug = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p.expeditionCmdStatusImpl(ctx, debug)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) expeditionCmdStatusImpl(ctx MessageContext, debug bool) error {
|
||||||
exp, err := getActiveExpedition(ctx.Sender)
|
exp, err := getActiveExpedition(ctx.Sender)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||||
@@ -298,21 +309,36 @@ func (p *AdventurePlugin) expeditionCmdStatus(ctx MessageContext) error {
|
|||||||
if c != nil {
|
if c != nil {
|
||||||
b.WriteString(fmt.Sprintf("❤️ **HP:** %d / %d\n", c.HPCurrent, c.HPMax))
|
b.WriteString(fmt.Sprintf("❤️ **HP:** %d / %d\n", c.HPCurrent, c.HPMax))
|
||||||
}
|
}
|
||||||
b.WriteString(fmt.Sprintf("🎒 **Supplies:** %.1f / %.1f SU _(burn %.1f/day → ~%d days left)_\n",
|
// Default view is verb/outcome-led: days-left summary + threat label,
|
||||||
exp.Supplies.Current, exp.Supplies.Max, currentBurn(exp),
|
// no raw SU / threat-out-of-100 / zone-stack / roll-modifier numbers.
|
||||||
estimateDays(exp.Supplies.Current, currentBurn(exp))))
|
// Pass `--debug` to !expedition status for the engineering view.
|
||||||
b.WriteString(fmt.Sprintf("⏰ **Threat:** %d / 100 — %s\n",
|
days := estimateDays(exp.Supplies.Current, currentBurn(exp))
|
||||||
exp.ThreatLevel, threatThresholdLabel(exp.ThreatLevel, exp.SiegeMode)))
|
if debug {
|
||||||
if exp.TemporalStack != 0 {
|
b.WriteString(fmt.Sprintf("🎒 **Supplies:** %.1f / %.1f SU _(burn %.1f/day → ~%d days left)_\n",
|
||||||
b.WriteString(fmt.Sprintf("🌡 **Zone stack:** %d\n", exp.TemporalStack))
|
exp.Supplies.Current, exp.Supplies.Max, currentBurn(exp), days))
|
||||||
|
b.WriteString(fmt.Sprintf("⏰ **Threat:** %d / 100 — %s\n",
|
||||||
|
exp.ThreatLevel, threatThresholdLabel(exp.ThreatLevel, exp.SiegeMode)))
|
||||||
|
if exp.TemporalStack != 0 {
|
||||||
|
b.WriteString(fmt.Sprintf("🌡 **Zone stack:** %d\n", exp.TemporalStack))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
b.WriteString(fmt.Sprintf("🎒 **Supplies:** ~%d day%s left\n",
|
||||||
|
days, plural(days)))
|
||||||
|
b.WriteString(fmt.Sprintf("⏰ **Threat:** %s\n",
|
||||||
|
threatThresholdLabel(exp.ThreatLevel, exp.SiegeMode)))
|
||||||
}
|
}
|
||||||
if exp.Camp != nil && exp.Camp.Active {
|
if exp.Camp != nil && exp.Camp.Active {
|
||||||
b.WriteString(fmt.Sprintf("⛺ **Camp:** %s (room %d)\n", exp.Camp.Type, exp.Camp.RoomIndex+1))
|
b.WriteString(fmt.Sprintf("⛺ **Camp:** %s (room %d)\n", exp.Camp.Type, exp.Camp.RoomIndex+1))
|
||||||
}
|
}
|
||||||
state := supplyDepletion(exp.Supplies)
|
state := supplyDepletion(exp.Supplies)
|
||||||
if state != SupplyNormal {
|
if state != SupplyNormal {
|
||||||
b.WriteString(fmt.Sprintf("⚠ **%s** — roll modifier %d\n",
|
if debug {
|
||||||
depletionLabel(state), supplyRollModifier(state)))
|
b.WriteString(fmt.Sprintf("⚠ **%s** — roll modifier %d\n",
|
||||||
|
depletionLabel(state), supplyRollModifier(state)))
|
||||||
|
} else {
|
||||||
|
b.WriteString(fmt.Sprintf("⚠ **%s** — you're slower and clumsier than usual.\n",
|
||||||
|
depletionLabel(state)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
b.WriteString(fmt.Sprintf("\nStarted: %s Last activity: %s",
|
b.WriteString(fmt.Sprintf("\nStarted: %s Last activity: %s",
|
||||||
exp.StartDate.Format("2006-01-02 15:04"),
|
exp.StartDate.Format("2006-01-02 15:04"),
|
||||||
|
|||||||
@@ -610,16 +610,26 @@ func renderConfirmPreview(c *DnDCharacter) string {
|
|||||||
func renderSetupComplete(c *DnDCharacter) string {
|
func renderSetupComplete(c *DnDCharacter) string {
|
||||||
ri, _ := raceInfo(c.Race)
|
ri, _ := raceInfo(c.Race)
|
||||||
ci, _ := classInfo(c.Class)
|
ci, _ := classInfo(c.Class)
|
||||||
|
nextLine := "Use `!sheet` anytime to review. `!zone list` to head out, or `!expedition list` for a longer run."
|
||||||
|
if isSpellcaster(c) {
|
||||||
|
// Casters get auto-granted spells on character creation but no
|
||||||
|
// in-game discovery prompt for them. Surface !spells / !cast so
|
||||||
|
// they can actually find their kit. Long-rest refresh is the
|
||||||
|
// other piece they need to know, but that becomes obvious the
|
||||||
|
// moment they look at !spells.
|
||||||
|
nextLine = "Use `!sheet` anytime to review. `!spells` lists what you can cast and `!cast <spell>` does the deed. `!zone list` heads out; `!expedition list` is the longer run."
|
||||||
|
}
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"⚔️ **Character Sheet Forged**\n\n"+
|
"⚔️ **Character Sheet Forged**\n\n"+
|
||||||
"You are a **Level %d %s %s**.\n"+
|
"You are a **Level %d %s %s**.\n"+
|
||||||
" HP %d/%d AC %d\n"+
|
" HP %d/%d AC %d\n"+
|
||||||
" STR %d DEX %d CON %d INT %d WIS %d CHA %d\n\n"+
|
" STR %d DEX %d CON %d INT %d WIS %d CHA %d\n\n"+
|
||||||
"_%s_\n\n"+
|
"_%s_\n\n"+
|
||||||
"Use `!sheet` anytime to review. `!zone list` to head out, or `!expedition list` for a longer run.",
|
"%s",
|
||||||
c.Level, ri.Display, ci.Display,
|
c.Level, ri.Display, ci.Display,
|
||||||
c.HPCurrent, c.HPMax, c.ArmorClass,
|
c.HPCurrent, c.HPMax, c.ArmorClass,
|
||||||
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA,
|
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA,
|
||||||
ri.Passive,
|
ri.Passive,
|
||||||
|
nextLine,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,9 +167,11 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attunements (re-using adventure_treasures per v1.1 §7.4)
|
// 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 {
|
if len(treasures) > 0 {
|
||||||
b.WriteString("\n**Attunements** (treasures)\n")
|
b.WriteString("\n**Treasures**\n")
|
||||||
// Group by treasure_key — one treasure can have multiple bonuses.
|
// Group by treasure_key — one treasure can have multiple bonuses.
|
||||||
byKey := map[string][]AdvTreasureBonus{}
|
byKey := map[string][]AdvTreasureBonus{}
|
||||||
var keys []string
|
var keys []string
|
||||||
|
|||||||
@@ -413,6 +413,24 @@ func buildSpellList() []SpellDefinition {
|
|||||||
{ID: "druidcraft", Name: "Druidcraft", Level: 0, School: "transmutation",
|
{ID: "druidcraft", Name: "Druidcraft", Level: 0, School: "transmutation",
|
||||||
Classes: []DnDClass{ClassDruid}, Effect: EffectUtility, CastTime: CastAction, AOE: true,
|
Classes: []DnDClass{ClassDruid}, Effect: EffectUtility, CastTime: CastAction, AOE: true,
|
||||||
Description: "Whisper to the spirits and they whisper back — usually about tomorrow's weather, sometimes a tiny gust, sometimes a sapling. Mostly harmless theatre."},
|
Description: "Whisper to the spirits and they whisper back — usually about tomorrow's weather, sometimes a tiny gust, sometimes a sapling. Mostly harmless theatre."},
|
||||||
|
{ID: "eldritch_blast", Name: "Eldritch Blast", Level: 0, School: "evocation",
|
||||||
|
Classes: []DnDClass{ClassWarlock}, Effect: EffectDamageAttack, CastTime: CastAction,
|
||||||
|
AttackRoll: true, DamageDice: "1d10", DamageType: "force",
|
||||||
|
Description: "A lance of pact-fed force punches out of your palm and into a target. Your patron's idea of a polite greeting."},
|
||||||
|
{ID: "find_familiar", Name: "Find Familiar", Level: 1, School: "conjuration",
|
||||||
|
Classes: []DnDClass{ClassMage}, Effect: EffectBuffSelf, CastTime: CastRitual,
|
||||||
|
Description: "Call up a small spirit-companion in animal shape — cat, raven, frog, the usual. It scouts, fetches, and judges you silently."},
|
||||||
|
{ID: "clairvoyance", Name: "Clairvoyance", Level: 3, School: "divination",
|
||||||
|
Classes: []DnDClass{ClassMage, ClassCleric, ClassBard, ClassSorcerer, ClassWarlock},
|
||||||
|
Effect: EffectBuffSelf, CastTime: CastAction, Concentration: true, MaterialCost: 100,
|
||||||
|
Description: "Plant an invisible eye-or-ear in a place you know or can describe, and watch the world through it from afar."},
|
||||||
|
{ID: "glyph_of_warding", Name: "Glyph of Warding", Level: 3, School: "abjuration",
|
||||||
|
Classes: []DnDClass{ClassMage, ClassCleric, ClassBard}, Effect: EffectDamageSave, CastTime: CastAction,
|
||||||
|
SaveStat: "DEX", DamageDice: "5d8", DamageType: "thunder", MaterialCost: 200, AOE: true,
|
||||||
|
Description: "Etch a hidden rune on a surface or in a sealed object. Whoever trips the trigger eats the blast — not you."},
|
||||||
|
{ID: "teleportation_circle", Name: "Teleportation Circle", Level: 5, School: "conjuration",
|
||||||
|
Classes: []DnDClass{ClassMage, ClassBard, ClassSorcerer}, Effect: EffectUtility, CastTime: CastAction,
|
||||||
|
Description: "Trace a sigil-circle on the ground that links to a far-off permanent circle you know. Step through and arrive there."},
|
||||||
{ID: "light", Name: "Light", Level: 0, School: "evocation",
|
{ID: "light", Name: "Light", Level: 0, School: "evocation",
|
||||||
Classes: []DnDClass{ClassMage, ClassCleric, ClassBard, ClassSorcerer}, Effect: EffectUtility, CastTime: CastAction, AOE: true,
|
Classes: []DnDClass{ClassMage, ClassCleric, ClassBard, ClassSorcerer}, Effect: EffectUtility, CastTime: CastAction, AOE: true,
|
||||||
Description: "Tap an object and it glows like a friendly torch. Easy to carry, harder to lose."},
|
Description: "Tap an object and it glows like a friendly torch. Easy to carry, harder to lose."},
|
||||||
|
|||||||
@@ -30,6 +30,17 @@ func TestSpellDescriptionsAreJargonFree(t *testing.T) {
|
|||||||
{"hit points equal to", regexp.MustCompile(`(?i)hit points equal to`)},
|
{"hit points equal to", regexp.MustCompile(`(?i)hit points equal to`)},
|
||||||
{"NdN dice notation", regexp.MustCompile(`\b\d+d\d+\b`)},
|
{"NdN dice notation", regexp.MustCompile(`\b\d+d\d+\b`)},
|
||||||
{"bare die notation", regexp.MustCompile(`\bd(4|6|8|10|12|20|100)\b`)},
|
{"bare die notation", regexp.MustCompile(`\bd(4|6|8|10|12|20|100)\b`)},
|
||||||
|
// Catch SRD-import placeholders that have leaked through past
|
||||||
|
// overlays. "Whatever" was the eldritch_blast tell; the truncation
|
||||||
|
// patterns caught water_walk/druidcraft/light in earlier audits.
|
||||||
|
// "Whatever" placeholder used by the SRD importer when a field
|
||||||
|
// went missing. Allow mid-prose "Whatever's"/"Whatever it"
|
||||||
|
// (legit contraction / sentence-start) by requiring a following
|
||||||
|
// space-then-lowercase, which is the signature of the bug.
|
||||||
|
{"Whatever placeholder", regexp.MustCompile(`\bWhatever [a-z]`)},
|
||||||
|
{"trailing ellipsis", regexp.MustCompile(`(?:\.\.\.|…)\s*$`)},
|
||||||
|
{"trailing truncated word", regexp.MustCompile(`\b[a-z]{1,3}(?:\.\.\.|…)\s*$`)},
|
||||||
|
{"empty size phrase", regexp.MustCompile(`(?i)\bno larger than in any\b`)},
|
||||||
}
|
}
|
||||||
|
|
||||||
for id, s := range dndSpellRegistry {
|
for id, s := range dndSpellRegistry {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import "math"
|
|
||||||
|
|
||||||
// Phase 10 SUB2a — subclass combat hooks.
|
// Phase 10 SUB2a — subclass combat hooks.
|
||||||
//
|
//
|
||||||
// applySubclassPassives layers subclass-driven flags onto CombatModifiers
|
// applySubclassPassives layers subclass-driven flags onto CombatModifiers
|
||||||
@@ -791,8 +789,10 @@ func init() {
|
|||||||
Description: "Knock the enemy's weapon aside: their damage is reduced for the rest of the fight (consumes 1 superiority die).",
|
Description: "Knock the enemy's weapon aside: their damage is reduced for the rest of the fight (consumes 1 superiority die).",
|
||||||
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
|
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
|
||||||
// 25% incoming-damage cut models a fighter who is now
|
// 25% incoming-damage cut models a fighter who is now
|
||||||
// swinging an improvised weapon at -d4-ish.
|
// swinging an improvised weapon at -d4-ish. DamageReduct
|
||||||
mods.DamageReduct = math.Max(mods.DamageReduct, 0.25)
|
// is multiplicative (1.0 neutral, lower = less damage),
|
||||||
|
// so multiply down.
|
||||||
|
mods.DamageReduct *= 0.75
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
dndActiveAbilities["menacing_attack"] = DnDAbility{
|
dndActiveAbilities["menacing_attack"] = DnDAbility{
|
||||||
@@ -814,7 +814,8 @@ func init() {
|
|||||||
Resource: "superiority",
|
Resource: "superiority",
|
||||||
Description: "Set yourself for incoming blows — physical damage taken is sharply reduced (consumes 1 superiority die).",
|
Description: "Set yourself for incoming blows — physical damage taken is sharply reduced (consumes 1 superiority die).",
|
||||||
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
|
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
|
||||||
mods.DamageReduct = math.Max(mods.DamageReduct, 0.40)
|
// 40% reduction; multiplicative (see disarming_attack note).
|
||||||
|
mods.DamageReduct *= 0.60
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -446,6 +446,45 @@ func TestTripAttack_ApplySetsEnemySkipFirst(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDisarmingAttack_ApplyMultipliesDamageReduct(t *testing.T) {
|
||||||
|
// Regression: this used to set DamageReduct via math.Max(_, 0.25),
|
||||||
|
// which against the default 1.0 was a no-op. Should multiply down.
|
||||||
|
ab, ok := dndActiveAbilities["disarming_attack"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("disarming_attack not registered")
|
||||||
|
}
|
||||||
|
mods := &CombatModifiers{DamageReduct: 1.0}
|
||||||
|
ab.Apply(&DnDCharacter{Class: ClassFighter, Subclass: SubclassBattleMaster, Level: 5}, mods)
|
||||||
|
if mods.DamageReduct >= 1.0 {
|
||||||
|
t.Errorf("Disarming Attack DamageReduct = %.3f, want < 1.0", mods.DamageReduct)
|
||||||
|
}
|
||||||
|
if mods.DamageReduct < 0.74 || mods.DamageReduct > 0.76 {
|
||||||
|
t.Errorf("Disarming Attack DamageReduct = %.3f, want ~0.75", mods.DamageReduct)
|
||||||
|
}
|
||||||
|
// Stacks multiplicatively with prior reductions.
|
||||||
|
mods2 := &CombatModifiers{DamageReduct: 0.90}
|
||||||
|
ab.Apply(&DnDCharacter{Class: ClassFighter, Subclass: SubclassBattleMaster, Level: 5}, mods2)
|
||||||
|
if mods2.DamageReduct >= 0.90 {
|
||||||
|
t.Errorf("Disarming Attack should stack: got %.3f, want < 0.90", mods2.DamageReduct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParry_ApplyMultipliesDamageReduct(t *testing.T) {
|
||||||
|
// Regression: math.Max(_, 0.40) against default 1.0 was a no-op.
|
||||||
|
ab, ok := dndActiveAbilities["parry"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("parry not registered")
|
||||||
|
}
|
||||||
|
mods := &CombatModifiers{DamageReduct: 1.0}
|
||||||
|
ab.Apply(&DnDCharacter{Class: ClassFighter, Subclass: SubclassBattleMaster, Level: 5}, mods)
|
||||||
|
if mods.DamageReduct >= 1.0 {
|
||||||
|
t.Errorf("Parry DamageReduct = %.3f, want < 1.0", mods.DamageReduct)
|
||||||
|
}
|
||||||
|
if mods.DamageReduct < 0.59 || mods.DamageReduct > 0.61 {
|
||||||
|
t.Errorf("Parry DamageReduct = %.3f, want ~0.60", mods.DamageReduct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRally_ApplySetsHealItem(t *testing.T) {
|
func TestRally_ApplySetsHealItem(t *testing.T) {
|
||||||
ab := dndActiveAbilities["rally"]
|
ab := dndActiveAbilities["rally"]
|
||||||
mods := &CombatModifiers{DamageReduct: 1.0}
|
mods := &CombatModifiers{DamageReduct: 1.0}
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ func zoneHelpText() string {
|
|||||||
b.WriteString("`!zone advance` — resolve the current room and move on\n")
|
b.WriteString("`!zone advance` — resolve the current room and move on\n")
|
||||||
b.WriteString("`!zone go <n>` — at a fork, take path #n\n")
|
b.WriteString("`!zone go <n>` — at a fork, take path #n\n")
|
||||||
b.WriteString("`!zone abandon` — end the active run (no rewards)\n")
|
b.WriteString("`!zone abandon` — end the active run (no rewards)\n")
|
||||||
b.WriteString("`!zone taunt` — poke TwinBee (mood −10)\n")
|
b.WriteString("`!zone taunt` — poke TwinBee (they'll remember)\n")
|
||||||
b.WriteString("`!zone compliment` — flatter TwinBee (mood +5)\n")
|
b.WriteString("`!zone compliment` — flatter TwinBee (they'll like that)\n")
|
||||||
b.WriteString("`!zone lore` — TwinBee shares zone history")
|
b.WriteString("`!zone lore` — TwinBee shares zone history")
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -311,12 +311,13 @@ func (p *AdventurePlugin) applyAmbientEffect(e *Expedition, ev ambientEvent) str
|
|||||||
if err := applyThreatDelta(e.ID, 1, "ambient: distant noise"); err != nil {
|
if err := applyThreatDelta(e.ID, 1, "ambient: distant noise"); err != nil {
|
||||||
slog.Warn("expedition: ambient threat delta", "expedition", e.ID, "err", err)
|
slog.Warn("expedition: ambient threat delta", "expedition", e.ID, "err", err)
|
||||||
}
|
}
|
||||||
return "Threat +1"
|
// Verb-form footer — "Threat +N" exposed the hidden meter.
|
||||||
|
return "The dungeon listens a little harder."
|
||||||
case "faction_whisper":
|
case "faction_whisper":
|
||||||
if err := applyThreatDelta(e.ID, 2, "ambient: faction whisper"); err != nil {
|
if err := applyThreatDelta(e.ID, 2, "ambient: faction whisper"); err != nil {
|
||||||
slog.Warn("expedition: ambient threat delta", "expedition", e.ID, "err", err)
|
slog.Warn("expedition: ambient threat delta", "expedition", e.ID, "err", err)
|
||||||
}
|
}
|
||||||
return "Threat +2"
|
return "Something out there is paying attention now."
|
||||||
case "pack_rat":
|
case "pack_rat":
|
||||||
drain := float32(0.2) + float32(rng.IntN(2))*float32(0.1) // 0.2 or 0.3
|
drain := float32(0.2) + float32(rng.IntN(2))*float32(0.1) // 0.2 or 0.3
|
||||||
ns := e.Supplies
|
ns := e.Supplies
|
||||||
@@ -328,7 +329,10 @@ func (p *AdventurePlugin) applyAmbientEffect(e *Expedition, ev ambientEvent) str
|
|||||||
slog.Warn("expedition: ambient supply drain", "expedition", e.ID, "err", err)
|
slog.Warn("expedition: ambient supply drain", "expedition", e.ID, "err", err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("Supplies −%.1f SU", drain)
|
// Don't surface the raw SU number — it's a hidden meter and
|
||||||
|
// "SU" doesn't appear in player vocab anywhere else.
|
||||||
|
_ = drain
|
||||||
|
return "A little less in the pack than there was."
|
||||||
case "lucky_find":
|
case "lucky_find":
|
||||||
coins := 1 + rng.IntN(4) // 1d4
|
coins := 1 + rng.IntN(4) // 1d4
|
||||||
if p.euro != nil {
|
if p.euro != nil {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
@@ -28,25 +29,29 @@ import (
|
|||||||
|
|
||||||
// ── Rarity / loot indexing ──────────────────────────────────────────────────
|
// ── Rarity / loot indexing ──────────────────────────────────────────────────
|
||||||
|
|
||||||
// magicItemsByRarity buckets the registry by rarity. Built once, lazily.
|
// magicItemsByRarity buckets the registry by rarity. Built once via
|
||||||
var magicItemsByRarityCache map[DnDRarity][]MagicItem
|
// 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 {
|
func magicItemsByRarity() map[DnDRarity][]MagicItem {
|
||||||
if magicItemsByRarityCache != nil {
|
magicItemsByRarityOnce.Do(func() {
|
||||||
return magicItemsByRarityCache
|
idx := make(map[DnDRarity][]MagicItem)
|
||||||
}
|
ids := make([]string, 0, len(magicItemRegistry))
|
||||||
idx := make(map[DnDRarity][]MagicItem)
|
for id := range magicItemRegistry {
|
||||||
ids := make([]string, 0, len(magicItemRegistry))
|
ids = append(ids, id)
|
||||||
for id := range magicItemRegistry {
|
}
|
||||||
ids = append(ids, id)
|
sort.Strings(ids) // deterministic bucket order
|
||||||
}
|
for _, id := range ids {
|
||||||
sort.Strings(ids) // deterministic bucket order
|
mi := magicItemRegistry[id]
|
||||||
for _, id := range ids {
|
idx[mi.Rarity] = append(idx[mi.Rarity], mi)
|
||||||
mi := magicItemRegistry[id]
|
}
|
||||||
idx[mi.Rarity] = append(idx[mi.Rarity], mi)
|
magicItemsByRarityCache = idx
|
||||||
}
|
})
|
||||||
magicItemsByRarityCache = idx
|
return magicItemsByRarityCache
|
||||||
return idx
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickMagicItemForRarity returns a uniform-random registry item of the given
|
// pickMagicItemForRarity returns a uniform-random registry item of the given
|
||||||
@@ -541,12 +546,26 @@ func (p *AdventurePlugin) resolveMagicEquipReply(ctx MessageContext, interaction
|
|||||||
attune = true
|
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.")
|
return p.SendDM(ctx.Sender, "Failed to equip that item.")
|
||||||
}
|
}
|
||||||
if err := removeAdvInventoryItem(it.ID); err != nil {
|
if err := equipMagicItem(ctx.Sender, mi.Slot, mi.ID, attune); err != nil {
|
||||||
slog.Error("magic-item: failed to remove equipped item from inventory",
|
// Roll back: try to put the item back in inventory so the player
|
||||||
"user", ctx.Sender, "item", mi.ID, "err", err)
|
// 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
|
var sb strings.Builder
|
||||||
@@ -554,10 +573,10 @@ func (p *AdventurePlugin) resolveMagicEquipReply(ctx MessageContext, interaction
|
|||||||
mi.Name, mi.Slot, magicItemEffectSummary(mi)))
|
mi.Name, mi.Slot, magicItemEffectSummary(mi)))
|
||||||
switch {
|
switch {
|
||||||
case mi.Attunement && attune:
|
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))
|
countAttunedMagicItems(equipped)+1, dndMagicItemAttuneLimit))
|
||||||
case mi.Attunement && atCap:
|
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))
|
dndMagicItemAttuneLimit))
|
||||||
}
|
}
|
||||||
return p.SendDM(ctx.Sender, sb.String())
|
return p.SendDM(ctx.Sender, sb.String())
|
||||||
|
|||||||
Reference in New Issue
Block a user