Compare 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
15 changed files with 988 additions and 64 deletions
@@ -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
+14
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.
+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{
+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))
} }
+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))
}
}