mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 10:51:09 +00:00
robbie: pay for the haul, not just for showing up
The gift was one consumable every 10th visit, flat. A visit is a 40% daily roll, so that works out to one item per ~25 real days — and it paid exactly the same for a stockpile of sixty items as it did for one rock. The player controls volume, not visit count, so volume is what the new track pays on: one consumable per 15 items carried off, capped at 3, stacking with the existing loyalty gift. Also stop Robbie stealing thieves' tools. He skips keys already, for exactly this reason — a key is bought to open something later, and a bandit who pockets it between the purchase and the door has taken the thing the player paid to still have. Tools are the same shape of promise.
This commit is contained in:
@@ -45,6 +45,12 @@ var robbieAllShopGear = "Nothing fancy today but that's alright. Clean inventory
|
|||||||
var robbieLeftConsumable = "Oh -- one more thing. I tucked a %s into your bag on the way out. " +
|
var robbieLeftConsumable = "Oh -- one more thing. I tucked a %s into your bag on the way out. " +
|
||||||
"You've had me round enough times now that it felt rude not to. For the trouble, eh? _winks_"
|
"You've had me round enough times now that it felt rude not to. For the trouble, eh? _winks_"
|
||||||
|
|
||||||
|
// robbieLeftForTheHaul is the big-haul variant: he took enough in one go that
|
||||||
|
// walking off with only a handling fee would look bad. Takes the item list.
|
||||||
|
var robbieLeftForTheHaul = "Oh -- and I left you something. %s. " +
|
||||||
|
"You had me carting that lot down four flights, and a man who takes that much " +
|
||||||
|
"and gives back nothing isn't a bandit, he's a landlord. _winks_"
|
||||||
|
|
||||||
// ── Room Announcements ───────────────────────────────────────────────────────
|
// ── Room Announcements ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
var robbieRoomStandard = "🎩 Robbie paid %s a visit and collected %d item(s) from their inventory. " +
|
var robbieRoomStandard = "🎩 Robbie paid %s a visit and collected %d item(s) from their inventory. " +
|
||||||
|
|||||||
@@ -164,20 +164,22 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
|
|||||||
gaveCard = true
|
gaveCard = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update visit count, and every 10th visit leave a small consumable
|
// Update visit count and work out what he leaves behind.
|
||||||
// "for the trouble" (D2 NPC arc).
|
var leftGifts []AdvItem
|
||||||
var leftGift *AdvItem
|
|
||||||
char, err := loadAdvCharacter(userID)
|
char, err := loadAdvCharacter(userID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
char.RobbieVisitCount++
|
char.RobbieVisitCount++
|
||||||
if char.RobbieVisitCount%robbieGiftEveryNVisits == 0 {
|
// Use the canonical DnD level (like the arena's tier gate), not the
|
||||||
// Use the canonical DnD level (like the arena's tier gate), not the
|
// frozen legacy CombatLevel — that snapshots at 1–3 once D&D setup
|
||||||
// frozen legacy CombatLevel — that snapshots at 1–3 once D&D setup
|
// completes, so reading it here would peg every gift at tier 1.
|
||||||
// completes, so reading it here would peg every gift at tier 1.
|
tier := robbieGiftTier(arenaDnDLevelOrZero(userID))
|
||||||
if gifts := consumableCache(robbieGiftTier(arenaDnDLevelOrZero(userID)), 1); len(gifts) > 0 {
|
for range robbieGiftCount(char.RobbieVisitCount, len(takenItems)) {
|
||||||
if err := addAdvInventoryItem(userID, gifts[0]); err == nil {
|
gifts := consumableCache(tier, 1)
|
||||||
leftGift = &gifts[0]
|
if len(gifts) == 0 {
|
||||||
}
|
break
|
||||||
|
}
|
||||||
|
if err := addAdvInventoryItem(userID, gifts[0]); err == nil {
|
||||||
|
leftGifts = append(leftGifts, gifts[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = saveAdvCharacter(char)
|
_ = saveAdvCharacter(char)
|
||||||
@@ -185,7 +187,7 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send DM
|
// Send DM
|
||||||
dm := renderRobbieDM(userID, takenItems, totalPayout, masterworkTaken, gaveCard, leftGift)
|
dm := renderRobbieDM(userID, takenItems, totalPayout, masterworkTaken, gaveCard, leftGifts)
|
||||||
if err := p.SendDM(userID, dm); err != nil {
|
if err := p.SendDM(userID, dm); err != nil {
|
||||||
slog.Error("adventure: robbie: failed to send DM", "user", userID, "err", err)
|
slog.Error("adventure: robbie: failed to send DM", "user", userID, "err", err)
|
||||||
}
|
}
|
||||||
@@ -213,12 +215,17 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
|
|||||||
func robbieQualifyingItems(inv []AdvItem, equip map[EquipmentSlot]*AdvEquipment) []AdvItem {
|
func robbieQualifyingItems(inv []AdvItem, equip map[EquipmentSlot]*AdvEquipment) []AdvItem {
|
||||||
var result []AdvItem
|
var result []AdvItem
|
||||||
for _, item := range inv {
|
for _, item := range inv {
|
||||||
// Never touch Arena gear, cards, consumables, or keys. Consumables are
|
// Never touch Arena gear, cards, consumables, keys, or tools.
|
||||||
// a player-curated stockpile (crafted or dropped); selling them is an
|
// Consumables are a player-curated stockpile (crafted or dropped);
|
||||||
// explicit decision the player must make themselves. Keys are cross-zone
|
// selling them is an explicit decision the player must make themselves.
|
||||||
// unlock tokens (N5/D4) that must persist in inventory to open their
|
// Keys are cross-zone unlock tokens (N5/D4) that must persist in
|
||||||
// vault later — sweeping one permanently breaks that unlock.
|
// inventory to open their vault later — sweeping one permanently breaks
|
||||||
if item.Type == "ArenaGear" || item.Type == "card" || item.Type == "consumable" || item.Type == "key" {
|
// that unlock. Tools are the same shape of promise: thieves' tools are
|
||||||
|
// bought precisely so a locked fork can be opened *later*, and a bandit
|
||||||
|
// who pockets them between the purchase and the door has taken the
|
||||||
|
// thing the player paid to still have.
|
||||||
|
switch item.Type {
|
||||||
|
case "ArenaGear", "card", "consumable", "key", thievesToolsItemType:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,9 +274,49 @@ func robbiePlayerHasCard(userID id.UserID) bool {
|
|||||||
|
|
||||||
// ── DM Rendering ─────────────────────────────────────────────────────────────
|
// ── DM Rendering ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// robbieGiftEveryNVisits is how often Robbie leaves a consumable behind.
|
// robbieGiftEveryNVisits is how often Robbie leaves a consumable behind on the
|
||||||
|
// loyalty track alone, independent of how much he hauled off.
|
||||||
const robbieGiftEveryNVisits = 10
|
const robbieGiftEveryNVisits = 10
|
||||||
|
|
||||||
|
// robbieHaulPerGift is how many items one visit has to be worth before Robbie
|
||||||
|
// leaves something for the trouble, and robbieMaxHaulGifts caps how generous a
|
||||||
|
// single monster haul can get.
|
||||||
|
//
|
||||||
|
// The loyalty track on its own was far too thin to read as a reward: a visit is
|
||||||
|
// a 40% daily roll, so every-10-visits works out to one consumable per ~25 real
|
||||||
|
// days — and it paid exactly the same for a stockpile of sixty items as it did
|
||||||
|
// for one rock. Volume is the thing the player actually controls, so volume is
|
||||||
|
// what the haul track pays on.
|
||||||
|
const (
|
||||||
|
robbieHaulPerGift = 15
|
||||||
|
robbieMaxHaulGifts = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// robbieGiftCount returns how many consumables Robbie leaves this visit: the
|
||||||
|
// every-Nth-visit loyalty gift plus one per robbieHaulPerGift items carried
|
||||||
|
// off, capped. Pure so the curve is testable without a DB or a Matrix stub.
|
||||||
|
func robbieGiftCount(visitCount, itemsTaken int) int {
|
||||||
|
n := 0
|
||||||
|
if visitCount > 0 && visitCount%robbieGiftEveryNVisits == 0 {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
if haul := itemsTaken / robbieHaulPerGift; haul > 0 {
|
||||||
|
n += min(haul, robbieMaxHaulGifts)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinAnd renders a list as "a", "a and b", or "a, b and c".
|
||||||
|
func joinAnd(xs []string) string {
|
||||||
|
switch len(xs) {
|
||||||
|
case 0:
|
||||||
|
return ""
|
||||||
|
case 1:
|
||||||
|
return xs[0]
|
||||||
|
}
|
||||||
|
return strings.Join(xs[:len(xs)-1], ", ") + " and " + xs[len(xs)-1]
|
||||||
|
}
|
||||||
|
|
||||||
// robbieGiftTier maps a player's combat level to a consumable tier, matching
|
// robbieGiftTier maps a player's combat level to a consumable tier, matching
|
||||||
// the arena tier bands (1–3 / 4–7 / 8–12 / 13–17 / 18+).
|
// the arena tier bands (1–3 / 4–7 / 8–12 / 13–17 / 18+).
|
||||||
func robbieGiftTier(level int) int {
|
func robbieGiftTier(level int) int {
|
||||||
@@ -287,7 +334,7 @@ func robbieGiftTier(level int) int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gaveCard bool, leftGift *AdvItem) string {
|
func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gaveCard bool, leftGifts []AdvItem) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
// Opening
|
// Opening
|
||||||
@@ -334,9 +381,19 @@ func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gav
|
|||||||
}
|
}
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
// Every-10th-visit consumable (D2).
|
// What he left behind: the every-10th-visit loyalty consumable (D2), the
|
||||||
if leftGift != nil {
|
// big-haul thank-you, or both rolled into one line. A single gift keeps the
|
||||||
sb.WriteString(fmt.Sprintf(robbieLeftConsumable, leftGift.Name))
|
// original loyalty phrasing; anything more is the haul talking.
|
||||||
|
if len(leftGifts) > 0 {
|
||||||
|
names := make([]string, 0, len(leftGifts))
|
||||||
|
for _, g := range leftGifts {
|
||||||
|
names = append(names, g.Name)
|
||||||
|
}
|
||||||
|
if len(names) == 1 {
|
||||||
|
sb.WriteString(fmt.Sprintf(robbieLeftConsumable, names[0]))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(fmt.Sprintf(robbieLeftForTheHaul, joinAnd(names)))
|
||||||
|
}
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestRobbieGiftCount pins the two tracks: the every-10th-visit loyalty gift
|
||||||
|
// and the volume track that pays for a big haul, capped so one monster
|
||||||
|
// stockpile can't mint an unbounded pile of consumables.
|
||||||
|
func TestRobbieGiftCount(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
visits, taken int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"small haul, off-loyalty visit", 7, 3, 0},
|
||||||
|
{"loyalty visit only", 10, 3, 1},
|
||||||
|
{"haul only", 7, 15, 1},
|
||||||
|
{"haul and loyalty stack", 20, 15, 2},
|
||||||
|
{"haul scales", 7, 45, 3},
|
||||||
|
{"haul capped", 7, 500, robbieMaxHaulGifts},
|
||||||
|
{"cap plus loyalty", 30, 500, robbieMaxHaulGifts + 1},
|
||||||
|
{"one under the haul threshold", 7, robbieHaulPerGift - 1, 0},
|
||||||
|
{"zeroth visit is not a loyalty visit", 0, 0, 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := robbieGiftCount(c.visits, c.taken); got != c.want {
|
||||||
|
t.Errorf("%s: robbieGiftCount(%d, %d) = %d, want %d",
|
||||||
|
c.name, c.visits, c.taken, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJoinAnd(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{nil, ""},
|
||||||
|
{[]string{"a"}, "a"},
|
||||||
|
{[]string{"a", "b"}, "a and b"},
|
||||||
|
{[]string{"a", "b", "c"}, "a, b and c"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := joinAnd(c.in); got != c.want {
|
||||||
|
t.Errorf("joinAnd(%v) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user