Compare commits

...
9 Commits
Author SHA1 Message Date
prosolis 2ce3e682ea zones: stop the tools/backtrack path losing rooms and inventory
Review fallout from the locked-doors commit. Five defects, all in the
seams that commit opened:

backtrackFromDeadFork stepped to VisitedNodes[idx-1]. That slice is a
first-entry ordered *set*, not a path stack (see appendVisited), so once
a run has doubled back once the entry before CurrentNode can sit on a
different branch entirely — the party teleports across the map to a room
no edge connects. `!revisit` refuses exactly that move via
adjacentNodes; the autopilot has no business doing what the player is
forbidden from doing. Now routed through backtrackTarget, which also
refuses to fall back into a corridor whose only exit is the sealed fork:
the lock rolls are seeded per (run, edge), so walking back in gets the
same answer every time, forever.

The autopilot spent the player's thieves' tools *before* committing the
move, so an advanceZoneRunNode failure left them charged and then had
the caller's backtrack clear the fork they had just paid to open. The
tools are now reported by autoPickWithTools and only removed once the
party is actually through the door.

`sell all` never learned the new "tool" type and turned a €600 set into
€300 of loot — the same silent deletion Robbie was taught to avoid two
commits ago. It was already doing this to "key" quest tokens, so both
now sit with the special gear.

The shop's tools branch asked "is the reply a substring of Thieves'
Tools", which is true for a bare "s", for "to", and for an empty message
body — and it runs ahead of the consumable list, so a stray keystroke in
the Supplies view bought a set. isThievesToolsReply matches on the
item's own words instead.

Plus two cleanups in the same code: Robbie built his haul gifts by
calling consumableCache(tier, 1) in a loop when it already takes a
count, and the unlock flow read the whole inventory three times per
command.
2026-07-21 00:20:24 -07:00
prosolis 6bcac41aa2 zones: make locked doors openable and stop autopilot stalling on them
Locks were fully implemented as pass/fail gates and nothing else. A
Perception or stat check rolls once per (run, edge), seeded so it can't
be reload-scummed — that half shipped in G5, the counterweight never did.
A bad roll simply deleted a branch of the graph for the rest of the run,
worst for a solo low-WIS character who quietly loses routes they never
learn existed. Three changes, one theme: a die roll should not be able to
permanently wall a player.

Party's best stat answers the check. evaluateEdgeLock read only the
acting character's mods, which made a party's rogue and its hired scout
decorative at every lock. Fold the whole roster — Pete included, since
excluding him would make hiring a scout worth less than the coins it
costs — and credit whoever got it open in the fork menu.

Thieves' tools as the escape hatch. A utility item (not a ConsumableDef,
or the fight engine would spend them for you) sold on Luigi's supplies
shelf, consumed by `!zone unlock <n>`. Deliberately not a skeleton key:
tools answer the two dice-driven locks only. A key lock is a quest token,
a level-min lock is progression, a region-clear lock is structure — none
of those are "you rolled badly", so none of them are pickable.

Autopilot picks a route instead of parking. The fork timeout was 8h,
which reads as "the player gets first say" and behaves as "the expedition
stops for a third of a day, at every fork" — a multi-day expedition
crosses a lot of forks. 30m keeps a genuine first say for anyone at the
keyboard. It now ranks by unvisited-then-edge-weight rather than taking
whatever the graph author happened to list first, spends tools when every
route is locked, and backtracks a room when it can't do even that, rather
than idling into the 24h reaper and losing the player days of progress to
a roll they never saw.

Sim A/B, same seeds, 90 runs/arm: 43.3% -> 42.2% clear, a single run
flipping and well inside the documented noise floor. A party+companion+pet
arm runs 31/32 clean through the new roster-folding path.
2026-07-20 23:52:20 -07:00
prosolis 71b97763ce robbie: pay for the haul, not just for showing up
The gift was one consumable every 10th visit, flat. A visit is a 40%
daily roll, so that works out to one item per ~25 real days — and it paid
exactly the same for a stockpile of sixty items as it did for one rock.
The player controls volume, not visit count, so volume is what the new
track pays on: one consumable per 15 items carried off, capped at 3,
stacking with the existing loyalty gift.

Also stop Robbie stealing thieves' tools. He skips keys already, for
exactly this reason — a key is bought to open something later, and a
bandit who pockets it between the purchase and the door has taken the
thing the player paid to still have. Tools are the same shape of promise.
2026-07-20 23:52:03 -07:00
prosolis 690ff758fe worldboss: spawn the monthly Siege without the day-1 deadlock
The Siege has never once spawned in prod. `select count(*) from
world_boss` is 0 and daily_prefetch has no worldboss_spawn row at all.

worldBossTick only auto-spawned when now.Day() == 1. The world boss
landed on main 2026-07-10..13 and the first deploy carrying it was after
July 1, so prod has never run a first-of-the-month tick with the code in
it. The feature has been live and unreachable for weeks, and the next
natural spawn would have been August 1.

The same gate also silently skipped any month where the bot happened to
be down or redeploying across the 1st, with no catch-up — one missed
minute costs the town a month.

The month key is already the whole dedup, so drop the day check and let
the rule be what it always read as: one Siege per calendar month, as
early as the process is up to run it. A missed 1st now self-heals on the
next tick.
2026-07-20 23:52:03 -07:00
prosolis ca2d1a8ea3 pets: earn XP from combat wins again
petGrantXP has been dead code since R1 deleted the legacy daily activity
loop it used to ride. Nothing replaced the call, so for the whole life of
Adventure 2.0 the only pet XP in the game came from a paid babysitter.
Prod bears it out: the one player who never subscribed has a pet sitting
at level 1 with 0 XP after months of play.

That is not cosmetic. DerivePlayerStats scales PetAttackProc,
PetDeflectProc and PetAttackDmg off pet level, so a frozen pet is a
permanently dead combat slot that the player has no way to revive.

Wire it into postCombatBookkeeping — the one seam all four combat
close-outs already meet, so a pet cannot level differently depending on
whether the fight auto-resolved or was played a round at a time. Both
slots earn on the same win, matching the babysit trickle: combat only
reads the two pets' averaged procs, so leveling both is not a spike.

Writes go through the narrow per-slot pet upserts rather than
saveAdvCharacter, because this runs on a path that does not hold the
per-user lock and a full-row write could clobber a concurrent save.

