mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 19:01:09 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ce3e682ea | ||
|
|
6bcac41aa2 | ||
|
|
71b97763ce | ||
|
|
690ff758fe | ||
|
|
ca2d1a8ea3 | ||
|
|
5a8d21f780 | ||
|
|
68c8cdff2d | ||
|
|
b29dcf4360 | ||
|
|
1f62a8e842 |
@@ -65,9 +65,19 @@ func main() {
|
||||
companion = flag.String("companion", "", "hire Pete into the party: \"auto\" fills the missing role, or name a class (cleric, fighter, …). Empty = no companion. He takes a seat but no loot/XP.")
|
||||
|
||||
jobs = flag.Int("jobs", 0, "matrix mode — concurrent worker count (each worker is a subprocess so it gets its own sqlite). 0 = runtime.NumCPU()")
|
||||
|
||||
seed = flag.Int64("seed", -1, "single-run mode — deterministic seed for zone layout + run id + combat sessions (peripheral procs stay random). <0 = off (default). Matrix mode passes this to each subprocess automatically; use -base-seed there.")
|
||||
baseSeed = flag.Int64("base-seed", -1, "matrix mode — deterministic base seed. Each cell's subprocess gets seed=mix(base,level,zone,rep) (class-independent, so every class faces identical dungeons + dice). <0 = off (default, time-seeded).")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
// Deterministic seeding for reproducible A/B tuning. Off unless -seed >= 0
|
||||
// (the matrix parent sets it per-subprocess from -base-seed). Prod never
|
||||
// calls SeedSim, so this is inert outside the sim.
|
||||
if *seed >= 0 {
|
||||
plugin.SeedSim(*seed)
|
||||
}
|
||||
|
||||
if *petLevel < 0 || *petLevel > 10 {
|
||||
fail("pet-level must be 0-10, got", *petLevel)
|
||||
}
|
||||
@@ -89,7 +99,7 @@ func main() {
|
||||
includeLog = *logFlag
|
||||
}
|
||||
})
|
||||
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses, *companion)
|
||||
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses, *companion, *baseSeed)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -200,7 +210,18 @@ type matrixJob struct {
|
||||
rep int
|
||||
}
|
||||
|
||||
func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool, jobs int, trace bool, petLevel, party int, partyClasses, companion string) {
|
||||
// mixSeed derives a per-cell subprocess seed from the base seed and the cell's
|
||||
// (level, zone, rep) — deliberately class-independent, so every class runs the
|
||||
// identical dungeon + combat dice at a given cell and A/B class deltas pair.
|
||||
func mixSeed(base int64, level int, zone string, rep int) int64 {
|
||||
h := uint64(1469598103934665603) // FNV-1a offset basis
|
||||
for _, c := range fmt.Sprintf("%d|%s|%d", level, zone, rep) {
|
||||
h = (h ^ uint64(c)) * 1099511628211
|
||||
}
|
||||
return int64((uint64(base) ^ h) &^ (uint64(1) << 63)) // non-negative
|
||||
}
|
||||
|
||||
func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool, jobs int, trace bool, petLevel, party int, partyClasses, companion string, baseSeed int64) {
|
||||
cs := splitNonEmpty(classes)
|
||||
ls := parseLevels(levels)
|
||||
zs := splitNonEmpty(zones)
|
||||
@@ -231,7 +252,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < jobs; i++ {
|
||||
wg.Add(1)
|
||||
go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses, companion)
|
||||
go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses, companion, baseSeed)
|
||||
}
|
||||
go func() {
|
||||
for _, j := range work {
|
||||
@@ -250,7 +271,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days
|
||||
}
|
||||
}
|
||||
|
||||
func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult, wg *sync.WaitGroup, bank float64, cap, days int, includeLog, trace bool, petLevel, party int, partyClasses, companion string) {
|
||||
func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult, wg *sync.WaitGroup, bank float64, cap, days int, includeLog, trace bool, petLevel, party int, partyClasses, companion string, baseSeed int64) {
|
||||
defer wg.Done()
|
||||
for j := range in {
|
||||
uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep)
|
||||
@@ -272,6 +293,9 @@ func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult,
|
||||
fmt.Sprintf("-pet-level=%d", petLevel),
|
||||
fmt.Sprintf("-party=%d", party),
|
||||
}
|
||||
if baseSeed >= 0 {
|
||||
args = append(args, "-seed", strconv.FormatInt(mixSeed(baseSeed, j.level, j.zone, j.rep), 10))
|
||||
}
|
||||
// Left empty, each cell's followers clone that cell's own -class.
|
||||
if partyClasses != "" {
|
||||
args = append(args, "-party-classes", partyClasses)
|
||||
|
||||
@@ -388,6 +388,33 @@ type PlayerDetail struct {
|
||||
Equipped []ItemView `json:"equipped,omitempty"`
|
||||
House HouseView `json:"house"`
|
||||
Pets []PetView `json:"pets,omitempty"`
|
||||
// Slots is the 5 standard equipment slots (weapon/armor/helmet/boots/tool) for
|
||||
// the web management panel. Worn masterwork/arena pieces surface here (via
|
||||
// CanTakeOff), not in Equipped, which stays magic-only (the DnD slots).
|
||||
Slots []EquipSlotView `json:"slots,omitempty"`
|
||||
// Balance is the owner's euro balance, for the web's upgrade/repair confirm.
|
||||
// No omitempty: a €0 balance is a real, informative fact (a broke player), not
|
||||
// an absent one — dropping it would let the confirm dialog show a stale amount.
|
||||
Balance float64 `json:"balance"`
|
||||
}
|
||||
|
||||
// EquipSlotView is one of the 5 standard equipment slots, carrying what the web
|
||||
// management panel needs: what's worn now, whether it round-trips to the pack
|
||||
// (masterwork/arena), the next tier's name and price for an upgrade offer, and a
|
||||
// repair cost when the piece is damaged. Pete renders it and trusts only these
|
||||
// facts — a client-forged tier or price is resolved back against this view.
|
||||
type EquipSlotView struct {
|
||||
Slot string `json:"slot"` // weapon|armor|helmet|boots|tool
|
||||
Name string `json:"name"`
|
||||
Tier int `json:"tier"`
|
||||
Condition int `json:"condition"`
|
||||
Masterwork bool `json:"masterwork,omitempty"`
|
||||
ArenaTier int `json:"arena_tier,omitempty"`
|
||||
CanTakeOff bool `json:"can_take_off,omitempty"` // masterwork/arena → round-trippable
|
||||
NextTier int `json:"next_tier,omitempty"` // 0 = at max tier (5)
|
||||
NextName string `json:"next_name,omitempty"`
|
||||
NextPrice float64 `json:"next_price,omitempty"`
|
||||
RepairCost int `json:"repair_cost,omitempty"` // 0 = full condition
|
||||
}
|
||||
|
||||
// ItemView is one item in the private panels — backpack, vault, or worn.
|
||||
@@ -692,7 +719,8 @@ type EquipOrder struct {
|
||||
ItemID int64 `json:"item_id"`
|
||||
ItemName string `json:"item_name"`
|
||||
Slot string `json:"slot"`
|
||||
Action string `json:"action"`
|
||||
Action string `json:"action"` // equip / unequip / upgrade / repair
|
||||
Tier int `json:"tier"` // upgrade target tier (an EquipmentSlot tier); unused by the others
|
||||
Status string `json:"status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -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. " +
|
||||
"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 ───────────────────────────────────────────────────────
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
|
||||
@@ -164,20 +164,18 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
|
||||
gaveCard = true
|
||||
}
|
||||
|
||||
// Update visit count, and every 10th visit leave a small consumable
|
||||
// "for the trouble" (D2 NPC arc).
|
||||
var leftGift *AdvItem
|
||||
// Update visit count and work out what he leaves behind.
|
||||
var leftGifts []AdvItem
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err == nil {
|
||||
char.RobbieVisitCount++
|
||||
if char.RobbieVisitCount%robbieGiftEveryNVisits == 0 {
|
||||
// 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
|
||||
// completes, so reading it here would peg every gift at tier 1.
|
||||
if gifts := consumableCache(robbieGiftTier(arenaDnDLevelOrZero(userID)), 1); len(gifts) > 0 {
|
||||
if err := addAdvInventoryItem(userID, gifts[0]); err == nil {
|
||||
leftGift = &gifts[0]
|
||||
}
|
||||
// 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
|
||||
// completes, so reading it here would peg every gift at tier 1.
|
||||
tier := robbieGiftTier(arenaDnDLevelOrZero(userID))
|
||||
for _, gift := range consumableCache(tier, robbieGiftCount(char.RobbieVisitCount, len(takenItems))) {
|
||||
if err := addAdvInventoryItem(userID, gift); err == nil {
|
||||
leftGifts = append(leftGifts, gift)
|
||||
}
|
||||
}
|
||||
_ = saveAdvCharacter(char)
|
||||
@@ -185,7 +183,7 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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 {
|
||||
var result []AdvItem
|
||||
for _, item := range inv {
|
||||
// Never touch Arena gear, cards, consumables, or keys. Consumables are
|
||||
// a player-curated stockpile (crafted or dropped); selling them is an
|
||||
// explicit decision the player must make themselves. Keys are cross-zone
|
||||
// unlock tokens (N5/D4) that must persist in inventory to open their
|
||||
// vault later — sweeping one permanently breaks that unlock.
|
||||
if item.Type == "ArenaGear" || item.Type == "card" || item.Type == "consumable" || item.Type == "key" {
|
||||
// Never touch Arena gear, cards, consumables, keys, or tools.
|
||||
// Consumables are a player-curated stockpile (crafted or dropped);
|
||||
// selling them is an explicit decision the player must make themselves.
|
||||
// Keys are cross-zone unlock tokens (N5/D4) that must persist in
|
||||
// inventory to open their vault later — sweeping one permanently breaks
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -267,9 +270,49 @@ func robbiePlayerHasCard(userID id.UserID) bool {
|
||||
|
||||
// ── 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
|
||||
|
||||
// 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
|
||||
// the arena tier bands (1–3 / 4–7 / 8–12 / 13–17 / 18+).
|
||||
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
|
||||
|
||||
// Opening
|
||||
@@ -334,9 +377,19 @@ func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gav
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Every-10th-visit consumable (D2).
|
||||
if leftGift != nil {
|
||||
sb.WriteString(fmt.Sprintf(robbieLeftConsumable, leftGift.Name))
|
||||
// What he left behind: the every-10th-visit loyalty consumable (D2), the
|
||||
// big-haul thank-you, or both rolled into one line. A single gift keeps the
|
||||
// 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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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 keptMagic int
|
||||
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++
|
||||
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("Stronger consumables drop from foraging, mining, fishing, and dungeons at T2+.")
|
||||
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.*")
|
||||
}
|
||||
|
||||
// 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
|
||||
var match *ConsumableDef
|
||||
for i := range consumableDefs {
|
||||
@@ -1115,6 +1131,34 @@ func (p *AdventurePlugin) resolveShopSupplyChoice(ctx MessageContext, interactio
|
||||
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) ────────────────────────────────────────────────────
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// worldBossTick rides the 1-minute event ticker. It auto-spawns a boss on the
|
||||
// first of each UTC month and resolves a live boss whose window has lapsed. The
|
||||
// defeat path is not here — a bout crossing the pool to zero resolves inline
|
||||
// (W2), because the ticker never sees the pool between two 60s reads.
|
||||
// worldBossTick rides the 1-minute event ticker. It auto-spawns the month's
|
||||
// boss and resolves a live boss whose window has lapsed. The defeat path is not
|
||||
// here — a bout crossing the pool to zero resolves inline (W2), because the
|
||||
// ticker never sees the pool between two 60s reads.
|
||||
func (p *AdventurePlugin) worldBossTick() {
|
||||
boss, err := loadActiveWorldBoss()
|
||||
if err != nil {
|
||||
@@ -423,10 +423,17 @@ func (p *AdventurePlugin) worldBossTick() {
|
||||
}
|
||||
return
|
||||
}
|
||||
// No boss camped — auto-spawn on the 1st of the month, once.
|
||||
if now.Day() != 1 {
|
||||
return
|
||||
}
|
||||
// No boss camped — spawn this month's Siege, once.
|
||||
//
|
||||
// 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")
|
||||
if db.JobCompleted("worldboss_spawn", monthKey) {
|
||||
return
|
||||
|
||||
@@ -32,6 +32,20 @@ func (p *AdventurePlugin) postCombatBookkeeping(
|
||||
if err := persistDnDPostCombatSubclass(dndChar, raged, result, mods); err != nil {
|
||||
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.
|
||||
@@ -371,7 +385,7 @@ type DeathTransitionResult struct {
|
||||
func transitionDeath(p DeathTransitionParams) DeathTransitionResult {
|
||||
var r DeathTransitionResult
|
||||
|
||||
if p.AllowPardon && p.ChatLevel >= 20 && p.Char.PardonAvailable() && rand.Float64() < 0.33 {
|
||||
if p.AllowPardon && p.ChatLevel >= 20 && p.Char.PardonAvailable() && simFloat64() < 0.33 {
|
||||
r.Pardoned = true
|
||||
now := time.Now().UTC()
|
||||
p.Char.LastPardonUsed = &now
|
||||
|
||||
@@ -707,6 +707,24 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
||||
PlayerHeal: out.PlayerHeal,
|
||||
EnemySkip: out.EnemySkip,
|
||||
}
|
||||
// Revive the arcane-blaster sustained cantrip floor in the turn engine.
|
||||
// combat_engine.go:590 deals CantripPerRound flat every round, but that
|
||||
// path is the swing-based engine (SimulateCombat). Auto-resolve — the
|
||||
// live path for every expedition — runs the turn engine, where a caster
|
||||
// autocasts and never weapon-swings, so the floor was dead code: casters
|
||||
// fought at bare cantrip dice (~4d10≈22 at L20). Lift LANDED cantrip
|
||||
// damage to the floor, but only when the cast already connected
|
||||
// (eff.EnemyDamage > 0) — a whiffed Fire Bolt still whiffs, so the ~35%
|
||||
// miss variance survives and the floor doesn't become a guaranteed flat
|
||||
// hammer (that overshot to ~99%). Damage cantrips only; slot spells keep
|
||||
// their rolled damage. Only Mage/Sorcerer/Warlock carry a nonzero
|
||||
// CantripPerRound, so this is self-targeting.
|
||||
if spell.Level == 0 && eff.EnemyDamage > 0 &&
|
||||
(spell.Effect == EffectDamageAttack || spell.Effect == EffectDamageSave || spell.Effect == EffectDamageAuto) {
|
||||
if floor := ct.players[seat].Mods.CantripPerRound; floor > eff.EnemyDamage {
|
||||
eff.EnemyDamage = floor
|
||||
}
|
||||
}
|
||||
// §1 — redirect the heal onto the named ally. The roll is the same; only
|
||||
// the body it lands on changes. This is the line that makes a cleric a
|
||||
// cleric: until it existed, every heal in the engine was a self-heal, and
|
||||
|
||||
@@ -482,6 +482,9 @@ const combatSessionCols = `
|
||||
|
||||
// newCombatSessionID — 16-char hex token. Same scheme as zone runs / expeditions.
|
||||
func newCombatSessionID() string {
|
||||
if simSeedOn() {
|
||||
return simHexToken()
|
||||
}
|
||||
var b [8]byte
|
||||
if _, err := cryptorand.Read(b[:]); err != nil {
|
||||
// Vanishingly unlikely; fall through with a zeroed prefix.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -843,45 +844,195 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
|
||||
// run graph / harvest tally / supplies / threat — same as before, just
|
||||
// no streamFlow here. compact==true switches the underlying combat
|
||||
// narration into terse mode and auto-resolves elite (not boss) rooms.
|
||||
// forkAutoPickTimeout — how long a background fork may sit unanswered
|
||||
// before the autopilot picks an available route itself. Short enough that
|
||||
// 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
|
||||
// say on a genuine fork.
|
||||
const forkAutoPickTimeout = 8 * time.Hour
|
||||
// forkAutoPickTimeout — how long a background fork may sit unanswered before
|
||||
// the autopilot picks a route itself.
|
||||
//
|
||||
// This was 8h, which reads as "the player gets first say" and behaves as "the
|
||||
// expedition stops for a third of a day, every fork." A multi-day expedition
|
||||
// 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
|
||||
// fork, advancing the run to that node exactly as `!zone go <n>` would
|
||||
// (advanceZoneRunNode + region-transition hook). Returns false — no pick —
|
||||
// when every option is locked, so the caller re-emits the prompt and the
|
||||
// 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.
|
||||
func (p *AdventurePlugin) autoPickStaleFork(exp *Expedition, run *DungeonRun, pf *pendingFork) bool {
|
||||
var chosen *pendingChoice
|
||||
for i := range pf.Options {
|
||||
if pf.Options[i].Unlocked {
|
||||
chosen = &pf.Options[i]
|
||||
break
|
||||
// rankForkOptions orders a fork's options by how much walking them is worth:
|
||||
// somewhere new first, then the fatter edge (Weight is the author's own "this
|
||||
// is the main line" signal), then menu order as the tiebreak so the pick is
|
||||
// deterministic. Only unlocked options are returned.
|
||||
//
|
||||
// The old rule was "first unlocked option in the menu", which is edge-authoring
|
||||
// order — meaningful to whoever wrote the graph, arbitrary to the player. It
|
||||
// walked past unvisited branches to loop through cleared ones often enough to
|
||||
// look broken.
|
||||
func rankForkOptions(g ZoneGraph, run *DungeonRun, pf *pendingFork) []pendingChoice {
|
||||
weights := map[string]int{}
|
||||
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 {
|
||||
return false // nothing unlocked — leave it for the player / reaper
|
||||
sort.SliceStable(open, func(i, j int) bool {
|
||||
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 {
|
||||
slog.Warn("expedition: auto-pick stale fork",
|
||||
"user", run.UserID, "run", run.RunID, "err", err)
|
||||
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])
|
||||
if exp != nil {
|
||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
|
||||
fmt.Sprintf("autopilot took an available path after %dh idle at the fork: %s",
|
||||
int(forkAutoPickTimeout.Hours()), chosen.Label), "")
|
||||
fmt.Sprintf("%s: %s", note, chosen.Label), "")
|
||||
}
|
||||
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 {
|
||||
exp, err := getActiveExpedition(ctx.Sender)
|
||||
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.
|
||||
if run, rerr := getActiveZoneRun(ctx.Sender); rerr == nil && run != nil {
|
||||
if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil {
|
||||
picked := compact &&
|
||||
time.Since(run.LastActionAt) > forkAutoPickTimeout &&
|
||||
p.autoPickStaleFork(exp, run, pf)
|
||||
stale := compact && time.Since(run.LastActionAt) > forkAutoPickTimeout
|
||||
picked := stale && 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 {
|
||||
zone := zoneOrFallback(run.ZoneID)
|
||||
return autopilotWalkResult{
|
||||
|
||||
@@ -23,7 +23,6 @@ package plugin
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/flavor"
|
||||
@@ -75,7 +74,7 @@ func resolveCombatInterrupt(
|
||||
rollFn func() int,
|
||||
) (CombatInterruptKind, int) {
|
||||
if rollFn == nil {
|
||||
rollFn = func() int { return rand.IntN(20) + 1 }
|
||||
rollFn = func() int { return simIntN(20) + 1 }
|
||||
}
|
||||
r := rollFn()
|
||||
mod := tier
|
||||
@@ -249,7 +248,7 @@ func surpriseRoundNickF(m DnDMonsterTemplate, tier, floorOverride int) int {
|
||||
if tier < 1 {
|
||||
tier = 1
|
||||
}
|
||||
dmg := 1 + rand.IntN(4) + m.AttackBonus/2
|
||||
dmg := 1 + simIntN(4) + m.AttackBonus/2
|
||||
floor := tier
|
||||
if floorOverride >= 0 {
|
||||
floor = floorOverride
|
||||
@@ -484,7 +483,7 @@ func (p *AdventurePlugin) tryPatrolEncounter(
|
||||
return
|
||||
}
|
||||
chance := rollPatrolChance(exp.ThreatLevel)
|
||||
if chance <= 0 || rand.Float64() > chance {
|
||||
if chance <= 0 || simFloat64() > chance {
|
||||
return
|
||||
}
|
||||
monster, ok := pickZoneEnemy(zone, run.RunID, run.CurrentRoom, false)
|
||||
|
||||
@@ -112,6 +112,56 @@ func applyRacePassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharacte
|
||||
// AutoCritFirst is already a one-shot bool.
|
||||
// - A Cleric carrying a healing potion stacks: passive 5 + potion 8 = 13.
|
||||
// The passive heal triggers first since both use the same threshold.
|
||||
// cantripDice is the 5e at-will cantrip die progression (Fire Bolt / Eldritch
|
||||
// Blast): 1 die L1–4, 2 at L5, 3 at L11, 4 at L17. Drives the per-round arcane
|
||||
// blaster damage (CantripPerRound) that models a caster's sustained at-will
|
||||
// floor — see CombatModifiers.CantripPerRound.
|
||||
func cantripDice(level int) int {
|
||||
switch {
|
||||
case level >= 17:
|
||||
return 4
|
||||
case level >= 11:
|
||||
return 3
|
||||
case level >= 5:
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// casterBlasterFloor gives the arcane blasters (Mage/Sorcerer/Warlock) their
|
||||
// shared sustained-DPS floor at T5. Two knobs, both LEVEL-SCALED so the L20
|
||||
// floor lift stays negligible at low tiers — a flat +40 HP doubled a L1 mage
|
||||
// and facerolled T5, which the class-balance guardrail (dnd_class_balance_test)
|
||||
// correctly rejected.
|
||||
//
|
||||
// - Cantrip: kill-speed is the T5 currency (fights are truncation-bound), so
|
||||
// the primary lever is damage. Multiplicative form — the spell modifier
|
||||
// (INT for Mage, CHA for Sorcerer/Warlock) rides EVERY die, matching 5e
|
||||
// Agonizing Blast. cantripDice scales 1→4 across levels.
|
||||
// - Survival: just enough Defense (scaled by level) that the caster lives long
|
||||
// enough for the cantrip to connect the kill — NOT a tank rider. This adds
|
||||
// Defense only (no HP add); calcDamage's diminishing returns keep the L20
|
||||
// +20 Def from becoming a wall.
|
||||
//
|
||||
// casterCantripBase / casterDefPerLevel are the caster tuning dials; see the
|
||||
// rebaseline plan for the sweep that set them. The cantrip floor only becomes a
|
||||
// live lever once combat_cmd.go bridges CantripPerRound into the turn engine
|
||||
// (the swing engine, combat_engine.go:590, is not the auto-resolve path). Mage
|
||||
// and Sorcerer take casterCantripBase; Warlock passes 0 — its bare-dice cantrip
|
||||
// plus its structural edge already lands it mid-band, so an added floor would
|
||||
// overshoot the 45 ceiling.
|
||||
const (
|
||||
casterCantripBase = 3 // per-die base before the ability modifier rides in
|
||||
casterDefPerLevel = 1 // ~+20 Def at L20, +1 at L1
|
||||
)
|
||||
|
||||
func casterBlasterFloor(stats *CombatStats, mods *CombatModifiers, level, abilityMod, base int, cantrip string) {
|
||||
mods.CantripPerRound = cantripDice(level) * (base + clampNonNeg(abilityMod))
|
||||
mods.CantripDesc = cantrip
|
||||
stats.Defense += casterDefPerLevel * level
|
||||
}
|
||||
|
||||
func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharacter) {
|
||||
switch c.Class {
|
||||
case ClassFighter:
|
||||
@@ -127,18 +177,23 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
|
||||
// re-tune in a follow-up if their win curves drift after this.
|
||||
switch {
|
||||
case c.Level >= 20:
|
||||
mods.ExtraAttacks += 3
|
||||
mods.ExtraAttacks += 2 // rebaseline: ceiling nerf — 3 swings at L20, was 4 (the engine-ceiling faceroll)
|
||||
case c.Level >= 11:
|
||||
mods.ExtraAttacks += 2
|
||||
case c.Level >= 5:
|
||||
mods.ExtraAttacks += 1
|
||||
}
|
||||
stats.AttackBonus -= 2 // rebaseline: sub-swing to-hit trim — 3 swings lands the Fighter in the ~60 band
|
||||
case ClassRogue:
|
||||
mods.AutoCritFirst = true
|
||||
if c.Level >= 5 { // rebaseline: 2nd swing (was 1) fixes the 1-swing action-economy floor at T5
|
||||
mods.ExtraAttacks += 1
|
||||
}
|
||||
stats.AttackBonus -= 3 // rebaseline: to-hit trim so the 2nd swing lands the Rogue in band, not the +75pp nuke
|
||||
// Phase 2 class-balance: rogue's once-per-fight auto-crit goes stale
|
||||
// at high tiers (T5 mean trails leaders by ~10pp pre-tune). Add a
|
||||
// modest steady-DPS rider so post-opener rounds aren't pure attrition.
|
||||
mods.DamageBonus += 0.05
|
||||
mods.DamageBonus += -0.10 // rebaseline: fine-trim the 2-swing Rogue down into the ~60 band
|
||||
// Class-identity audit (2026-05-16) — actual Sneak Attack as Nd6
|
||||
// per hit, scaling with level per 5e (1d6 L1-2 ... 10d6 L19-20).
|
||||
// AutoCritFirst + DamageBonus alone left the rogue's defining
|
||||
@@ -154,6 +209,8 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
|
||||
mods.SneakAttackDie += sneakDice
|
||||
case ClassMage:
|
||||
stats.AttackBonus++
|
||||
// At-will Fire Bolt + level-scaled survival — the sustained arcane floor.
|
||||
casterBlasterFloor(stats, mods, c.Level, abilityModifier(c.INT), casterCantripBase, "Fire Bolt")
|
||||
// Phase 2 class-balance: +1 attack alone left Mage mid-pack on damage
|
||||
// per round. A modest damage rider lifts weapon hits (DamageBonus does
|
||||
// not multiply queued SpellPreDamage — that path is its own field).
|
||||
@@ -190,10 +247,19 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
|
||||
mods.ExtraAttacks += 1
|
||||
}
|
||||
case ClassDruid:
|
||||
// Wild Resilience — multiplicative, so it stacks cleanly with the
|
||||
// subclass DamageReduct riders. DamageReduct is initialized to 1.0
|
||||
// by DerivePlayerStats before passives run.
|
||||
// Multiplicative, so it stacks cleanly with the subclass DamageReduct
|
||||
// riders (DamageReduct is initialized to 1.0 by DerivePlayerStats before
|
||||
// passives run). NOTE the player-defender direction: DamageReduct feeds
|
||||
// calcDamage as a defense multiplier, so <1 = MORE damage taken. This
|
||||
// line is therefore a mild survival trim in the same direction as the
|
||||
// rebaseline *0.2 below — not the damage cut the "Wild Resilience" name
|
||||
// suggests. Left as-is because the rebaseline sweep is tuned to it.
|
||||
mods.DamageReduct *= 0.95
|
||||
// rebaseline: the Druid wins T5 rooms on the survival tiebreak, immune to
|
||||
// every damage lever. DamageReduct is a defense multiplier (calcDamage) —
|
||||
// <1 = take MORE damage. Combined with a damage trim to pull it to band.
|
||||
mods.DamageReduct *= 0.2
|
||||
mods.DamageBonus += -0.20
|
||||
// Phase 3 class-balance: druid was the only caster chassis with a
|
||||
// purely defensive passive, and the off-tier numbers showed it —
|
||||
// L1/T2 mean 0.04 vs Mage 0.27. Mirror the other caster bursts so
|
||||
@@ -219,6 +285,7 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
|
||||
stats.AttackBonus++
|
||||
mods.DamageBonus += 0.05
|
||||
mods.FlatDmgStart += c.Level + clampNonNeg(abilityModifier(c.CHA))
|
||||
mods.DamageReduct *= 0.4 // rebaseline: Bard also wins T5 on the survival tiebreak — take more damage to reach band
|
||||
case ClassSorcerer:
|
||||
// Innate Sorcery — pre-combat burst, CHA-scaled like the Sorcerer's
|
||||
// spellcasting stat. Floors at the flat base for low-CHA builds.
|
||||
@@ -231,6 +298,9 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
|
||||
// touching the +0.05 rider that already saturates at high tier.
|
||||
mods.FlatDmgStart += 5 + c.Level + clampNonNeg(abilityModifier(c.CHA))
|
||||
mods.DamageBonus += 0.05
|
||||
stats.AttackBonus++ // rebaseline: Sorcerer lagged the other blasters — match their +1 to-hit
|
||||
// At-will Fire Bolt + level-scaled survival — sustained arcane floor.
|
||||
casterBlasterFloor(stats, mods, c.Level, abilityModifier(c.CHA), casterCantripBase, "Fire Bolt")
|
||||
case ClassWarlock:
|
||||
// Phase 2 class-balance: bumped from 10% to 12% damage + 1 attack —
|
||||
// the Warlock chassis read mid-pack at T5 (0.52) pre-tune. Eldritch
|
||||
@@ -240,6 +310,8 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
|
||||
mods.DamageBonus += 0.12
|
||||
stats.AttackBonus++
|
||||
mods.FlatDmgStart += c.Level + clampNonNeg(abilityModifier(c.CHA))
|
||||
// At-will Eldritch Blast + level-scaled survival — sustained arcane floor.
|
||||
casterBlasterFloor(stats, mods, c.Level, abilityModifier(c.CHA), 0, "Eldritch Blast")
|
||||
case ClassPaladin:
|
||||
// Class-identity audit (2026-05-16) — Divine Smite as actual
|
||||
// per-hit radiant bonus + L5 Extra Attack. 5e: smite consumes a
|
||||
@@ -249,8 +321,9 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
|
||||
// down so an extra-attack paladin doesn't trivialize every fight.
|
||||
// Rides DivineStrikePerHit (already in the weapon-hit damage path).
|
||||
// Previous FlatDmgStart opener felt like Lay on Hands, not Smite.
|
||||
smite := 3 + c.Level/3
|
||||
smite := 4 + c.Level/2 // rebaseline: bigger Divine Smite lifts the Paladin from floor into band
|
||||
mods.DivineStrikePerHit += smite
|
||||
mods.DamageReduct *= 0.9 // rebaseline: small survival trim between the two integer smite steps for a ~60 landing
|
||||
if c.Level >= 5 {
|
||||
mods.ExtraAttacks += 1
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -117,7 +116,7 @@ func (p *AdventurePlugin) handleDnDShortRest(ctx MessageContext) error {
|
||||
before := c.HPCurrent
|
||||
if !hpFull {
|
||||
conMod := abilityModifier(c.CON)
|
||||
healDie := 1 + rand.IntN(6) // 1d6
|
||||
healDie := 1 + simIntN(6) // 1d6
|
||||
heal := healDie + conMod
|
||||
if heal < 1 {
|
||||
heal = 1
|
||||
|
||||
@@ -3,7 +3,6 @@ package plugin
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
@@ -128,7 +127,7 @@ func applyMageSubclassSpellHooks(c *DnDCharacter, spell SpellDefinition, slotLev
|
||||
// applySpellDamageAttack — Fire Bolt, Inflict Wounds, Chill Touch, etc.
|
||||
// Roll d20 + spell attack vs enemy AC; nat 20 doubles dice damage.
|
||||
func applySpellDamageAttack(spell SpellDefinition, atk int, mods *CombatModifiers, enemy *CombatStats, slot, charLevel int) {
|
||||
roll := 1 + rand.IntN(20)
|
||||
roll := 1 + simIntN(20)
|
||||
isCrit := roll == 20
|
||||
isFumble := roll == 1
|
||||
if isFumble || (!isCrit && roll+atk < enemy.AC) {
|
||||
@@ -158,7 +157,7 @@ func applySpellDamageAttack(spell SpellDefinition, atk int, mods *CombatModifier
|
||||
// future multi-enemy combat (Phase 11+) but is not consulted here.
|
||||
func applySpellDamageSave(spell SpellDefinition, dc int, c *DnDCharacter, mods *CombatModifiers, enemy *CombatStats, slot int) {
|
||||
saveMod := enemySpellSaveMod(enemy)
|
||||
saveRoll := 1 + rand.IntN(20)
|
||||
saveRoll := 1 + simIntN(20)
|
||||
saved := saveRoll+saveMod >= dc
|
||||
dmg := rollSpellDamageDice(spell, slot, c.Level)
|
||||
if saved {
|
||||
@@ -182,7 +181,7 @@ func applySpellDamageAuto(spell SpellDefinition, mods *CombatModifiers, slot, ch
|
||||
}
|
||||
total := 0
|
||||
for i := 0; i < darts; i++ {
|
||||
total += 1 + rand.IntN(4) + 1
|
||||
total += 1 + simIntN(4) + 1
|
||||
}
|
||||
mods.SpellPreDamage += total
|
||||
mods.SpellPreDamageDesc = fmt.Sprintf("Magic Missile (%d darts, %d dmg)", darts, total)
|
||||
@@ -223,7 +222,7 @@ func enemySpellSaveMod(enemy *CombatStats) int {
|
||||
// double damage (5e: paralyzed creatures auto-crit on melee hits).
|
||||
func applySpellControl(spell SpellDefinition, dc int, mods *CombatModifiers, enemy *CombatStats, slot int) {
|
||||
saveMod := enemySpellSaveMod(enemy)
|
||||
saveRoll := 1 + rand.IntN(20)
|
||||
saveRoll := 1 + simIntN(20)
|
||||
if saveRoll+saveMod >= dc {
|
||||
mods.SpellPreDamageDesc = spell.Name + " — resisted"
|
||||
return
|
||||
@@ -382,7 +381,7 @@ func rollTurnSpellHeal(c *DnDCharacter, spell SpellDefinition, slotLevel int) in
|
||||
if supreme {
|
||||
heal += faces
|
||||
} else {
|
||||
heal += 1 + rand.IntN(faces)
|
||||
heal += 1 + simIntN(faces)
|
||||
}
|
||||
}
|
||||
heal += abilityModifier(c.WIS)
|
||||
@@ -418,7 +417,7 @@ func rollSpellDamageDice(spell SpellDefinition, slot, charLevel int) int {
|
||||
}
|
||||
total := flat
|
||||
for i := 0; i < dice; i++ {
|
||||
total += 1 + rand.IntN(faces)
|
||||
total += 1 + simIntN(faces)
|
||||
}
|
||||
if total < 1 {
|
||||
total = 1
|
||||
|
||||
@@ -224,7 +224,14 @@ func TestApplyClassPassives(t *testing.T) {
|
||||
wantFlatStart int
|
||||
wantInitBias float64
|
||||
}{
|
||||
{ClassFighter, 0.05, 0, false, 0, 1.0, 0, 0},
|
||||
// Fighter-ceiling rebaseline (2026-07-16): Fighter picked up a -2 to-hit
|
||||
// sub-swing trim; Rogue's steady rider flipped +0.05→-0.10 and it took a
|
||||
// -3 to-hit trim (both offset a new 2nd swing gated at L5, not seen here);
|
||||
// Druid took a survival trim (DR 0.95→0.19) + -0.20 damage; Bard a DR 0.4
|
||||
// survival trim; Sorcerer a +1 to-hit to match the blasters; Paladin a
|
||||
// 0.9 DR survival trim. Caster CantripPerRound / Defense adds are
|
||||
// not asserted here.
|
||||
{ClassFighter, 0.05, -2, false, 0, 1.0, 0, 0},
|
||||
// Phase 2 class-balance rebalance: rogue picked up +5% damage,
|
||||
// Mage/Bard/Warlock gained a level-scaled FlatDmgStart burst, Sorcerer's
|
||||
// burst now also scales with level, and Warlock picked up +1 attack.
|
||||
@@ -233,7 +240,7 @@ func TestApplyClassPassives(t *testing.T) {
|
||||
// Phase 3 class-balance: Druid picked up a WIS-scaled FlatDmgStart burst
|
||||
// (lvl 1 + clamp(mod(WIS=0)) = 1), and Sorcerer's burst base went 3→5
|
||||
// (5 + 1 + clamp(mod(CHA=10)=0) = 6).
|
||||
{ClassRogue, 0.05, 0, true, 0, 1.0, 0, 0},
|
||||
{ClassRogue, -0.10, -3, true, 0, 1.0, 0, 0},
|
||||
{ClassMage, 0.05, 1, false, 0, 1.0, 1, 0},
|
||||
{ClassCleric, 0, 0, false, 5, 1.0, 0, 0},
|
||||
// Class-identity audit (2026-05-16): Ranger Hunter's Mark is now
|
||||
@@ -243,11 +250,11 @@ func TestApplyClassPassives(t *testing.T) {
|
||||
// FlatDmgStart compensation riders are gone; +1 to-hit stays on
|
||||
// Ranger as the "read prey tells" half.
|
||||
{ClassRanger, 0, 1, false, 0, 1.0, 0, 0},
|
||||
{ClassDruid, 0, 0, false, 0, 0.95, 1, 0},
|
||||
{ClassBard, 0.05, 1, false, 0, 1.0, 1, 1},
|
||||
{ClassSorcerer, 0.05, 0, false, 0, 1.0, 6, 0},
|
||||
{ClassDruid, -0.20, 0, false, 0, 0.95 * 0.2, 1, 0},
|
||||
{ClassBard, 0.05, 1, false, 0, 0.4, 1, 1},
|
||||
{ClassSorcerer, 0.05, 1, false, 0, 1.0, 6, 0},
|
||||
{ClassWarlock, 0.12, 1, false, 0, 1.0, 1, 0},
|
||||
{ClassPaladin, 0, 0, false, 0, 1.0, 0, 0},
|
||||
{ClassPaladin, 0, 0, false, 0, 0.9, 0, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
stats := CombatStats{AttackBonus: 5}
|
||||
|
||||
@@ -53,6 +53,9 @@ func (p *AdventurePlugin) handleDnDZoneCmd(ctx MessageContext, args string) erro
|
||||
// fork pending) the handler short-circuits with a friendly
|
||||
// message — see zoneCmdGo for the full surface.
|
||||
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":
|
||||
return p.zoneCmdStatus(ctx)
|
||||
case "map", "m":
|
||||
@@ -83,6 +86,7 @@ func zoneHelpText() string {
|
||||
b.WriteString("`!zone map` — show the room layout\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 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("`!zone abandon` — end the active run (no rewards)\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 {
|
||||
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)
|
||||
if aerr != nil {
|
||||
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)
|
||||
nextRoom := nodeKindToRoomType(nextNode.Kind)
|
||||
var b strings.Builder
|
||||
b.WriteString(header)
|
||||
if kind := autoBreakCampOnMove(ctx.Sender); kind != "" {
|
||||
b.WriteString(fmt.Sprintf("⛺ Camp struck (**%s**) — the party moved on.\n\n", kind))
|
||||
}
|
||||
|
||||
@@ -358,7 +358,10 @@ func pickLootEntry(zone map[LootTier][]ZoneLootDrop, tier LootTier, rng *rand.Ra
|
||||
// while production paths use the package-global generator.
|
||||
func rngFloat(rng *rand.Rand) float64 {
|
||||
if rng == nil {
|
||||
return rand.Float64()
|
||||
// Auto-resolve rooms pass nil. simFloat64 routes to the seeded combat
|
||||
// stream when the sim is seeding, else the package global — so prod
|
||||
// (never seeded) stays byte-identical while sim room combat pairs.
|
||||
return simFloat64()
|
||||
}
|
||||
return rng.Float64()
|
||||
}
|
||||
@@ -368,7 +371,7 @@ func rngIntN(rng *rand.Rand, n int) int {
|
||||
return 0
|
||||
}
|
||||
if rng == nil {
|
||||
return rand.IntN(n)
|
||||
return simIntN(n)
|
||||
}
|
||||
return rng.IntN(n)
|
||||
}
|
||||
|
||||
@@ -175,6 +175,9 @@ func generateRoomSequence(zone ZoneDefinition, rng *rand.Rand) []RoomType {
|
||||
|
||||
// newRunID — 16-char hex token. Crypto-random; collision-resistant.
|
||||
func newRunID() string {
|
||||
if simSeedOn() {
|
||||
return simHexToken()
|
||||
}
|
||||
var b [8]byte
|
||||
if _, err := cryptorand.Read(b[:]); err != nil {
|
||||
// Fall back to math/rand if /dev/urandom is unavailable.
|
||||
@@ -240,7 +243,11 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand)
|
||||
}
|
||||
|
||||
if rng == nil {
|
||||
rng = rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), uint64(time.Now().UnixMicro())))
|
||||
if simSeedOn() {
|
||||
rng = simZoneRNG()
|
||||
} else {
|
||||
rng = rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), uint64(time.Now().UnixMicro())))
|
||||
}
|
||||
}
|
||||
seq := generateRoomSequence(zone, rng)
|
||||
|
||||
|
||||
@@ -318,8 +318,16 @@ func applyClassBaselineStats(c *DnDCharacter) {
|
||||
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 16, 13, 15, 8, 12, 10
|
||||
case ClassRogue, ClassRanger:
|
||||
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 10, 16, 14, 12, 13, 8
|
||||
case ClassMage, ClassSorcerer:
|
||||
case ClassMage:
|
||||
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 8, 14, 13, 16, 12, 10
|
||||
case ClassSorcerer:
|
||||
// Sorcerer is a CHA caster (spellcastingMod → CHA), so its 16 goes in
|
||||
// CHA, not INT. Previously it shared the Mage's INT-heavy array, which
|
||||
// left the synthetic sorcerer casting at CHA mod 0 — every CHA-scaled
|
||||
// ability (cantrip, Innate Sorcery, spell DCs) ran crippled and sorc
|
||||
// trailed the field in every sweep. Prod players always placed 16 in
|
||||
// CHA; this makes the sim's sorcerer match a real one.
|
||||
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 8, 14, 13, 10, 12, 16
|
||||
case ClassCleric, ClassDruid:
|
||||
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 12, 10, 14, 8, 16, 13
|
||||
case ClassBard, ClassWarlock:
|
||||
|
||||
@@ -138,7 +138,7 @@ func TestDetailSnapshotKeyedByLocalpart(t *testing.T) {
|
||||
t.Fatalf("saveAdvCharacter: %v", err)
|
||||
}
|
||||
|
||||
snap, err := buildDetailSnapshot(time.Now().UTC())
|
||||
snap, err := (&AdventurePlugin{}).buildDetailSnapshot(time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("buildDetailSnapshot: %v", err)
|
||||
}
|
||||
@@ -189,7 +189,7 @@ func TestDetailSnapshotIgnoresOptOut(t *testing.T) {
|
||||
}
|
||||
|
||||
// ...but the private detail set keeps them both.
|
||||
detail, err := buildDetailSnapshot(time.Now().UTC())
|
||||
detail, err := (&AdventurePlugin{}).buildDetailSnapshot(time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("buildDetailSnapshot: %v", err)
|
||||
}
|
||||
@@ -218,7 +218,7 @@ func TestDetailSnapshotSkipsDeadPlayers(t *testing.T) {
|
||||
t.Fatalf("kill player: %v", err)
|
||||
}
|
||||
|
||||
snap, err := buildDetailSnapshot(time.Now().UTC())
|
||||
snap, err := (&AdventurePlugin{}).buildDetailSnapshot(time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("buildDetailSnapshot: %v", err)
|
||||
}
|
||||
|
||||
@@ -122,6 +122,14 @@ func (p *AdventurePlugin) fulfilEquipOrder(ctx context.Context, order peteclient
|
||||
// a human note for Pete, or retry=true for a transient fault that should leave the
|
||||
// order pending. It records nothing and pushes nothing — the caller does both.
|
||||
func (p *AdventurePlugin) applyEquipOrder(owner id.UserID, order peteclient.EquipOrder) (status, detail string, retry bool) {
|
||||
// Serialize against the owner's own Matrix-side mutations (!give, !equip, !sell,
|
||||
// arena, …), all of which hold this same per-user lock. Without it the poll
|
||||
// goroutine's equip could interleave with a concurrent !give of the very item it
|
||||
// resolved — the duplication the DM equip confirm takes this lock to prevent.
|
||||
userMu := p.advUserLock(owner)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
switch order.Action {
|
||||
case "equip":
|
||||
inv, err := loadAdvInventory(owner)
|
||||
@@ -142,6 +150,21 @@ func (p *AdventurePlugin) applyEquipOrder(owner id.UserID, order peteclient.Equi
|
||||
// a different item — this is a clean miss, not a wrong hit.
|
||||
return "rejected_not_owned", "That item wasn't in your pack anymore.", false
|
||||
}
|
||||
// Masterwork/arena pieces equip into a standard slot; everything else takes
|
||||
// the magic-item path. Type alone routes it — the id resolved the same row.
|
||||
if it.Type == "MasterworkGear" || it.Type == "ArenaGear" {
|
||||
out, err := applyMasterworkEquip(owner, it)
|
||||
if errors.Is(err, errItemNotEquippable) {
|
||||
return "rejected_not_equippable", "That item can't be worn.", false
|
||||
}
|
||||
if errors.Is(err, errEquipDowngrade) {
|
||||
return "rejected_downgrade", "That isn't an upgrade over what you're wearing.", false
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", true // transient DB fault
|
||||
}
|
||||
return "applied", masterworkEquipDetail(out), false
|
||||
}
|
||||
out, err := applyMagicEquip(owner, it)
|
||||
if errors.Is(err, errItemNotEquippable) {
|
||||
return "rejected_not_equippable", "That item can't be worn.", false
|
||||
@@ -152,6 +175,19 @@ func (p *AdventurePlugin) applyEquipOrder(owner id.UserID, order peteclient.Equi
|
||||
return "applied", equipAppliedDetail(out), false
|
||||
|
||||
case "unequip":
|
||||
// A standard slot (weapon/armor/…) takes a masterwork/arena piece off; a DnD
|
||||
// slot takes a magic item off. The vocabularies are disjoint, so the slot
|
||||
// string alone tells the two apart.
|
||||
if isEquipmentSlot(order.Slot) {
|
||||
out, err := applyMasterworkUnequip(owner, EquipmentSlot(order.Slot))
|
||||
if errors.Is(err, errSlotEmpty) {
|
||||
return "rejected_not_worn", "There was nothing to take off there.", false
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", true
|
||||
}
|
||||
return "applied", fmt.Sprintf("Took %s off, back in your pack.", out.Name), false
|
||||
}
|
||||
out, err := applyMagicUnequip(owner, DnDSlot(order.Slot))
|
||||
if errors.Is(err, errSlotEmpty) {
|
||||
return "rejected_not_worn", "That slot was already empty.", false
|
||||
@@ -165,6 +201,12 @@ func (p *AdventurePlugin) applyEquipOrder(owner id.UserID, order peteclient.Equi
|
||||
}
|
||||
return "applied", note, false
|
||||
|
||||
case "upgrade":
|
||||
return p.purchaseEquipmentTier(owner, EquipmentSlot(order.Slot), order.Tier, order.GUID)
|
||||
|
||||
case "repair":
|
||||
return p.repairSlot(owner, EquipmentSlot(order.Slot), order.GUID)
|
||||
|
||||
default:
|
||||
// Pete validates the action before it ever queues an order, so this is a
|
||||
// contract breach, not a user mistake. Reject permanently rather than spin.
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
package plugin
|
||||
|
||||
// Ask 7: full equipment management from the web.
|
||||
//
|
||||
// The magic-item equip path (magic_items_gameplay.go) only ever touched the DnD
|
||||
// slots — off_hand, rings, and the like — which are almost always empty. Almost
|
||||
// everything a player actually wears lives in the OTHER two systems: the 5
|
||||
// standard EquipmentSlots (weapon/armor/helmet/boots/tool), whose power is the
|
||||
// slot's integer Tier, and the masterwork/arena pieces that get equipped INTO a
|
||||
// standard slot. This file is the game-side of managing all of that from Pete:
|
||||
//
|
||||
// - applyMasterworkEquip / applyMasterworkUnequip — move a masterwork/arena
|
||||
// piece between the pack and a standard slot (no money).
|
||||
// - purchaseEquipmentTier — buy the next standard tier with euros (confirm-gated
|
||||
// on the web), the headless twin of the shop's advBuyEquipment.
|
||||
// - repairSlot — mend a slot's condition with euros, the headless twin of the
|
||||
// blacksmith's executeRepair.
|
||||
// - buildEquipSlotViews — the owner-only snapshot the web panel renders from.
|
||||
//
|
||||
// The two euro-spending mutators run on the retrying poll wire, so every money
|
||||
// move goes through the idempotent euro variants keyed on the order guid: a
|
||||
// re-offered order that already debited skips the charge and just re-runs the
|
||||
// idempotent slot write. That is why neither refunds on a later DB fault — a
|
||||
// refund keyed on a fresh id, followed by a guid-guarded retry that no longer
|
||||
// re-debits, would hand the player both the gear and their money back. The casino
|
||||
// escrow (pete_games.go) settles the same way: idempotent move, then retry.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"gogobee/internal/peteclient"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// errEquipDowngrade is a permanent refusal: the incoming piece is no better than
|
||||
// what is worn. Downgrades are blocked by user decision (equip and upgrade both).
|
||||
var errEquipDowngrade = errors.New("equip: would be a downgrade")
|
||||
|
||||
// mwEquipOutcome is what a masterwork/arena equip did, for the verdict note.
|
||||
type mwEquipOutcome struct {
|
||||
Name string
|
||||
Slot EquipmentSlot
|
||||
Tier int
|
||||
Arena bool
|
||||
SwappedBack string // the special occupant evicted back to the pack, or ""
|
||||
}
|
||||
|
||||
// applyMasterworkEquip wears one masterwork/arena backpack piece into its standard
|
||||
// slot. Ordering is anti-duplication AND safe under the equip poll's 30s retry:
|
||||
// remove the incoming row FIRST (restoring it on a save fault), then write the
|
||||
// slot, and only THEN evict any displaced special occupant back to the pack. The
|
||||
// eviction comes last, once the slot no longer references the occupant, so it can
|
||||
// never mint a duplicate; and it is best-effort — a failure there is logged, not
|
||||
// aborted on, the same tolerance the DM confirm handler (adventure_masterwork.go)
|
||||
// lives with. Aborting after the slot write would strand a completed equip for a
|
||||
// retry that re-evicts the occupant on every tick.
|
||||
func applyMasterworkEquip(uid id.UserID, it AdvItem) (mwEquipOutcome, error) {
|
||||
if it.Slot == "" || (it.Type != "MasterworkGear" && it.Type != "ArenaGear") {
|
||||
return mwEquipOutcome{}, errItemNotEquippable
|
||||
}
|
||||
equip, err := loadAdvEquipment(uid)
|
||||
if err != nil {
|
||||
return mwEquipOutcome{}, err
|
||||
}
|
||||
slot := it.Slot
|
||||
cur := equip[slot]
|
||||
|
||||
// Downgrade block: the incoming effective tier must beat the current occupant.
|
||||
incoming := &AdvEquipment{Tier: it.Tier}
|
||||
if it.Type == "ArenaGear" {
|
||||
incoming.ArenaTier = it.Tier
|
||||
} else {
|
||||
incoming.Masterwork = true
|
||||
}
|
||||
if advEffectiveTier(incoming) <= advEffectiveTier(cur) {
|
||||
return mwEquipOutcome{}, errEquipDowngrade
|
||||
}
|
||||
|
||||
// Capture the special occupant to evict, if any, BEFORE the slot write below
|
||||
// mutates cur in place. A plain shop-tier occupant is not an item — it is just
|
||||
// the slot's tier — so it is overwritten, not evicted, the same as the DM confirm
|
||||
// handler and the shop. The actual re-pack happens after the slot write (below),
|
||||
// so it can never duplicate the piece.
|
||||
var evicted *AdvItem
|
||||
if cur != nil && (cur.Masterwork || cur.ArenaTier > 0) {
|
||||
old := AdvItem{Name: cur.Name, Type: "MasterworkGear", Tier: cur.Tier, Slot: slot, SkillSource: cur.SkillSource}
|
||||
if cur.ArenaTier > 0 {
|
||||
old.Type = "ArenaGear"
|
||||
}
|
||||
evicted = &old
|
||||
}
|
||||
|
||||
// Destructive op first: pull the incoming row before writing the slot, so a save
|
||||
// fault can't leave it both worn and in the pack. Restore it on failure.
|
||||
if err := removeAdvInventoryItem(it.ID); err != nil {
|
||||
return mwEquipOutcome{}, err
|
||||
}
|
||||
eq := cur
|
||||
if eq == nil {
|
||||
eq = &AdvEquipment{Slot: slot}
|
||||
}
|
||||
eq.Tier = it.Tier
|
||||
eq.Condition = 100
|
||||
eq.Name = it.Name
|
||||
eq.ActionsUsed = 0
|
||||
if it.Type == "ArenaGear" {
|
||||
eq.Masterwork = false
|
||||
eq.SkillSource = ""
|
||||
eq.ArenaTier = it.Tier
|
||||
eq.ArenaSet = ""
|
||||
if gs := arenaGearByName(it.Name); gs != nil {
|
||||
eq.ArenaSet = gs.SetKey
|
||||
}
|
||||
} else {
|
||||
eq.ArenaTier = 0
|
||||
eq.ArenaSet = ""
|
||||
eq.Masterwork = true
|
||||
eq.SkillSource = it.SkillSource
|
||||
}
|
||||
if err := saveAdvEquipment(uid, eq); err != nil {
|
||||
restored := AdvItem{Name: it.Name, Type: it.Type, Tier: it.Tier, Value: it.Value, Slot: it.Slot, SkillSource: it.SkillSource}
|
||||
if rbErr := addAdvInventoryItem(uid, restored); rbErr != nil {
|
||||
slog.Error("equip: masterwork save failed AND inventory rollback failed",
|
||||
"user", uid, "item", it.Name, "save_err", err, "rollback_err", rbErr)
|
||||
}
|
||||
return mwEquipOutcome{}, err
|
||||
}
|
||||
|
||||
// The slot now holds the incoming piece, so the former occupant is referenced
|
||||
// nowhere — re-packing it now cannot duplicate it. Best-effort: a failure is a
|
||||
// bounded, non-compounding loss we log rather than abort on, since the equip has
|
||||
// already succeeded and aborting would re-run (and re-evict) on the next poll.
|
||||
var swappedBack string
|
||||
if evicted != nil {
|
||||
if err := addAdvInventoryItem(uid, *evicted); err != nil {
|
||||
slog.Error("equip: masterwork equipped but evicted piece failed to return to pack",
|
||||
"user", uid, "evicted", evicted.Name, "err", err)
|
||||
} else {
|
||||
swappedBack = evicted.Name
|
||||
}
|
||||
}
|
||||
return mwEquipOutcome{Name: it.Name, Slot: slot, Tier: it.Tier, Arena: it.Type == "ArenaGear", SwappedBack: swappedBack}, nil
|
||||
}
|
||||
|
||||
// mwUnequipOutcome is what a masterwork/arena take-off did.
|
||||
type mwUnequipOutcome struct {
|
||||
Name string
|
||||
Slot EquipmentSlot
|
||||
}
|
||||
|
||||
// applyMasterworkUnequip takes a worn masterwork/arena piece off a standard slot,
|
||||
// returns it to the pack, and resets the slot to its tier-0 default. A plain
|
||||
// shop-tier slot has nothing round-trippable (its tier is not an item), so that is
|
||||
// errSlotEmpty — reverting a shop tier is not a take-off. The 5 slot rows are an
|
||||
// invariant (PK user_id+slot), so the row is reset, never deleted. Destructive op
|
||||
// first — reset the slot, then mint the pack row, restoring the slot on failure —
|
||||
// mirroring the magic unequip so a fault can't duplicate the piece.
|
||||
func applyMasterworkUnequip(uid id.UserID, slot EquipmentSlot) (mwUnequipOutcome, error) {
|
||||
equip, err := loadAdvEquipment(uid)
|
||||
if err != nil {
|
||||
return mwUnequipOutcome{}, err
|
||||
}
|
||||
cur := equip[slot]
|
||||
if cur == nil || (!cur.Masterwork && cur.ArenaTier == 0) {
|
||||
return mwUnequipOutcome{}, errSlotEmpty
|
||||
}
|
||||
prev := *cur // snapshot for rollback
|
||||
|
||||
def0 := equipmentTiers[slot][0]
|
||||
reset := &AdvEquipment{Slot: slot, Tier: 0, Condition: 100, Name: def0.Name, ActionsUsed: 0, ArenaTier: 0, ArenaSet: "", Masterwork: false, SkillSource: ""}
|
||||
if err := saveAdvEquipment(uid, reset); err != nil {
|
||||
return mwUnequipOutcome{}, err
|
||||
}
|
||||
|
||||
old := AdvItem{Name: cur.Name, Type: "MasterworkGear", Tier: cur.Tier, Slot: slot, SkillSource: cur.SkillSource}
|
||||
if cur.ArenaTier > 0 {
|
||||
old.Type = "ArenaGear"
|
||||
}
|
||||
if err := addAdvInventoryItem(uid, old); err != nil {
|
||||
if rbErr := saveAdvEquipment(uid, &prev); rbErr != nil {
|
||||
slog.Error("equip: masterwork take-off failed AND slot rollback failed",
|
||||
"user", uid, "slot", slot, "add_err", err, "rollback_err", rbErr)
|
||||
}
|
||||
return mwUnequipOutcome{}, err
|
||||
}
|
||||
return mwUnequipOutcome{Name: cur.Name, Slot: slot}, nil
|
||||
}
|
||||
|
||||
// purchaseEquipmentTier buys a standard slot's tier with euros — the headless twin
|
||||
// of advBuyEquipment, minus flavor. It returns a terminal verdict for Pete or
|
||||
// retry=true for a transient fault. Money moves once, keyed on the order guid; the
|
||||
// web only ever offers the next tier over a PLAIN shop-tier slot (buildEquipSlotViews
|
||||
// suppresses the offer on special gear), so there is no occupant to evict here and
|
||||
// the whole body is idempotent under a re-offered order.
|
||||
func (p *AdventurePlugin) purchaseEquipmentTier(uid id.UserID, slot EquipmentSlot, tier int, guid string) (status, detail string, retry bool) {
|
||||
defs, ok := equipmentTiers[slot]
|
||||
if !ok {
|
||||
return "rejected_not_equippable", "That isn't an equipment slot.", false
|
||||
}
|
||||
if tier < 1 || tier >= len(defs) {
|
||||
// tier 0 is the free default, not a purchase; >= len is past the top tier.
|
||||
return "rejected_max_tier", "That slot is already at the top tier.", false
|
||||
}
|
||||
def := defs[tier]
|
||||
|
||||
equip, err := loadAdvEquipment(uid)
|
||||
if err != nil {
|
||||
return "", "", true
|
||||
}
|
||||
cur := equip[slot]
|
||||
if cur != nil {
|
||||
// Buying a shop tier over a special piece strips its bonus — a downgrade in
|
||||
// practice even when the raw number rises. Take it off first, then buy.
|
||||
if cur.Masterwork || cur.ArenaTier > 0 {
|
||||
return "rejected_downgrade", "Take off your special gear in that slot before buying a tier.", false
|
||||
}
|
||||
if cur.Tier >= def.Tier {
|
||||
return "rejected_downgrade", "You already have that tier or better.", false
|
||||
}
|
||||
}
|
||||
|
||||
price := def.Price
|
||||
if !p.euro.HasExternalTx(guid) {
|
||||
ok, _, err := p.euro.DebitIdem(uid, price, "adventure_equip_upgrade", guid)
|
||||
if err != nil {
|
||||
return "", "", true
|
||||
}
|
||||
if !ok {
|
||||
return "rejected_insufficient_funds", fmt.Sprintf("That upgrade costs €%.0f and you can't cover it.", price), false
|
||||
}
|
||||
}
|
||||
|
||||
eq := &AdvEquipment{Slot: slot, Tier: def.Tier, Condition: 100, Name: def.Name, ActionsUsed: 0}
|
||||
if err := saveAdvEquipment(uid, eq); err != nil {
|
||||
// No refund: the debit is guid-idempotent, so the next poll re-runs this with
|
||||
// the charge already settled and only the (idempotent) slot write left to do.
|
||||
// Refunding here would double-pay once that retry lands the gear.
|
||||
return "", "", true
|
||||
}
|
||||
return "applied", fmt.Sprintf("Upgraded your %s to %s (T%d) for €%.0f.", slot, def.Name, def.Tier, price), false
|
||||
}
|
||||
|
||||
// repairSlot mends one standard slot's condition with euros — the headless twin of
|
||||
// the blacksmith's executeRepair. Idempotent on the order guid: the debit runs
|
||||
// once, and setting condition to 100 is itself idempotent, so a re-offered order is
|
||||
// safe with no refund.
|
||||
func (p *AdventurePlugin) repairSlot(uid id.UserID, slot EquipmentSlot, guid string) (status, detail string, retry bool) {
|
||||
equip, err := loadAdvEquipment(uid)
|
||||
if err != nil {
|
||||
return "", "", true
|
||||
}
|
||||
eq := equip[slot]
|
||||
if eq == nil {
|
||||
return "rejected_not_worn", "There's nothing in that slot to repair.", false
|
||||
}
|
||||
cost := blacksmithRepairCost(eq)
|
||||
if cost <= 0 {
|
||||
// Already full — nothing to charge for. Report it as applied so the order
|
||||
// reaches a terminal state rather than parking.
|
||||
return "applied", "That piece was already at full condition.", false
|
||||
}
|
||||
if !p.euro.HasExternalTx(guid) {
|
||||
ok, _, err := p.euro.DebitIdem(uid, float64(cost), "adventure_repair", guid)
|
||||
if err != nil {
|
||||
return "", "", true
|
||||
}
|
||||
if !ok {
|
||||
return "rejected_insufficient_funds", fmt.Sprintf("The repair costs €%d and you can't cover it.", cost), false
|
||||
}
|
||||
}
|
||||
eq.Condition = 100
|
||||
if err := saveAdvEquipment(uid, eq); err != nil {
|
||||
return "", "", true // retry; the idempotent debit means no double-charge
|
||||
}
|
||||
return "applied", fmt.Sprintf("Repaired your %s for €%d.", eq.Name, cost), false
|
||||
}
|
||||
|
||||
// buildEquipSlotViews is the owner-only snapshot of the 5 standard slots the web
|
||||
// management panel renders from. Worn masterwork/arena pieces surface here (via
|
||||
// CanTakeOff), not in the magic Equipped set. An upgrade is offered only over a
|
||||
// plain shop-tier slot below max — a special piece is taken off, not shop-upgraded.
|
||||
func buildEquipSlotViews(uid id.UserID) []peteclient.EquipSlotView {
|
||||
equip, err := loadAdvEquipment(uid)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []peteclient.EquipSlotView
|
||||
for _, slot := range allSlots {
|
||||
eq := equip[slot]
|
||||
if eq == nil {
|
||||
continue
|
||||
}
|
||||
v := peteclient.EquipSlotView{
|
||||
Slot: string(slot),
|
||||
Name: eq.Name,
|
||||
Tier: eq.Tier,
|
||||
Condition: eq.Condition,
|
||||
Masterwork: eq.Masterwork,
|
||||
ArenaTier: eq.ArenaTier,
|
||||
CanTakeOff: eq.Masterwork || eq.ArenaTier > 0,
|
||||
RepairCost: blacksmithRepairCost(eq),
|
||||
}
|
||||
if !eq.Masterwork && eq.ArenaTier == 0 && eq.Tier < 5 {
|
||||
next := equipmentTiers[slot][eq.Tier+1]
|
||||
v.NextTier = next.Tier
|
||||
v.NextName = next.Name
|
||||
v.NextPrice = next.Price
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// masterworkEquipDetail turns a masterwork/arena equip outcome into the verdict
|
||||
// note Pete shows.
|
||||
func masterworkEquipDetail(out mwEquipOutcome) string {
|
||||
kind := "masterwork"
|
||||
if out.Arena {
|
||||
kind = "arena"
|
||||
}
|
||||
b := fmt.Sprintf("Now worn in your %s slot (%s T%d).", out.Slot, kind, out.Tier)
|
||||
if out.SwappedBack != "" {
|
||||
b += fmt.Sprintf(" %s went back to your pack.", out.SwappedBack)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// isEquipmentSlot reports whether a slot string names one of the 5 standard slots.
|
||||
// The magic DnD slots and the standard slots are disjoint vocabularies, so this
|
||||
// alone routes an unequip to the right path.
|
||||
func isEquipmentSlot(slot string) bool {
|
||||
for _, s := range allSlots {
|
||||
if string(s) == slot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Ask 7: the headless equipment mutators the web equip queue drives. These pin the
|
||||
// rules that cross the wire — downgrade block, max tier, insufficient funds,
|
||||
// idempotent replay (one debit), masterwork evict/overwrite/take-off — at the
|
||||
// gogobee end, on real rows.
|
||||
|
||||
// seedEquipPlayer stands up a playable adventurer (player_meta + tier-0 gear) with
|
||||
// a euro plugin funded to `bankroll`, and returns the wired AdventurePlugin.
|
||||
func seedEquipPlayer(t *testing.T, uid id.UserID, bankroll float64) *AdventurePlugin {
|
||||
t.Helper()
|
||||
if err := createAdvCharacter(uid, "Rurina"); err != nil {
|
||||
t.Fatalf("createAdvCharacter: %v", err)
|
||||
}
|
||||
euro := &EuroPlugin{}
|
||||
euro.ensureBalance(uid)
|
||||
if bankroll > 0 {
|
||||
euro.Credit(uid, bankroll, "test bankroll")
|
||||
}
|
||||
return &AdventurePlugin{euro: euro}
|
||||
}
|
||||
|
||||
func slotOf(t *testing.T, uid id.UserID, slot EquipmentSlot) *AdvEquipment {
|
||||
t.Helper()
|
||||
equip, err := loadAdvEquipment(uid)
|
||||
if err != nil {
|
||||
t.Fatalf("loadAdvEquipment: %v", err)
|
||||
}
|
||||
return equip[slot]
|
||||
}
|
||||
|
||||
// TestPurchaseEquipmentTierHappyAndIdempotentDebit: buying the next tier debits
|
||||
// once and raises the slot, and the euro move is keyed on the order guid so a
|
||||
// re-offer moves no money. (A re-offer never re-enters this function in prod — the
|
||||
// equip_applied_orders ledger short-circuits it — but the guid is the belt to that
|
||||
// suspenders, and the retry-after-save-fault path below leans on it directly.)
|
||||
func TestPurchaseEquipmentTierHappyAndIdempotentDebit(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
uid := id.UserID("@rurina:test")
|
||||
p := seedEquipPlayer(t, uid, 100000)
|
||||
|
||||
before := p.euro.GetBalance(uid)
|
||||
price := equipmentTiers[SlotBoots][1].Price // Dead Man's Boots, €75
|
||||
|
||||
status, _, retry := p.purchaseEquipmentTier(uid, SlotBoots, 1, "guid-up-1")
|
||||
if retry || status != "applied" {
|
||||
t.Fatalf("upgrade = %q retry=%v, want applied", status, retry)
|
||||
}
|
||||
if got := slotOf(t, uid, SlotBoots); got.Tier != 1 || got.Name != equipmentTiers[SlotBoots][1].Name {
|
||||
t.Fatalf("boots slot = %+v, want tier 1", got)
|
||||
}
|
||||
if got := p.euro.GetBalance(uid); got != before-price {
|
||||
t.Fatalf("balance = %.2f, want %.2f (one debit of %.2f)", got, before-price, price)
|
||||
}
|
||||
// The guid is now a settled money move: a replayed debit on it is a no-op.
|
||||
if !p.euro.HasExternalTx("guid-up-1") {
|
||||
t.Fatal("the upgrade debit was not logged under the order guid")
|
||||
}
|
||||
if ok, _, err := p.euro.DebitIdem(uid, price, "adventure_equip_upgrade", "guid-up-1"); err != nil || !ok {
|
||||
t.Fatalf("replayed debit = ok:%v err:%v, want a no-op ok", ok, err)
|
||||
}
|
||||
if got := p.euro.GetBalance(uid); got != before-price {
|
||||
t.Fatalf("replayed debit double-charged: balance = %.2f, want %.2f", got, before-price)
|
||||
}
|
||||
|
||||
// The retry-after-save-fault path: a prior attempt debited but its slot write
|
||||
// never landed, so the slot is still tier 0. The retry must skip the debit
|
||||
// (guid already settled) and finish the write, moving no further money.
|
||||
helmetGUID := "guid-helm"
|
||||
hprice := equipmentTiers[SlotHelmet][1].Price
|
||||
if ok, _, err := p.euro.DebitIdem(uid, hprice, "adventure_equip_upgrade", helmetGUID); err != nil || !ok {
|
||||
t.Fatalf("seed prior debit = ok:%v err:%v", ok, err)
|
||||
}
|
||||
mid := p.euro.GetBalance(uid)
|
||||
status, _, retry = p.purchaseEquipmentTier(uid, SlotHelmet, 1, helmetGUID)
|
||||
if retry || status != "applied" {
|
||||
t.Fatalf("retry after fault = %q retry=%v, want applied", status, retry)
|
||||
}
|
||||
if got := slotOf(t, uid, SlotHelmet); got.Tier != 1 {
|
||||
t.Fatalf("helmet not upgraded on retry: tier %d", got.Tier)
|
||||
}
|
||||
if got := p.euro.GetBalance(uid); got != mid {
|
||||
t.Fatalf("retry re-debited: balance %.2f → %.2f", mid, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPurchaseEquipmentTierRejections: a downgrade, a special-gear slot, the top
|
||||
// tier, and an empty wallet all bounce without moving the slot or the money.
|
||||
func TestPurchaseEquipmentTierRejections(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
uid := id.UserID("@rurina:test")
|
||||
p := seedEquipPlayer(t, uid, 100000)
|
||||
|
||||
// Lift boots to tier 3 so we have something to (not) downgrade from.
|
||||
if s, _, _ := p.purchaseEquipmentTier(uid, SlotBoots, 3, "seed-t3"); s != "applied" {
|
||||
t.Fatalf("seed to tier 3 = %q", s)
|
||||
}
|
||||
// Same tier or lower is a downgrade.
|
||||
if s, _, _ := p.purchaseEquipmentTier(uid, SlotBoots, 3, "g-eq"); s != "rejected_downgrade" {
|
||||
t.Errorf("re-buy same tier = %q, want rejected_downgrade", s)
|
||||
}
|
||||
if s, _, _ := p.purchaseEquipmentTier(uid, SlotBoots, 2, "g-down"); s != "rejected_downgrade" {
|
||||
t.Errorf("buy lower tier = %q, want rejected_downgrade", s)
|
||||
}
|
||||
// Past the top tier.
|
||||
if s, _, _ := p.purchaseEquipmentTier(uid, SlotBoots, 6, "g-max"); s != "rejected_max_tier" {
|
||||
t.Errorf("buy tier 6 = %q, want rejected_max_tier", s)
|
||||
}
|
||||
// A special piece in the slot: buying a plain tier over it strips the bonus.
|
||||
weapon := slotOf(t, uid, SlotWeapon)
|
||||
weapon.Masterwork = true
|
||||
weapon.Tier = 2
|
||||
weapon.Name = "Miner's Masterwork Blade"
|
||||
weapon.SkillSource = "mining"
|
||||
if err := saveAdvEquipment(uid, weapon); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, _, _ := p.purchaseEquipmentTier(uid, SlotWeapon, 3, "g-special"); s != "rejected_downgrade" {
|
||||
t.Errorf("buy plain over masterwork = %q, want rejected_downgrade", s)
|
||||
}
|
||||
|
||||
// Insufficient funds: a broke player can't buy a €30000 tier-5 weapon.
|
||||
broke := id.UserID("@broke:test")
|
||||
pb := seedEquipPlayer(t, broke, 0)
|
||||
bal := pb.euro.GetBalance(broke)
|
||||
if s, _, _ := pb.purchaseEquipmentTier(broke, SlotWeapon, 5, "g-broke"); s != "rejected_insufficient_funds" {
|
||||
t.Errorf("broke upgrade = %q, want rejected_insufficient_funds", s)
|
||||
}
|
||||
if got := pb.euro.GetBalance(broke); got != bal {
|
||||
t.Errorf("a rejected upgrade moved money: %.2f → %.2f", bal, got)
|
||||
}
|
||||
if got := slotOf(t, broke, SlotWeapon); got.Tier != 0 {
|
||||
t.Errorf("a rejected upgrade changed the slot: tier %d", got.Tier)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepairSlotHappyAndNoop: repairing a damaged slot debits the blacksmith cost
|
||||
// and restores condition; repairing a full slot is a no-op that charges nothing.
|
||||
func TestRepairSlotHappyAndNoop(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
uid := id.UserID("@rurina:test")
|
||||
p := seedEquipPlayer(t, uid, 100000)
|
||||
|
||||
weapon := slotOf(t, uid, SlotWeapon)
|
||||
weapon.Condition = 50
|
||||
if err := saveAdvEquipment(uid, weapon); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cost := blacksmithRepairCost(weapon)
|
||||
if cost <= 0 {
|
||||
t.Fatalf("expected a positive repair cost, got %d", cost)
|
||||
}
|
||||
before := p.euro.GetBalance(uid)
|
||||
|
||||
status, _, retry := p.repairSlot(uid, SlotWeapon, "guid-rep-1")
|
||||
if retry || status != "applied" {
|
||||
t.Fatalf("repair = %q retry=%v, want applied", status, retry)
|
||||
}
|
||||
if got := slotOf(t, uid, SlotWeapon); got.Condition != 100 {
|
||||
t.Fatalf("condition = %d, want 100", got.Condition)
|
||||
}
|
||||
if got := p.euro.GetBalance(uid); got != before-float64(cost) {
|
||||
t.Fatalf("balance = %.2f, want %.2f (debit %d)", got, before-float64(cost), cost)
|
||||
}
|
||||
|
||||
// Now at full condition: a fresh repair is an applied no-op, no charge.
|
||||
after := p.euro.GetBalance(uid)
|
||||
status, _, _ = p.repairSlot(uid, SlotWeapon, "guid-rep-2")
|
||||
if status != "applied" {
|
||||
t.Fatalf("no-op repair = %q, want applied", status)
|
||||
}
|
||||
if got := p.euro.GetBalance(uid); got != after {
|
||||
t.Errorf("no-op repair charged: %.2f → %.2f", after, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMasterworkEquipEvictsOverwritesAndTakesOff: a masterwork piece equips into a
|
||||
// plain slot (overwriting the tier), a better one evicts the special occupant back
|
||||
// to the pack, a worse one is blocked, and take-off resets the slot to tier 0.
|
||||
func TestMasterworkEquipEvictsOverwritesAndTakesOff(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
uid := id.UserID("@rurina:test")
|
||||
seedEquipPlayer(t, uid, 0)
|
||||
|
||||
mw := func(name string, tier int) AdvItem {
|
||||
if err := addAdvInventoryItem(uid, AdvItem{Name: name, Type: "MasterworkGear", Tier: tier, Slot: SlotWeapon, SkillSource: "mining"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inv, _ := loadAdvInventory(uid)
|
||||
for _, it := range inv {
|
||||
if it.Name == name {
|
||||
return it
|
||||
}
|
||||
}
|
||||
t.Fatalf("just-added item %q not in inventory", name)
|
||||
return AdvItem{}
|
||||
}
|
||||
|
||||
// Equip a T3 masterwork over the tier-0 plain weapon: it overwrites, no eviction.
|
||||
out, err := applyMasterworkEquip(uid, mw("Deepforged Blade", 3))
|
||||
if err != nil {
|
||||
t.Fatalf("equip T3: %v", err)
|
||||
}
|
||||
if out.SwappedBack != "" {
|
||||
t.Errorf("plain occupant should be overwritten, not evicted; got swap %q", out.SwappedBack)
|
||||
}
|
||||
if got := slotOf(t, uid, SlotWeapon); !got.Masterwork || got.Tier != 3 || got.Name != "Deepforged Blade" {
|
||||
t.Fatalf("weapon = %+v, want masterwork T3 Deepforged Blade", got)
|
||||
}
|
||||
|
||||
// A better masterwork (T4) evicts the T3 back to the pack.
|
||||
out, err = applyMasterworkEquip(uid, mw("Sunforged Blade", 4))
|
||||
if err != nil {
|
||||
t.Fatalf("equip T4: %v", err)
|
||||
}
|
||||
if out.SwappedBack != "Deepforged Blade" {
|
||||
t.Errorf("evicted = %q, want Deepforged Blade", out.SwappedBack)
|
||||
}
|
||||
invHas := func(name string) bool {
|
||||
inv, _ := loadAdvInventory(uid)
|
||||
for _, it := range inv {
|
||||
if it.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !invHas("Deepforged Blade") {
|
||||
t.Error("the evicted T3 masterwork is not back in the pack")
|
||||
}
|
||||
|
||||
// A worse masterwork (T2) is a blocked downgrade.
|
||||
if _, err := applyMasterworkEquip(uid, mw("Rusty Masterwork", 2)); err != errEquipDowngrade {
|
||||
t.Errorf("equip worse masterwork err = %v, want errEquipDowngrade", err)
|
||||
}
|
||||
|
||||
// Take off the worn T4: it returns to the pack and the slot resets to tier 0.
|
||||
un, err := applyMasterworkUnequip(uid, SlotWeapon)
|
||||
if err != nil {
|
||||
t.Fatalf("take off: %v", err)
|
||||
}
|
||||
if un.Name != "Sunforged Blade" {
|
||||
t.Errorf("took off %q, want Sunforged Blade", un.Name)
|
||||
}
|
||||
if got := slotOf(t, uid, SlotWeapon); got.Masterwork || got.Tier != 0 || got.Name != equipmentTiers[SlotWeapon][0].Name {
|
||||
t.Fatalf("weapon after take-off = %+v, want tier-0 default", got)
|
||||
}
|
||||
if !invHas("Sunforged Blade") {
|
||||
t.Error("the taken-off masterwork is not back in the pack")
|
||||
}
|
||||
|
||||
// Taking off a plain slot has nothing round-trippable.
|
||||
if _, err := applyMasterworkUnequip(uid, SlotArmor); err != errSlotEmpty {
|
||||
t.Errorf("take off plain slot err = %v, want errSlotEmpty", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildEquipSlotViews: the panel snapshot offers an upgrade only over a plain
|
||||
// sub-max slot, take-off only on special gear, and a repair cost only when damaged.
|
||||
func TestBuildEquipSlotViews(t *testing.T) {
|
||||
newMischiefTestDB(t)
|
||||
uid := id.UserID("@rurina:test")
|
||||
seedEquipPlayer(t, uid, 0)
|
||||
|
||||
// Boots: masterwork T3, damaged → take off + repair, no upgrade offer.
|
||||
boots := slotOf(t, uid, SlotBoots)
|
||||
boots.Masterwork = true
|
||||
boots.Tier = 3
|
||||
boots.Name = "The Wandering Sole"
|
||||
boots.Condition = 70
|
||||
if err := saveAdvEquipment(uid, boots); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Helmet: plain T2 → upgrade to T3 offered, no take off, no repair.
|
||||
helmet := slotOf(t, uid, SlotHelmet)
|
||||
helmet.Tier = 2
|
||||
helmet.Name = equipmentTiers[SlotHelmet][2].Name
|
||||
if err := saveAdvEquipment(uid, helmet); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
views := buildEquipSlotViews(uid)
|
||||
bySlot := map[string]struct {
|
||||
takeOff bool
|
||||
nextTier int
|
||||
repair int
|
||||
}{}
|
||||
for _, v := range views {
|
||||
bySlot[v.Slot] = struct {
|
||||
takeOff bool
|
||||
nextTier int
|
||||
repair int
|
||||
}{v.CanTakeOff, v.NextTier, v.RepairCost}
|
||||
}
|
||||
if len(views) != len(allSlots) {
|
||||
t.Fatalf("got %d slot views, want %d", len(views), len(allSlots))
|
||||
}
|
||||
if b := bySlot["boots"]; !b.takeOff || b.nextTier != 0 || b.repair <= 0 {
|
||||
t.Errorf("boots view = %+v, want take-off, no upgrade, positive repair", b)
|
||||
}
|
||||
if h := bySlot["helmet"]; h.takeOff || h.nextTier != 3 || h.repair != 0 {
|
||||
t.Errorf("helmet view = %+v, want upgrade to T3, no take-off, no repair", h)
|
||||
}
|
||||
// A pristine tier-0 slot: upgrade offered to T1, no take-off, no repair.
|
||||
if w := bySlot["weapon"]; w.takeOff || w.nextTier != 1 || w.repair != 0 {
|
||||
t.Errorf("weapon view = %+v, want upgrade to T1", w)
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ func (p *AdventurePlugin) pushRoster() {
|
||||
var detailPushOK bool
|
||||
|
||||
func (p *AdventurePlugin) pushDetails() {
|
||||
snap, err := buildDetailSnapshot(time.Now().UTC())
|
||||
snap, err := p.buildDetailSnapshot(time.Now().UTC())
|
||||
if err != nil {
|
||||
slog.Error("roster: build detail snapshot failed", "err", err)
|
||||
return
|
||||
@@ -158,7 +158,7 @@ func rosterDetail(uid id.UserID, c *DnDCharacter) *peteclient.RosterDetail {
|
||||
// owner it belongs to, so hiding a player from it would only deny them their own
|
||||
// sheet. The board token rides along so Pete can match owner↔page without ever
|
||||
// reversing the one-way token.
|
||||
func buildDetailSnapshot(now time.Time) (peteclient.DetailSnapshot, error) {
|
||||
func (p *AdventurePlugin) buildDetailSnapshot(now time.Time) (peteclient.DetailSnapshot, error) {
|
||||
snap := peteclient.DetailSnapshot{SnapshotAt: now.Unix()}
|
||||
rows, err := db.Get().Query(`SELECT user_id FROM player_meta WHERE alive = 1`)
|
||||
if err != nil {
|
||||
@@ -208,6 +208,13 @@ func buildDetailSnapshot(now time.Time) (peteclient.DetailSnapshot, error) {
|
||||
pd.Vault = itemViews(items)
|
||||
}
|
||||
pd.Equipped = equippedViews(uid)
|
||||
// Ask 7: the 5 standard slots for the web management panel, plus the euro
|
||||
// balance the upgrade/repair confirm dialogs show. Balance is nil-guarded so
|
||||
// the free-standing tests (which build no euro plugin) still run.
|
||||
pd.Slots = buildEquipSlotViews(uid)
|
||||
if p.euro != nil {
|
||||
pd.Balance = p.euro.GetBalance(uid)
|
||||
}
|
||||
snap.Players = append(snap.Players, pd)
|
||||
}
|
||||
return snap, nil
|
||||
@@ -256,6 +263,12 @@ func itemViews(items []AdvItem) []peteclient.ItemView {
|
||||
} else if it.Slot != "" {
|
||||
// Shop equipment resolves by (slot, tier) — Name is decorative.
|
||||
v.Desc = equipmentDefByTier(it.Slot, it.Tier).Description
|
||||
// A masterwork/arena piece carries a real slot, so it can be worn from the
|
||||
// web (into a standard slot) — give it the equip handle. Plain shop gear in
|
||||
// the pack stays button-less: its slot is just a category, not a wearable.
|
||||
if it.Type == "MasterworkGear" || it.Type == "ArenaGear" {
|
||||
v.ID = it.ID
|
||||
}
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Deterministic sim seeding — OFF by default, so the production binary is
|
||||
// byte-identical in behaviour. The expedition-sim harness calls SeedSim once at
|
||||
// subprocess startup to make a run reproducible: the three nondeterminism seams
|
||||
// that dominate outcome variance — the zone-layout RNG, the run id (traps hash
|
||||
// off it), and each combat SessionID (the whole turn engine hashes from it) —
|
||||
// all derive from a single base seed via a process-local counter.
|
||||
//
|
||||
// This does NOT touch the peripheral top-level math/rand/v2 procs (pardon rolls,
|
||||
// minor damage jitter, ambient events); those stay random. Seeding the three
|
||||
// dominant seams collapses the batch-to-batch drift (identical dungeons + combat
|
||||
// dice across arms) so an A/B passive change reads at ~0 noise. If a residual
|
||||
// proc ever proves load-bearing, seed it too — but validate empirically first.
|
||||
//
|
||||
// Ordering contract: within one subprocess an expedition is resolved on a single
|
||||
// goroutine, so nextSimSeed() is drawn in a deterministic order (zone rng, run
|
||||
// id, then one per combat session in creation order). Two arms sharing a base
|
||||
// seed draw identical values up to the point a passive change diverges them —
|
||||
// and SessionIDs are assigned at session *creation*, before a fight resolves, so
|
||||
// the Nth combat pairs regardless of how the fight plays out.
|
||||
var (
|
||||
simSeedActive atomic.Bool
|
||||
simSeedBase uint64
|
||||
simSeedCtr atomic.Uint64
|
||||
)
|
||||
|
||||
// simCombatRand is an INDEPENDENT seeded stream for the outcome-decisive
|
||||
// in-combat rolls that the three dominant seams don't cover: the spell
|
||||
// attack/save/damage d20s (dnd_spell_combat.go), the 33% pardon death-cheat
|
||||
// (combat_bridge.go), and the short-rest heal die (dnd_rest.go). These fire
|
||||
// disproportionately in caster / borderline-boss runs — exactly the population
|
||||
// being certified — and leaving them on the global generator was the main
|
||||
// source of the per-cell residual (the fighter+ranger repro never exercised
|
||||
// them, so it read ~0 noise while bard swung 8pp on identical code).
|
||||
//
|
||||
// It is a SEPARATE stream from nextSimSeed()'s counter so it never perturbs the
|
||||
// zone-layout / run-id / SessionID draw order the martial repro validated.
|
||||
// Within a subprocess an expedition runs on one goroutine; the mutex is belt-
|
||||
// and-braces so a stray concurrent draw can't race, not a correctness crutch.
|
||||
var (
|
||||
simCombatMu sync.Mutex
|
||||
simCombatRand *rand.Rand
|
||||
)
|
||||
|
||||
// SeedSim activates deterministic seeding for this process. A negative seed
|
||||
// disables it (the default). Call once at startup, before any expedition runs —
|
||||
// it is not safe to toggle while a run is in flight.
|
||||
func SeedSim(seed int64) {
|
||||
if seed < 0 {
|
||||
simSeedActive.Store(false)
|
||||
simCombatRand = nil
|
||||
return
|
||||
}
|
||||
simSeedBase = uint64(seed)
|
||||
simSeedCtr.Store(0)
|
||||
// 0x5EED5 gives the peripheral-combat stream a distinct sub-stream from the
|
||||
// seam counter so the two never correlate or share draws.
|
||||
simCombatRand = rand.New(rand.NewPCG(uint64(seed), 0x5EED5))
|
||||
simSeedActive.Store(true)
|
||||
}
|
||||
|
||||
func simSeedOn() bool { return simSeedActive.Load() }
|
||||
|
||||
// nextSimSeed returns the next counter-mixed seed. Golden-ratio odd multiplier
|
||||
// decorrelates successive draws.
|
||||
func nextSimSeed() uint64 {
|
||||
n := simSeedCtr.Add(1)
|
||||
return simSeedBase ^ (n * 0x9E3779B97F4A7C15)
|
||||
}
|
||||
|
||||
// simHexToken renders one seeded draw as the same 16-char hex shape the
|
||||
// crypto-random id helpers produce.
|
||||
func simHexToken() string {
|
||||
v := nextSimSeed()
|
||||
var b [8]byte
|
||||
for i := range b {
|
||||
b[i] = byte(v >> (8 * i))
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// simZoneRNG returns a deterministic generator for one zone layout.
|
||||
func simZoneRNG() *rand.Rand {
|
||||
return rand.New(rand.NewPCG(nextSimSeed(), 0xC0FFEE))
|
||||
}
|
||||
|
||||
// simIntN / simFloat64 draw an outcome-decisive in-combat roll from the seeded
|
||||
// peripheral stream when seeding is active; otherwise they fall through to the
|
||||
// global generator so the production binary stays byte-identical (prod never
|
||||
// calls SeedSim, so simSeedOn() is always false there).
|
||||
func simIntN(n int) int {
|
||||
if !simSeedOn() {
|
||||
return rand.IntN(n)
|
||||
}
|
||||
simCombatMu.Lock()
|
||||
defer simCombatMu.Unlock()
|
||||
return simCombatRand.IntN(n)
|
||||
}
|
||||
|
||||
func simFloat64() float64 {
|
||||
if !simSeedOn() {
|
||||
return rand.Float64()
|
||||
}
|
||||
simCombatMu.Lock()
|
||||
defer simCombatMu.Unlock()
|
||||
return simCombatRand.Float64()
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// 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
|
||||
// evaluateForkEdges from the live run + character.
|
||||
type edgeUnlockCtx struct {
|
||||
RunID string
|
||||
FromNode string
|
||||
CharLevel int
|
||||
AbilityMods [6]int // STR, DEX, CON, INT, WIS, CHA — matches DnDCharacter.Modifiers()
|
||||
RunID string
|
||||
FromNode string
|
||||
CharLevel int
|
||||
// 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
|
||||
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
|
||||
// now, with a player-facing reason on failure. Per plan §G5: Perception
|
||||
// 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)
|
||||
total := roll + ctx.AbilityMods[4]
|
||||
if total >= dc {
|
||||
return true, ""
|
||||
return true, ctx.creditFor(4)
|
||||
}
|
||||
return false, fmt.Sprintf("Perception %d vs DC %d", total, dc)
|
||||
case LockKey:
|
||||
@@ -138,7 +168,7 @@ func evaluateEdgeLock(e ZoneEdge, ctx edgeUnlockCtx) (unlocked bool, reason stri
|
||||
roll := perceptionRollForEdge(ctx.RunID, ctx.FromNode, e.To)
|
||||
total := roll + ctx.AbilityMods[idx]
|
||||
if total >= dc {
|
||||
return true, ""
|
||||
return true, ctx.creditFor(idx)
|
||||
}
|
||||
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 {
|
||||
ctx.Expedition = exp
|
||||
foldPartyAbilities(&ctx, exp, c.UserID)
|
||||
}
|
||||
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
|
||||
// and produces a pending-choice list ready to be persisted. Locked
|
||||
// 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))
|
||||
for _, c := range pf.Options {
|
||||
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:
|
||||
b.WriteString(fmt.Sprintf("**%d.** %s\n", c.Index, c.Label))
|
||||
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