package world import ( "strings" ) const DropDespawnTicks = 1000 const ReserveTicks = 100 type GroundItemInfo struct { ItemID string Quantity int Quality int MaxQuality int ReservedFor string ReserveTimer int DespawnTimer int IsSpawn bool } type groundEntry struct { itemID string quantity int quality int maxQuality int isSpawn bool respawnTimer int // >0 = counting down to respawn respawnQty int respawnDelay int despawnTimer int // >0 = counting down to despawn (dropped items) reservedFor string reserveTimer int // >0 = counting down reservation } func (w *World) GroundItemsDetailed(roomID int) []GroundItemInfo { w.mu.Lock() defer w.mu.Unlock() var out []GroundItemInfo for _, e := range w.groundItems[roomID] { if e.quantity <= 0 { continue } info := GroundItemInfo{ ItemID: e.itemID, Quantity: e.quantity, Quality: e.quality, MaxQuality: e.maxQuality, DespawnTimer: e.despawnTimer, IsSpawn: e.isSpawn, } if e.reserveTimer > 0 && e.reservedFor != "" { info.ReservedFor = e.reservedFor info.ReserveTimer = e.reserveTimer } out = append(out, info) } return out } func (w *World) GroundItems(roomID int) map[string]int { w.mu.Lock() defer w.mu.Unlock() out := make(map[string]int) for _, e := range w.groundItems[roomID] { if e.quantity > 0 { out[e.itemID] += e.quantity } } return out } func (w *World) AddReservedItem(roomID int, itemID string, qty int, owner string) { w.AddReservedItemQ(roomID, itemID, qty, owner, 0, 0) } // AddReservedItemQ is AddReservedItem with the item's quality preserved (a // non-stackable max_quality item dropped on death/trigger keeps its charge). func (w *World) AddReservedItemQ(roomID int, itemID string, qty int, owner string, quality, maxQuality int) { w.mu.Lock() defer w.mu.Unlock() e := &groundEntry{ itemID: itemID, quantity: qty, quality: quality, maxQuality: maxQuality, despawnTimer: DropDespawnTicks, reservedFor: owner, reserveTimer: ReserveTicks, } w.groundItems[roomID] = append(w.groundItems[roomID], e) } func (w *World) AddGroundItem(roomID int, itemID string, qty int) { w.AddGroundItemQ(roomID, itemID, qty, 0, 0) } // AddGroundItemQ is AddGroundItem with the item's quality preserved. A // dropped partially-used max_quality item must not come back full on pickup. func (w *World) AddGroundItemQ(roomID int, itemID string, qty, quality, maxQuality int) { w.mu.Lock() defer w.mu.Unlock() e := &groundEntry{ itemID: itemID, quantity: qty, quality: quality, maxQuality: maxQuality, despawnTimer: DropDespawnTicks, } w.groundItems[roomID] = append(w.groundItems[roomID], e) } // RemoveReservedItem removes up to qty of itemID from the room's ground, // refusing entries reserved for another player. It is ALL-OR-NOTHING: // when any part of the quantity is unobtainable (a reserved-for-others // entry, or too little accessible stock) nothing is taken and (0, false) // is returned. Callers must never lose already-removed items to a failed // pickup, so the removal is verified before any state is touched. func (w *World) RemoveReservedItem(roomID int, itemID string, qty int, owner string) (int, bool) { removed, _, _, ok := w.removeReservedItem(roomID, itemID, qty, owner) return removed, ok } // RemoveReservedItemQ is RemoveReservedItem that also reports the quality of // the entry the pickup actually came from, so a partially-used non-stackable // item keeps its charge through a drop/pickup round trip. func (w *World) RemoveReservedItemQ(roomID int, itemID string, qty int, owner string) (removed, quality, maxQuality int, ok bool) { return w.removeReservedItem(roomID, itemID, qty, owner) } func (w *World) removeReservedItem(roomID int, itemID string, qty int, owner string) (removed, quality, maxQuality int, ok bool) { w.mu.Lock() defer w.mu.Unlock() entries := w.groundItems[roomID] // Pass 1: verify the full quantity is takable by this owner without // mutating anything, so a blocked pickup can never leave the ground // half-emptied with nothing delivered. available := 0 for _, e := range entries { if available >= qty { break } if e.quantity <= 0 || !strings.EqualFold(e.itemID, itemID) { continue } if e.reserveTimer > 0 && e.reservedFor != "" && e.reservedFor != owner { continue } available += e.quantity } if available < qty { return 0, 0, 0, false } // Pass 2: take. Every entry reachable here is takeable, so no // mid-loop abort is possible. The ownership check MUST match pass 1: // without it a player could consume a stack reserved for someone else // when a public stack and a reserved stack coexist (the reserved entry // comes first in the slice, so the take loop would drain it first). remaining := qty tookQuality, tookMaxQuality := 0, 0 tookFromEntry := false for _, e := range entries { if remaining <= 0 { break } if e.quantity <= 0 || !strings.EqualFold(e.itemID, itemID) { continue } if e.reserveTimer > 0 && e.reservedFor != "" && e.reservedFor != owner { continue } take := e.quantity if take > remaining { take = remaining } if !tookFromEntry { tookQuality, tookMaxQuality = e.quality, e.maxQuality tookFromEntry = true } e.quantity -= take removed += take remaining -= take if e.isSpawn && e.quantity <= 0 && e.respawnDelay > 0 { e.respawnTimer = e.respawnDelay } } return removed, tookQuality, tookMaxQuality, true } // AccessibleGroundQuality reports the quality of the first ground entry this // owner may take, so a pickup can stamp the item it is about to create. The // boolean is false when no accessible entry exists. func (w *World) AccessibleGroundQuality(roomID int, itemID, owner string) (quality, maxQuality int, ok bool) { w.mu.Lock() defer w.mu.Unlock() for _, e := range w.groundItems[roomID] { if e.quantity <= 0 || !strings.EqualFold(e.itemID, itemID) { continue } if e.reserveTimer > 0 && e.reservedFor != "" && e.reservedFor != owner { continue } return e.quality, e.maxQuality, true } return 0, 0, false } // RemoveAvailableItem removes up to maxQty of itemID from the room's // ground, skipping entries reserved for another player, and returns the // quantity actually removed. Unlike RemoveReservedItem it is not // all-or-nothing: callers pass a generous upper bound (credit pickups use // a sentinel far above any possible stock) and take whatever is accessible // at the moment, so a request larger than the available stock still // succeeds. func (w *World) RemoveAvailableItem(roomID int, itemID string, maxQty int, owner string) int { w.mu.Lock() defer w.mu.Unlock() removed := 0 remaining := maxQty for _, e := range w.groundItems[roomID] { if remaining <= 0 { break } if e.quantity <= 0 || !strings.EqualFold(e.itemID, itemID) { continue } if e.reserveTimer > 0 && e.reservedFor != "" && e.reservedFor != owner { continue } take := e.quantity if take > remaining { take = remaining } e.quantity -= take removed += take remaining -= take if e.isSpawn && e.quantity <= 0 && e.respawnDelay > 0 { e.respawnTimer = e.respawnDelay } } return removed } // AccessibleGround returns the ground items in roomID this owner may take: // entries reserved for another player are excluded. Pickup flows use this // to size requests and match names against takeable stock only. func (w *World) AccessibleGround(roomID int, owner string) map[string]int { w.mu.Lock() defer w.mu.Unlock() out := make(map[string]int) for _, e := range w.groundItems[roomID] { if e.quantity <= 0 { continue } if e.reserveTimer > 0 && e.reservedFor != "" && e.reservedFor != owner { continue } out[e.itemID] += e.quantity } return out } func (w *World) RemoveGroundItem(roomID int, itemID string, qty int) int { w.mu.Lock() defer w.mu.Unlock() removed := 0 remaining := qty for _, e := range w.groundItems[roomID] { if remaining <= 0 { break } if !strings.EqualFold(e.itemID, itemID) { continue } if e.quantity <= 0 { continue } // This helper has no owner, so it must not consume another player's // reserved entry (its siblings are reservation-aware). if e.reserveTimer > 0 && e.reservedFor != "" { continue } take := e.quantity if take > remaining { take = remaining } e.quantity -= take removed += take remaining -= take if e.isSpawn && e.quantity <= 0 && e.respawnDelay > 0 { e.respawnTimer = e.respawnDelay } } return removed } func (w *World) tickGroundItems() { for roomID, entries := range w.groundItems { for _, e := range entries { if e.respawnTimer > 0 { e.respawnTimer-- if e.respawnTimer <= 0 { e.quantity = e.respawnQty w.mergeNearbyItems(entries, e) } } if e.despawnTimer > 0 { e.despawnTimer-- if e.despawnTimer <= 0 { e.quantity = 0 } } if e.reserveTimer > 0 { e.reserveTimer-- if e.reserveTimer <= 0 { e.reservedFor = "" } } } // Prune dead entries: without this every drop/loot/firemaking action // appends an entry that lives for the process lifetime and is scanned // on every tick and pickup. A spawn entry awaiting respawn keeps its // slot; everything else with no quantity is gone for good. kept := entries[:0] for _, e := range entries { if e.quantity <= 0 && e.respawnTimer <= 0 { continue } kept = append(kept, e) } if len(kept) == 0 { delete(w.groundItems, roomID) } else { w.groundItems[roomID] = kept } } } func (w *World) mergeNearbyItems(entries []*groundEntry, target *groundEntry) { for _, other := range entries { if other != target && other.quantity > 0 && strings.EqualFold(other.itemID, target.itemID) && other.quality == target.quality && other.maxQuality == target.maxQuality && (other.reserveTimer <= 0 || other.reservedFor == "") && other.despawnTimer <= 0 { target.quantity += other.quantity other.quantity = 0 } } }