Verified against the sim: a level-3 pet finishes one L10 expedition at
level 4 with carryover, where before it finished exactly where it started.
2026-07-20 23:51:49 -07:00
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
33 changed files with 2020 additions and 104 deletions
+28 -4
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.") 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()") 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() 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 { if *petLevel < 0 || *petLevel > 10 {
fail("pet-level must be 0-10, got", *petLevel) fail("pet-level must be 0-10, got", *petLevel)
} }
@@ -89,7 +99,7 @@ func main() {
includeLog = *logFlag 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 return
} }
@@ -200,7 +210,18 @@ type matrixJob struct {
rep int 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) cs := splitNonEmpty(classes)
ls := parseLevels(levels) ls := parseLevels(levels)
zs := splitNonEmpty(zones) zs := splitNonEmpty(zones)
@@ -231,7 +252,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days
var wg sync.WaitGroup var wg sync.WaitGroup
for i := 0; i < jobs; i++ { for i := 0; i < jobs; i++ {
wg.Add(1) 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() { go func() {
for _, j := range work { 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() defer wg.Done()
for j := range in { for j := range in {
uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep) 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("-pet-level=%d", petLevel),
fmt.Sprintf("-party=%d", party), 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. // Left empty, each cell's followers clone that cell's own -class.
if partyClasses != "" { if partyClasses != "" {
args = append(args, "-party-classes", partyClasses) args = append(args, "-party-classes", partyClasses)
+29 -1
View File
@@ -388,6 +388,33 @@ type PlayerDetail struct {
Equipped []ItemView `json:"equipped,omitempty"` Equipped []ItemView `json:"equipped,omitempty"`
House HouseView `json:"house"` House HouseView `json:"house"`
Pets []PetView `json:"pets,omitempty"` 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. // ItemView is one item in the private panels — backpack, vault, or worn.
@@ -692,7 +719,8 @@ type EquipOrder struct {
ItemID int64 `json:"item_id"` ItemID int64 `json:"item_id"`
ItemName string `json:"item_name"` ItemName string `json:"item_name"`
Slot string `json:"slot"` 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"` Status string `json:"status"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
} }
@@ -45,6 +45,12 @@ var robbieAllShopGear = "Nothing fancy today but that's alright. Clean inventory
var robbieLeftConsumable = "Oh -- one more thing. I tucked a %s into your bag on the way out. " + var robbieLeftConsumable = "Oh -- one more thing. I tucked a %s into your bag on the way out. " +
"You've had me round enough times now that it felt rude not to. For the trouble, eh? _winks_" "You've had me round enough times now that it felt rude not to. For the trouble, eh? _winks_"
// robbieLeftForTheHaul is the big-haul variant: he took enough in one go that
// walking off with only a handling fee would look bad. Takes the item list.
var robbieLeftForTheHaul = "Oh -- and I left you something. %s. " +
"You had me carting that lot down four flights, and a man who takes that much " +
"and gives back nothing isn't a bandit, he's a landlord. _winks_"
// ── Room Announcements ─────────────────────────────────────────────────────── // ── Room Announcements ───────────────────────────────────────────────────────
var robbieRoomStandard = "🎩 Robbie paid %s a visit and collected %d item(s) from their inventory. " + var robbieRoomStandard = "🎩 Robbie paid %s a visit and collected %d item(s) from their inventory. " +
@@ -0,0 +1,118 @@
package plugin
import (
"testing"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
func newPetXPTestDB(t *testing.T) {
t.Helper()
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
}
// TestGrantPetCombatXPPersists is the regression guard for the bug this fixes:
// petGrantXP existed but nothing called it, so an un-babysat pet sat at its
// adoption level forever. A win must move XP on disk.
func TestGrantPetCombatXPPersists(t *testing.T) {
newPetXPTestDB(t)
uid := id.UserID("@petxp:test")
pet := PetState{Type: "dog", Name: "Rex", Arrived: true, Level: 1, XP: 0}
if err := upsertPlayerMetaPetState(uid, pet); err != nil {
t.Fatal(err)
}
if leveled := grantPetCombatXP(uid); len(leveled) != 0 {
t.Errorf("one win should not level a fresh pet, got %v", leveled)
}
got, err := loadPetState(uid)
if err != nil {
t.Fatal(err)
}
if got.XP != int(petXPPerAction*100) {
t.Errorf("XP = %d, want %d", got.XP, int(petXPPerAction*100))
}
if got.Level != 1 {
t.Errorf("Level = %d, want 1", got.Level)
}
}
// TestGrantPetCombatXPLevelsBothSlots checks the second pet earns off the same
// win, matching the babysit trickle — combat only reads the two pets' averaged
// procs, so leveling both is not a power spike.
func TestGrantPetCombatXPLevelsBothSlots(t *testing.T) {
newPetXPTestDB(t)
uid := id.UserID("@petxp2:test")
// Both one grant short of level 2 (needs 10 XP = 1000 centi-XP).
short := 1000 - int(petXPPerAction*100)
if err := upsertPlayerMetaPetState(uid,
PetState{Type: "dog", Name: "Rex", Arrived: true, Level: 1, XP: short}); err != nil {
t.Fatal(err)
}
if err := upsertPlayerMetaPet2State(uid,
PetState{Type: "cat", Name: "Whiskers", Arrived: true, Level: 1, XP: short}); err != nil {
t.Fatal(err)
}
leveled := grantPetCombatXP(uid)
if len(leveled) != 2 {
t.Fatalf("expected both pets to level, got %v", leveled)
}
p1, _ := loadPetState(uid)
p2, _ := loadPet2State(uid)
if p1.Level != 2 || p2.Level != 2 {
t.Errorf("levels = %d/%d, want 2/2", p1.Level, p2.Level)
}
}
// TestGrantPetCombatXPIgnoresChasedAway — a pet that isn't with you doesn't
// fight, so it doesn't earn.
func TestGrantPetCombatXPIgnoresChasedAway(t *testing.T) {
newPetXPTestDB(t)
uid := id.UserID("@petxp3:test")
if err := upsertPlayerMetaPetState(uid, PetState{
Type: "dog", Name: "Rex", Arrived: true, ChasedAway: true, Level: 3, XP: 100,
}); err != nil {
t.Fatal(err)
}
grantPetCombatXP(uid)
got, _ := loadPetState(uid)
if got.XP != 100 {
t.Errorf("chased-away pet gained XP: %d, want 100", got.XP)
}
}
// TestGrantPetCombatXPCapsAtTen — a maxed pet stops earning rather than
// accumulating dead XP.
func TestGrantPetCombatXPCapsAtTen(t *testing.T) {
newPetXPTestDB(t)
uid := id.UserID("@petxp4:test")
if err := upsertPlayerMetaPetState(uid, PetState{
Type: "dog", Name: "Rex", Arrived: true, Level: 10, XP: 0,
}); err != nil {
t.Fatal(err)
}
if leveled := grantPetCombatXP(uid); len(leveled) != 0 {
t.Errorf("L10 pet should not level, got %v", leveled)
}
got, _ := loadPetState(uid)
if got.XP != 0 || got.Level != 10 {
t.Errorf("L10 pet moved: level %d xp %d", got.Level, got.XP)
}
}
+45
View File
@@ -41,6 +41,51 @@ func petGrantXP(pet *PetState) bool {
return advancePetLevelsFromXP(&pet.XP, &pet.Level, &pet.Level10Date, int(petXPPerAction*100)) return advancePetLevelsFromXP(&pet.XP, &pet.Level, &pet.Level10Date, int(petXPPerAction*100))
} }
// grantPetCombatXP pays both pet slots their per-action XP for a fight the
// player won, and returns the names of any pet that leveled so the caller can
// narrate it.
//
// This is the pet's only *earned* XP source. It used to ride the legacy daily
// activity loop, which R1 deleted — and for the whole life of Adventure 2.0
// nothing replaced it, leaving petGrantXP orphaned and every un-babysat pet
// frozen at the level it was adopted with. Pet level is not cosmetic
// (DerivePlayerStats scales PetAttackProc / PetDeflectProc / PetAttackDmg off
// it), so a frozen pet is a permanently dead combat slot.
//
// Both slots earn on the same win, matching the babysit trickle: combat only
// ever reads the two pets' *averaged* procs, so leveling both is not a spike.
//
// Writes go through the narrow per-slot pet upserts rather than
// saveAdvCharacter: this runs on the combat close-out path, which does not
// hold the per-user lock, and a full-row write from here could clobber a
// concurrent character save.
func grantPetCombatXP(userID id.UserID) []string {
var leveled []string
slots := []struct {
n int
load func(id.UserID) (PetState, error)
upsert func(id.UserID, PetState) error
}{
{1, loadPetState, upsertPlayerMetaPetState},
{2, loadPet2State, upsertPlayerMetaPet2State},
}
for _, s := range slots {
pet, err := s.load(userID)
if err != nil || !pet.HasPet() {
continue
}
didLevel := petGrantXP(&pet)
if uerr := s.upsert(userID, pet); uerr != nil {
slog.Error("adventure: pet xp persist", "user", userID, "slot", s.n, "err", uerr)
continue
}
if didLevel {
leveled = append(leveled, fmt.Sprintf("%s reached level %d", pet.Name, pet.Level))
}
}
return leveled
}
// advancePetLevelsFromXP adds centi-XP to a pet and applies any level-ups, up // advancePetLevelsFromXP adds centi-XP to a pet and applies any level-ups, up
// to the level-10 cap, stamping the level-10 date on first reaching it. Shared // to the level-10 cap, stamping the level-10 date on first reaching it. Shared
// by both pet slots (the babysit trickle). Returns true if the pet leveled. // by both pet slots (the babysit trickle). Returns true if the pet leveled.
+76 -23
View File
@@ -164,20 +164,18 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
gaveCard = true gaveCard = true
} }
// Update visit count, and every 10th visit leave a small consumable // Update visit count and work out what he leaves behind.
// "for the trouble" (D2 NPC arc). var leftGifts []AdvItem
var leftGift *AdvItem
char, err := loadAdvCharacter(userID) char, err := loadAdvCharacter(userID)
if err == nil { if err == nil {
char.RobbieVisitCount++ char.RobbieVisitCount++
if char.RobbieVisitCount%robbieGiftEveryNVisits == 0 { // Use the canonical DnD level (like the arena's tier gate), not the
// Use the canonical DnD level (like the arena's tier gate), not the // frozen legacy CombatLevel — that snapshots at 13 once D&D setup
// frozen legacy CombatLevel — that snapshots at 13 once D&D setup // completes, so reading it here would peg every gift at tier 1.
// completes, so reading it here would peg every gift at tier 1. tier := robbieGiftTier(arenaDnDLevelOrZero(userID))
if gifts := consumableCache(robbieGiftTier(arenaDnDLevelOrZero(userID)), 1); len(gifts) > 0 { for _, gift := range consumableCache(tier, robbieGiftCount(char.RobbieVisitCount, len(takenItems))) {
if err := addAdvInventoryItem(userID, gifts[0]); err == nil { if err := addAdvInventoryItem(userID, gift); err == nil {
leftGift = &gifts[0] leftGifts = append(leftGifts, gift)
}
} }
} }
_ = saveAdvCharacter(char) _ = saveAdvCharacter(char)
@@ -185,7 +183,7 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
} }
// Send DM // Send DM
dm := renderRobbieDM(userID, takenItems, totalPayout, masterworkTaken, gaveCard, leftGift) dm := renderRobbieDM(userID, takenItems, totalPayout, masterworkTaken, gaveCard, leftGifts)
if err := p.SendDM(userID, dm); err != nil { if err := p.SendDM(userID, dm); err != nil {
slog.Error("adventure: robbie: failed to send DM", "user", userID, "err", err) slog.Error("adventure: robbie: failed to send DM", "user", userID, "err", err)
} }
@@ -213,12 +211,17 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
func robbieQualifyingItems(inv []AdvItem, equip map[EquipmentSlot]*AdvEquipment) []AdvItem { func robbieQualifyingItems(inv []AdvItem, equip map[EquipmentSlot]*AdvEquipment) []AdvItem {
var result []AdvItem var result []AdvItem
for _, item := range inv { for _, item := range inv {
// Never touch Arena gear, cards, consumables, or keys. Consumables are // Never touch Arena gear, cards, consumables, keys, or tools.
// a player-curated stockpile (crafted or dropped); selling them is an // Consumables are a player-curated stockpile (crafted or dropped);
// explicit decision the player must make themselves. Keys are cross-zone // selling them is an explicit decision the player must make themselves.
// unlock tokens (N5/D4) that must persist in inventory to open their // Keys are cross-zone unlock tokens (N5/D4) that must persist in
// vault later — sweeping one permanently breaks that unlock. // inventory to open their vault later — sweeping one permanently breaks
if item.Type == "ArenaGear" || item.Type == "card" || item.Type == "consumable" || item.Type == "key" { // that unlock. Tools are the same shape of promise: thieves' tools are
// bought precisely so a locked fork can be opened *later*, and a bandit
// who pockets them between the purchase and the door has taken the
// thing the player paid to still have.
switch item.Type {
case "ArenaGear", "card", "consumable", "key", thievesToolsItemType:
continue continue
} }
@@ -267,9 +270,49 @@ func robbiePlayerHasCard(userID id.UserID) bool {
// ── DM Rendering ───────────────────────────────────────────────────────────── // ── DM Rendering ─────────────────────────────────────────────────────────────
// robbieGiftEveryNVisits is how often Robbie leaves a consumable behind. // robbieGiftEveryNVisits is how often Robbie leaves a consumable behind on the
// loyalty track alone, independent of how much he hauled off.
const robbieGiftEveryNVisits = 10 const robbieGiftEveryNVisits = 10
// robbieHaulPerGift is how many items one visit has to be worth before Robbie
// leaves something for the trouble, and robbieMaxHaulGifts caps how generous a
// single monster haul can get.
//
// The loyalty track on its own was far too thin to read as a reward: a visit is
// a 40% daily roll, so every-10-visits works out to one consumable per ~25 real
// days — and it paid exactly the same for a stockpile of sixty items as it did
// for one rock. Volume is the thing the player actually controls, so volume is
// what the haul track pays on.
const (
robbieHaulPerGift = 15
robbieMaxHaulGifts = 3
)
// robbieGiftCount returns how many consumables Robbie leaves this visit: the
// every-Nth-visit loyalty gift plus one per robbieHaulPerGift items carried
// off, capped. Pure so the curve is testable without a DB or a Matrix stub.
func robbieGiftCount(visitCount, itemsTaken int) int {
n := 0
if visitCount > 0 && visitCount%robbieGiftEveryNVisits == 0 {
n++
}
if haul := itemsTaken / robbieHaulPerGift; haul > 0 {
n += min(haul, robbieMaxHaulGifts)
}
return n
}
// joinAnd renders a list as "a", "a and b", or "a, b and c".
func joinAnd(xs []string) string {
switch len(xs) {
case 0:
return ""
case 1:
return xs[0]
}
return strings.Join(xs[:len(xs)-1], ", ") + " and " + xs[len(xs)-1]
}
// robbieGiftTier maps a player's combat level to a consumable tier, matching // robbieGiftTier maps a player's combat level to a consumable tier, matching
// the arena tier bands (13 / 47 / 812 / 1317 / 18+). // the arena tier bands (13 / 47 / 812 / 1317 / 18+).
func robbieGiftTier(level int) int { func robbieGiftTier(level int) int {
@@ -287,7 +330,7 @@ func robbieGiftTier(level int) int {
} }
} }
func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gaveCard bool, leftGift *AdvItem) string { func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gaveCard bool, leftGifts []AdvItem) string {
var sb strings.Builder var sb strings.Builder
// Opening // Opening
@@ -334,9 +377,19 @@ func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gav
} }
sb.WriteString("\n\n") sb.WriteString("\n\n")
// Every-10th-visit consumable (D2). // What he left behind: the every-10th-visit loyalty consumable (D2), the
if leftGift != nil { // big-haul thank-you, or both rolled into one line. A single gift keeps the
sb.WriteString(fmt.Sprintf(robbieLeftConsumable, leftGift.Name)) // original loyalty phrasing; anything more is the haul talking.
if len(leftGifts) > 0 {
names := make([]string, 0, len(leftGifts))
for _, g := range leftGifts {
names = append(names, g.Name)
}
if len(names) == 1 {
sb.WriteString(fmt.Sprintf(robbieLeftConsumable, names[0]))
} else {
sb.WriteString(fmt.Sprintf(robbieLeftForTheHaul, joinAnd(names)))
}
sb.WriteString("\n\n") sb.WriteString("\n\n")
} }
@@ -0,0 +1,47 @@
package plugin
import "testing"
// TestRobbieGiftCount pins the two tracks: the every-10th-visit loyalty gift
// and the volume track that pays for a big haul, capped so one monster
// stockpile can't mint an unbounded pile of consumables.
func TestRobbieGiftCount(t *testing.T) {
cases := []struct {
name string
visits, taken int
want int
}{
{"small haul, off-loyalty visit", 7, 3, 0},
{"loyalty visit only", 10, 3, 1},
{"haul only", 7, 15, 1},
{"haul and loyalty stack", 20, 15, 2},
{"haul scales", 7, 45, 3},
{"haul capped", 7, 500, robbieMaxHaulGifts},
{"cap plus loyalty", 30, 500, robbieMaxHaulGifts + 1},
{"one under the haul threshold", 7, robbieHaulPerGift - 1, 0},
{"zeroth visit is not a loyalty visit", 0, 0, 0},
}
for _, c := range cases {
if got := robbieGiftCount(c.visits, c.taken); got != c.want {
t.Errorf("%s: robbieGiftCount(%d, %d) = %d, want %d",
c.name, c.visits, c.taken, got, c.want)
}
}
}
func TestJoinAnd(t *testing.T) {
cases := []struct {
in []string
want string
}{
{nil, ""},
{[]string{"a"}, "a"},
{[]string{"a", "b"}, "a and b"},
{[]string{"a", "b", "c"}, "a, b and c"},
}
for _, c := range cases {
if got := joinAnd(c.in); got != c.want {
t.Errorf("joinAnd(%v) = %q, want %q", c.in, got, c.want)
}
}
}
+45 -1
View File
@@ -845,7 +845,13 @@ func (p *AdventurePlugin) advSellAll(userID id.UserID) string {
var keptConsumable int var keptConsumable int
var keptMagic int var keptMagic int
for _, item := range items { for _, item := range items {
if item.Type == "MasterworkGear" || item.Type == "ArenaGear" || item.Type == "card" { // Keys and tools ride along with the special gear here for the same
// reason Robbie won't take them: both are bought or found precisely so a
// door can be opened *later*, and `sell all` is a bulk-loot verb the
// player fires after every haul without reading the list. Turning one
// into €300 silently deletes the unlock it was carried for.
switch item.Type {
case "MasterworkGear", "ArenaGear", "card", "key", thievesToolsItemType:
keptSpecial++ keptSpecial++
continue continue
} }
@@ -1019,6 +1025,9 @@ func luigiSuppliesView(_ id.UserID, balance float64) string {
} }
} }
sb.WriteString(fmt.Sprintf("**%s** — €%d\n Opens one dungeon path a failed check closed (`!zone unlock <n>`). Not used in combat.\n\n",
thievesToolsName, thievesToolsPrice))
sb.WriteString("Reply with an item name to buy, or `back` to return.\n") sb.WriteString("Reply with an item name to buy, or `back` to return.\n")
sb.WriteString("Stronger consumables drop from foraging, mining, fishing, and dungeons at T2+.") sb.WriteString("Stronger consumables drop from foraging, mining, fishing, and dungeons at T2+.")
return sb.String() return sb.String()
@@ -1073,6 +1082,13 @@ func (p *AdventurePlugin) resolveShopSupplyChoice(ctx MessageContext, interactio
return p.SendDM(ctx.Sender, "*Luigi nods and gestures toward the main counter.*") return p.SendDM(ctx.Sender, "*Luigi nods and gestures toward the main counter.*")
} }
// Thieves' tools sit on the supplies shelf but are not a ConsumableDef:
// the combat engine scans inventory against that table and would happily
// spend them mid-fight. They get their own branch and their own item type.
if isThievesToolsReply(reply) {
return p.buyThievesTools(ctx, interaction)
}
// Find matching consumable // Find matching consumable
var match *ConsumableDef var match *ConsumableDef
for i := range consumableDefs { for i := range consumableDefs {
@@ -1115,6 +1131,34 @@ func (p *AdventurePlugin) resolveShopSupplyChoice(ctx MessageContext, interactio
match.Name, consumablePrice, newBalance)) match.Name, consumablePrice, newBalance))
} }
// buyThievesTools sells one set off the supplies shelf. Mirrors the consumable
// purchase beside it — same session price factor, same 5% pot cut — and leaves
// the player in the supplies view so they can buy a second.
func (p *AdventurePlugin) buyThievesTools(ctx MessageContext, interaction *advPendingInteraction) error {
price := float64(thievesToolsPrice) * p.shopSessionPriceFactor(ctx.Sender)
balance := p.euro.GetBalance(ctx.Sender)
if balance < price {
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, fmt.Sprintf("You need €%.0f for %s but only have €%.0f.",
price, thievesToolsName, balance))
}
p.euro.Debit(ctx.Sender, price, "shop_thieves_tools")
if potCut := int(math.Round(price * 0.05)); potCut > 0 {
communityPotAdd(potCut)
trackTaxPaid(ctx.Sender, potCut)
}
_ = addAdvInventoryItem(ctx.Sender, AdvItem{
Name: thievesToolsName,
Type: thievesToolsItemType,
Tier: 1,
Value: thievesToolsPrice / 2,
})
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, fmt.Sprintf(
"Purchased **%s** for €%.0f. You carry %d.\n💰 Balance: €%.0f\n\nReply with another item name or `back` to return.",
thievesToolsName, price, countThievesTools(ctx.Sender), p.euro.GetBalance(ctx.Sender)))
}
// ── Curios (Magic Items) ──────────────────────────────────────────────────── // ── Curios (Magic Items) ────────────────────────────────────────────────────
// curiosStockSize — how many registry magic items Luigi stocks per day. // curiosStockSize — how many registry magic items Luigi stocks per day.
+15 -8
View File
@@ -398,10 +398,10 @@ func (p *AdventurePlugin) spawnWorldBoss(eventKey string) (*worldBossState, erro
return boss, nil return boss, nil
} }
// worldBossTick rides the 1-minute event ticker. It auto-spawns a boss on the // worldBossTick rides the 1-minute event ticker. It auto-spawns the month's
// first of each UTC month and resolves a live boss whose window has lapsed. The // boss and resolves a live boss whose window has lapsed. The defeat path is not
// defeat path is not here — a bout crossing the pool to zero resolves inline // here — a bout crossing the pool to zero resolves inline (W2), because the
// (W2), because the ticker never sees the pool between two 60s reads. // ticker never sees the pool between two 60s reads.
func (p *AdventurePlugin) worldBossTick() { func (p *AdventurePlugin) worldBossTick() {
boss, err := loadActiveWorldBoss() boss, err := loadActiveWorldBoss()
if err != nil { if err != nil {
@@ -423,10 +423,17 @@ func (p *AdventurePlugin) worldBossTick() {
} }
return return
} }
// No boss camped — auto-spawn on the 1st of the month, once. // No boss camped — spawn this month's Siege, once.
if now.Day() != 1 { //
return // The month key below is the whole dedup, so the rule is simply "one Siege
} // per calendar month, as early as the process is up to run it." It used to
// additionally require now.Day() == 1, which deadlocked the entire feature:
// the world boss shipped mid-July 2026 and prod never once ran a first-of-
// the-month tick with the code in it, so `select count(*) from world_boss`
// was still 0 weeks later. The day gate also silently skipped any month
// where the bot happened to be down or redeploying across the 1st, with no
// catch-up. Dropping it makes a missed 1st self-heal on the next tick
// instead of costing the town a month.
monthKey := now.Format("2006-01") monthKey := now.Format("2006-01")
if db.JobCompleted("worldboss_spawn", monthKey) { if db.JobCompleted("worldboss_spawn", monthKey) {
return return
+15 -1
View File
@@ -32,6 +32,20 @@ func (p *AdventurePlugin) postCombatBookkeeping(
if err := persistDnDPostCombatSubclass(dndChar, raged, result, mods); err != nil { if err := persistDnDPostCombatSubclass(dndChar, raged, result, mods); err != nil {
slog.Error("dnd: post-combat subclass persist", "user", userID, "err", err) slog.Error("dnd: post-combat subclass persist", "user", userID, "err", err)
} }
// The pet fought too. A win is its only earned XP — see grantPetCombatXP
// for why this seam and not the room-clear one: it is the single place all
// four close-outs already meet, so a pet cannot level differently depending
// on whether the fight auto-resolved or was played a round at a time.
if result.PlayerWon {
if leveled := grantPetCombatXP(userID); len(leveled) > 0 {
for _, line := range leveled {
slog.Info("adventure: pet leveled", "user", userID, "pet", line)
}
if err := p.SendDM(userID, "🐾 "+strings.Join(leveled, "\n🐾 ")); err != nil {
slog.Warn("adventure: pet level-up DM", "user", userID, "err", err)
}
}
}
} }
// grantCombatAchievements checks combat results for achievement-worthy moments. // grantCombatAchievements checks combat results for achievement-worthy moments.
@@ -371,7 +385,7 @@ type DeathTransitionResult struct {
func transitionDeath(p DeathTransitionParams) DeathTransitionResult { func transitionDeath(p DeathTransitionParams) DeathTransitionResult {
var r 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 r.Pardoned = true
now := time.Now().UTC() now := time.Now().UTC()
p.Char.LastPardonUsed = &now p.Char.LastPardonUsed = &now
+18
View File
@@ -707,6 +707,24 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
PlayerHeal: out.PlayerHeal, PlayerHeal: out.PlayerHeal,
EnemySkip: out.EnemySkip, 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 // §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 // 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 // cleric: until it existed, every heal in the engine was a self-heal, and
+3
View File
@@ -482,6 +482,9 @@ const combatSessionCols = `
// newCombatSessionID — 16-char hex token. Same scheme as zone runs / expeditions. // newCombatSessionID — 16-char hex token. Same scheme as zone runs / expeditions.
func newCombatSessionID() string { func newCombatSessionID() string {
if simSeedOn() {
return simHexToken()
}
var b [8]byte var b [8]byte
if _, err := cryptorand.Read(b[:]); err != nil { if _, err := cryptorand.Read(b[:]); err != nil {
// Vanishingly unlikely; fall through with a zeroed prefix. // Vanishingly unlikely; fall through with a zeroed prefix.
+187 -26
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"math" "math"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -843,45 +844,195 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
// run graph / harvest tally / supplies / threat — same as before, just // run graph / harvest tally / supplies / threat — same as before, just
// no streamFlow here. compact==true switches the underlying combat // no streamFlow here. compact==true switches the underlying combat
// narration into terse mode and auto-resolves elite (not boss) rooms. // narration into terse mode and auto-resolves elite (not boss) rooms.
// forkAutoPickTimeout — how long a background fork may sit unanswered // forkAutoPickTimeout — how long a background fork may sit unanswered before
// before the autopilot picks an available route itself. Short enough that // the autopilot picks a route itself.
// the expedition keeps moving rather than idling out to the 24h stale-run //
// reaper; long enough that a player away for the evening still gets first // This was 8h, which reads as "the player gets first say" and behaves as "the
// say on a genuine fork. // expedition stops for a third of a day, every fork." A multi-day expedition
const forkAutoPickTimeout = 8 * time.Hour // crosses a lot of forks; at 8h apiece the autopilot spends more of its life
// parked than walking, and a player who is simply asleep loses a night per
// branch. 30m keeps a genuine first say for anyone actually at the keyboard and
// costs an absent player almost nothing.
const forkAutoPickTimeout = 30 * time.Minute
// autoPickStaleFork commits the first unlocked option of a stale background // rankForkOptions orders a fork's options by how much walking them is worth:
// fork, advancing the run to that node exactly as `!zone go <n>` would // somewhere new first, then the fatter edge (Weight is the author's own "this
// (advanceZoneRunNode + region-transition hook). Returns false — no pick // is the main line" signal), then menu order as the tiebreak so the pick is
// when every option is locked, so the caller re-emits the prompt and the // deterministic. Only unlocked options are returned.
// run idles on toward the reaper. The choice is logged as a narrative entry //
// so the end-of-day digest can surface the decision the player missed. // The old rule was "first unlocked option in the menu", which is edge-authoring
func (p *AdventurePlugin) autoPickStaleFork(exp *Expedition, run *DungeonRun, pf *pendingFork) bool { // order — meaningful to whoever wrote the graph, arbitrary to the player. It
var chosen *pendingChoice // walked past unvisited branches to loop through cleared ones often enough to
for i := range pf.Options { // look broken.
if pf.Options[i].Unlocked { func rankForkOptions(g ZoneGraph, run *DungeonRun, pf *pendingFork) []pendingChoice {
chosen = &pf.Options[i] weights := map[string]int{}
break for _, e := range g.outgoingEdges(run.CurrentNode) {
weights[e.To] = e.Weight
}
visited := map[string]bool{}
for _, n := range run.VisitedNodes {
visited[n] = true
}
open := make([]pendingChoice, 0, len(pf.Options))
for _, o := range pf.Options {
if o.Unlocked {
open = append(open, o)
} }
} }
if chosen == nil { sort.SliceStable(open, func(i, j int) bool {
return false // nothing unlocked — leave it for the player / reaper vi, vj := visited[open[i].To], visited[open[j].To]
if vi != vj {
return !vi // unvisited first
}
if wi, wj := weights[open[i].To], weights[open[j].To]; wi != wj {
return wi > wj
}
return open[i].Index < open[j].Index
})
return open
}
// autoPickStaleFork commits a stale background fork, advancing the run exactly
// as `!zone go <n>` would (advanceZoneRunNode + region-transition hook). The
// choice is logged as a narrative entry so the end-of-day digest can surface
// the decision the player missed.
//
// When every route is locked it does not give up: it spends a set of thieves'
// tools if the party is carrying any and one of the locks is the pickable kind.
// Returns false only when there is genuinely nothing it can do — the caller
// then backtracks rather than idling the expedition into the 24h reaper.
func (p *AdventurePlugin) autoPickStaleFork(exp *Expedition, run *DungeonRun, pf *pendingFork) bool {
g, _ := loadZoneGraph(run.ZoneID)
ranked := rankForkOptions(g, run, pf)
note := "autopilot took the most promising path"
var spendTool int64
if len(ranked) == 0 {
picked, toolID, ok := p.autoPickWithTools(run, pf)
if !ok {
return false
}
ranked = []pendingChoice{picked}
spendTool = toolID
note = "autopilot spent " + thievesToolsName + " on the only way forward"
} }
chosen := ranked[0]
if _, err := advanceZoneRunNode(run.RunID, chosen.To); err != nil { if _, err := advanceZoneRunNode(run.RunID, chosen.To); err != nil {
slog.Warn("expedition: auto-pick stale fork", slog.Warn("expedition: auto-pick stale fork",
"user", run.UserID, "run", run.RunID, "err", err) "user", run.UserID, "run", run.RunID, "err", err)
return false return false
} }
g, _ := loadZoneGraph(run.ZoneID) // The door is behind us, so now the set is actually used up. Billing before
// the advance would charge the player for a move that failed — and the
// caller's backtrack would then clear the fork the tools just paid for.
if spendTool != 0 {
if err := removeAdvInventoryItem(spendTool); err != nil {
slog.Warn("expedition: autopilot tools spend", "user", run.UserID, "err", err)
}
}
fireGraphRegionTransition(run.UserID, g.Nodes[run.CurrentNode], g.Nodes[chosen.To]) fireGraphRegionTransition(run.UserID, g.Nodes[run.CurrentNode], g.Nodes[chosen.To])
if exp != nil { if exp != nil {
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
fmt.Sprintf("autopilot took an available path after %dh idle at the fork: %s", fmt.Sprintf("%s: %s", note, chosen.Label), "")
int(forkAutoPickTimeout.Hours()), chosen.Label), "")
} }
return true return true
} }
// autoPickWithTools finds a route a set of thieves' tools could open when every
// option on the fork is locked, so a bad Perception roll can't quietly end an
// expedition the player paid days into. It only ever fires when there is no free
// route left — the player's tools are their own, and the autopilot does not get
// to burn them for convenience.
//
// It reports the route and the inventory row to spend, but does not spend it:
// the caller charges the player only once the move has actually committed.
func (p *AdventurePlugin) autoPickWithTools(run *DungeonRun, pf *pendingFork) (pendingChoice, int64, bool) {
toolID, ok := findThievesTools(id.UserID(run.UserID))
if !ok {
return pendingChoice{}, 0, false
}
for i := range pf.Options {
if pf.Options[i].Unlocked || !pickableLock(pf.Options[i].Lock) {
continue
}
// Local copy only — advanceZoneRunNode clears node_choices on success,
// and on failure the fork must stay as locked as the player left it
// rather than reading "open" for a set nobody paid for.
chosen := pf.Options[i]
chosen.Unlocked = true
chosen.Reason = "opened with " + thievesToolsName
return chosen, toolID, true
}
return pendingChoice{}, 0, false
}
// backtrackFromDeadFork walks the run back one room when a fork has no route
// the autopilot can take and no tools to buy one with. Without this the run
// simply sits there until the 24h stale reaper ends the expedition — a player
// losing days of progress to a die roll they never saw and could not answer.
// Backtracking at least returns them to a room with other exits.
//
// Returns false at the entry node, where there is nowhere behind to go, and
// wherever no fallback room would actually help — see backtrackTarget.
func (p *AdventurePlugin) backtrackFromDeadFork(exp *Expedition, run *DungeonRun) bool {
g, ok := loadZoneGraph(run.ZoneID)
if !ok {
return false
}
target, ok := backtrackTarget(g, run)
if !ok {
return false
}
// Clear the fork first: it belongs to the node being left, and both
// `!zone advance` and `!zone go` would otherwise resolve a prompt pointing
// at a room the party is no longer standing in.
if err := clearPendingFork(run.RunID); err != nil {
slog.Warn("expedition: backtrack clear fork", "run", run.RunID, "err", err)
return false
}
if _, err := revisitZoneRun(run.RunID, target, run.VisitedNodes); err != nil {
slog.Warn("expedition: backtrack from dead fork", "run", run.RunID, "err", err)
return false
}
if exp != nil {
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
"every way on was sealed — the party doubled back", "")
}
return true
}
// backtrackTarget picks the room a dead fork falls back to: the most recently
// entered visited node that is genuinely joined to the current one by an edge
// and that still offers a way on other than the sealed room.
//
// Both halves are load-bearing. VisitedNodes is a first-entry ordered *set*, not
// a path stack (see appendVisited) — after any earlier backtrack the entry
// before CurrentNode can sit on a completely different branch, so stepping to it
// blind teleports the party across the map. `!revisit` refuses exactly that move
// via adjacentNodes, and the autopilot has no business doing what the player is
// forbidden from doing. The second half stops the other failure: falling back
// into a corridor whose only exit is the fork we just fled from just walks
// straight back in — and the lock rolls are seeded per (run, edge), so the
// result is identical every time. That is an infinite loop, not a recovery.
func backtrackTarget(g ZoneGraph, run *DungeonRun) (string, bool) {
adj := adjacentNodes(g, run.CurrentNode)
for i := pathIndexOf(run.VisitedNodes, run.CurrentNode) - 1; i >= 0; i-- {
n := run.VisitedNodes[i]
if !adj[n] {
continue
}
for _, e := range g.outgoingEdges(n) {
if e.To != run.CurrentNode {
return n, true
}
}
}
return "", false
}
func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, compact, inlineBossCombat bool) autopilotWalkResult { func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, compact, inlineBossCombat bool) autopilotWalkResult {
exp, err := getActiveExpedition(ctx.Sender) exp, err := getActiveExpedition(ctx.Sender)
if err != nil { if err != nil {
@@ -903,9 +1054,19 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
// (unlocked) route and keep walking instead of stalling out. // (unlocked) route and keep walking instead of stalling out.
if run, rerr := getActiveZoneRun(ctx.Sender); rerr == nil && run != nil { if run, rerr := getActiveZoneRun(ctx.Sender); rerr == nil && run != nil {
if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil { if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil {
picked := compact && stale := compact && time.Since(run.LastActionAt) > forkAutoPickTimeout
time.Since(run.LastActionAt) > forkAutoPickTimeout && picked := stale && p.autoPickStaleFork(exp, run, pf)
p.autoPickStaleFork(exp, run, pf) // Stale and nothing takeable: every route locked, no tools. Back out
// one room rather than sitting here until the 24h reaper ends an
// expedition the player may be days into. The backtrack clears the
// fork, so the next tick walks from the previous room normally.
if stale && !picked && p.backtrackFromDeadFork(exp, run) {
return autopilotWalkResult{
finalMsg: "🔒 Every way on was sealed. The party doubled back to look for another line.",
rooms: 0,
reason: stopFork,
}
}
if !picked { if !picked {
zone := zoneOrFallback(run.ZoneID) zone := zoneOrFallback(run.ZoneID)
return autopilotWalkResult{ return autopilotWalkResult{
+3 -4
View File
@@ -23,7 +23,6 @@ package plugin
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"math/rand/v2"
"strings" "strings"
"gogobee/internal/flavor" "gogobee/internal/flavor"
@@ -75,7 +74,7 @@ func resolveCombatInterrupt(
rollFn func() int, rollFn func() int,
) (CombatInterruptKind, int) { ) (CombatInterruptKind, int) {
if rollFn == nil { if rollFn == nil {
rollFn = func() int { return rand.IntN(20) + 1 } rollFn = func() int { return simIntN(20) + 1 }
} }
r := rollFn() r := rollFn()
mod := tier mod := tier
@@ -249,7 +248,7 @@ func surpriseRoundNickF(m DnDMonsterTemplate, tier, floorOverride int) int {
if tier < 1 { if tier < 1 {
tier = 1 tier = 1
} }
dmg := 1 + rand.IntN(4) + m.AttackBonus/2 dmg := 1 + simIntN(4) + m.AttackBonus/2
floor := tier floor := tier
if floorOverride >= 0 { if floorOverride >= 0 {
floor = floorOverride floor = floorOverride
@@ -484,7 +483,7 @@ func (p *AdventurePlugin) tryPatrolEncounter(
return return
} }
chance := rollPatrolChance(exp.ThreatLevel) chance := rollPatrolChance(exp.ThreatLevel)
if chance <= 0 || rand.Float64() > chance { if chance <= 0 || simFloat64() > chance {
return return
} }
monster, ok := pickZoneEnemy(zone, run.RunID, run.CurrentRoom, false) monster, ok := pickZoneEnemy(zone, run.RunID, run.CurrentRoom, false)
+79 -6
View File
@@ -112,6 +112,56 @@ func applyRacePassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharacte
// AutoCritFirst is already a one-shot bool. // AutoCritFirst is already a one-shot bool.
// - A Cleric carrying a healing potion stacks: passive 5 + potion 8 = 13. // - A Cleric carrying a healing potion stacks: passive 5 + potion 8 = 13.
// The passive heal triggers first since both use the same threshold. // 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) { func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharacter) {
switch c.Class { switch c.Class {
case ClassFighter: 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. // re-tune in a follow-up if their win curves drift after this.
switch { switch {
case c.Level >= 20: 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: case c.Level >= 11:
mods.ExtraAttacks += 2 mods.ExtraAttacks += 2
case c.Level >= 5: case c.Level >= 5:
mods.ExtraAttacks += 1 mods.ExtraAttacks += 1
} }
stats.AttackBonus -= 2 // rebaseline: sub-swing to-hit trim — 3 swings lands the Fighter in the ~60 band
case ClassRogue: case ClassRogue:
mods.AutoCritFirst = true 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 // 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 // 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. // 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 // Class-identity audit (2026-05-16) — actual Sneak Attack as Nd6
// per hit, scaling with level per 5e (1d6 L1-2 ... 10d6 L19-20). // per hit, scaling with level per 5e (1d6 L1-2 ... 10d6 L19-20).
// AutoCritFirst + DamageBonus alone left the rogue's defining // AutoCritFirst + DamageBonus alone left the rogue's defining
@@ -154,6 +209,8 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
mods.SneakAttackDie += sneakDice mods.SneakAttackDie += sneakDice
case ClassMage: case ClassMage:
stats.AttackBonus++ 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 // Phase 2 class-balance: +1 attack alone left Mage mid-pack on damage
// per round. A modest damage rider lifts weapon hits (DamageBonus does // per round. A modest damage rider lifts weapon hits (DamageBonus does
// not multiply queued SpellPreDamage — that path is its own field). // 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 mods.ExtraAttacks += 1
} }
case ClassDruid: case ClassDruid:
// Wild Resilience — multiplicative, so it stacks cleanly with the // Multiplicative, so it stacks cleanly with the subclass DamageReduct
// subclass DamageReduct riders. DamageReduct is initialized to 1.0 // riders (DamageReduct is initialized to 1.0 by DerivePlayerStats before
// by DerivePlayerStats before passives run. // 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 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 // Phase 3 class-balance: druid was the only caster chassis with a
// purely defensive passive, and the off-tier numbers showed it — // 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 // 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++ stats.AttackBonus++
mods.DamageBonus += 0.05 mods.DamageBonus += 0.05
mods.FlatDmgStart += c.Level + clampNonNeg(abilityModifier(c.CHA)) 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: case ClassSorcerer:
// Innate Sorcery — pre-combat burst, CHA-scaled like the Sorcerer's // Innate Sorcery — pre-combat burst, CHA-scaled like the Sorcerer's
// spellcasting stat. Floors at the flat base for low-CHA builds. // 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. // touching the +0.05 rider that already saturates at high tier.
mods.FlatDmgStart += 5 + c.Level + clampNonNeg(abilityModifier(c.CHA)) mods.FlatDmgStart += 5 + c.Level + clampNonNeg(abilityModifier(c.CHA))
mods.DamageBonus += 0.05 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: case ClassWarlock:
// Phase 2 class-balance: bumped from 10% to 12% damage + 1 attack — // 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 // 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 mods.DamageBonus += 0.12
stats.AttackBonus++ stats.AttackBonus++
mods.FlatDmgStart += c.Level + clampNonNeg(abilityModifier(c.CHA)) 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: case ClassPaladin:
// Class-identity audit (2026-05-16) — Divine Smite as actual // Class-identity audit (2026-05-16) — Divine Smite as actual
// per-hit radiant bonus + L5 Extra Attack. 5e: smite consumes a // 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. // down so an extra-attack paladin doesn't trivialize every fight.
// Rides DivineStrikePerHit (already in the weapon-hit damage path). // Rides DivineStrikePerHit (already in the weapon-hit damage path).
// Previous FlatDmgStart opener felt like Lay on Hands, not Smite. // 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.DivineStrikePerHit += smite
mods.DamageReduct *= 0.9 // rebaseline: small survival trim between the two integer smite steps for a ~60 landing
if c.Level >= 5 { if c.Level >= 5 {
mods.ExtraAttacks += 1 mods.ExtraAttacks += 1
} }
+1 -2
View File
@@ -2,7 +2,6 @@ package plugin
import ( import (
"fmt" "fmt"
"math/rand/v2"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -117,7 +116,7 @@ func (p *AdventurePlugin) handleDnDShortRest(ctx MessageContext) error {
before := c.HPCurrent before := c.HPCurrent
if !hpFull { if !hpFull {
conMod := abilityModifier(c.CON) conMod := abilityModifier(c.CON)
healDie := 1 + rand.IntN(6) // 1d6 healDie := 1 + simIntN(6) // 1d6
heal := healDie + conMod heal := healDie + conMod
if heal < 1 { if heal < 1 {
heal = 1 heal = 1
+6 -7
View File
@@ -3,7 +3,6 @@ package plugin
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"math/rand/v2"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
) )
@@ -128,7 +127,7 @@ func applyMageSubclassSpellHooks(c *DnDCharacter, spell SpellDefinition, slotLev
// applySpellDamageAttack — Fire Bolt, Inflict Wounds, Chill Touch, etc. // applySpellDamageAttack — Fire Bolt, Inflict Wounds, Chill Touch, etc.
// Roll d20 + spell attack vs enemy AC; nat 20 doubles dice damage. // 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) { 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 isCrit := roll == 20
isFumble := roll == 1 isFumble := roll == 1
if isFumble || (!isCrit && roll+atk < enemy.AC) { 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. // 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) { func applySpellDamageSave(spell SpellDefinition, dc int, c *DnDCharacter, mods *CombatModifiers, enemy *CombatStats, slot int) {
saveMod := enemySpellSaveMod(enemy) saveMod := enemySpellSaveMod(enemy)
saveRoll := 1 + rand.IntN(20) saveRoll := 1 + simIntN(20)
saved := saveRoll+saveMod >= dc saved := saveRoll+saveMod >= dc
dmg := rollSpellDamageDice(spell, slot, c.Level) dmg := rollSpellDamageDice(spell, slot, c.Level)
if saved { if saved {
@@ -182,7 +181,7 @@ func applySpellDamageAuto(spell SpellDefinition, mods *CombatModifiers, slot, ch
} }
total := 0 total := 0
for i := 0; i < darts; i++ { for i := 0; i < darts; i++ {
total += 1 + rand.IntN(4) + 1 total += 1 + simIntN(4) + 1
} }
mods.SpellPreDamage += total mods.SpellPreDamage += total
mods.SpellPreDamageDesc = fmt.Sprintf("Magic Missile (%d darts, %d dmg)", darts, 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). // double damage (5e: paralyzed creatures auto-crit on melee hits).
func applySpellControl(spell SpellDefinition, dc int, mods *CombatModifiers, enemy *CombatStats, slot int) { func applySpellControl(spell SpellDefinition, dc int, mods *CombatModifiers, enemy *CombatStats, slot int) {
saveMod := enemySpellSaveMod(enemy) saveMod := enemySpellSaveMod(enemy)
saveRoll := 1 + rand.IntN(20) saveRoll := 1 + simIntN(20)
if saveRoll+saveMod >= dc { if saveRoll+saveMod >= dc {
mods.SpellPreDamageDesc = spell.Name + " — resisted" mods.SpellPreDamageDesc = spell.Name + " — resisted"
return return
@@ -382,7 +381,7 @@ func rollTurnSpellHeal(c *DnDCharacter, spell SpellDefinition, slotLevel int) in
if supreme { if supreme {
heal += faces heal += faces
} else { } else {
heal += 1 + rand.IntN(faces) heal += 1 + simIntN(faces)
} }
} }
heal += abilityModifier(c.WIS) heal += abilityModifier(c.WIS)
@@ -418,7 +417,7 @@ func rollSpellDamageDice(spell SpellDefinition, slot, charLevel int) int {
} }
total := flat total := flat
for i := 0; i < dice; i++ { for i := 0; i < dice; i++ {
total += 1 + rand.IntN(faces) total += 1 + simIntN(faces)
} }
if total < 1 { if total < 1 {
total = 1 total = 1
+13 -6
View File
@@ -224,7 +224,14 @@ func TestApplyClassPassives(t *testing.T) {
wantFlatStart int wantFlatStart int
wantInitBias float64 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, // Phase 2 class-balance rebalance: rogue picked up +5% damage,
// Mage/Bard/Warlock gained a level-scaled FlatDmgStart burst, Sorcerer's // Mage/Bard/Warlock gained a level-scaled FlatDmgStart burst, Sorcerer's
// burst now also scales with level, and Warlock picked up +1 attack. // 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 // 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 // (lvl 1 + clamp(mod(WIS=0)) = 1), and Sorcerer's burst base went 3→5
// (5 + 1 + clamp(mod(CHA=10)=0) = 6). // (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}, {ClassMage, 0.05, 1, false, 0, 1.0, 1, 0},
{ClassCleric, 0, 0, false, 5, 1.0, 0, 0}, {ClassCleric, 0, 0, false, 5, 1.0, 0, 0},
// Class-identity audit (2026-05-16): Ranger Hunter's Mark is now // 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 // FlatDmgStart compensation riders are gone; +1 to-hit stays on
// Ranger as the "read prey tells" half. // Ranger as the "read prey tells" half.
{ClassRanger, 0, 1, false, 0, 1.0, 0, 0}, {ClassRanger, 0, 1, false, 0, 1.0, 0, 0},
{ClassDruid, 0, 0, false, 0, 0.95, 1, 0}, {ClassDruid, -0.20, 0, false, 0, 0.95 * 0.2, 1, 0},
{ClassBard, 0.05, 1, false, 0, 1.0, 1, 1}, {ClassBard, 0.05, 1, false, 0, 0.4, 1, 1},
{ClassSorcerer, 0.05, 0, false, 0, 1.0, 6, 0}, {ClassSorcerer, 0.05, 1, false, 0, 1.0, 6, 0},
{ClassWarlock, 0.12, 1, false, 0, 1.0, 1, 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 { for _, tc := range cases {
stats := CombatStats{AttackBonus: 5} stats := CombatStats{AttackBonus: 5}
+4
View File
@@ -53,6 +53,9 @@ func (p *AdventurePlugin) handleDnDZoneCmd(ctx MessageContext, args string) erro
// fork pending) the handler short-circuits with a friendly // fork pending) the handler short-circuits with a friendly
// message — see zoneCmdGo for the full surface. // message — see zoneCmdGo for the full surface.
return p.zoneCmdGo(ctx, rest) return p.zoneCmdGo(ctx, rest)
case "unlock", "pick", "force":
// Spend thieves' tools on a fork option a failed check closed.
return p.zoneCmdUnlock(ctx, rest)
case "status", "info": case "status", "info":
return p.zoneCmdStatus(ctx) return p.zoneCmdStatus(ctx)
case "map", "m": case "map", "m":
@@ -83,6 +86,7 @@ func zoneHelpText() string {
b.WriteString("`!zone map` — show the room layout\n") b.WriteString("`!zone map` — show the room layout\n")
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 unlock <n>` — spend thieves' tools to open a path you couldn't\n")
b.WriteString("`!revisit <n>` — walk back to a room you've already cleared\n") b.WriteString("`!revisit <n>` — walk back to a room you've already cleared\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 (they'll remember)\n") b.WriteString("`!zone taunt` — poke TwinBee (they'll remember)\n")
+12
View File
@@ -188,6 +188,17 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
if cerr != nil { if cerr != nil {
return p.SendDM(ctx.Sender, cerr.Error()) return p.SendDM(ctx.Sender, cerr.Error())
} }
return p.commitForkChoice(ctx, run, chosen, "")
}
// commitForkChoice advances the run onto an already-validated fork option and
// emits the arrival teaser. Split out of zoneCmdGo so `!zone unlock` — which
// reaches the same place by paying for it — cannot drift from the plain
// `!zone go` arrival: same region-transition hook, same camp strike, same
// boss/elite prompt. header, if set, is printed above the move.
func (p *AdventurePlugin) commitForkChoice(
ctx MessageContext, run *DungeonRun, chosen pendingChoice, header string,
) error {
nextIdx, aerr := advanceZoneRunNode(run.RunID, chosen.To) nextIdx, aerr := advanceZoneRunNode(run.RunID, chosen.To)
if aerr != nil { if aerr != nil {
return p.SendDM(ctx.Sender, "Couldn't advance: "+aerr.Error()) return p.SendDM(ctx.Sender, "Couldn't advance: "+aerr.Error())
@@ -200,6 +211,7 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
fireGraphRegionTransition(run.UserID, fromNode, nextNode) fireGraphRegionTransition(run.UserID, fromNode, nextNode)
nextRoom := nodeKindToRoomType(nextNode.Kind) nextRoom := nodeKindToRoomType(nextNode.Kind)
var b strings.Builder var b strings.Builder
b.WriteString(header)
if kind := autoBreakCampOnMove(ctx.Sender); kind != "" { if kind := autoBreakCampOnMove(ctx.Sender); kind != "" {
b.WriteString(fmt.Sprintf("⛺ Camp struck (**%s**) — the party moved on.\n\n", kind)) b.WriteString(fmt.Sprintf("⛺ Camp struck (**%s**) — the party moved on.\n\n", kind))
} }
+5 -2
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. // while production paths use the package-global generator.
func rngFloat(rng *rand.Rand) float64 { func rngFloat(rng *rand.Rand) float64 {
if rng == nil { 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() return rng.Float64()
} }
@@ -368,7 +371,7 @@ func rngIntN(rng *rand.Rand, n int) int {
return 0 return 0
} }
if rng == nil { if rng == nil {
return rand.IntN(n) return simIntN(n)
} }
return rng.IntN(n) return rng.IntN(n)
} }
+8 -1
View File
@@ -175,6 +175,9 @@ func generateRoomSequence(zone ZoneDefinition, rng *rand.Rand) []RoomType {
// newRunID — 16-char hex token. Crypto-random; collision-resistant. // newRunID — 16-char hex token. Crypto-random; collision-resistant.
func newRunID() string { func newRunID() string {
if simSeedOn() {
return simHexToken()
}
var b [8]byte var b [8]byte
if _, err := cryptorand.Read(b[:]); err != nil { if _, err := cryptorand.Read(b[:]); err != nil {
// Fall back to math/rand if /dev/urandom is unavailable. // 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 { 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) seq := generateRoomSequence(zone, rng)
+9 -1
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 c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 16, 13, 15, 8, 12, 10
case ClassRogue, ClassRanger: case ClassRogue, ClassRanger:
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 10, 16, 14, 12, 13, 8 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 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: case ClassCleric, ClassDruid:
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 12, 10, 14, 8, 16, 13 c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 12, 10, 14, 8, 16, 13
case ClassBard, ClassWarlock: case ClassBard, ClassWarlock:
+3 -3
View File
@@ -138,7 +138,7 @@ func TestDetailSnapshotKeyedByLocalpart(t *testing.T) {
t.Fatalf("saveAdvCharacter: %v", err) t.Fatalf("saveAdvCharacter: %v", err)
} }
snap, err := buildDetailSnapshot(time.Now().UTC()) snap, err := (&AdventurePlugin{}).buildDetailSnapshot(time.Now().UTC())
if err != nil { if err != nil {
t.Fatalf("buildDetailSnapshot: %v", err) t.Fatalf("buildDetailSnapshot: %v", err)
} }
@@ -189,7 +189,7 @@ func TestDetailSnapshotIgnoresOptOut(t *testing.T) {
} }
// ...but the private detail set keeps them both. // ...but the private detail set keeps them both.
detail, err := buildDetailSnapshot(time.Now().UTC()) detail, err := (&AdventurePlugin{}).buildDetailSnapshot(time.Now().UTC())
if err != nil { if err != nil {
t.Fatalf("buildDetailSnapshot: %v", err) t.Fatalf("buildDetailSnapshot: %v", err)
} }
@@ -218,7 +218,7 @@ func TestDetailSnapshotSkipsDeadPlayers(t *testing.T) {
t.Fatalf("kill player: %v", err) t.Fatalf("kill player: %v", err)
} }
snap, err := buildDetailSnapshot(time.Now().UTC()) snap, err := (&AdventurePlugin{}).buildDetailSnapshot(time.Now().UTC())
if err != nil { if err != nil {
t.Fatalf("buildDetailSnapshot: %v", err) t.Fatalf("buildDetailSnapshot: %v", err)
} }
+42
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 // 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. // 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) { 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 { switch order.Action {
case "equip": case "equip":
inv, err := loadAdvInventory(owner) 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. // 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 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) out, err := applyMagicEquip(owner, it)
if errors.Is(err, errItemNotEquippable) { if errors.Is(err, errItemNotEquippable) {
return "rejected_not_equippable", "That item can't be worn.", false 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 return "applied", equipAppliedDetail(out), false
case "unequip": 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)) out, err := applyMagicUnequip(owner, DnDSlot(order.Slot))
if errors.Is(err, errSlotEmpty) { if errors.Is(err, errSlotEmpty) {
return "rejected_not_worn", "That slot was already empty.", false 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 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: default:
// Pete validates the action before it ever queues an order, so this is a // 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. // contract breach, not a user mistake. Reject permanently rather than spin.
+340
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
}
+314
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)
}
}
+15 -2
View File
@@ -97,7 +97,7 @@ func (p *AdventurePlugin) pushRoster() {
var detailPushOK bool var detailPushOK bool
func (p *AdventurePlugin) pushDetails() { func (p *AdventurePlugin) pushDetails() {
snap, err := buildDetailSnapshot(time.Now().UTC()) snap, err := p.buildDetailSnapshot(time.Now().UTC())
if err != nil { if err != nil {
slog.Error("roster: build detail snapshot failed", "err", err) slog.Error("roster: build detail snapshot failed", "err", err)
return 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 // 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 // sheet. The board token rides along so Pete can match owner↔page without ever
// reversing the one-way token. // 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()} snap := peteclient.DetailSnapshot{SnapshotAt: now.Unix()}
rows, err := db.Get().Query(`SELECT user_id FROM player_meta WHERE alive = 1`) rows, err := db.Get().Query(`SELECT user_id FROM player_meta WHERE alive = 1`)
if err != nil { if err != nil {
@@ -208,6 +208,13 @@ func buildDetailSnapshot(now time.Time) (peteclient.DetailSnapshot, error) {
pd.Vault = itemViews(items) pd.Vault = itemViews(items)
} }
pd.Equipped = equippedViews(uid) 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) snap.Players = append(snap.Players, pd)
} }
return snap, nil return snap, nil
@@ -256,6 +263,12 @@ func itemViews(items []AdvItem) []peteclient.ItemView {
} else if it.Slot != "" { } else if it.Slot != "" {
// Shop equipment resolves by (slot, tier) — Name is decorative. // Shop equipment resolves by (slot, tier) — Name is decorative.
v.Desc = equipmentDefByTier(it.Slot, it.Tier).Description 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) out = append(out, v)
} }
+115
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()
}
+60
View File
@@ -0,0 +1,60 @@
package plugin
import "testing"
// backtrackGraph — a fork at z.fork with two branches, plus a dead-end spur
// hanging off z.b whose only exit is back into z.b.
//
// z.entry → z.fork → z.a
// → z.b → z.spur
func backtrackGraph() ZoneGraph {
return ZoneGraph{
Nodes: map[string]ZoneNode{},
Edges: map[string][]ZoneEdge{
"z.entry": {{From: "z.entry", To: "z.fork"}},
"z.fork": {{From: "z.fork", To: "z.a"}, {From: "z.fork", To: "z.b"}},
"z.b": {{From: "z.b", To: "z.spur"}},
},
}
}
// TestBacktrackTargetRequiresAdjacency is the regression guard for the bug this
// fixes: VisitedNodes is a first-entry ordered set, not a path stack, so the
// entry before CurrentNode can sit on a branch the party never walked from
// here. Stepping to it blind teleports them across the map.
func TestBacktrackTargetRequiresAdjacency(t *testing.T) {
// Walked entry → fork → a, doubled back, then took fork → b. The visited
// set is [entry, fork, a, b]; z.a is *not* joined to z.b.
run := &DungeonRun{
CurrentNode: "z.b",
VisitedNodes: []string{"z.entry", "z.fork", "z.a", "z.b"},
}
got, ok := backtrackTarget(backtrackGraph(), run)
if !ok {
t.Fatal("should fall back to the fork")
}
if got != "z.fork" {
t.Errorf("backtrack target = %s, want z.fork (z.a shares no edge with z.b)", got)
}
}
// TestBacktrackTargetSkipsOneWayCorridor — backing into a room whose only exit
// is the sealed fork walks straight back in, and the lock rolls are seeded per
// (run, edge), so it would do it forever. Refuse instead.
func TestBacktrackTargetSkipsOneWayCorridor(t *testing.T) {
run := &DungeonRun{
CurrentNode: "z.spur",
VisitedNodes: []string{"z.entry", "z.fork", "z.b", "z.spur"},
}
if got, ok := backtrackTarget(backtrackGraph(), run); ok {
t.Errorf("backtracked to %s; z.b only leads back to z.spur, so this loops", got)
}
}
// TestBacktrackTargetAtEntry — nowhere behind the entry node.
func TestBacktrackTargetAtEntry(t *testing.T) {
run := &DungeonRun{CurrentNode: "z.entry", VisitedNodes: []string{"z.entry"}}
if _, ok := backtrackTarget(backtrackGraph(), run); ok {
t.Error("entry node has nowhere to back out to")
}
}
+75 -6
View File
@@ -19,6 +19,8 @@ import (
"strings" "strings"
"gogobee/internal/db" "gogobee/internal/db"
"maunium.net/go/mautrix/id"
) )
// pendingFork is the typed shape of dnd_zone_run.node_choices when the // pendingFork is the typed shape of dnd_zone_run.node_choices when the
@@ -79,14 +81,42 @@ func decodePendingFork(m map[string]any) (*pendingFork, error) {
// test them without going through the live DB. Filled in by // test them without going through the live DB. Filled in by
// evaluateForkEdges from the live run + character. // evaluateForkEdges from the live run + character.
type edgeUnlockCtx struct { type edgeUnlockCtx struct {
RunID string RunID string
FromNode string FromNode string
CharLevel int CharLevel int
AbilityMods [6]int // STR, DEX, CON, INT, WIS, CHA — matches DnDCharacter.Modifiers() // AbilityMods is the *party's best* modifier per ability — STR, DEX, CON,
// INT, WIS, CHA, matching DnDCharacter.Modifiers(). A door doesn't care
// which set of eyes spotted the seam, and reading only the leader's sheet
// meant a party's rogue and its hired scout were decorative at every lock.
// AbilityWho names whoever supplied each best, empty when it's the leader,
// so the fork prompt can say who got it open.
AbilityMods [6]int
AbilityWho [6]string
InventoryNames map[string]bool InventoryNames map[string]bool
Expedition *Expedition Expedition *Expedition
} }
// creditFor names the party member whose ability carried a check, phrased for
// the fork prompt. Empty when the acting character managed it alone — there is
// nobody to credit and the menu stays quiet.
func (c edgeUnlockCtx) creditFor(ability int) string {
if who := c.AbilityWho[ability]; who != "" {
return who + " got it open"
}
return ""
}
// bestAbility folds one body's modifiers into the running party-best, recording
// the contributor's name for any ability it improves on.
func (c *edgeUnlockCtx) bestAbility(mods [6]int, who string) {
for i, m := range mods {
if m > c.AbilityMods[i] {
c.AbilityMods[i] = m
c.AbilityWho[i] = who
}
}
}
// evaluateEdgeLock returns whether the player can take this edge right // evaluateEdgeLock returns whether the player can take this edge right
// now, with a player-facing reason on failure. Per plan §G5: Perception // now, with a player-facing reason on failure. Per plan §G5: Perception
// rolls fire once at fork-arrival (deterministic seed) and the result // rolls fire once at fork-arrival (deterministic seed) and the result
@@ -101,7 +131,7 @@ func evaluateEdgeLock(e ZoneEdge, ctx edgeUnlockCtx) (unlocked bool, reason stri
roll := perceptionRollForEdge(ctx.RunID, ctx.FromNode, e.To) roll := perceptionRollForEdge(ctx.RunID, ctx.FromNode, e.To)
total := roll + ctx.AbilityMods[4] total := roll + ctx.AbilityMods[4]
if total >= dc { if total >= dc {
return true, "" return true, ctx.creditFor(4)
} }
return false, fmt.Sprintf("Perception %d vs DC %d", total, dc) return false, fmt.Sprintf("Perception %d vs DC %d", total, dc)
case LockKey: case LockKey:
@@ -138,7 +168,7 @@ func evaluateEdgeLock(e ZoneEdge, ctx edgeUnlockCtx) (unlocked bool, reason stri
roll := perceptionRollForEdge(ctx.RunID, ctx.FromNode, e.To) roll := perceptionRollForEdge(ctx.RunID, ctx.FromNode, e.To)
total := roll + ctx.AbilityMods[idx] total := roll + ctx.AbilityMods[idx]
if total >= dc { if total >= dc {
return true, "" return true, ctx.creditFor(idx)
} }
return false, fmt.Sprintf("%s %d vs DC %d", stat, total, dc) return false, fmt.Sprintf("%s %d vs DC %d", stat, total, dc)
} }
@@ -219,10 +249,45 @@ func buildUnlockCtx(c *DnDCharacter, runID, fromNode string) edgeUnlockCtx {
} }
if exp, err := getActiveExpedition(c.UserID); err == nil && exp != nil { if exp, err := getActiveExpedition(c.UserID); err == nil && exp != nil {
ctx.Expedition = exp ctx.Expedition = exp
foldPartyAbilities(&ctx, exp, c.UserID)
} }
return ctx return ctx
} }
// foldPartyAbilities raises ctx.AbilityMods to the best any body on the
// expedition can offer. The companion counts: he is a seat that walks the same
// corridor, and excluding him would make hiring a scout worth less than the
// coins it costs.
//
// Errors are swallowed rather than propagated — a roster read that fails leaves
// the leader's own mods standing, which is exactly the pre-party behaviour and
// never harder than it was.
func foldPartyAbilities(ctx *edgeUnlockCtx, exp *Expedition, acting id.UserID) {
seats, err := expeditionParty(exp.ID, string(exp.UserID))
if err != nil {
return
}
for _, s := range seats {
if s.Kind == SeatCompanion {
class, level := companionLoadout(exp.ID)
ctx.bestAbility(companionSheet(class, level).Modifiers(), companionDisplayName)
continue
}
if s.UserID == acting {
continue // whoever we built the ctx from is already the baseline
}
mate, err := LoadDnDCharacter(s.UserID)
if err != nil || mate == nil {
continue
}
name, _ := loadDisplayName(s.UserID)
if name == "" {
name = string(s.UserID)
}
ctx.bestAbility(mate.Modifiers(), name)
}
}
// evaluateForkEdges walks all outgoing edges of fromNode in the graph // evaluateForkEdges walks all outgoing edges of fromNode in the graph
// and produces a pending-choice list ready to be persisted. Locked // and produces a pending-choice list ready to be persisted. Locked
// edges that have a Hint stay in the menu (the player needs the // edges that have a Hint stay in the menu (the player needs the
@@ -288,6 +353,10 @@ func renderForkPrompt(zone ZoneDefinition, pf pendingFork) string {
b.WriteString(fmt.Sprintf("**%s — Path divides.** Choose with `!zone go <n>`.\n\n", zone.Display)) b.WriteString(fmt.Sprintf("**%s — Path divides.** Choose with `!zone go <n>`.\n\n", zone.Display))
for _, c := range pf.Options { for _, c := range pf.Options {
switch { switch {
case c.Unlocked && c.Reason != "":
// A party-mate's ability beat the check — say so, so the player can
// see what the roster bought them.
b.WriteString(fmt.Sprintf("**%d.** %s _(%s)_\n", c.Index, c.Label, c.Reason))
case c.Unlocked: case c.Unlocked:
b.WriteString(fmt.Sprintf("**%d.** %s\n", c.Index, c.Label)) b.WriteString(fmt.Sprintf("**%d.** %s\n", c.Index, c.Label))
case c.Hint != "": case c.Hint != "":
+175
View File
@@ -0,0 +1,175 @@
package plugin
import (
"fmt"
"strings"
"maunium.net/go/mautrix/id"
)
// Thieves' tools — the counterweight to a failed skill check.
//
// A Perception / stat-check lock rolls once per (run, edge) and the roll is
// seeded, deliberately, so re-reading the fork can't reroll it (plan §G5). That
// half shipped; the other half never did, so a bad roll simply deleted a branch
// of the graph for the rest of the run with no recourse at all — worst for a
// solo low-WIS character, who quietly loses routes they never learn existed.
//
// Tools are that recourse: a consumable that answers the check instead of the
// character. They are deliberately NOT a skeleton key. A key lock is a quest
// token, a level-min lock is progression, and a region-clear lock is structure —
// none of those are "you rolled badly", so none of them are pickable. Tools open
// exactly the locks that luck closed.
// thievesToolsName is the inventory item name. Matching is case-folded so a
// player typing it back at the shop doesn't have to find the apostrophe.
const thievesToolsName = "Thieves' Tools"
// thievesToolsItemType keeps them out of the combat consumable scan. They are a
// utility item like the medical-debt card, not something the fight engine may
// spend on the player's behalf.
const thievesToolsItemType = "tool"
// thievesToolsPrice is what Luigi charges. Priced against a T1 consumable so
// carrying a couple is a routine purchase rather than a considered one — the
// point is that no run is ever hard-walled, not to open a money sink.
const thievesToolsPrice int64 = 600
// pickableLock reports whether tools can answer this lock. Only the two
// dice-driven kinds qualify; see the file comment for why the rest don't.
func pickableLock(kind string) bool {
return kind == string(LockPerception) || kind == string(LockStatCheck)
}
// isThievesToolsReply matches a shop reply against the tools.
//
// Deliberately not "is the reply a substring of the name": that predicate is
// true for a bare "s", for "to", and for an empty message body — and this branch
// sits ahead of the consumable list, so a stray keystroke in the Supplies view
// would silently bill the player €600. Match on the item's own words instead.
func isThievesToolsReply(reply string) bool {
return containsFold(reply, "thieves") || containsFold(reply, "thief") ||
containsFold(reply, "tools")
}
// thievesToolsHeld returns the inventory row IDs of every set the player is
// carrying. One read answers both "have they got any" and "how many are left",
// which the unlock flow would otherwise ask the inventory table three times.
func thievesToolsHeld(userID id.UserID) []int64 {
inv, err := loadAdvInventory(userID)
if err != nil {
return nil
}
var ids []int64
for _, it := range inv {
if strings.EqualFold(it.Name, thievesToolsName) {
ids = append(ids, it.ID)
}
}
return ids
}
// findThievesTools returns the inventory row ID of one set of tools, or ok=false
// if the player is carrying none.
func findThievesTools(userID id.UserID) (int64, bool) {
held := thievesToolsHeld(userID)
if len(held) == 0 {
return 0, false
}
return held[0], true
}
// countThievesTools reports how many sets the player carries, for the "N left"
// line after a use.
func countThievesTools(userID id.UserID) int {
return len(thievesToolsHeld(userID))
}
// zoneCmdUnlock handles `!zone unlock <n>`: spend one set of thieves' tools to
// open a locked fork option, then commit the move exactly as `!zone go <n>`
// would. Every guard zoneCmdGo applies applies here too — same run, same leader
// rule, same mid-fight refusal — because this is that command with a different
// admission price.
func (p *AdventurePlugin) zoneCmdUnlock(ctx MessageContext, rest string) error {
run, isLeader, err := activeZoneRunFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
}
if run == nil {
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
}
if !isLeader {
return p.SendDM(ctx.Sender, msgLeaderPicksPath)
}
if cs, _ := activeCombatSessionFor(ctx.Sender); cs != nil {
return p.SendDM(ctx.Sender, "⚔️ Finish your fight first — `!attack` or `!flee`.")
}
pf, derr := decodePendingFork(run.NodeChoices)
if derr != nil {
return p.SendDM(ctx.Sender, "Couldn't decode pending fork: "+derr.Error())
}
if pf == nil {
return p.SendDM(ctx.Sender, "No fork pending. Use "+continueHint(ctx.Sender))
}
held := thievesToolsHeld(ctx.Sender)
rest = strings.TrimSpace(rest)
if rest == "" {
zone := zoneOrFallback(run.ZoneID)
return p.SendDM(ctx.Sender, "**Which one?**\n\n"+renderForkPrompt(zone, *pf)+
fmt.Sprintf("\n\n_`!zone unlock <n>` — spends one set of %s. You carry %d._",
thievesToolsName, len(held)))
}
choice := atoiSafe(rest)
if choice < 1 || choice > len(pf.Options) {
return p.SendDM(ctx.Sender, fmt.Sprintf("Choice must be a number from the menu (1%d).", len(pf.Options)))
}
chosen := pf.Options[choice-1]
if chosen.Unlocked {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"**%s** is already open — no need to spend tools. `!zone go %d`.", chosen.Label, choice))
}
if !pickableLock(chosen.Lock) {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"🔒 Tools won't help here. %s\n\n_Thieves' tools answer a failed check, not a locked gate._",
lockRefusalFor(chosen)))
}
if len(held) == 0 {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"🔒 **%s** needs %s and you're carrying none.\n\nLuigi stocks them under `!shop` → Supplies.",
chosen.Label, thievesToolsName))
}
if rerr := removeAdvInventoryItem(held[0]); rerr != nil {
return p.SendDM(ctx.Sender, "Couldn't spend the tools: "+rerr.Error())
}
// The tools answered the check, so the option is open from here on. Commit
// it back to the pending fork before advancing: if the advance fails, the
// player has paid and must not be told the door is shut again.
pf.Options[choice-1].Unlocked = true
pf.Options[choice-1].Reason = "opened with " + thievesToolsName
_ = writePendingFork(run.RunID, *pf)
header := fmt.Sprintf("🔓 **%s** — picked. _(%s used, %d left)_\n\n",
chosen.Label, thievesToolsName, len(held)-1)
return p.commitForkChoice(ctx, run, pf.Options[choice-1], header)
}
// lockRefusalFor phrases why a non-pickable lock stays shut, preferring the
// evaluator's own reason so the player sees the same wording the menu gave.
func lockRefusalFor(c pendingChoice) string {
if c.Reason != "" {
return c.Reason + "."
}
switch c.Lock {
case string(LockKey):
return "That door wants a key, and a key is a thing you find, not a thing you force."
case string(LockLevelMin):
return "That way is beyond you yet. Come back stronger."
case string(LockRegionClear):
return "Somewhere else has to fall first."
}
return "That one isn't going to open."
}
+109
View File
@@ -0,0 +1,109 @@
package plugin
import "testing"
// TestPickableLock pins which locks thieves' tools may answer. Tools substitute
// for a failed die roll, never for progression: a key is a quest token, a level
// gate is progression, a region-clear gate is structure.
func TestPickableLock(t *testing.T) {
pickable := []ZoneEdgeLockKind{LockPerception, LockStatCheck}
for _, k := range pickable {
if !pickableLock(string(k)) {
t.Errorf("%s should be pickable", k)
}
}
sealed := []ZoneEdgeLockKind{LockKey, LockLevelMin, LockRegionClear, LockNone, ""}
for _, k := range sealed {
if pickableLock(string(k)) {
t.Errorf("%s must not be pickable", k)
}
}
}
// TestBestAbilityTakesPartyMax is the core of the party-check fix: a door reads
// the best eyes present, not the leader's.
func TestBestAbilityTakesPartyMax(t *testing.T) {
ctx := edgeUnlockCtx{AbilityMods: [6]int{0, 1, 0, 0, 1, 0}}
ctx.bestAbility([6]int{0, 5, 0, 0, -1, 0}, "Josie")
ctx.bestAbility([6]int{0, 2, 0, 0, 6, 0}, "Pete")
if ctx.AbilityMods[1] != 5 || ctx.AbilityWho[1] != "Josie" {
t.Errorf("DEX = %d by %q, want 5 by Josie", ctx.AbilityMods[1], ctx.AbilityWho[1])
}
if ctx.AbilityMods[4] != 6 || ctx.AbilityWho[4] != "Pete" {
t.Errorf("WIS = %d by %q, want 6 by Pete", ctx.AbilityMods[4], ctx.AbilityWho[4])
}
// Nobody beat the leader's STR, so nobody is credited for it.
if ctx.AbilityWho[0] != "" {
t.Errorf("STR credited to %q, want nobody", ctx.AbilityWho[0])
}
}
// TestEvaluateEdgeLockUsesPartyBest — a check the leader fails and a party-mate
// passes must open, and must say who opened it.
func TestEvaluateEdgeLockUsesPartyBest(t *testing.T) {
e := ZoneEdge{To: "z.secret", Lock: LockPerception, LockData: map[string]any{"dc": 30}}
ctx := edgeUnlockCtx{RunID: "run1", FromNode: "z.fork"}
if ok, _ := evaluateEdgeLock(e, ctx); ok {
t.Fatal("DC 30 should be unreachable with no modifiers")
}
// perceptionRollForEdge is seeded, so a mod big enough to clear DC 30 from
// any roll makes this deterministic without pinning the roll itself.
ctx.bestAbility([6]int{0, 0, 0, 0, 29, 0}, "Pete")
ok, reason := evaluateEdgeLock(e, ctx)
if !ok {
t.Fatalf("party best should open the door, got %q", reason)
}
if reason != "Pete got it open" {
t.Errorf("reason = %q, want credit to Pete", reason)
}
}
// TestRankForkOptionsPrefersUnvisitedThenWeight guards the autopilot's route
// choice. Menu order is edge-authoring order and means nothing to a player.
func TestRankForkOptionsPrefersUnvisitedThenWeight(t *testing.T) {
g := ZoneGraph{
Nodes: map[string]ZoneNode{},
Edges: map[string][]ZoneEdge{
"z.fork": {
{From: "z.fork", To: "z.seen", Weight: 9},
{From: "z.fork", To: "z.thin", Weight: 1},
{From: "z.fork", To: "z.fat", Weight: 5},
},
},
}
run := &DungeonRun{CurrentNode: "z.fork", VisitedNodes: []string{"z.fork", "z.seen"}}
pf := &pendingFork{Options: []pendingChoice{
{Index: 1, To: "z.seen", Label: "Seen", Unlocked: true},
{Index: 2, To: "z.thin", Label: "Thin", Unlocked: true},
{Index: 3, To: "z.fat", Label: "Fat", Unlocked: true},
{Index: 4, To: "z.shut", Label: "Shut", Unlocked: false},
}}
got := rankForkOptions(g, run, pf)
want := []string{"z.fat", "z.thin", "z.seen"}
if len(got) != len(want) {
t.Fatalf("got %d options, want %d (locked routes must be dropped)", len(got), len(want))
}
for i, w := range want {
if got[i].To != w {
t.Errorf("rank[%d] = %s, want %s", i, got[i].To, w)
}
}
}
// TestRankForkOptionsAllLocked — nothing takeable means nothing returned, which
// is what sends the autopilot to the tools/backtrack path instead of the reaper.
func TestRankForkOptionsAllLocked(t *testing.T) {
g := ZoneGraph{Nodes: map[string]ZoneNode{}, Edges: map[string][]ZoneEdge{}}
run := &DungeonRun{CurrentNode: "z.fork", VisitedNodes: []string{"z.fork"}}
pf := &pendingFork{Options: []pendingChoice{
{Index: 1, To: "z.a", Unlocked: false},
{Index: 2, To: "z.b", Unlocked: false},
}}
if got := rankForkOptions(g, run, pf); len(got) != 0 {
t.Errorf("got %d takeable options, want 0", len(got))
}
}