4 Commits

Author SHA1 Message Date
prosolis
5a8d21f780 adventure: fix concurrency + dup hazards in web equip path
Code review of the Ask-7 web equipment-management path surfaced three
correctness issues, all fixed here:

- applyEquipOrder ran the poll-goroutine equip mutations without the
  per-user advUserLock that every Matrix-side mutation (!give, !equip,
  arena, …) holds, so the lock gave no mutual exclusion against the web
  path. A concurrent !give of the item being equipped could duplicate it.
  Now takes advUserLock(owner) for the whole apply, matching the DM path.

- applyMasterworkEquip evicted the displaced occupant to the pack BEFORE
  the destructive slot write, so a fault left the piece both worn and in
  the pack — and the 30s equip poll retry re-evicted it every tick. Now
  removes the incoming row, writes the slot, then re-packs the occupant
  last as a best-effort step: once the slot no longer references it, the
  re-pack cannot duplicate, and a failure is logged not aborted on (the
  DM confirm handler's tolerance).

- PlayerDetail.Balance dropped omitempty: a real €0 balance is an
  informative fact, not an absent one, and omitting it left the web
  confirm dialog with no balance to show.
2026-07-17 21:04:38 -07:00
prosolis
68c8cdff2d adventure: ask 7 — apply web equipment management (poll half)
Mirror of Pete's ask 7. gogobee polls the equip queue and applies the new
actions against the five standard gear slots:
  - equip: routes MasterworkGear/ArenaGear to applyMasterworkEquip (evicts
    any special occupant back to the pack; downgrade-blocked), else the
    existing applyMagicEquip.
  - unequip: EquipmentSlot vocabulary -> applyMasterworkUnequip (resets the
    slot to its tier-0 default, keeps the row), else applyMagicUnequip.
  - upgrade: purchaseEquipmentTier, euro-idempotent (DebitIdem keyed on the
    order GUID), downgrade + max-tier guarded.
  - repair: repair(), euro-idempotent, recomputes blacksmithRepairCost.

Detail push now carries Slots (EquipSlotView x5) + Balance; itemViews gives
masterwork/arena backpack rows an equip id; the compare decorator is guarded
to magic-only. buildDetailSnapshot is a method so it can read the euro balance.

Retry-safety: no CreditIdem refund on a later save fault (would double-pay a
guid-guarded retry) — we return retry=true and let the next poll re-run, since
the debit is guid-idempotent and the slot write is idempotent. Matches the
casino escrow precedent. Unit tests cover downgrade block, max-tier,
insufficient funds, idempotent replay, eviction, and take-off reset.

Deploy AFTER Pete: Pete's ingest must accept the new verdict strings before
this side emits them.
2026-07-17 20:34:56 -07:00
prosolis
b29dcf4360 combat: relabel DamageReduct comments to match calcDamage player-defender direction
Review follow-up on the caster-floor rebaseline: the survival-half doc in
casterBlasterFloor described an HP add the code never makes (Defense only),
and the pre-existing Druid *0.95 rider still read as a damage cut when, for a
player-defender through calcDamage, DamageReduct<1 raises damage taken. Comment
-only; no runtime change, guardrail test unaffected.
2026-07-17 17:22:06 -07:00
prosolis
1f62a8e842 combat: revive the caster sustained-cantrip floor in the turn engine
The arcane-blaster "sustained floor" passives (CantripPerRound, DamageBonus,
FlatDmgStart from casterBlasterFloor) were built for the swing-based engine
(SimulateCombat, combat_engine.go:590). But every live expedition auto-resolves
through the turn engine (autoDriveCombat -> session -> combat_turn_engine),
where casters autocast every turn and never weapon-swing -- so CantripPerRound
never fired and DamageBonus was inert. Casters fought at bare cantrip dice
(~4d10~=22 at L20) instead of their intended floor, in sim AND in prod. This is
why every caster damage dial read as a dead lever across the whole rebaseline.

Fix (combat_cmd.go): bridge the already-computed CantripPerRound into the
turn-engine damage-cantrip cast, hit-gated (only lift a cast that already
connected, so the ~35% miss variance survives and the floor isn't a guaranteed
flat hammer). Self-targeting: only Mage/Sorcerer/Warlock carry a nonzero
CantripPerRound -- martials swing (untouched), cleric/bard/druid have floor 0.

Tuning (dnd_passives.go): casterCantripBase 9 -> 3, now a live, class-specific
lever. Mage/Sorcerer take base 3; Warlock passes 0 (its bare-dice cantrip plus
a structural edge already lands it mid-band, so an added floor overshoots).
Removed the dead casterHPPerLevel rider (it inflated the truncation-fraction
denominator without adding startable HP -- a bug).

Also lands the deterministic-seeding infra (sim_seed.go + simIntN/simFloat64
threading) used to read these deltas out of the process-seed noise; prod is
byte-identical (unseeded -> package rand).

Confirmation (expedition-sim, L20 T5 dragons_lair+abyss_portal, n=250):
casters now in the 35-45 floor -- sorcerer 39, mage 38, warlock 36; martial
leaders undisturbed (rogue 68, druid 66, ranger 65, fighter 64, ... paladin 55).
2026-07-17 16:17:41 -07:00
19 changed files with 1032 additions and 40 deletions

View File

@@ -65,9 +65,19 @@ func main() {
companion = flag.String("companion", "", "hire Pete into the party: \"auto\" fills the missing role, or name a class (cleric, fighter, …). Empty = no companion. He takes a seat but no loot/XP.")
jobs = flag.Int("jobs", 0, "matrix mode — concurrent worker count (each worker is a subprocess so it gets its own sqlite). 0 = runtime.NumCPU()")
seed = flag.Int64("seed", -1, "single-run mode — deterministic seed for zone layout + run id + combat sessions (peripheral procs stay random). <0 = off (default). Matrix mode passes this to each subprocess automatically; use -base-seed there.")
baseSeed = flag.Int64("base-seed", -1, "matrix mode — deterministic base seed. Each cell's subprocess gets seed=mix(base,level,zone,rep) (class-independent, so every class faces identical dungeons + dice). <0 = off (default, time-seeded).")
)
flag.Parse()
// Deterministic seeding for reproducible A/B tuning. Off unless -seed >= 0
// (the matrix parent sets it per-subprocess from -base-seed). Prod never
// calls SeedSim, so this is inert outside the sim.
if *seed >= 0 {
plugin.SeedSim(*seed)
}
if *petLevel < 0 || *petLevel > 10 {
fail("pet-level must be 0-10, got", *petLevel)
}
@@ -89,7 +99,7 @@ func main() {
includeLog = *logFlag
}
})
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses, *companion)
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses, *companion, *baseSeed)
return
}
@@ -200,7 +210,18 @@ type matrixJob struct {
rep int
}
func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool, jobs int, trace bool, petLevel, party int, partyClasses, companion string) {
// mixSeed derives a per-cell subprocess seed from the base seed and the cell's
// (level, zone, rep) — deliberately class-independent, so every class runs the
// identical dungeon + combat dice at a given cell and A/B class deltas pair.
func mixSeed(base int64, level int, zone string, rep int) int64 {
h := uint64(1469598103934665603) // FNV-1a offset basis
for _, c := range fmt.Sprintf("%d|%s|%d", level, zone, rep) {
h = (h ^ uint64(c)) * 1099511628211
}
return int64((uint64(base) ^ h) &^ (uint64(1) << 63)) // non-negative
}
func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool, jobs int, trace bool, petLevel, party int, partyClasses, companion string, baseSeed int64) {
cs := splitNonEmpty(classes)
ls := parseLevels(levels)
zs := splitNonEmpty(zones)
@@ -231,7 +252,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days
var wg sync.WaitGroup
for i := 0; i < jobs; i++ {
wg.Add(1)
go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses, companion)
go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses, companion, baseSeed)
}
go func() {
for _, j := range work {
@@ -250,7 +271,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days
}
}
func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult, wg *sync.WaitGroup, bank float64, cap, days int, includeLog, trace bool, petLevel, party int, partyClasses, companion string) {
func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult, wg *sync.WaitGroup, bank float64, cap, days int, includeLog, trace bool, petLevel, party int, partyClasses, companion string, baseSeed int64) {
defer wg.Done()
for j := range in {
uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep)
@@ -272,6 +293,9 @@ func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult,
fmt.Sprintf("-pet-level=%d", petLevel),
fmt.Sprintf("-party=%d", party),
}
if baseSeed >= 0 {
args = append(args, "-seed", strconv.FormatInt(mixSeed(baseSeed, j.level, j.zone, j.rep), 10))
}
// Left empty, each cell's followers clone that cell's own -class.
if partyClasses != "" {
args = append(args, "-party-classes", partyClasses)

View File

@@ -388,6 +388,33 @@ type PlayerDetail struct {
Equipped []ItemView `json:"equipped,omitempty"`
House HouseView `json:"house"`
Pets []PetView `json:"pets,omitempty"`
// Slots is the 5 standard equipment slots (weapon/armor/helmet/boots/tool) for
// the web management panel. Worn masterwork/arena pieces surface here (via
// CanTakeOff), not in Equipped, which stays magic-only (the DnD slots).
Slots []EquipSlotView `json:"slots,omitempty"`
// Balance is the owner's euro balance, for the web's upgrade/repair confirm.
// No omitempty: a €0 balance is a real, informative fact (a broke player), not
// an absent one — dropping it would let the confirm dialog show a stale amount.
Balance float64 `json:"balance"`
}
// EquipSlotView is one of the 5 standard equipment slots, carrying what the web
// management panel needs: what's worn now, whether it round-trips to the pack
// (masterwork/arena), the next tier's name and price for an upgrade offer, and a
// repair cost when the piece is damaged. Pete renders it and trusts only these
// facts — a client-forged tier or price is resolved back against this view.
type EquipSlotView struct {
Slot string `json:"slot"` // weapon|armor|helmet|boots|tool
Name string `json:"name"`
Tier int `json:"tier"`
Condition int `json:"condition"`
Masterwork bool `json:"masterwork,omitempty"`
ArenaTier int `json:"arena_tier,omitempty"`
CanTakeOff bool `json:"can_take_off,omitempty"` // masterwork/arena → round-trippable
NextTier int `json:"next_tier,omitempty"` // 0 = at max tier (5)
NextName string `json:"next_name,omitempty"`
NextPrice float64 `json:"next_price,omitempty"`
RepairCost int `json:"repair_cost,omitempty"` // 0 = full condition
}
// ItemView is one item in the private panels — backpack, vault, or worn.
@@ -692,7 +719,8 @@ type EquipOrder struct {
ItemID int64 `json:"item_id"`
ItemName string `json:"item_name"`
Slot string `json:"slot"`
Action string `json:"action"`
Action string `json:"action"` // equip / unequip / upgrade / repair
Tier int `json:"tier"` // upgrade target tier (an EquipmentSlot tier); unused by the others
Status string `json:"status"`
CreatedAt int64 `json:"created_at"`
}

View File

@@ -371,7 +371,7 @@ type DeathTransitionResult struct {
func transitionDeath(p DeathTransitionParams) DeathTransitionResult {
var r DeathTransitionResult
if p.AllowPardon && p.ChatLevel >= 20 && p.Char.PardonAvailable() && rand.Float64() < 0.33 {
if p.AllowPardon && p.ChatLevel >= 20 && p.Char.PardonAvailable() && simFloat64() < 0.33 {
r.Pardoned = true
now := time.Now().UTC()
p.Char.LastPardonUsed = &now

View File

@@ -707,6 +707,24 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
PlayerHeal: out.PlayerHeal,
EnemySkip: out.EnemySkip,
}
// Revive the arcane-blaster sustained cantrip floor in the turn engine.
// combat_engine.go:590 deals CantripPerRound flat every round, but that
// path is the swing-based engine (SimulateCombat). Auto-resolve — the
// live path for every expedition — runs the turn engine, where a caster
// autocasts and never weapon-swings, so the floor was dead code: casters
// fought at bare cantrip dice (~4d10≈22 at L20). Lift LANDED cantrip
// damage to the floor, but only when the cast already connected
// (eff.EnemyDamage > 0) — a whiffed Fire Bolt still whiffs, so the ~35%
// miss variance survives and the floor doesn't become a guaranteed flat
// hammer (that overshot to ~99%). Damage cantrips only; slot spells keep
// their rolled damage. Only Mage/Sorcerer/Warlock carry a nonzero
// CantripPerRound, so this is self-targeting.
if spell.Level == 0 && eff.EnemyDamage > 0 &&
(spell.Effect == EffectDamageAttack || spell.Effect == EffectDamageSave || spell.Effect == EffectDamageAuto) {
if floor := ct.players[seat].Mods.CantripPerRound; floor > eff.EnemyDamage {
eff.EnemyDamage = floor
}
}
// §1 — redirect the heal onto the named ally. The roll is the same; only
// the body it lands on changes. This is the line that makes a cleric a
// cleric: until it existed, every heal in the engine was a self-heal, and

View File

@@ -482,6 +482,9 @@ const combatSessionCols = `
// newCombatSessionID — 16-char hex token. Same scheme as zone runs / expeditions.
func newCombatSessionID() string {
if simSeedOn() {
return simHexToken()
}
var b [8]byte
if _, err := cryptorand.Read(b[:]); err != nil {
// Vanishingly unlikely; fall through with a zeroed prefix.

View File

@@ -23,7 +23,6 @@ package plugin
import (
"encoding/json"
"fmt"
"math/rand/v2"
"strings"
"gogobee/internal/flavor"
@@ -75,7 +74,7 @@ func resolveCombatInterrupt(
rollFn func() int,
) (CombatInterruptKind, int) {
if rollFn == nil {
rollFn = func() int { return rand.IntN(20) + 1 }
rollFn = func() int { return simIntN(20) + 1 }
}
r := rollFn()
mod := tier
@@ -249,7 +248,7 @@ func surpriseRoundNickF(m DnDMonsterTemplate, tier, floorOverride int) int {
if tier < 1 {
tier = 1
}
dmg := 1 + rand.IntN(4) + m.AttackBonus/2
dmg := 1 + simIntN(4) + m.AttackBonus/2
floor := tier
if floorOverride >= 0 {
floor = floorOverride
@@ -484,7 +483,7 @@ func (p *AdventurePlugin) tryPatrolEncounter(
return
}
chance := rollPatrolChance(exp.ThreatLevel)
if chance <= 0 || rand.Float64() > chance {
if chance <= 0 || simFloat64() > chance {
return
}
monster, ok := pickZoneEnemy(zone, run.RunID, run.CurrentRoom, false)

View File

@@ -112,6 +112,56 @@ func applyRacePassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharacte
// AutoCritFirst is already a one-shot bool.
// - A Cleric carrying a healing potion stacks: passive 5 + potion 8 = 13.
// The passive heal triggers first since both use the same threshold.
// cantripDice is the 5e at-will cantrip die progression (Fire Bolt / Eldritch
// Blast): 1 die L14, 2 at L5, 3 at L11, 4 at L17. Drives the per-round arcane
// blaster damage (CantripPerRound) that models a caster's sustained at-will
// floor — see CombatModifiers.CantripPerRound.
func cantripDice(level int) int {
switch {
case level >= 17:
return 4
case level >= 11:
return 3
case level >= 5:
return 2
default:
return 1
}
}
// casterBlasterFloor gives the arcane blasters (Mage/Sorcerer/Warlock) their
// shared sustained-DPS floor at T5. Two knobs, both LEVEL-SCALED so the L20
// floor lift stays negligible at low tiers — a flat +40 HP doubled a L1 mage
// and facerolled T5, which the class-balance guardrail (dnd_class_balance_test)
// correctly rejected.
//
// - Cantrip: kill-speed is the T5 currency (fights are truncation-bound), so
// the primary lever is damage. Multiplicative form — the spell modifier
// (INT for Mage, CHA for Sorcerer/Warlock) rides EVERY die, matching 5e
// Agonizing Blast. cantripDice scales 1→4 across levels.
// - Survival: just enough Defense (scaled by level) that the caster lives long
// enough for the cantrip to connect the kill — NOT a tank rider. This adds
// Defense only (no HP add); calcDamage's diminishing returns keep the L20
// +20 Def from becoming a wall.
//
// casterCantripBase / casterDefPerLevel are the caster tuning dials; see the
// rebaseline plan for the sweep that set them. The cantrip floor only becomes a
// live lever once combat_cmd.go bridges CantripPerRound into the turn engine
// (the swing engine, combat_engine.go:590, is not the auto-resolve path). Mage
// and Sorcerer take casterCantripBase; Warlock passes 0 — its bare-dice cantrip
// plus its structural edge already lands it mid-band, so an added floor would
// overshoot the 45 ceiling.
const (
casterCantripBase = 3 // per-die base before the ability modifier rides in
casterDefPerLevel = 1 // ~+20 Def at L20, +1 at L1
)
func casterBlasterFloor(stats *CombatStats, mods *CombatModifiers, level, abilityMod, base int, cantrip string) {
mods.CantripPerRound = cantripDice(level) * (base + clampNonNeg(abilityMod))
mods.CantripDesc = cantrip
stats.Defense += casterDefPerLevel * level
}
func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharacter) {
switch c.Class {
case ClassFighter:
@@ -127,18 +177,23 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
// re-tune in a follow-up if their win curves drift after this.
switch {
case c.Level >= 20:
mods.ExtraAttacks += 3
mods.ExtraAttacks += 2 // rebaseline: ceiling nerf — 3 swings at L20, was 4 (the engine-ceiling faceroll)
case c.Level >= 11:
mods.ExtraAttacks += 2
case c.Level >= 5:
mods.ExtraAttacks += 1
}
stats.AttackBonus -= 2 // rebaseline: sub-swing to-hit trim — 3 swings lands the Fighter in the ~60 band
case ClassRogue:
mods.AutoCritFirst = true
if c.Level >= 5 { // rebaseline: 2nd swing (was 1) fixes the 1-swing action-economy floor at T5
mods.ExtraAttacks += 1
}
stats.AttackBonus -= 3 // rebaseline: to-hit trim so the 2nd swing lands the Rogue in band, not the +75pp nuke
// Phase 2 class-balance: rogue's once-per-fight auto-crit goes stale
// at high tiers (T5 mean trails leaders by ~10pp pre-tune). Add a
// modest steady-DPS rider so post-opener rounds aren't pure attrition.
mods.DamageBonus += 0.05
mods.DamageBonus += -0.10 // rebaseline: fine-trim the 2-swing Rogue down into the ~60 band
// Class-identity audit (2026-05-16) — actual Sneak Attack as Nd6
// per hit, scaling with level per 5e (1d6 L1-2 ... 10d6 L19-20).
// AutoCritFirst + DamageBonus alone left the rogue's defining
@@ -154,6 +209,8 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
mods.SneakAttackDie += sneakDice
case ClassMage:
stats.AttackBonus++
// At-will Fire Bolt + level-scaled survival — the sustained arcane floor.
casterBlasterFloor(stats, mods, c.Level, abilityModifier(c.INT), casterCantripBase, "Fire Bolt")
// Phase 2 class-balance: +1 attack alone left Mage mid-pack on damage
// per round. A modest damage rider lifts weapon hits (DamageBonus does
// not multiply queued SpellPreDamage — that path is its own field).
@@ -190,10 +247,19 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
mods.ExtraAttacks += 1
}
case ClassDruid:
// Wild Resilience — multiplicative, so it stacks cleanly with the
// subclass DamageReduct riders. DamageReduct is initialized to 1.0
// by DerivePlayerStats before passives run.
// Multiplicative, so it stacks cleanly with the subclass DamageReduct
// riders (DamageReduct is initialized to 1.0 by DerivePlayerStats before
// passives run). NOTE the player-defender direction: DamageReduct feeds
// calcDamage as a defense multiplier, so <1 = MORE damage taken. This
// line is therefore a mild survival trim in the same direction as the
// rebaseline *0.2 below — not the damage cut the "Wild Resilience" name
// suggests. Left as-is because the rebaseline sweep is tuned to it.
mods.DamageReduct *= 0.95
// rebaseline: the Druid wins T5 rooms on the survival tiebreak, immune to
// every damage lever. DamageReduct is a defense multiplier (calcDamage) —
// <1 = take MORE damage. Combined with a damage trim to pull it to band.
mods.DamageReduct *= 0.2
mods.DamageBonus += -0.20
// Phase 3 class-balance: druid was the only caster chassis with a
// purely defensive passive, and the off-tier numbers showed it —
// L1/T2 mean 0.04 vs Mage 0.27. Mirror the other caster bursts so
@@ -219,6 +285,7 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
stats.AttackBonus++
mods.DamageBonus += 0.05
mods.FlatDmgStart += c.Level + clampNonNeg(abilityModifier(c.CHA))
mods.DamageReduct *= 0.4 // rebaseline: Bard also wins T5 on the survival tiebreak — take more damage to reach band
case ClassSorcerer:
// Innate Sorcery — pre-combat burst, CHA-scaled like the Sorcerer's
// spellcasting stat. Floors at the flat base for low-CHA builds.
@@ -231,6 +298,9 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
// touching the +0.05 rider that already saturates at high tier.
mods.FlatDmgStart += 5 + c.Level + clampNonNeg(abilityModifier(c.CHA))
mods.DamageBonus += 0.05
stats.AttackBonus++ // rebaseline: Sorcerer lagged the other blasters — match their +1 to-hit
// At-will Fire Bolt + level-scaled survival — sustained arcane floor.
casterBlasterFloor(stats, mods, c.Level, abilityModifier(c.CHA), casterCantripBase, "Fire Bolt")
case ClassWarlock:
// Phase 2 class-balance: bumped from 10% to 12% damage + 1 attack —
// the Warlock chassis read mid-pack at T5 (0.52) pre-tune. Eldritch
@@ -240,6 +310,8 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
mods.DamageBonus += 0.12
stats.AttackBonus++
mods.FlatDmgStart += c.Level + clampNonNeg(abilityModifier(c.CHA))
// At-will Eldritch Blast + level-scaled survival — sustained arcane floor.
casterBlasterFloor(stats, mods, c.Level, abilityModifier(c.CHA), 0, "Eldritch Blast")
case ClassPaladin:
// Class-identity audit (2026-05-16) — Divine Smite as actual
// per-hit radiant bonus + L5 Extra Attack. 5e: smite consumes a
@@ -249,8 +321,9 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
// down so an extra-attack paladin doesn't trivialize every fight.
// Rides DivineStrikePerHit (already in the weapon-hit damage path).
// Previous FlatDmgStart opener felt like Lay on Hands, not Smite.
smite := 3 + c.Level/3
smite := 4 + c.Level/2 // rebaseline: bigger Divine Smite lifts the Paladin from floor into band
mods.DivineStrikePerHit += smite
mods.DamageReduct *= 0.9 // rebaseline: small survival trim between the two integer smite steps for a ~60 landing
if c.Level >= 5 {
mods.ExtraAttacks += 1
}

View File

@@ -2,7 +2,6 @@ package plugin
import (
"fmt"
"math/rand/v2"
"sort"
"strings"
"time"
@@ -117,7 +116,7 @@ func (p *AdventurePlugin) handleDnDShortRest(ctx MessageContext) error {
before := c.HPCurrent
if !hpFull {
conMod := abilityModifier(c.CON)
healDie := 1 + rand.IntN(6) // 1d6
healDie := 1 + simIntN(6) // 1d6
heal := healDie + conMod
if heal < 1 {
heal = 1

View File

@@ -3,7 +3,6 @@ package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"maunium.net/go/mautrix/id"
)
@@ -128,7 +127,7 @@ func applyMageSubclassSpellHooks(c *DnDCharacter, spell SpellDefinition, slotLev
// applySpellDamageAttack — Fire Bolt, Inflict Wounds, Chill Touch, etc.
// Roll d20 + spell attack vs enemy AC; nat 20 doubles dice damage.
func applySpellDamageAttack(spell SpellDefinition, atk int, mods *CombatModifiers, enemy *CombatStats, slot, charLevel int) {
roll := 1 + rand.IntN(20)
roll := 1 + simIntN(20)
isCrit := roll == 20
isFumble := roll == 1
if isFumble || (!isCrit && roll+atk < enemy.AC) {
@@ -158,7 +157,7 @@ func applySpellDamageAttack(spell SpellDefinition, atk int, mods *CombatModifier
// future multi-enemy combat (Phase 11+) but is not consulted here.
func applySpellDamageSave(spell SpellDefinition, dc int, c *DnDCharacter, mods *CombatModifiers, enemy *CombatStats, slot int) {
saveMod := enemySpellSaveMod(enemy)
saveRoll := 1 + rand.IntN(20)
saveRoll := 1 + simIntN(20)
saved := saveRoll+saveMod >= dc
dmg := rollSpellDamageDice(spell, slot, c.Level)
if saved {
@@ -182,7 +181,7 @@ func applySpellDamageAuto(spell SpellDefinition, mods *CombatModifiers, slot, ch
}
total := 0
for i := 0; i < darts; i++ {
total += 1 + rand.IntN(4) + 1
total += 1 + simIntN(4) + 1
}
mods.SpellPreDamage += total
mods.SpellPreDamageDesc = fmt.Sprintf("Magic Missile (%d darts, %d dmg)", darts, total)
@@ -223,7 +222,7 @@ func enemySpellSaveMod(enemy *CombatStats) int {
// double damage (5e: paralyzed creatures auto-crit on melee hits).
func applySpellControl(spell SpellDefinition, dc int, mods *CombatModifiers, enemy *CombatStats, slot int) {
saveMod := enemySpellSaveMod(enemy)
saveRoll := 1 + rand.IntN(20)
saveRoll := 1 + simIntN(20)
if saveRoll+saveMod >= dc {
mods.SpellPreDamageDesc = spell.Name + " — resisted"
return
@@ -382,7 +381,7 @@ func rollTurnSpellHeal(c *DnDCharacter, spell SpellDefinition, slotLevel int) in
if supreme {
heal += faces
} else {
heal += 1 + rand.IntN(faces)
heal += 1 + simIntN(faces)
}
}
heal += abilityModifier(c.WIS)
@@ -418,7 +417,7 @@ func rollSpellDamageDice(spell SpellDefinition, slot, charLevel int) int {
}
total := flat
for i := 0; i < dice; i++ {
total += 1 + rand.IntN(faces)
total += 1 + simIntN(faces)
}
if total < 1 {
total = 1

View File

@@ -224,7 +224,14 @@ func TestApplyClassPassives(t *testing.T) {
wantFlatStart int
wantInitBias float64
}{
{ClassFighter, 0.05, 0, false, 0, 1.0, 0, 0},
// Fighter-ceiling rebaseline (2026-07-16): Fighter picked up a -2 to-hit
// sub-swing trim; Rogue's steady rider flipped +0.05→-0.10 and it took a
// -3 to-hit trim (both offset a new 2nd swing gated at L5, not seen here);
// Druid took a survival trim (DR 0.95→0.19) + -0.20 damage; Bard a DR 0.4
// survival trim; Sorcerer a +1 to-hit to match the blasters; Paladin a
// 0.9 DR survival trim. Caster CantripPerRound / Defense adds are
// not asserted here.
{ClassFighter, 0.05, -2, false, 0, 1.0, 0, 0},
// Phase 2 class-balance rebalance: rogue picked up +5% damage,
// Mage/Bard/Warlock gained a level-scaled FlatDmgStart burst, Sorcerer's
// burst now also scales with level, and Warlock picked up +1 attack.
@@ -233,7 +240,7 @@ func TestApplyClassPassives(t *testing.T) {
// Phase 3 class-balance: Druid picked up a WIS-scaled FlatDmgStart burst
// (lvl 1 + clamp(mod(WIS=0)) = 1), and Sorcerer's burst base went 3→5
// (5 + 1 + clamp(mod(CHA=10)=0) = 6).
{ClassRogue, 0.05, 0, true, 0, 1.0, 0, 0},
{ClassRogue, -0.10, -3, true, 0, 1.0, 0, 0},
{ClassMage, 0.05, 1, false, 0, 1.0, 1, 0},
{ClassCleric, 0, 0, false, 5, 1.0, 0, 0},
// Class-identity audit (2026-05-16): Ranger Hunter's Mark is now
@@ -243,11 +250,11 @@ func TestApplyClassPassives(t *testing.T) {
// FlatDmgStart compensation riders are gone; +1 to-hit stays on
// Ranger as the "read prey tells" half.
{ClassRanger, 0, 1, false, 0, 1.0, 0, 0},
{ClassDruid, 0, 0, false, 0, 0.95, 1, 0},
{ClassBard, 0.05, 1, false, 0, 1.0, 1, 1},
{ClassSorcerer, 0.05, 0, false, 0, 1.0, 6, 0},
{ClassDruid, -0.20, 0, false, 0, 0.95 * 0.2, 1, 0},
{ClassBard, 0.05, 1, false, 0, 0.4, 1, 1},
{ClassSorcerer, 0.05, 1, false, 0, 1.0, 6, 0},
{ClassWarlock, 0.12, 1, false, 0, 1.0, 1, 0},
{ClassPaladin, 0, 0, false, 0, 1.0, 0, 0},
{ClassPaladin, 0, 0, false, 0, 0.9, 0, 0},
}
for _, tc := range cases {
stats := CombatStats{AttackBonus: 5}

View File

@@ -358,7 +358,10 @@ func pickLootEntry(zone map[LootTier][]ZoneLootDrop, tier LootTier, rng *rand.Ra
// while production paths use the package-global generator.
func rngFloat(rng *rand.Rand) float64 {
if rng == nil {
return rand.Float64()
// Auto-resolve rooms pass nil. simFloat64 routes to the seeded combat
// stream when the sim is seeding, else the package global — so prod
// (never seeded) stays byte-identical while sim room combat pairs.
return simFloat64()
}
return rng.Float64()
}
@@ -368,7 +371,7 @@ func rngIntN(rng *rand.Rand, n int) int {
return 0
}
if rng == nil {
return rand.IntN(n)
return simIntN(n)
}
return rng.IntN(n)
}

View File

@@ -175,6 +175,9 @@ func generateRoomSequence(zone ZoneDefinition, rng *rand.Rand) []RoomType {
// newRunID — 16-char hex token. Crypto-random; collision-resistant.
func newRunID() string {
if simSeedOn() {
return simHexToken()
}
var b [8]byte
if _, err := cryptorand.Read(b[:]); err != nil {
// Fall back to math/rand if /dev/urandom is unavailable.
@@ -240,7 +243,11 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand)
}
if rng == nil {
rng = rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), uint64(time.Now().UnixMicro())))
if simSeedOn() {
rng = simZoneRNG()
} else {
rng = rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), uint64(time.Now().UnixMicro())))
}
}
seq := generateRoomSequence(zone, rng)

View File

@@ -318,8 +318,16 @@ func applyClassBaselineStats(c *DnDCharacter) {
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 16, 13, 15, 8, 12, 10
case ClassRogue, ClassRanger:
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 10, 16, 14, 12, 13, 8
case ClassMage, ClassSorcerer:
case ClassMage:
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 8, 14, 13, 16, 12, 10
case ClassSorcerer:
// Sorcerer is a CHA caster (spellcastingMod → CHA), so its 16 goes in
// CHA, not INT. Previously it shared the Mage's INT-heavy array, which
// left the synthetic sorcerer casting at CHA mod 0 — every CHA-scaled
// ability (cantrip, Innate Sorcery, spell DCs) ran crippled and sorc
// trailed the field in every sweep. Prod players always placed 16 in
// CHA; this makes the sim's sorcerer match a real one.
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 8, 14, 13, 10, 12, 16
case ClassCleric, ClassDruid:
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 12, 10, 14, 8, 16, 13
case ClassBard, ClassWarlock:

View File

@@ -138,7 +138,7 @@ func TestDetailSnapshotKeyedByLocalpart(t *testing.T) {
t.Fatalf("saveAdvCharacter: %v", err)
}
snap, err := buildDetailSnapshot(time.Now().UTC())
snap, err := (&AdventurePlugin{}).buildDetailSnapshot(time.Now().UTC())
if err != nil {
t.Fatalf("buildDetailSnapshot: %v", err)
}
@@ -189,7 +189,7 @@ func TestDetailSnapshotIgnoresOptOut(t *testing.T) {
}
// ...but the private detail set keeps them both.
detail, err := buildDetailSnapshot(time.Now().UTC())
detail, err := (&AdventurePlugin{}).buildDetailSnapshot(time.Now().UTC())
if err != nil {
t.Fatalf("buildDetailSnapshot: %v", err)
}
@@ -218,7 +218,7 @@ func TestDetailSnapshotSkipsDeadPlayers(t *testing.T) {
t.Fatalf("kill player: %v", err)
}
snap, err := buildDetailSnapshot(time.Now().UTC())
snap, err := (&AdventurePlugin{}).buildDetailSnapshot(time.Now().UTC())
if err != nil {
t.Fatalf("buildDetailSnapshot: %v", err)
}

View File

@@ -122,6 +122,14 @@ func (p *AdventurePlugin) fulfilEquipOrder(ctx context.Context, order peteclient
// a human note for Pete, or retry=true for a transient fault that should leave the
// order pending. It records nothing and pushes nothing — the caller does both.
func (p *AdventurePlugin) applyEquipOrder(owner id.UserID, order peteclient.EquipOrder) (status, detail string, retry bool) {
// Serialize against the owner's own Matrix-side mutations (!give, !equip, !sell,
// arena, …), all of which hold this same per-user lock. Without it the poll
// goroutine's equip could interleave with a concurrent !give of the very item it
// resolved — the duplication the DM equip confirm takes this lock to prevent.
userMu := p.advUserLock(owner)
userMu.Lock()
defer userMu.Unlock()
switch order.Action {
case "equip":
inv, err := loadAdvInventory(owner)
@@ -142,6 +150,21 @@ func (p *AdventurePlugin) applyEquipOrder(owner id.UserID, order peteclient.Equi
// a different item — this is a clean miss, not a wrong hit.
return "rejected_not_owned", "That item wasn't in your pack anymore.", false
}
// Masterwork/arena pieces equip into a standard slot; everything else takes
// the magic-item path. Type alone routes it — the id resolved the same row.
if it.Type == "MasterworkGear" || it.Type == "ArenaGear" {
out, err := applyMasterworkEquip(owner, it)
if errors.Is(err, errItemNotEquippable) {
return "rejected_not_equippable", "That item can't be worn.", false
}
if errors.Is(err, errEquipDowngrade) {
return "rejected_downgrade", "That isn't an upgrade over what you're wearing.", false
}
if err != nil {
return "", "", true // transient DB fault
}
return "applied", masterworkEquipDetail(out), false
}
out, err := applyMagicEquip(owner, it)
if errors.Is(err, errItemNotEquippable) {
return "rejected_not_equippable", "That item can't be worn.", false
@@ -152,6 +175,19 @@ func (p *AdventurePlugin) applyEquipOrder(owner id.UserID, order peteclient.Equi
return "applied", equipAppliedDetail(out), false
case "unequip":
// A standard slot (weapon/armor/…) takes a masterwork/arena piece off; a DnD
// slot takes a magic item off. The vocabularies are disjoint, so the slot
// string alone tells the two apart.
if isEquipmentSlot(order.Slot) {
out, err := applyMasterworkUnequip(owner, EquipmentSlot(order.Slot))
if errors.Is(err, errSlotEmpty) {
return "rejected_not_worn", "There was nothing to take off there.", false
}
if err != nil {
return "", "", true
}
return "applied", fmt.Sprintf("Took %s off, back in your pack.", out.Name), false
}
out, err := applyMagicUnequip(owner, DnDSlot(order.Slot))
if errors.Is(err, errSlotEmpty) {
return "rejected_not_worn", "That slot was already empty.", false
@@ -165,6 +201,12 @@ func (p *AdventurePlugin) applyEquipOrder(owner id.UserID, order peteclient.Equi
}
return "applied", note, false
case "upgrade":
return p.purchaseEquipmentTier(owner, EquipmentSlot(order.Slot), order.Tier, order.GUID)
case "repair":
return p.repairSlot(owner, EquipmentSlot(order.Slot), order.GUID)
default:
// Pete validates the action before it ever queues an order, so this is a
// contract breach, not a user mistake. Reject permanently rather than spin.

View File

@@ -0,0 +1,340 @@
package plugin
// Ask 7: full equipment management from the web.
//
// The magic-item equip path (magic_items_gameplay.go) only ever touched the DnD
// slots — off_hand, rings, and the like — which are almost always empty. Almost
// everything a player actually wears lives in the OTHER two systems: the 5
// standard EquipmentSlots (weapon/armor/helmet/boots/tool), whose power is the
// slot's integer Tier, and the masterwork/arena pieces that get equipped INTO a
// standard slot. This file is the game-side of managing all of that from Pete:
//
// - applyMasterworkEquip / applyMasterworkUnequip — move a masterwork/arena
// piece between the pack and a standard slot (no money).
// - purchaseEquipmentTier — buy the next standard tier with euros (confirm-gated
// on the web), the headless twin of the shop's advBuyEquipment.
// - repairSlot — mend a slot's condition with euros, the headless twin of the
// blacksmith's executeRepair.
// - buildEquipSlotViews — the owner-only snapshot the web panel renders from.
//
// The two euro-spending mutators run on the retrying poll wire, so every money
// move goes through the idempotent euro variants keyed on the order guid: a
// re-offered order that already debited skips the charge and just re-runs the
// idempotent slot write. That is why neither refunds on a later DB fault — a
// refund keyed on a fresh id, followed by a guid-guarded retry that no longer
// re-debits, would hand the player both the gear and their money back. The casino
// escrow (pete_games.go) settles the same way: idempotent move, then retry.
import (
"errors"
"fmt"
"log/slog"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id"
)
// errEquipDowngrade is a permanent refusal: the incoming piece is no better than
// what is worn. Downgrades are blocked by user decision (equip and upgrade both).
var errEquipDowngrade = errors.New("equip: would be a downgrade")
// mwEquipOutcome is what a masterwork/arena equip did, for the verdict note.
type mwEquipOutcome struct {
Name string
Slot EquipmentSlot
Tier int
Arena bool
SwappedBack string // the special occupant evicted back to the pack, or ""
}
// applyMasterworkEquip wears one masterwork/arena backpack piece into its standard
// slot. Ordering is anti-duplication AND safe under the equip poll's 30s retry:
// remove the incoming row FIRST (restoring it on a save fault), then write the
// slot, and only THEN evict any displaced special occupant back to the pack. The
// eviction comes last, once the slot no longer references the occupant, so it can
// never mint a duplicate; and it is best-effort — a failure there is logged, not
// aborted on, the same tolerance the DM confirm handler (adventure_masterwork.go)
// lives with. Aborting after the slot write would strand a completed equip for a
// retry that re-evicts the occupant on every tick.
func applyMasterworkEquip(uid id.UserID, it AdvItem) (mwEquipOutcome, error) {
if it.Slot == "" || (it.Type != "MasterworkGear" && it.Type != "ArenaGear") {
return mwEquipOutcome{}, errItemNotEquippable
}
equip, err := loadAdvEquipment(uid)
if err != nil {
return mwEquipOutcome{}, err
}
slot := it.Slot
cur := equip[slot]
// Downgrade block: the incoming effective tier must beat the current occupant.
incoming := &AdvEquipment{Tier: it.Tier}
if it.Type == "ArenaGear" {
incoming.ArenaTier = it.Tier
} else {
incoming.Masterwork = true
}
if advEffectiveTier(incoming) <= advEffectiveTier(cur) {
return mwEquipOutcome{}, errEquipDowngrade
}
// Capture the special occupant to evict, if any, BEFORE the slot write below
// mutates cur in place. A plain shop-tier occupant is not an item — it is just
// the slot's tier — so it is overwritten, not evicted, the same as the DM confirm
// handler and the shop. The actual re-pack happens after the slot write (below),
// so it can never duplicate the piece.
var evicted *AdvItem
if cur != nil && (cur.Masterwork || cur.ArenaTier > 0) {
old := AdvItem{Name: cur.Name, Type: "MasterworkGear", Tier: cur.Tier, Slot: slot, SkillSource: cur.SkillSource}
if cur.ArenaTier > 0 {
old.Type = "ArenaGear"
}
evicted = &old
}
// Destructive op first: pull the incoming row before writing the slot, so a save
// fault can't leave it both worn and in the pack. Restore it on failure.
if err := removeAdvInventoryItem(it.ID); err != nil {
return mwEquipOutcome{}, err
}
eq := cur
if eq == nil {
eq = &AdvEquipment{Slot: slot}
}
eq.Tier = it.Tier
eq.Condition = 100
eq.Name = it.Name
eq.ActionsUsed = 0
if it.Type == "ArenaGear" {
eq.Masterwork = false
eq.SkillSource = ""
eq.ArenaTier = it.Tier
eq.ArenaSet = ""
if gs := arenaGearByName(it.Name); gs != nil {
eq.ArenaSet = gs.SetKey
}
} else {
eq.ArenaTier = 0
eq.ArenaSet = ""
eq.Masterwork = true
eq.SkillSource = it.SkillSource
}
if err := saveAdvEquipment(uid, eq); err != nil {
restored := AdvItem{Name: it.Name, Type: it.Type, Tier: it.Tier, Value: it.Value, Slot: it.Slot, SkillSource: it.SkillSource}
if rbErr := addAdvInventoryItem(uid, restored); rbErr != nil {
slog.Error("equip: masterwork save failed AND inventory rollback failed",
"user", uid, "item", it.Name, "save_err", err, "rollback_err", rbErr)
}
return mwEquipOutcome{}, err
}
// The slot now holds the incoming piece, so the former occupant is referenced
// nowhere — re-packing it now cannot duplicate it. Best-effort: a failure is a
// bounded, non-compounding loss we log rather than abort on, since the equip has
// already succeeded and aborting would re-run (and re-evict) on the next poll.
var swappedBack string
if evicted != nil {
if err := addAdvInventoryItem(uid, *evicted); err != nil {
slog.Error("equip: masterwork equipped but evicted piece failed to return to pack",
"user", uid, "evicted", evicted.Name, "err", err)
} else {
swappedBack = evicted.Name
}
}
return mwEquipOutcome{Name: it.Name, Slot: slot, Tier: it.Tier, Arena: it.Type == "ArenaGear", SwappedBack: swappedBack}, nil
}
// mwUnequipOutcome is what a masterwork/arena take-off did.
type mwUnequipOutcome struct {
Name string
Slot EquipmentSlot
}
// applyMasterworkUnequip takes a worn masterwork/arena piece off a standard slot,
// returns it to the pack, and resets the slot to its tier-0 default. A plain
// shop-tier slot has nothing round-trippable (its tier is not an item), so that is
// errSlotEmpty — reverting a shop tier is not a take-off. The 5 slot rows are an
// invariant (PK user_id+slot), so the row is reset, never deleted. Destructive op
// first — reset the slot, then mint the pack row, restoring the slot on failure —
// mirroring the magic unequip so a fault can't duplicate the piece.
func applyMasterworkUnequip(uid id.UserID, slot EquipmentSlot) (mwUnequipOutcome, error) {
equip, err := loadAdvEquipment(uid)
if err != nil {
return mwUnequipOutcome{}, err
}
cur := equip[slot]
if cur == nil || (!cur.Masterwork && cur.ArenaTier == 0) {
return mwUnequipOutcome{}, errSlotEmpty
}
prev := *cur // snapshot for rollback
def0 := equipmentTiers[slot][0]
reset := &AdvEquipment{Slot: slot, Tier: 0, Condition: 100, Name: def0.Name, ActionsUsed: 0, ArenaTier: 0, ArenaSet: "", Masterwork: false, SkillSource: ""}
if err := saveAdvEquipment(uid, reset); err != nil {
return mwUnequipOutcome{}, err
}
old := AdvItem{Name: cur.Name, Type: "MasterworkGear", Tier: cur.Tier, Slot: slot, SkillSource: cur.SkillSource}
if cur.ArenaTier > 0 {
old.Type = "ArenaGear"
}
if err := addAdvInventoryItem(uid, old); err != nil {
if rbErr := saveAdvEquipment(uid, &prev); rbErr != nil {
slog.Error("equip: masterwork take-off failed AND slot rollback failed",
"user", uid, "slot", slot, "add_err", err, "rollback_err", rbErr)
}
return mwUnequipOutcome{}, err
}
return mwUnequipOutcome{Name: cur.Name, Slot: slot}, nil
}
// purchaseEquipmentTier buys a standard slot's tier with euros — the headless twin
// of advBuyEquipment, minus flavor. It returns a terminal verdict for Pete or
// retry=true for a transient fault. Money moves once, keyed on the order guid; the
// web only ever offers the next tier over a PLAIN shop-tier slot (buildEquipSlotViews
// suppresses the offer on special gear), so there is no occupant to evict here and
// the whole body is idempotent under a re-offered order.
func (p *AdventurePlugin) purchaseEquipmentTier(uid id.UserID, slot EquipmentSlot, tier int, guid string) (status, detail string, retry bool) {
defs, ok := equipmentTiers[slot]
if !ok {
return "rejected_not_equippable", "That isn't an equipment slot.", false
}
if tier < 1 || tier >= len(defs) {
// tier 0 is the free default, not a purchase; >= len is past the top tier.
return "rejected_max_tier", "That slot is already at the top tier.", false
}
def := defs[tier]
equip, err := loadAdvEquipment(uid)
if err != nil {
return "", "", true
}
cur := equip[slot]
if cur != nil {
// Buying a shop tier over a special piece strips its bonus — a downgrade in
// practice even when the raw number rises. Take it off first, then buy.
if cur.Masterwork || cur.ArenaTier > 0 {
return "rejected_downgrade", "Take off your special gear in that slot before buying a tier.", false
}
if cur.Tier >= def.Tier {
return "rejected_downgrade", "You already have that tier or better.", false
}
}
price := def.Price
if !p.euro.HasExternalTx(guid) {
ok, _, err := p.euro.DebitIdem(uid, price, "adventure_equip_upgrade", guid)
if err != nil {
return "", "", true
}
if !ok {
return "rejected_insufficient_funds", fmt.Sprintf("That upgrade costs €%.0f and you can't cover it.", price), false
}
}
eq := &AdvEquipment{Slot: slot, Tier: def.Tier, Condition: 100, Name: def.Name, ActionsUsed: 0}
if err := saveAdvEquipment(uid, eq); err != nil {
// No refund: the debit is guid-idempotent, so the next poll re-runs this with
// the charge already settled and only the (idempotent) slot write left to do.
// Refunding here would double-pay once that retry lands the gear.
return "", "", true
}
return "applied", fmt.Sprintf("Upgraded your %s to %s (T%d) for €%.0f.", slot, def.Name, def.Tier, price), false
}
// repairSlot mends one standard slot's condition with euros — the headless twin of
// the blacksmith's executeRepair. Idempotent on the order guid: the debit runs
// once, and setting condition to 100 is itself idempotent, so a re-offered order is
// safe with no refund.
func (p *AdventurePlugin) repairSlot(uid id.UserID, slot EquipmentSlot, guid string) (status, detail string, retry bool) {
equip, err := loadAdvEquipment(uid)
if err != nil {
return "", "", true
}
eq := equip[slot]
if eq == nil {
return "rejected_not_worn", "There's nothing in that slot to repair.", false
}
cost := blacksmithRepairCost(eq)
if cost <= 0 {
// Already full — nothing to charge for. Report it as applied so the order
// reaches a terminal state rather than parking.
return "applied", "That piece was already at full condition.", false
}
if !p.euro.HasExternalTx(guid) {
ok, _, err := p.euro.DebitIdem(uid, float64(cost), "adventure_repair", guid)
if err != nil {
return "", "", true
}
if !ok {
return "rejected_insufficient_funds", fmt.Sprintf("The repair costs €%d and you can't cover it.", cost), false
}
}
eq.Condition = 100
if err := saveAdvEquipment(uid, eq); err != nil {
return "", "", true // retry; the idempotent debit means no double-charge
}
return "applied", fmt.Sprintf("Repaired your %s for €%d.", eq.Name, cost), false
}
// buildEquipSlotViews is the owner-only snapshot of the 5 standard slots the web
// management panel renders from. Worn masterwork/arena pieces surface here (via
// CanTakeOff), not in the magic Equipped set. An upgrade is offered only over a
// plain shop-tier slot below max — a special piece is taken off, not shop-upgraded.
func buildEquipSlotViews(uid id.UserID) []peteclient.EquipSlotView {
equip, err := loadAdvEquipment(uid)
if err != nil {
return nil
}
var out []peteclient.EquipSlotView
for _, slot := range allSlots {
eq := equip[slot]
if eq == nil {
continue
}
v := peteclient.EquipSlotView{
Slot: string(slot),
Name: eq.Name,
Tier: eq.Tier,
Condition: eq.Condition,
Masterwork: eq.Masterwork,
ArenaTier: eq.ArenaTier,
CanTakeOff: eq.Masterwork || eq.ArenaTier > 0,
RepairCost: blacksmithRepairCost(eq),
}
if !eq.Masterwork && eq.ArenaTier == 0 && eq.Tier < 5 {
next := equipmentTiers[slot][eq.Tier+1]
v.NextTier = next.Tier
v.NextName = next.Name
v.NextPrice = next.Price
}
out = append(out, v)
}
return out
}
// masterworkEquipDetail turns a masterwork/arena equip outcome into the verdict
// note Pete shows.
func masterworkEquipDetail(out mwEquipOutcome) string {
kind := "masterwork"
if out.Arena {
kind = "arena"
}
b := fmt.Sprintf("Now worn in your %s slot (%s T%d).", out.Slot, kind, out.Tier)
if out.SwappedBack != "" {
b += fmt.Sprintf(" %s went back to your pack.", out.SwappedBack)
}
return b
}
// isEquipmentSlot reports whether a slot string names one of the 5 standard slots.
// The magic DnD slots and the standard slots are disjoint vocabularies, so this
// alone routes an unequip to the right path.
func isEquipmentSlot(slot string) bool {
for _, s := range allSlots {
if string(s) == slot {
return true
}
}
return false
}

View File

@@ -0,0 +1,314 @@
package plugin
import (
"testing"
"maunium.net/go/mautrix/id"
)
// Ask 7: the headless equipment mutators the web equip queue drives. These pin the
// rules that cross the wire — downgrade block, max tier, insufficient funds,
// idempotent replay (one debit), masterwork evict/overwrite/take-off — at the
// gogobee end, on real rows.
// seedEquipPlayer stands up a playable adventurer (player_meta + tier-0 gear) with
// a euro plugin funded to `bankroll`, and returns the wired AdventurePlugin.
func seedEquipPlayer(t *testing.T, uid id.UserID, bankroll float64) *AdventurePlugin {
t.Helper()
if err := createAdvCharacter(uid, "Rurina"); err != nil {
t.Fatalf("createAdvCharacter: %v", err)
}
euro := &EuroPlugin{}
euro.ensureBalance(uid)
if bankroll > 0 {
euro.Credit(uid, bankroll, "test bankroll")
}
return &AdventurePlugin{euro: euro}
}
func slotOf(t *testing.T, uid id.UserID, slot EquipmentSlot) *AdvEquipment {
t.Helper()
equip, err := loadAdvEquipment(uid)
if err != nil {
t.Fatalf("loadAdvEquipment: %v", err)
}
return equip[slot]
}
// TestPurchaseEquipmentTierHappyAndIdempotentDebit: buying the next tier debits
// once and raises the slot, and the euro move is keyed on the order guid so a
// re-offer moves no money. (A re-offer never re-enters this function in prod — the
// equip_applied_orders ledger short-circuits it — but the guid is the belt to that
// suspenders, and the retry-after-save-fault path below leans on it directly.)
func TestPurchaseEquipmentTierHappyAndIdempotentDebit(t *testing.T) {
newMischiefTestDB(t)
uid := id.UserID("@rurina:test")
p := seedEquipPlayer(t, uid, 100000)
before := p.euro.GetBalance(uid)
price := equipmentTiers[SlotBoots][1].Price // Dead Man's Boots, €75
status, _, retry := p.purchaseEquipmentTier(uid, SlotBoots, 1, "guid-up-1")
if retry || status != "applied" {
t.Fatalf("upgrade = %q retry=%v, want applied", status, retry)
}
if got := slotOf(t, uid, SlotBoots); got.Tier != 1 || got.Name != equipmentTiers[SlotBoots][1].Name {
t.Fatalf("boots slot = %+v, want tier 1", got)
}
if got := p.euro.GetBalance(uid); got != before-price {
t.Fatalf("balance = %.2f, want %.2f (one debit of %.2f)", got, before-price, price)
}
// The guid is now a settled money move: a replayed debit on it is a no-op.
if !p.euro.HasExternalTx("guid-up-1") {
t.Fatal("the upgrade debit was not logged under the order guid")
}
if ok, _, err := p.euro.DebitIdem(uid, price, "adventure_equip_upgrade", "guid-up-1"); err != nil || !ok {
t.Fatalf("replayed debit = ok:%v err:%v, want a no-op ok", ok, err)
}
if got := p.euro.GetBalance(uid); got != before-price {
t.Fatalf("replayed debit double-charged: balance = %.2f, want %.2f", got, before-price)
}
// The retry-after-save-fault path: a prior attempt debited but its slot write
// never landed, so the slot is still tier 0. The retry must skip the debit
// (guid already settled) and finish the write, moving no further money.
helmetGUID := "guid-helm"
hprice := equipmentTiers[SlotHelmet][1].Price
if ok, _, err := p.euro.DebitIdem(uid, hprice, "adventure_equip_upgrade", helmetGUID); err != nil || !ok {
t.Fatalf("seed prior debit = ok:%v err:%v", ok, err)
}
mid := p.euro.GetBalance(uid)
status, _, retry = p.purchaseEquipmentTier(uid, SlotHelmet, 1, helmetGUID)
if retry || status != "applied" {
t.Fatalf("retry after fault = %q retry=%v, want applied", status, retry)
}
if got := slotOf(t, uid, SlotHelmet); got.Tier != 1 {
t.Fatalf("helmet not upgraded on retry: tier %d", got.Tier)
}
if got := p.euro.GetBalance(uid); got != mid {
t.Fatalf("retry re-debited: balance %.2f → %.2f", mid, got)
}
}
// TestPurchaseEquipmentTierRejections: a downgrade, a special-gear slot, the top
// tier, and an empty wallet all bounce without moving the slot or the money.
func TestPurchaseEquipmentTierRejections(t *testing.T) {
newMischiefTestDB(t)
uid := id.UserID("@rurina:test")
p := seedEquipPlayer(t, uid, 100000)
// Lift boots to tier 3 so we have something to (not) downgrade from.
if s, _, _ := p.purchaseEquipmentTier(uid, SlotBoots, 3, "seed-t3"); s != "applied" {
t.Fatalf("seed to tier 3 = %q", s)
}
// Same tier or lower is a downgrade.
if s, _, _ := p.purchaseEquipmentTier(uid, SlotBoots, 3, "g-eq"); s != "rejected_downgrade" {
t.Errorf("re-buy same tier = %q, want rejected_downgrade", s)
}
if s, _, _ := p.purchaseEquipmentTier(uid, SlotBoots, 2, "g-down"); s != "rejected_downgrade" {
t.Errorf("buy lower tier = %q, want rejected_downgrade", s)
}
// Past the top tier.
if s, _, _ := p.purchaseEquipmentTier(uid, SlotBoots, 6, "g-max"); s != "rejected_max_tier" {
t.Errorf("buy tier 6 = %q, want rejected_max_tier", s)
}
// A special piece in the slot: buying a plain tier over it strips the bonus.
weapon := slotOf(t, uid, SlotWeapon)
weapon.Masterwork = true
weapon.Tier = 2
weapon.Name = "Miner's Masterwork Blade"
weapon.SkillSource = "mining"
if err := saveAdvEquipment(uid, weapon); err != nil {
t.Fatal(err)
}
if s, _, _ := p.purchaseEquipmentTier(uid, SlotWeapon, 3, "g-special"); s != "rejected_downgrade" {
t.Errorf("buy plain over masterwork = %q, want rejected_downgrade", s)
}
// Insufficient funds: a broke player can't buy a €30000 tier-5 weapon.
broke := id.UserID("@broke:test")
pb := seedEquipPlayer(t, broke, 0)
bal := pb.euro.GetBalance(broke)
if s, _, _ := pb.purchaseEquipmentTier(broke, SlotWeapon, 5, "g-broke"); s != "rejected_insufficient_funds" {
t.Errorf("broke upgrade = %q, want rejected_insufficient_funds", s)
}
if got := pb.euro.GetBalance(broke); got != bal {
t.Errorf("a rejected upgrade moved money: %.2f → %.2f", bal, got)
}
if got := slotOf(t, broke, SlotWeapon); got.Tier != 0 {
t.Errorf("a rejected upgrade changed the slot: tier %d", got.Tier)
}
}
// TestRepairSlotHappyAndNoop: repairing a damaged slot debits the blacksmith cost
// and restores condition; repairing a full slot is a no-op that charges nothing.
func TestRepairSlotHappyAndNoop(t *testing.T) {
newMischiefTestDB(t)
uid := id.UserID("@rurina:test")
p := seedEquipPlayer(t, uid, 100000)
weapon := slotOf(t, uid, SlotWeapon)
weapon.Condition = 50
if err := saveAdvEquipment(uid, weapon); err != nil {
t.Fatal(err)
}
cost := blacksmithRepairCost(weapon)
if cost <= 0 {
t.Fatalf("expected a positive repair cost, got %d", cost)
}
before := p.euro.GetBalance(uid)
status, _, retry := p.repairSlot(uid, SlotWeapon, "guid-rep-1")
if retry || status != "applied" {
t.Fatalf("repair = %q retry=%v, want applied", status, retry)
}
if got := slotOf(t, uid, SlotWeapon); got.Condition != 100 {
t.Fatalf("condition = %d, want 100", got.Condition)
}
if got := p.euro.GetBalance(uid); got != before-float64(cost) {
t.Fatalf("balance = %.2f, want %.2f (debit %d)", got, before-float64(cost), cost)
}
// Now at full condition: a fresh repair is an applied no-op, no charge.
after := p.euro.GetBalance(uid)
status, _, _ = p.repairSlot(uid, SlotWeapon, "guid-rep-2")
if status != "applied" {
t.Fatalf("no-op repair = %q, want applied", status)
}
if got := p.euro.GetBalance(uid); got != after {
t.Errorf("no-op repair charged: %.2f → %.2f", after, got)
}
}
// TestMasterworkEquipEvictsOverwritesAndTakesOff: a masterwork piece equips into a
// plain slot (overwriting the tier), a better one evicts the special occupant back
// to the pack, a worse one is blocked, and take-off resets the slot to tier 0.
func TestMasterworkEquipEvictsOverwritesAndTakesOff(t *testing.T) {
newMischiefTestDB(t)
uid := id.UserID("@rurina:test")
seedEquipPlayer(t, uid, 0)
mw := func(name string, tier int) AdvItem {
if err := addAdvInventoryItem(uid, AdvItem{Name: name, Type: "MasterworkGear", Tier: tier, Slot: SlotWeapon, SkillSource: "mining"}); err != nil {
t.Fatal(err)
}
inv, _ := loadAdvInventory(uid)
for _, it := range inv {
if it.Name == name {
return it
}
}
t.Fatalf("just-added item %q not in inventory", name)
return AdvItem{}
}
// Equip a T3 masterwork over the tier-0 plain weapon: it overwrites, no eviction.
out, err := applyMasterworkEquip(uid, mw("Deepforged Blade", 3))
if err != nil {
t.Fatalf("equip T3: %v", err)
}
if out.SwappedBack != "" {
t.Errorf("plain occupant should be overwritten, not evicted; got swap %q", out.SwappedBack)
}
if got := slotOf(t, uid, SlotWeapon); !got.Masterwork || got.Tier != 3 || got.Name != "Deepforged Blade" {
t.Fatalf("weapon = %+v, want masterwork T3 Deepforged Blade", got)
}
// A better masterwork (T4) evicts the T3 back to the pack.
out, err = applyMasterworkEquip(uid, mw("Sunforged Blade", 4))
if err != nil {
t.Fatalf("equip T4: %v", err)
}
if out.SwappedBack != "Deepforged Blade" {
t.Errorf("evicted = %q, want Deepforged Blade", out.SwappedBack)
}
invHas := func(name string) bool {
inv, _ := loadAdvInventory(uid)
for _, it := range inv {
if it.Name == name {
return true
}
}
return false
}
if !invHas("Deepforged Blade") {
t.Error("the evicted T3 masterwork is not back in the pack")
}
// A worse masterwork (T2) is a blocked downgrade.
if _, err := applyMasterworkEquip(uid, mw("Rusty Masterwork", 2)); err != errEquipDowngrade {
t.Errorf("equip worse masterwork err = %v, want errEquipDowngrade", err)
}
// Take off the worn T4: it returns to the pack and the slot resets to tier 0.
un, err := applyMasterworkUnequip(uid, SlotWeapon)
if err != nil {
t.Fatalf("take off: %v", err)
}
if un.Name != "Sunforged Blade" {
t.Errorf("took off %q, want Sunforged Blade", un.Name)
}
if got := slotOf(t, uid, SlotWeapon); got.Masterwork || got.Tier != 0 || got.Name != equipmentTiers[SlotWeapon][0].Name {
t.Fatalf("weapon after take-off = %+v, want tier-0 default", got)
}
if !invHas("Sunforged Blade") {
t.Error("the taken-off masterwork is not back in the pack")
}
// Taking off a plain slot has nothing round-trippable.
if _, err := applyMasterworkUnequip(uid, SlotArmor); err != errSlotEmpty {
t.Errorf("take off plain slot err = %v, want errSlotEmpty", err)
}
}
// TestBuildEquipSlotViews: the panel snapshot offers an upgrade only over a plain
// sub-max slot, take-off only on special gear, and a repair cost only when damaged.
func TestBuildEquipSlotViews(t *testing.T) {
newMischiefTestDB(t)
uid := id.UserID("@rurina:test")
seedEquipPlayer(t, uid, 0)
// Boots: masterwork T3, damaged → take off + repair, no upgrade offer.
boots := slotOf(t, uid, SlotBoots)
boots.Masterwork = true
boots.Tier = 3
boots.Name = "The Wandering Sole"
boots.Condition = 70
if err := saveAdvEquipment(uid, boots); err != nil {
t.Fatal(err)
}
// Helmet: plain T2 → upgrade to T3 offered, no take off, no repair.
helmet := slotOf(t, uid, SlotHelmet)
helmet.Tier = 2
helmet.Name = equipmentTiers[SlotHelmet][2].Name
if err := saveAdvEquipment(uid, helmet); err != nil {
t.Fatal(err)
}
views := buildEquipSlotViews(uid)
bySlot := map[string]struct {
takeOff bool
nextTier int
repair int
}{}
for _, v := range views {
bySlot[v.Slot] = struct {
takeOff bool
nextTier int
repair int
}{v.CanTakeOff, v.NextTier, v.RepairCost}
}
if len(views) != len(allSlots) {
t.Fatalf("got %d slot views, want %d", len(views), len(allSlots))
}
if b := bySlot["boots"]; !b.takeOff || b.nextTier != 0 || b.repair <= 0 {
t.Errorf("boots view = %+v, want take-off, no upgrade, positive repair", b)
}
if h := bySlot["helmet"]; h.takeOff || h.nextTier != 3 || h.repair != 0 {
t.Errorf("helmet view = %+v, want upgrade to T3, no take-off, no repair", h)
}
// A pristine tier-0 slot: upgrade offered to T1, no take-off, no repair.
if w := bySlot["weapon"]; w.takeOff || w.nextTier != 1 || w.repair != 0 {
t.Errorf("weapon view = %+v, want upgrade to T1", w)
}
}

View File

@@ -97,7 +97,7 @@ func (p *AdventurePlugin) pushRoster() {
var detailPushOK bool
func (p *AdventurePlugin) pushDetails() {
snap, err := buildDetailSnapshot(time.Now().UTC())
snap, err := p.buildDetailSnapshot(time.Now().UTC())
if err != nil {
slog.Error("roster: build detail snapshot failed", "err", err)
return
@@ -158,7 +158,7 @@ func rosterDetail(uid id.UserID, c *DnDCharacter) *peteclient.RosterDetail {
// owner it belongs to, so hiding a player from it would only deny them their own
// sheet. The board token rides along so Pete can match owner↔page without ever
// reversing the one-way token.
func buildDetailSnapshot(now time.Time) (peteclient.DetailSnapshot, error) {
func (p *AdventurePlugin) buildDetailSnapshot(now time.Time) (peteclient.DetailSnapshot, error) {
snap := peteclient.DetailSnapshot{SnapshotAt: now.Unix()}
rows, err := db.Get().Query(`SELECT user_id FROM player_meta WHERE alive = 1`)
if err != nil {
@@ -208,6 +208,13 @@ func buildDetailSnapshot(now time.Time) (peteclient.DetailSnapshot, error) {
pd.Vault = itemViews(items)
}
pd.Equipped = equippedViews(uid)
// Ask 7: the 5 standard slots for the web management panel, plus the euro
// balance the upgrade/repair confirm dialogs show. Balance is nil-guarded so
// the free-standing tests (which build no euro plugin) still run.
pd.Slots = buildEquipSlotViews(uid)
if p.euro != nil {
pd.Balance = p.euro.GetBalance(uid)
}
snap.Players = append(snap.Players, pd)
}
return snap, nil
@@ -256,6 +263,12 @@ func itemViews(items []AdvItem) []peteclient.ItemView {
} else if it.Slot != "" {
// Shop equipment resolves by (slot, tier) — Name is decorative.
v.Desc = equipmentDefByTier(it.Slot, it.Tier).Description
// A masterwork/arena piece carries a real slot, so it can be worn from the
// web (into a standard slot) — give it the equip handle. Plain shop gear in
// the pack stays button-less: its slot is just a category, not a wearable.
if it.Type == "MasterworkGear" || it.Type == "ArenaGear" {
v.ID = it.ID
}
}
out = append(out, v)
}

115
internal/plugin/sim_seed.go Normal file
View File

@@ -0,0 +1,115 @@
package plugin
import (
"encoding/hex"
"math/rand/v2"
"sync"
"sync/atomic"
)
// Deterministic sim seeding — OFF by default, so the production binary is
// byte-identical in behaviour. The expedition-sim harness calls SeedSim once at
// subprocess startup to make a run reproducible: the three nondeterminism seams
// that dominate outcome variance — the zone-layout RNG, the run id (traps hash
// off it), and each combat SessionID (the whole turn engine hashes from it) —
// all derive from a single base seed via a process-local counter.
//
// This does NOT touch the peripheral top-level math/rand/v2 procs (pardon rolls,
// minor damage jitter, ambient events); those stay random. Seeding the three
// dominant seams collapses the batch-to-batch drift (identical dungeons + combat
// dice across arms) so an A/B passive change reads at ~0 noise. If a residual
// proc ever proves load-bearing, seed it too — but validate empirically first.
//
// Ordering contract: within one subprocess an expedition is resolved on a single
// goroutine, so nextSimSeed() is drawn in a deterministic order (zone rng, run
// id, then one per combat session in creation order). Two arms sharing a base
// seed draw identical values up to the point a passive change diverges them —
// and SessionIDs are assigned at session *creation*, before a fight resolves, so
// the Nth combat pairs regardless of how the fight plays out.
var (
simSeedActive atomic.Bool
simSeedBase uint64
simSeedCtr atomic.Uint64
)
// simCombatRand is an INDEPENDENT seeded stream for the outcome-decisive
// in-combat rolls that the three dominant seams don't cover: the spell
// attack/save/damage d20s (dnd_spell_combat.go), the 33% pardon death-cheat
// (combat_bridge.go), and the short-rest heal die (dnd_rest.go). These fire
// disproportionately in caster / borderline-boss runs — exactly the population
// being certified — and leaving them on the global generator was the main
// source of the per-cell residual (the fighter+ranger repro never exercised
// them, so it read ~0 noise while bard swung 8pp on identical code).
//
// It is a SEPARATE stream from nextSimSeed()'s counter so it never perturbs the
// zone-layout / run-id / SessionID draw order the martial repro validated.
// Within a subprocess an expedition runs on one goroutine; the mutex is belt-
// and-braces so a stray concurrent draw can't race, not a correctness crutch.
var (
simCombatMu sync.Mutex
simCombatRand *rand.Rand
)
// SeedSim activates deterministic seeding for this process. A negative seed
// disables it (the default). Call once at startup, before any expedition runs —
// it is not safe to toggle while a run is in flight.
func SeedSim(seed int64) {
if seed < 0 {
simSeedActive.Store(false)
simCombatRand = nil
return
}
simSeedBase = uint64(seed)
simSeedCtr.Store(0)
// 0x5EED5 gives the peripheral-combat stream a distinct sub-stream from the
// seam counter so the two never correlate or share draws.
simCombatRand = rand.New(rand.NewPCG(uint64(seed), 0x5EED5))
simSeedActive.Store(true)
}
func simSeedOn() bool { return simSeedActive.Load() }
// nextSimSeed returns the next counter-mixed seed. Golden-ratio odd multiplier
// decorrelates successive draws.
func nextSimSeed() uint64 {
n := simSeedCtr.Add(1)
return simSeedBase ^ (n * 0x9E3779B97F4A7C15)
}
// simHexToken renders one seeded draw as the same 16-char hex shape the
// crypto-random id helpers produce.
func simHexToken() string {
v := nextSimSeed()
var b [8]byte
for i := range b {
b[i] = byte(v >> (8 * i))
}
return hex.EncodeToString(b[:])
}
// simZoneRNG returns a deterministic generator for one zone layout.
func simZoneRNG() *rand.Rand {
return rand.New(rand.NewPCG(nextSimSeed(), 0xC0FFEE))
}
// simIntN / simFloat64 draw an outcome-decisive in-combat roll from the seeded
// peripheral stream when seeding is active; otherwise they fall through to the
// global generator so the production binary stays byte-identical (prod never
// calls SeedSim, so simSeedOn() is always false there).
func simIntN(n int) int {
if !simSeedOn() {
return rand.IntN(n)
}
simCombatMu.Lock()
defer simCombatMu.Unlock()
return simCombatRand.IntN(n)
}
func simFloat64() float64 {
if !simSeedOn() {
return rand.Float64()
}
simCombatMu.Lock()
defer simCombatMu.Unlock()
return simCombatRand.Float64()
}