From 68c8cdff2db38d40c95f1788ef8e0ef6857db207 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:34:56 -0700 Subject: [PATCH] =?UTF-8?q?adventure:=20ask=207=20=E2=80=94=20apply=20web?= =?UTF-8?q?=20equipment=20management=20(poll=20half)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of Pete's ask 7. gogobee polls the equip queue and applies the new actions against the five standard gear slots: - equip: routes MasterworkGear/ArenaGear to applyMasterworkEquip (evicts any special occupant back to the pack; downgrade-blocked), else the existing applyMagicEquip. - unequip: EquipmentSlot vocabulary -> applyMasterworkUnequip (resets the slot to its tier-0 default, keeps the row), else applyMagicUnequip. - upgrade: purchaseEquipmentTier, euro-idempotent (DebitIdem keyed on the order GUID), downgrade + max-tier guarded. - repair: repair(), euro-idempotent, recomputes blacksmithRepairCost. Detail push now carries Slots (EquipSlotView x5) + Balance; itemViews gives masterwork/arena backpack rows an equip id; the compare decorator is guarded to magic-only. buildDetailSnapshot is a method so it can read the euro balance. Retry-safety: no CreditIdem refund on a later save fault (would double-pay a guid-guarded retry) — we return retry=true and let the next poll re-run, since the debit is guid-idempotent and the slot write is idempotent. Matches the casino escrow precedent. Unit tests cover downgrade block, max-tier, insufficient funds, idempotent replay, eviction, and take-off reset. Deploy AFTER Pete: Pete's ingest must accept the new verdict strings before this side emits them. --- internal/peteclient/client.go | 28 +- internal/plugin/pete_detail_test.go | 6 +- internal/plugin/pete_equip.go | 34 +++ internal/plugin/pete_equip_manage.go | 323 ++++++++++++++++++++++ internal/plugin/pete_equip_manage_test.go | 314 +++++++++++++++++++++ internal/plugin/pete_roster.go | 17 +- 6 files changed, 716 insertions(+), 6 deletions(-) create mode 100644 internal/plugin/pete_equip_manage.go create mode 100644 internal/plugin/pete_equip_manage_test.go diff --git a/internal/peteclient/client.go b/internal/peteclient/client.go index 57e1f4f..618f480 100644 --- a/internal/peteclient/client.go +++ b/internal/peteclient/client.go @@ -388,6 +388,31 @@ 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. + Balance float64 `json:"balance,omitempty"` +} + +// 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 +717,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"` } diff --git a/internal/plugin/pete_detail_test.go b/internal/plugin/pete_detail_test.go index 6ca2b91..77ab756 100644 --- a/internal/plugin/pete_detail_test.go +++ b/internal/plugin/pete_detail_test.go @@ -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) } diff --git a/internal/plugin/pete_equip.go b/internal/plugin/pete_equip.go index 76cb024..84331e7 100644 --- a/internal/plugin/pete_equip.go +++ b/internal/plugin/pete_equip.go @@ -142,6 +142,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 +167,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 +193,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. diff --git a/internal/plugin/pete_equip_manage.go b/internal/plugin/pete_equip_manage.go new file mode 100644 index 0000000..1e89f0b --- /dev/null +++ b/internal/plugin/pete_equip_manage.go @@ -0,0 +1,323 @@ +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, mirroring the magic path's anti-duplication ordering: evict any special +// occupant, then remove the incoming row FIRST, then write the slot, restoring the +// row on a save fault. One implementation of that ordering per family of gear — +// the DM confirm handler (adventure_masterwork.go) is the message-carrying twin. +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 + } + + // Evict a special occupant back to the pack. 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. + var swappedBack string + 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" + } + if err := addAdvInventoryItem(uid, old); err != nil { + return mwEquipOutcome{}, err + } + swappedBack = cur.Name + } + + // 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 + } + 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 +} diff --git a/internal/plugin/pete_equip_manage_test.go b/internal/plugin/pete_equip_manage_test.go new file mode 100644 index 0000000..cc7b776 --- /dev/null +++ b/internal/plugin/pete_equip_manage_test.go @@ -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) + } +} diff --git a/internal/plugin/pete_roster.go b/internal/plugin/pete_roster.go index af037fd..0e3ad7d 100644 --- a/internal/plugin/pete_roster.go +++ b/internal/plugin/pete_roster.go @@ -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) }