mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 19:01:09 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ce3e682ea | ||
|
|
6bcac41aa2 | ||
|
|
71b97763ce | ||
|
|
690ff758fe | ||
|
|
ca2d1a8ea3 |
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 1–3 once D&D setup
|
||||||
// frozen legacy CombatLevel — that snapshots at 1–3 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 (1–3 / 4–7 / 8–12 / 13–17 / 18+).
|
// the arena tier bands (1–3 / 4–7 / 8–12 / 13–17 / 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 != "":
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user