From 8fe9e3c3fef0049df82281a62afe35dda516dfe2 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 17:48:26 -0400 Subject: fix: close combat ghost, admin traversal, login throttle, journal double-pay, and quality/economy gaps --- internal/world/ground.go | 87 +++++++++++++++++++++++++++++++++-- internal/world/ground_quality_test.go | 43 +++++++++++++++++ internal/world/world.go | 1 + 3 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 internal/world/ground_quality_test.go (limited to 'internal/world') diff --git a/internal/world/ground.go b/internal/world/ground.go index 6915072..9cfc40c 100644 --- a/internal/world/ground.go +++ b/internal/world/ground.go @@ -10,6 +10,8 @@ const ReserveTicks = 100 type GroundItemInfo struct { ItemID string Quantity int + Quality int + MaxQuality int ReservedFor string ReserveTimer int DespawnTimer int @@ -19,6 +21,8 @@ type GroundItemInfo struct { type groundEntry struct { itemID string quantity int + quality int + maxQuality int isSpawn bool respawnTimer int // >0 = counting down to respawn respawnQty int @@ -39,6 +43,8 @@ func (w *World) GroundItemsDetailed(roomID int) []GroundItemInfo { info := GroundItemInfo{ ItemID: e.itemID, Quantity: e.quantity, + Quality: e.quality, + MaxQuality: e.maxQuality, DespawnTimer: e.despawnTimer, IsSpawn: e.isSpawn, } @@ -64,11 +70,19 @@ func (w *World) GroundItems(roomID int) map[string]int { } 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, @@ -77,11 +91,19 @@ func (w *World) AddReservedItem(roomID int, itemID string, qty int, owner string } 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) @@ -94,6 +116,18 @@ func (w *World) AddGroundItem(roomID int, itemID string, qty int) { // 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] @@ -115,7 +149,7 @@ func (w *World) RemoveReservedItem(roomID int, itemID string, qty int, owner str available += e.quantity } if available < qty { - return 0, false + return 0, 0, 0, false } // Pass 2: take. Every entry reachable here is takeable, so no @@ -123,8 +157,9 @@ func (w *World) RemoveReservedItem(roomID int, itemID string, qty int, owner str // 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). - removed := 0 remaining := qty + tookQuality, tookMaxQuality := 0, 0 + tookFromEntry := false for _, e := range entries { if remaining <= 0 { break @@ -139,6 +174,10 @@ func (w *World) RemoveReservedItem(roomID int, itemID string, qty int, owner str if take > remaining { take = remaining } + if !tookFromEntry { + tookQuality, tookMaxQuality = e.quality, e.maxQuality + tookFromEntry = true + } e.quantity -= take removed += take remaining -= take @@ -146,7 +185,25 @@ func (w *World) RemoveReservedItem(roomID int, itemID string, qty int, owner str e.respawnTimer = e.respawnDelay } } - return removed, true + 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 @@ -221,6 +278,11 @@ func (w *World) RemoveGroundItem(roomID int, itemID string, qty int) int { 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 @@ -237,7 +299,7 @@ func (w *World) RemoveGroundItem(roomID int, itemID string, qty int) int { } func (w *World) tickGroundItems() { - for _, entries := range w.groundItems { + for roomID, entries := range w.groundItems { for _, e := range entries { if e.respawnTimer > 0 { e.respawnTimer-- @@ -259,12 +321,29 @@ func (w *World) tickGroundItems() { } } } + // 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 diff --git a/internal/world/ground_quality_test.go b/internal/world/ground_quality_test.go new file mode 100644 index 0000000..e287642 --- /dev/null +++ b/internal/world/ground_quality_test.go @@ -0,0 +1,43 @@ +package world + +import "testing" + +// TestGroundPreservesQualityAcrossDropAndPickup pins the deferred item-quality +// fix: a partially-used max_quality item dropped on the ground must not come +// back at full quality when picked up. +func TestGroundPreservesQualityAcrossDropAndPickup(t *testing.T) { + w := New(t.TempDir()) + w.AddGroundItemQ(1, "lighter", 1, 42, 100) + + if q, mq, ok := w.AccessibleGroundQuality(1, "lighter", "alice"); !ok || q != 42 || mq != 100 { + t.Fatalf("ground quality = (%d,%d,%v), want (42,100,true)", q, mq, ok) + } + removed, q, mq, ok := w.RemoveReservedItemQ(1, "lighter", 1, "alice") + if !ok || removed != 1 || q != 42 || mq != 100 { + t.Fatalf("pickup = (%d,%d,%d,%v), want (1,42,100,true)", removed, q, mq, ok) + } +} + +// TestGroundMergeRequiresMatchingQuality: a full-quality spawn stack must not +// absorb a used player-dropped stack (which would erase the charge). +func TestGroundMergeRequiresMatchingQuality(t *testing.T) { + w := New(t.TempDir()) + w.AddGroundItemQ(1, "lighter", 1, 100, 100) + w.AddGroundItemQ(1, "lighter", 1, 20, 100) + if got := len(w.GroundItemsDetailed(1)); got != 2 { + t.Fatalf("distinct-quality entries merged: %d entries, want 2", got) + } +} + +// TestRemoveGroundItemSkipsReserved: the firemaking helper has no owner, so it +// must never consume another player's reserved loot. +func TestRemoveGroundItemSkipsReserved(t *testing.T) { + w := New(t.TempDir()) + w.AddReservedItem(1, "log", 3, "bob") + if got := w.RemoveGroundItem(1, "log", 3); got != 0 { + t.Fatalf("RemoveGroundItem consumed a reserved stack: took %d", got) + } + if got := w.AccessibleGround(1, "bob")["log"]; got != 3 { + t.Fatalf("bob's reserved stock = %d, want 3", got) + } +} diff --git a/internal/world/world.go b/internal/world/world.go index a084f63..cb53b77 100644 --- a/internal/world/world.go +++ b/internal/world/world.go @@ -114,6 +114,7 @@ func (w *World) SeedGroundItems(roomID int) { merged := false for _, e := range w.groundItems[roomID] { if e.quantity > 0 && strings.EqualFold(e.itemID, s.ID) && + e.quality == 0 && e.maxQuality == 0 && (e.reserveTimer <= 0 || e.reservedFor == "") && e.despawnTimer <= 0 { e.quantity += s.Quantity -- cgit v1.2.3