mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
L: openParty had no production callers (invite path uses joinParty -> seatLeader, which seats the leader the same way but reads the owner off the expedition row). Removed it; the tests now seat the leader through a seatLeaderFixture that wraps seatLeader in a transaction. I: promoted parseTemperIndex to parseMenuIndex and routed resolveMagicEquipReply and handleMasterworkEquipReply through it, replacing two hand-inlined copies of the same digit parser. Behaviour-preserving (the parsed flag was redundant with the idx<0 guard); the retry-on-bad-parse disagreement is left as-is since this is a pure dedup. Full plugin suite green. Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
411 lines
14 KiB
Go
411 lines
14 KiB
Go
package plugin
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// ── Tempering ───────────────────────────────────────────────────────────────
|
|
//
|
|
// The blacksmith's endgame role: push an owned magic item one rung up the
|
|
// rarity ladder (gogobee_engagement_plan.md B1). Rarity flows through the
|
|
// existing scalar formulas in magic_items_gameplay.go, so tempering adds no
|
|
// new combat math — and because it caps at Legendary it accelerates reaching
|
|
// the existing best-in-slot ceiling rather than raising it. Every duplicate
|
|
// drop becomes a candidate instead of vendor trash.
|
|
//
|
|
// Rarity itself lives on the registry definition, so a tempered instance
|
|
// records its progress as a `temper` step count on its own row; effective
|
|
// rarity is derived on read (see temperedItem).
|
|
|
|
// temperMaterialNames are the legendary crafting materials the final rung
|
|
// consumes. Both drop as UniqueAlways entries on their T5 zone slate, so one
|
|
// clear of the Underforge or the Abyss Portal yields exactly one.
|
|
var temperMaterialNames = []string{"Thyraks Core", "Portal Fragment"}
|
|
|
|
// temperStepCost is what one rung costs, keyed by the *target* rarity's tier.
|
|
// The euro costs and foraging gates mirror the crafting recipe tiers in
|
|
// adventure_consumables.go, so the blacksmith reads as an extension of the
|
|
// crafting ladder rather than a parallel economy.
|
|
type temperStepCost struct {
|
|
Euros int
|
|
MinForaging int
|
|
NeedsMaterial bool
|
|
}
|
|
|
|
// Only the final rung demands a T5 material. Gating the lower rungs on one too
|
|
// would make tempering unreachable until a player already clears T5 content —
|
|
// which is exactly when they stop needing it.
|
|
var temperCosts = map[int]temperStepCost{
|
|
2: {Euros: 10_000, MinForaging: 15},
|
|
3: {Euros: 25_000, MinForaging: 20},
|
|
4: {Euros: 60_000, MinForaging: 25},
|
|
5: {Euros: 150_000, MinForaging: 30, NeedsMaterial: true},
|
|
}
|
|
|
|
// temperTarget is one temperable item, from either side of the equip line.
|
|
type temperTarget struct {
|
|
Equipped bool
|
|
Slot DnDSlot // set when Equipped
|
|
InvID int64 // set when not Equipped
|
|
Base MagicItem
|
|
Temper int
|
|
}
|
|
|
|
// Effective is the item as it stands today, before this temper.
|
|
func (t temperTarget) Effective() MagicItem { return temperedItem(t.Base, t.Temper) }
|
|
|
|
// NextRarity is the rung this temper would buy.
|
|
func (t temperTarget) NextRarity() DnDRarity { return temperedRarity(t.Base.Rarity, t.Temper+1) }
|
|
|
|
// cost is the price of this target's next rung.
|
|
func (t temperTarget) cost() temperStepCost { return temperCosts[rarityLootTierNum(t.NextRarity())] }
|
|
|
|
// sameItemAs reports whether other is the same physical instance, used to
|
|
// re-find a target after a fresh reload so a concurrent equip/sell can't let a
|
|
// stale pending confirm temper the wrong thing.
|
|
func (t temperTarget) sameItemAs(other temperTarget) bool {
|
|
if t.Equipped != other.Equipped {
|
|
return false
|
|
}
|
|
if t.Equipped {
|
|
return t.Slot == other.Slot && t.Base.ID == other.Base.ID
|
|
}
|
|
return t.InvID == other.InvID
|
|
}
|
|
|
|
type advPendingTemperPick struct {
|
|
Targets []temperTarget
|
|
}
|
|
|
|
type advPendingTemperConfirm struct {
|
|
Target temperTarget
|
|
Material *AdvItem // consumed on confirm; nil when the rung needs none
|
|
}
|
|
|
|
// collectTemperTargets gathers every magic item the player owns that still has
|
|
// a rung ahead of it — worn or stowed. Potions and scrolls are excluded: they
|
|
// carry a zero combat effect, so a rarity bump would buy nothing.
|
|
func collectTemperTargets(userID id.UserID) ([]temperTarget, error) {
|
|
var out []temperTarget
|
|
|
|
equipped, err := loadEquippedMagicItems(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, ds := range dndSlotOrder {
|
|
e, ok := equipped[ds]
|
|
if !ok || e.Item.ID == "" {
|
|
continue
|
|
}
|
|
if temperStepsToLegendary(e.Item.Rarity, e.Temper) == 0 {
|
|
continue
|
|
}
|
|
out = append(out, temperTarget{Equipped: true, Slot: ds, Base: e.Item, Temper: e.Temper})
|
|
}
|
|
|
|
items, err := loadAdvInventory(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, it := range items {
|
|
if it.Type != "magic_item" {
|
|
continue
|
|
}
|
|
mi, ok := magicItemFromAdvItem(it)
|
|
if !ok || mi.Kind == MagicItemPotion || mi.Kind == MagicItemScroll {
|
|
continue
|
|
}
|
|
if temperStepsToLegendary(mi.Rarity, it.Temper) == 0 {
|
|
continue
|
|
}
|
|
out = append(out, temperTarget{InvID: it.ID, Base: mi, Temper: it.Temper})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// findTemperMaterial returns the first legendary material row in inventory.
|
|
func findTemperMaterial(items []AdvItem) (AdvItem, bool) {
|
|
for _, it := range items {
|
|
for _, name := range temperMaterialNames {
|
|
if it.Name == name {
|
|
return it, true
|
|
}
|
|
}
|
|
}
|
|
return AdvItem{}, false
|
|
}
|
|
|
|
// parseMenuIndex reads a leading 1-indexed number off a menu reply and returns
|
|
// the bounds-checked 0-indexed position. The single parser behind every "reply
|
|
// with a number from the list" prompt — temper, magic-item equip, masterwork
|
|
// equip. Leading whitespace and any trailing text after the digits are ignored;
|
|
// a reply with no leading digit, or one outside [1, n], returns ok=false.
|
|
func parseMenuIndex(reply string, n int) (int, bool) {
|
|
idx, parsed := 0, false
|
|
for _, c := range strings.TrimSpace(reply) {
|
|
if c < '0' || c > '9' {
|
|
break
|
|
}
|
|
idx = idx*10 + int(c-'0')
|
|
parsed = true
|
|
}
|
|
idx--
|
|
if !parsed || idx < 0 || idx >= n {
|
|
return 0, false
|
|
}
|
|
return idx, true
|
|
}
|
|
|
|
// ── Command ─────────────────────────────────────────────────────────────────
|
|
|
|
func (p *AdventurePlugin) handleTemperCmd(ctx MessageContext) error {
|
|
char, _, err := p.ensureCharacter(ctx.Sender)
|
|
if err != nil {
|
|
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
|
}
|
|
|
|
targets, err := collectTemperTargets(ctx.Sender)
|
|
if err != nil {
|
|
return p.SendDM(ctx.Sender, "Failed to read your curios.")
|
|
}
|
|
if len(targets) == 0 {
|
|
return p.SendDM(ctx.Sender,
|
|
"_He looks over your kit and shrugs._ Nothing here I can push any further. "+
|
|
"Bring me something that isn't already Legendary.")
|
|
}
|
|
|
|
greeting, _ := advPickFlavor(blacksmithTemperGreetings, ctx.Sender, "temper_greet")
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString("🔨 **Tempering**\n\n")
|
|
sb.WriteString(greeting)
|
|
sb.WriteString("\n\n")
|
|
for i, t := range targets {
|
|
cur := t.Effective()
|
|
cost := t.cost()
|
|
where := "stowed"
|
|
if t.Equipped {
|
|
where = "worn — " + string(t.Slot)
|
|
}
|
|
gate := ""
|
|
switch {
|
|
case char.ForagingSkill < cost.MinForaging:
|
|
gate = fmt.Sprintf(" — 🔒 needs Foraging %d", cost.MinForaging)
|
|
case cost.NeedsMaterial:
|
|
gate = " — needs a legendary material"
|
|
}
|
|
sb.WriteString(fmt.Sprintf("%d. **%s** _(%s → %s)_ — €%d _(%s)_%s\n",
|
|
i+1, cur.Name, cur.Rarity, t.NextRarity(), cost.Euros, where, gate))
|
|
}
|
|
sb.WriteString("\nReply with a number to temper it, or \"cancel\".")
|
|
|
|
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
|
Type: "temper_pick",
|
|
Data: &advPendingTemperPick{Targets: targets},
|
|
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
|
})
|
|
return p.SendDM(ctx.Sender, sb.String())
|
|
}
|
|
|
|
func (p *AdventurePlugin) resolveTemperPick(ctx MessageContext, interaction *advPendingInteraction) error {
|
|
data := interaction.Data.(*advPendingTemperPick)
|
|
reply := strings.TrimSpace(ctx.Body)
|
|
if strings.EqualFold(reply, "cancel") {
|
|
return p.SendDM(ctx.Sender, "_The forge keeps burning._ Come back when you've decided.")
|
|
}
|
|
idx, ok := parseMenuIndex(reply, len(data.Targets))
|
|
if !ok {
|
|
p.pending.Store(string(ctx.Sender), interaction)
|
|
return p.SendDM(ctx.Sender, "Reply with a number from the list, or \"cancel\".")
|
|
}
|
|
return p.buildTemperConfirm(ctx.Sender, data.Targets[idx])
|
|
}
|
|
|
|
// buildTemperConfirm re-checks every gate against fresh state before quoting a
|
|
// price, then parks a confirm. executeTemper re-checks them all again — this
|
|
// pass exists to give the player a real error message, not to be trusted.
|
|
func (p *AdventurePlugin) buildTemperConfirm(userID id.UserID, t temperTarget) error {
|
|
char, _, err := p.ensureCharacter(userID)
|
|
if err != nil {
|
|
return p.SendDM(userID, "Failed to load your character.")
|
|
}
|
|
cost := t.cost()
|
|
if char.ForagingSkill < cost.MinForaging {
|
|
return p.SendDM(userID, fmt.Sprintf(
|
|
"_He turns the piece over, then sets it down._ I could ruin this. Come back when you know your "+
|
|
"materials better — **Foraging %d**. You're at %d.", cost.MinForaging, char.ForagingSkill))
|
|
}
|
|
|
|
items, err := loadAdvInventory(userID)
|
|
if err != nil {
|
|
return p.SendDM(userID, "Failed to read your inventory.")
|
|
}
|
|
var material *AdvItem
|
|
if cost.NeedsMaterial {
|
|
mat, ok := findTemperMaterial(items)
|
|
if !ok {
|
|
return p.SendDM(userID, fmt.Sprintf(
|
|
"_He exhales through his teeth._ Legendary is a different conversation. I need a **%s** on this "+
|
|
"table before I touch it. They come out of the deepest places — the Underforge, the Portal.",
|
|
strings.Join(temperMaterialNames, "** or a **")))
|
|
}
|
|
material = &mat
|
|
}
|
|
|
|
if bal := p.euro.GetBalance(userID); bal < float64(cost.Euros) {
|
|
return p.SendDM(userID, fmt.Sprintf(
|
|
"_He names the price without apology._ €%d. You have €%.0f. The forge doesn't run on good intentions.",
|
|
cost.Euros, bal))
|
|
}
|
|
|
|
cur := t.Effective()
|
|
next := temperedItem(t.Base, t.Temper+1)
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString(fmt.Sprintf("🔨 **Temper %s?**\n\n", cur.Name))
|
|
sb.WriteString(fmt.Sprintf(" %s _(%s)_ → %s _(%s)_\n", cur.Name, cur.Rarity, next.Name, next.Rarity))
|
|
sb.WriteString(fmt.Sprintf(" %s\n ↳ %s\n\n", magicItemEffectSummary(cur), magicItemEffectSummary(next)))
|
|
sb.WriteString(fmt.Sprintf(" Cost: **€%d**", cost.Euros))
|
|
if material != nil {
|
|
sb.WriteString(fmt.Sprintf(" + one **%s**", material.Name))
|
|
}
|
|
sb.WriteString("\n\nReply \"yes\" to hand it over, or anything else to keep it as it is.")
|
|
|
|
p.pending.Store(string(userID), &advPendingInteraction{
|
|
Type: "temper_confirm",
|
|
Data: &advPendingTemperConfirm{Target: t, Material: material},
|
|
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
|
})
|
|
return p.SendDM(userID, sb.String())
|
|
}
|
|
|
|
func (p *AdventurePlugin) resolveTemperConfirm(ctx MessageContext, interaction *advPendingInteraction) error {
|
|
userMu := p.advUserLock(ctx.Sender)
|
|
userMu.Lock()
|
|
defer userMu.Unlock()
|
|
|
|
data := interaction.Data.(*advPendingTemperConfirm)
|
|
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
|
|
if reply != "yes" && reply != "y" && reply != "confirm" {
|
|
return p.SendDM(ctx.Sender, "_He nods once and turns back to the fire._ It's a good piece as it is.")
|
|
}
|
|
return p.executeTemper(ctx.Sender, data)
|
|
}
|
|
|
|
// executeTemper re-validates against fresh state, then charges and applies.
|
|
// Order matters: the euro debit is the only atomic guard we have, so it goes
|
|
// first; the material and the temper write both roll back onto it.
|
|
func (p *AdventurePlugin) executeTemper(userID id.UserID, data *advPendingTemperConfirm) error {
|
|
char, _, err := p.ensureCharacter(userID)
|
|
if err != nil {
|
|
return p.SendDM(userID, "Failed to load your character.")
|
|
}
|
|
|
|
// Re-find the target. Between the confirm prompt and this reply the
|
|
// player may have equipped, sold, or already tempered it.
|
|
fresh, err := collectTemperTargets(userID)
|
|
if err != nil {
|
|
return p.SendDM(userID, "Failed to read your curios.")
|
|
}
|
|
var target temperTarget
|
|
found := false
|
|
for _, t := range fresh {
|
|
if t.sameItemAs(data.Target) {
|
|
target, found = t, true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return p.SendDM(userID, "_He looks at the empty table._ That piece isn't here any more.")
|
|
}
|
|
if target.Temper != data.Target.Temper {
|
|
return p.SendDM(userID, "_He raises an eyebrow._ Someone's already been at this one. Ask me again.")
|
|
}
|
|
|
|
cost := target.cost()
|
|
if char.ForagingSkill < cost.MinForaging {
|
|
return p.SendDM(userID, fmt.Sprintf("You need **Foraging %d** for that.", cost.MinForaging))
|
|
}
|
|
|
|
// Re-locate the material by row id — a concurrent craft may have eaten it.
|
|
var material *AdvItem
|
|
if cost.NeedsMaterial {
|
|
items, err := loadAdvInventory(userID)
|
|
if err != nil {
|
|
return p.SendDM(userID, "Failed to read your inventory.")
|
|
}
|
|
mat, ok := findTemperMaterial(items)
|
|
if !ok {
|
|
return p.SendDM(userID, "_He taps the empty spot on the table._ The material's gone. Find another.")
|
|
}
|
|
material = &mat
|
|
}
|
|
|
|
if !p.euro.Debit(userID, float64(cost.Euros), "temper") {
|
|
return p.SendDM(userID, fmt.Sprintf("Payment failed — tempering costs €%d.", cost.Euros))
|
|
}
|
|
|
|
refund := func(reason string) {
|
|
p.euro.Credit(userID, float64(cost.Euros), "temper_refund")
|
|
slog.Error("temper: rolled back", "user", userID, "item", target.Base.ID, "reason", reason)
|
|
}
|
|
|
|
if material != nil {
|
|
if err := removeAdvInventoryItem(material.ID); err != nil {
|
|
refund("material consume failed")
|
|
return p.SendDM(userID, "Something went wrong at the forge. Your money is back.")
|
|
}
|
|
}
|
|
|
|
newTemper := target.Temper + 1
|
|
if target.Equipped {
|
|
err = temperEquippedItem(userID, target.Slot, newTemper)
|
|
} else {
|
|
bumped := temperedItem(target.Base, newTemper)
|
|
err = temperInventoryItem(target.InvID, newTemper, rarityLootTierNum(bumped.Rarity), int64(bumped.Value))
|
|
}
|
|
if err != nil {
|
|
refund("temper write failed")
|
|
if material != nil {
|
|
if rbErr := addAdvInventoryItem(userID, *material); rbErr != nil {
|
|
slog.Error("temper: material rollback failed",
|
|
"user", userID, "material", material.Name, "err", rbErr)
|
|
}
|
|
}
|
|
return p.SendDM(userID, "Something went wrong at the forge. Your money is back.")
|
|
}
|
|
|
|
result := temperedItem(target.Base, newTemper)
|
|
line, _ := advPickFlavor(blacksmithTemperCompletion, userID, "temper_done")
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString(line)
|
|
sb.WriteString(fmt.Sprintf("\n\n🔨 **%s** is now **%s**.\n%s\n",
|
|
result.Name, result.Rarity, magicItemEffectSummary(result)))
|
|
if remaining := temperStepsToLegendary(target.Base.Rarity, newTemper); remaining > 0 {
|
|
sb.WriteString(fmt.Sprintf("\n_%d more temper%s would make it Legendary._",
|
|
remaining, pluralS(remaining)))
|
|
}
|
|
if err := p.SendDM(userID, sb.String()); err != nil {
|
|
return err
|
|
}
|
|
|
|
if result.Rarity == RarityLegendary {
|
|
if p.achievements != nil {
|
|
p.achievements.GrantAchievement(userID, "temper_legendary")
|
|
}
|
|
if gr := gamesRoom(); gr != "" {
|
|
displayName, _ := loadDisplayName(userID)
|
|
p.SendMessage(id.RoomID(gr), fmt.Sprintf(
|
|
"🔨 The forge in town ran hot all night. **%s** walked out with a **Legendary %s**.",
|
|
displayName, result.Name))
|
|
}
|
|
}
|
|
return nil
|
|
}
|