adventure: fix concurrency + dup hazards in web equip path

Code review of the Ask-7 web equipment-management path surfaced three
correctness issues, all fixed here:

- applyEquipOrder ran the poll-goroutine equip mutations without the
  per-user advUserLock that every Matrix-side mutation (!give, !equip,
  arena, …) holds, so the lock gave no mutual exclusion against the web
  path. A concurrent !give of the item being equipped could duplicate it.
  Now takes advUserLock(owner) for the whole apply, matching the DM path.

- applyMasterworkEquip evicted the displaced occupant to the pack BEFORE
  the destructive slot write, so a fault left the piece both worn and in
  the pack — and the 30s equip poll retry re-evicted it every tick. Now
  removes the incoming row, writes the slot, then re-packs the occupant
  last as a best-effort step: once the slot no longer references it, the
  re-pack cannot duplicate, and a failure is logged not aborted on (the
  DM confirm handler's tolerance).

- PlayerDetail.Balance dropped omitempty: a real €0 balance is an
  informative fact, not an absent one, and omitting it left the web
  confirm dialog with no balance to show.
This commit is contained in:
prosolis
2026-07-17 21:04:38 -07:00
parent 68c8cdff2d
commit 5a8d21f780
3 changed files with 40 additions and 13 deletions

View File

@@ -122,6 +122,14 @@ func (p *AdventurePlugin) fulfilEquipOrder(ctx context.Context, order peteclient
// a human note for Pete, or retry=true for a transient fault that should leave the
// order pending. It records nothing and pushes nothing — the caller does both.
func (p *AdventurePlugin) applyEquipOrder(owner id.UserID, order peteclient.EquipOrder) (status, detail string, retry bool) {
// Serialize against the owner's own Matrix-side mutations (!give, !equip, !sell,
// arena, …), all of which hold this same per-user lock. Without it the poll
// goroutine's equip could interleave with a concurrent !give of the very item it
// resolved — the duplication the DM equip confirm takes this lock to prevent.
userMu := p.advUserLock(owner)
userMu.Lock()
defer userMu.Unlock()
switch order.Action {
case "equip":
inv, err := loadAdvInventory(owner)