mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-20 10:52:42 +00:00
adventure: ask 7 — apply web equipment management (poll half)
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.
This commit is contained in:
@@ -388,6 +388,31 @@ type PlayerDetail struct {
|
|||||||
Equipped []ItemView `json:"equipped,omitempty"`
|
Equipped []ItemView `json:"equipped,omitempty"`
|
||||||
House HouseView `json:"house"`
|
House HouseView `json:"house"`
|
||||||
Pets []PetView `json:"pets,omitempty"`
|
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.
|
// ItemView is one item in the private panels — backpack, vault, or worn.
|
||||||
@@ -692,7 +717,8 @@ type EquipOrder struct {
|
|||||||
ItemID int64 `json:"item_id"`
|
ItemID int64 `json:"item_id"`
|
||||||
ItemName string `json:"item_name"`
|
ItemName string `json:"item_name"`
|
||||||
Slot string `json:"slot"`
|
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"`
|
Status string `json:"status"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ func TestDetailSnapshotKeyedByLocalpart(t *testing.T) {
|
|||||||
t.Fatalf("saveAdvCharacter: %v", err)
|
t.Fatalf("saveAdvCharacter: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
snap, err := buildDetailSnapshot(time.Now().UTC())
|
snap, err := (&AdventurePlugin{}).buildDetailSnapshot(time.Now().UTC())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("buildDetailSnapshot: %v", err)
|
t.Fatalf("buildDetailSnapshot: %v", err)
|
||||||
}
|
}
|
||||||
@@ -189,7 +189,7 @@ func TestDetailSnapshotIgnoresOptOut(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ...but the private detail set keeps them both.
|
// ...but the private detail set keeps them both.
|
||||||
detail, err := buildDetailSnapshot(time.Now().UTC())
|
detail, err := (&AdventurePlugin{}).buildDetailSnapshot(time.Now().UTC())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("buildDetailSnapshot: %v", err)
|
t.Fatalf("buildDetailSnapshot: %v", err)
|
||||||
}
|
}
|
||||||
@@ -218,7 +218,7 @@ func TestDetailSnapshotSkipsDeadPlayers(t *testing.T) {
|
|||||||
t.Fatalf("kill player: %v", err)
|
t.Fatalf("kill player: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
snap, err := buildDetailSnapshot(time.Now().UTC())
|
snap, err := (&AdventurePlugin{}).buildDetailSnapshot(time.Now().UTC())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("buildDetailSnapshot: %v", err)
|
t.Fatalf("buildDetailSnapshot: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
// 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
|
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)
|
out, err := applyMagicEquip(owner, it)
|
||||||
if errors.Is(err, errItemNotEquippable) {
|
if errors.Is(err, errItemNotEquippable) {
|
||||||
return "rejected_not_equippable", "That item can't be worn.", false
|
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
|
return "applied", equipAppliedDetail(out), false
|
||||||
|
|
||||||
case "unequip":
|
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))
|
out, err := applyMagicUnequip(owner, DnDSlot(order.Slot))
|
||||||
if errors.Is(err, errSlotEmpty) {
|
if errors.Is(err, errSlotEmpty) {
|
||||||
return "rejected_not_worn", "That slot was already empty.", false
|
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
|
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:
|
default:
|
||||||
// Pete validates the action before it ever queues an order, so this is a
|
// 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.
|
// contract breach, not a user mistake. Reject permanently rather than spin.
|
||||||
|
|||||||
323
internal/plugin/pete_equip_manage.go
Normal file
323
internal/plugin/pete_equip_manage.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
314
internal/plugin/pete_equip_manage_test.go
Normal file
314
internal/plugin/pete_equip_manage_test.go
Normal file
@@ -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
|
var detailPushOK bool
|
||||||
|
|
||||||
func (p *AdventurePlugin) pushDetails() {
|
func (p *AdventurePlugin) pushDetails() {
|
||||||
snap, err := buildDetailSnapshot(time.Now().UTC())
|
snap, err := p.buildDetailSnapshot(time.Now().UTC())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("roster: build detail snapshot failed", "err", err)
|
slog.Error("roster: build detail snapshot failed", "err", err)
|
||||||
return
|
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
|
// 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
|
// sheet. The board token rides along so Pete can match owner↔page without ever
|
||||||
// reversing the one-way token.
|
// 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()}
|
snap := peteclient.DetailSnapshot{SnapshotAt: now.Unix()}
|
||||||
rows, err := db.Get().Query(`SELECT user_id FROM player_meta WHERE alive = 1`)
|
rows, err := db.Get().Query(`SELECT user_id FROM player_meta WHERE alive = 1`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -208,6 +208,13 @@ func buildDetailSnapshot(now time.Time) (peteclient.DetailSnapshot, error) {
|
|||||||
pd.Vault = itemViews(items)
|
pd.Vault = itemViews(items)
|
||||||
}
|
}
|
||||||
pd.Equipped = equippedViews(uid)
|
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)
|
snap.Players = append(snap.Players, pd)
|
||||||
}
|
}
|
||||||
return snap, nil
|
return snap, nil
|
||||||
@@ -256,6 +263,12 @@ func itemViews(items []AdvItem) []peteclient.ItemView {
|
|||||||
} else if it.Slot != "" {
|
} else if it.Slot != "" {
|
||||||
// Shop equipment resolves by (slot, tier) — Name is decorative.
|
// Shop equipment resolves by (slot, tier) — Name is decorative.
|
||||||
v.Desc = equipmentDefByTier(it.Slot, it.Tier).Description
|
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)
|
out = append(out, v)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user