From 6799d18d43eadbb83cff6658369f85df966f2e2b Mon Sep 17 00:00:00 2001 From: trunksbomb Date: Thu, 1 Oct 2026 20:29:22 -0400 Subject: [PATCH 1/5] fix GE Buy limit comparator: Previously `>`, changed to `>=` because `TimeUnit.MILLISECONDS.toHours` truncates to the hour, causing e.g. 4hr59min to truncate to 4hr. Setting Buy Limit to `x` was effectively `x+1` hours. --- .../kotlin/content/social/trade/exchange/limit/BuyLimits.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/game/src/main/kotlin/content/social/trade/exchange/limit/BuyLimits.kt b/game/src/main/kotlin/content/social/trade/exchange/limit/BuyLimits.kt index 89bc2df67b..7c8796606a 100644 --- a/game/src/main/kotlin/content/social/trade/exchange/limit/BuyLimits.kt +++ b/game/src/main/kotlin/content/social/trade/exchange/limit/BuyLimits.kt @@ -39,7 +39,7 @@ class BuyLimits { } val now = epochMilliseconds() for ((player, limit) in limits) { - if (TimeUnit.MILLISECONDS.toHours(now - limit.timestamp) > hours) { + if (TimeUnit.MILLISECONDS.toHours(now - limit.timestamp) >= hours) { limits.remove(player) } } From 08b540650bd1ef897a43ca302e27dcfa989232de Mon Sep 17 00:00:00 2001 From: trunksbomb Date: Thu, 1 Oct 2026 21:10:16 -0400 Subject: [PATCH 2/5] fix GE Buy Limit `limits.record`: Previously incorrectly adjusted the seller's Buy Limit sometimes instead of always adjusting the buyer's Buy Limit; only happened when an open buy order was matched to a new sell order. --- .../kotlin/content/social/trade/exchange/GrandExchange.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/game/src/main/kotlin/content/social/trade/exchange/GrandExchange.kt b/game/src/main/kotlin/content/social/trade/exchange/GrandExchange.kt index 43aa995081..7aafae348e 100644 --- a/game/src/main/kotlin/content/social/trade/exchange/GrandExchange.kt +++ b/game/src/main/kotlin/content/social/trade/exchange/GrandExchange.kt @@ -253,8 +253,9 @@ class GrandExchange( // if offer has more or same as other val required = offer.amount - offer.completed val available = open.remaining + val buyer = if (offer.sell) open.account else account var traded = if (required >= available) available else required - val limit = limits.limit(if (offer.sell) open.account else account, offer.item) + val limit = limits.limit(buyer, offer.item) if (limit != -1) { traded = traded.coerceAtMost(limit) if (traded <= 0) { @@ -270,7 +271,7 @@ class GrandExchange( } claim(open.id, open.account, offer.item, traded, traderPrice, offer.price, !offer.sell) // Record the successful exchange - limits.record(account, offer.item, traded) + limits.record(buyer, offer.item, traded) history.record(offer.item, traded, offer.price) return true } From 57232799436d4cecb6835be3bbb5416286b3c883 Mon Sep 17 00:00:00 2001 From: trunksbomb Date: Thu, 1 Oct 2026 21:15:40 -0400 Subject: [PATCH 3/5] Fix GE history recording: Previously, the new offer's price was recorded in `history.record` even if it didn't match `traderPrice` which was what the offer actually settled for. --- .../main/kotlin/content/social/trade/exchange/GrandExchange.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/game/src/main/kotlin/content/social/trade/exchange/GrandExchange.kt b/game/src/main/kotlin/content/social/trade/exchange/GrandExchange.kt index 7aafae348e..0a3ec8025a 100644 --- a/game/src/main/kotlin/content/social/trade/exchange/GrandExchange.kt +++ b/game/src/main/kotlin/content/social/trade/exchange/GrandExchange.kt @@ -272,7 +272,7 @@ class GrandExchange( claim(open.id, open.account, offer.item, traded, traderPrice, offer.price, !offer.sell) // Record the successful exchange limits.record(buyer, offer.item, traded) - history.record(offer.item, traded, offer.price) + history.record(offer.item, traded, traderPrice) return true } From 64e7303a8c166dc17b6fd2c9791ed1d065103c52 Mon Sep 17 00:00:00 2001 From: trunksbomb Date: Wed, 7 Oct 2026 01:13:08 -0400 Subject: [PATCH 4/5] fix previous commits broke instant sell --- .../social/trade/exchange/GrandExchange.kt | 6 +++-- .../trade/exchange/GrandExchangeTest.kt | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/game/src/main/kotlin/content/social/trade/exchange/GrandExchange.kt b/game/src/main/kotlin/content/social/trade/exchange/GrandExchange.kt index 0a3ec8025a..7811ad1fb7 100644 --- a/game/src/main/kotlin/content/social/trade/exchange/GrandExchange.kt +++ b/game/src/main/kotlin/content/social/trade/exchange/GrandExchange.kt @@ -255,7 +255,7 @@ class GrandExchange( val available = open.remaining val buyer = if (offer.sell) open.account else account var traded = if (required >= available) available else required - val limit = limits.limit(buyer, offer.item) + val limit = if (buyer.isEmpty()) -1 else limits.limit(buyer, offer.item) if (limit != -1) { traded = traded.coerceAtMost(limit) if (traded <= 0) { @@ -271,7 +271,9 @@ class GrandExchange( } claim(open.id, open.account, offer.item, traded, traderPrice, offer.price, !offer.sell) // Record the successful exchange - limits.record(buyer, offer.item, traded) + if (buyer.isNotEmpty()) { + limits.record(buyer, offer.item, traded) + } history.record(offer.item, traded, traderPrice) return true } diff --git a/game/src/test/kotlin/content/social/trade/exchange/GrandExchangeTest.kt b/game/src/test/kotlin/content/social/trade/exchange/GrandExchangeTest.kt index eab89f8a47..adadf6f4c8 100644 --- a/game/src/test/kotlin/content/social/trade/exchange/GrandExchangeTest.kt +++ b/game/src/test/kotlin/content/social/trade/exchange/GrandExchangeTest.kt @@ -9,8 +9,10 @@ import npcOption import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.koin.test.get +import world.gregs.voidps.cache.definition.Params import world.gregs.voidps.engine.client.ui.dialogue.Dialogues import world.gregs.voidps.engine.data.Settings +import world.gregs.voidps.engine.data.definition.ItemDefinitions import world.gregs.voidps.engine.data.exchange.ExchangeOffer import world.gregs.voidps.engine.data.exchange.OfferState import world.gregs.voidps.engine.entity.character.npc.NPC @@ -46,6 +48,28 @@ class GrandExchangeTest : WorldTest() { clerk = createNPC("grand_exchange_clerk_short", Tile(3164, 3488)) } + @Test + fun `Instant sells do not share a synthetic buyer limit`() { + Settings.load(mapOf("grandExchange.instantOffer" to "true", "grandExchange.instantSellUnderMarketPrice" to "0.0")) + val definition = ItemDefinitions.get("rune_longsword") + val originalParams = definition.params + definition.params = (originalParams ?: emptyMap()) + (Params.LIMIT to 1) + try { + for (name in listOf("sellerone", "sellertwo")) { + val seller = createPlayer(Tile(3164, 3487), name) + seller.inventory.add("rune_longsword") + sell(seller, "rune_longsword") + confirm(seller) + tick() + val offer = seller.offers[1] + assertEquals(OfferState.CompletedSell, offer.state) + assertEquals(1, offer.completed) + } + } finally { + definition.params = originalParams + } + } + @Test fun `Sell item mid price using coins in bank`() { val seller = createPlayer(Tile(3164, 3487), "seller") From 3658e5c148c4fa148c6b6b7822fbebc64a1427e8 Mon Sep 17 00:00:00 2001 From: trunksbomb Date: Wed, 7 Oct 2026 01:28:42 -0400 Subject: [PATCH 5/5] fix GE counting items without a noted form (stackables, mostly) twice, which could cause an integer overflow; also adjust how the total noted + unnoted count is summed (convert to Long then cap at Int.MAX_VALUE and convert back to Int) to fix basically the same Integer Overflow --- .../trade/exchange/GrandExchangeControls.kt | 9 +++-- .../trade/exchange/GrandExchangeTest.kt | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/game/src/main/kotlin/content/social/trade/exchange/GrandExchangeControls.kt b/game/src/main/kotlin/content/social/trade/exchange/GrandExchangeControls.kt index ca7949597d..efe2967377 100644 --- a/game/src/main/kotlin/content/social/trade/exchange/GrandExchangeControls.kt +++ b/game/src/main/kotlin/content/social/trade/exchange/GrandExchangeControls.kt @@ -206,12 +206,11 @@ class GrandExchangeControls : Script { fun Player.totalItems(): Int { val item = Item(get("grand_exchange_item", "")) val noted = item.noted - var total = 0 - if (noted != null) { - total += inventory.count(noted.id) + var total = inventory.count(item.id).toLong() + if (noted != null && noted.id != item.id) { + total += inventory.count(noted.id).toLong() } - total += inventory.count(item.id) - return total + return total.coerceAtMost(Int.MAX_VALUE.toLong()).toInt() } /** diff --git a/game/src/test/kotlin/content/social/trade/exchange/GrandExchangeTest.kt b/game/src/test/kotlin/content/social/trade/exchange/GrandExchangeTest.kt index adadf6f4c8..400944f49b 100644 --- a/game/src/test/kotlin/content/social/trade/exchange/GrandExchangeTest.kt +++ b/game/src/test/kotlin/content/social/trade/exchange/GrandExchangeTest.kt @@ -48,6 +48,39 @@ class GrandExchangeTest : WorldTest() { clerk = createNPC("grand_exchange_clerk_short", Tile(3164, 3488)) } + @Test + fun `Edit sell quantity with a large stack of unnoteable items`() { + val seller = createPlayer(Tile(3164, 3487), "seller") + seller.inventory.add("fire_rune", 2_140_000_000) + + sell(seller, "fire_rune") + seller.interfaceOption("grand_exchange", "add_x", "Edit Quantity") + (seller.suspension as Suspension.IntEntry).resume(100_000) + + assertEquals(100_000, seller.get("grand_exchange_quantity")) + tick() + seller.interfaceOption("grand_exchange", "add_x", "Edit Quantity") + (seller.suspension as Suspension.IntEntry).resume(Int.MAX_VALUE) + assertEquals(2_140_000_000, seller.get("grand_exchange_quantity")) + } + + @Test + fun `Combined noted and unnoted sell quantities saturate at integer maximum`() { + val seller = createPlayer(Tile(3164, 3487), "seller") + seller.inventory.add("rune_longsword_noted", Int.MAX_VALUE) + seller.inventory.add("rune_longsword") + + sell(seller, "rune_longsword") + seller.interfaceOption("grand_exchange", "add_x", "Edit Quantity") + (seller.suspension as Suspension.IntEntry).resume(100_000) + + assertEquals(100_000, seller.get("grand_exchange_quantity")) + tick() + seller.interfaceOption("grand_exchange", "add_x", "Edit Quantity") + (seller.suspension as Suspension.IntEntry).resume(Int.MAX_VALUE) + assertEquals(Int.MAX_VALUE, seller.get("grand_exchange_quantity")) + } + @Test fun `Instant sells do not share a synthetic buyer limit`() { Settings.load(mapOf("grandExchange.instantOffer" to "true", "grandExchange.instantSellUnderMarketPrice" to "0.0"))