feat: create Grand Exchange sell offers from inventory clicks

This commit is contained in:
max bittker committed 2026-09-07 23:55:50 -04:00
1 parent e5282efdd9
commit 4906fb6e83
9 files changed
+239 -54

No files matched your search

+8 -2
View File
@@ -18,8 +18,14 @@ at `/market`.
| Read-only web/API | `server/engine/src/web/pages/market.ts`, `public/market.html`, web route and navigation |
| SDK | `sdk/market.ts`, `sdk/ge-actions.ts`, `sdk/ge-types.d.ts` and methods in `sdk/index.ts` / `sdk/actions.ts` |
Reuses `IF_OPENMAIN`, `IF_SETTEXT`, `IF_BUTTON` and `P_COUNTDIALOG`. The standard
and bot/lite clients receive these existing messages. Typed search sends the bounded
Reuses `IF_OPENMAIN_SIDE`, `UPDATE_INV_FULL`, `INV_BUTTON1`, `IF_SETTEXT`,
`IF_BUTTON` and `P_COUNTDIALOG`. The standard
and bot/lite clients receive these existing messages. While the exchange is open,
a transmitted backpack sidebar offers a left-click Sell action. Clicking an item
opens a sell draft in the selected empty slot (or the first free slot), combines
loose items and notes, and defaults to the quantity owned. No items leave the
backpack until Confirm. Sell quantity buttons stop at the amount owned, and
manual quantities above it are rejected. Typed search sends the bounded
`MARKET_SEARCH` client packet (243): ASCII query (max 48 characters) and selected
item ID (0 means filter). No new server-to-client packet is introduced.
Keyboard input stays in the field and is debounced by 180 ms; Enter flushes it.
+2
View File
@@ -11476,3 +11476,5 @@
11475
11476
11477
11478
11479
+2
View File
@@ -11476,3 +11476,5 @@
11475=grand_exchange:view_cancel
11476=grand_exchange:agent_metadata
11477=grand_exchange:agent_state
11478=grand_exchange_side
11479=grand_exchange_side:inv
@@ -0,0 +1,8 @@
[inv]
type=inv
x=16
y=8
width=4
height=7
margin=10,4
option1=Sell
@@ -11,7 +11,7 @@ import Component from '#/cache/config/Component.js';
import InvType from '#/cache/config/InvType.js';
import ObjType from '#/cache/config/ObjType.js';
import type Player from '#/engine/entity/Player.js';
import IfOpenMain from '#/network/game/server/model/IfOpenMain.js';
import IfOpenMainSide from '#/network/game/server/model/IfOpenMainSide.js';
import IfSetText from '#/network/game/server/model/IfSetText.js';
import IfSetObject from '#/network/game/server/model/IfSetObject.js';
import PCountDialog from '#/network/game/server/model/PCountDialog.js';
@@ -46,9 +46,28 @@ type Session = {
const sessions = new WeakMap<Player, Session>();
const com = (name: string) => Component.getId(`grand_exchange:${name}`);
const root = () => Component.getId('grand_exchange');
const sideRoot = () => Component.getId('grand_exchange_side');
const sideInventory = () => Component.getId('grand_exchange_side:inv');
const coins = () => ObjType.getId('coins');
const backpack = (p: Player) => p.getInventory(InvType.getId('inv'))!;
/** Combine loose items and notes into the quantities actually available to sell. */
function sellableInventory(p: Player): Map<number, number> {
const owned = new Map<number, number>();
for (const item of backpack(p).itemsFiltered) {
try {
const id = canonicalItem(item.id);
owned.set(id, (owned.get(id) ?? 0) + item.count);
} catch {
// Coins and untradeable items cannot be offered.
}
}
return owned;
}
function availableQuantity(p: Player, item: number): number {
return Math.min(MAX_GP, sellableInventory(p).get(item) ?? 0);
}
/** World-space footprint of the existing bank table (rotated in m49_53) and stationary teller. */
export const EXCHANGE_TABLE = { x: 3180, z: 3443, width: 1, length: 2 } as const;
export const EXCHANGE_TELLER = { x: 3181, z: 3445, width: 1, length: 1 } as const;
@@ -90,7 +109,8 @@ export function openExchange(p: Player, x: number, z: number) {
z
});
text(p, 'agent_state', '');
p.openMainModal(root());
p.invListenOnCom(InvType.getId('inv'), sideInventory(), p.uid);
p.openMainSideModal(root(), sideRoot());
render(p);
}
function active(p: Player): Session | undefined {
@@ -229,10 +249,7 @@ function publishExchange(p: Player, notice = '') {
screen: s.screen,
offers,
selectedOffer: s.screen === 'offer' ? s.offer : null,
draft:
s.screen === 'draft'
? { item: s.item, name: ObjType.get(s.item).name ?? 'Item', side: s.side, quantity: s.quantity, price: s.price, slot: s.slot ?? null }
: null,
draft: s.screen === 'draft' ? { item: s.item, name: ObjType.get(s.item).name ?? 'Item', side: s.side, quantity: s.quantity, price: s.price, slot: s.slot ?? null } : null,
search:
s.screen === 'catalog'
? {
@@ -261,7 +278,7 @@ function render(p: Player, notice = '') {
// search after its field, results and receipt arrive, not before them.
if (s.screen !== 'catalog') hide(p, 'search', true);
text(p, 'search_clear', s.screen === 'catalog' ? 'Clear search' : '');
text(p, 'subtitle', s.screen === 'home' ? 'Select an offer slot to set up or view an offer.' : 'Choose an item, quantity and price.');
text(p, 'subtitle', s.screen === 'home' ? 'Click a backpack item to sell, or choose an offer slot.' : 'Choose an item, quantity and price.');
for (let i = 0; i < 8; i++) text(p, `row${i}`, '');
text(p, 'previous', '');
text(p, 'next', '');
@@ -272,19 +289,11 @@ function render(p: Player, notice = '') {
renderGrid(p);
} else if (s.screen === 'catalog') {
text(p, 'heading', 'Grand Exchange');
text(p, 'subtitle', s.side === 'buy' ? 'Search for an item to buy.' : 'Search the items in your backpack.');
text(p, 'subtitle', s.side === 'buy' ? 'Search for an item to buy.' : 'Choose an item from your backpack. Quantities include notes.');
text(p, 'search_prompt', `What would you like to ${s.side}?`);
text(p, 'search_query', s.query || 'Type a name or alias...');
text(p, 'search_sort', { relevance: 'Sort: Best match', name: 'Sort: A-Z', 'name-desc': 'Sort: Z-A' }[s.sort]);
const owned = new Set(
backpack(p).itemsFiltered.flatMap(i => {
try {
return [canonicalItem(i.id)];
} catch {
return [];
}
})
);
const owned = sellableInventory(p);
const items = catalog(s.query, s.sort).filter(o => s.side === 'buy' || owned.has(o.id));
s.matches = items.length;
s.page = Math.min(s.page, Math.max(0, Math.ceil(items.length / 12) - 1));
@@ -293,10 +302,12 @@ function render(p: Player, notice = '') {
for (let i = 0; i < 12; i++) {
hide(p, `result${i}`, !page[i]);
if (!page[i]) continue;
icon(p, `result${i}_icon`, page[i].id, 1);
icon(p, `result${i}_icon`, page[i].id, s.side === 'sell' ? owned.get(page[i].id)! : 1);
text(p, `result${i}_name`, wrapItemName(page[i].name));
}
if (!items.length) text(p, 'subtitle', 'No matching items. Try another search.');
if (!items.length) {
text(p, 'subtitle', s.side === 'sell' ? (owned.size ? 'No backpack items match. Clear the search to see what you can sell.' : 'No sellable items in your backpack. Bring items from your bank.') : 'No matching items. Try another search.');
}
text(p, 'search_ack', s.query);
hide(p, 'search', false);
text(p, 'previous', s.page > 0 ? 'Previous page' : '');
@@ -306,7 +317,7 @@ function render(p: Player, notice = '') {
const total = s.price * s.quantity;
hide(p, 'offer_sell_quantity', s.side !== 'sell');
text(p, 'heading', 'Grand Exchange: Set up offer');
text(p, 'subtitle', s.side === 'buy' ? 'Buy offer' : 'Sell offer');
text(p, 'subtitle', s.side === 'buy' ? 'Buy offer' : `Sell offer - ${availableQuantity(p, s.item).toLocaleString('en-US')} available in your backpack`);
text(p, 'offer_name', item.name!);
const description = item.desc ?? 'Choose a quantity and a price for your offer.';
const lines: string[] = [''];
@@ -360,13 +371,42 @@ export function tickExchange(p: Player): void {
if (!s || !active(p) || (s.screen !== 'home' && s.screen !== 'offer')) return;
if (s.offerSnapshot !== JSON.stringify(marketStore().offers(p.username))) render(p);
}
/** Inventory clicks create a draft only; the player still chooses a price and confirms. */
export function exchangeInventoryButton(p: Player, component: number, slot: number, item: number, op: number): boolean {
if (component !== sideInventory()) return false;
const s = active(p);
if (!s || p.modalSide !== sideRoot() || op !== 1 || !backpack(p).hasAt(slot, item)) return true;
s.actionRevision++;
if (s.input) p.write(new IfOpenMainSide(root(), sideRoot()));
s.input = undefined;
s.error = null;
s.receipt = null;
try {
const id = canonicalItem(item);
const offers = marketStore().offers(p.username);
const selectedSlot = s.screen === 'catalog' || s.screen === 'draft' ? s.slot : undefined;
const freeSlot = selectedSlot !== undefined && !offers.some(o => o.slot === selectedSlot) ? selectedSlot : Array.from({ length: 6 }, (_, i) => i).find(i => !offers.some(o => o.slot === i));
if (freeSlot === undefined) throw new Error('All offer slots are full. Collect a completed offer to free a slot.');
s.side = 'sell';
s.slot = freeSlot;
s.query = '';
s.page = 0;
draft(p, id);
render(p);
} catch (error) {
s.error = error instanceof Error ? error.message : 'Item unavailable.';
render(p, s.error);
}
return true;
}
export function exchangeButton(p: Player, id: number): boolean {
if (Component.get(id)?.rootLayer !== root()) return false;
const s = active(p);
if (!s) return true;
s.actionRevision++;
// A different click invalidates the numeric prompt on both server and client.
if (s.input) p.write(new IfOpenMain(root()));
if (s.input) p.write(new IfOpenMainSide(root(), sideRoot()));
s.input = undefined;
s.error = null;
s.receipt = null;
@@ -382,10 +422,10 @@ export function exchangeButton(p: Player, id: number): boolean {
offer_qty_hundred: 100,
offer_qty_thousand: 1000
};
for (const [button, step] of Object.entries(quantitySteps)) if (id === com(button)) s.quantity = Math.max(1, Math.min(MAX_GP, s.quantity + step));
const quantityLimit = s.side === 'sell' ? availableQuantity(p, s.item) : MAX_GP;
for (const [button, step] of Object.entries(quantitySteps)) if (id === com(button)) s.quantity = Math.max(1, Math.min(quantityLimit, s.quantity + step));
if (id === com('offer_qty_all') && s.side === 'sell') {
const owned = [s.item, noteFor(s.item)].filter((id, i, a) => a.indexOf(id) === i).reduce((sum, id) => sum + backpack(p).getItemCount(id), 0);
s.quantity = Math.max(1, owned);
s.quantity = Math.max(1, quantityLimit);
}
if (id === com('offer_price_minus')) s.price = Math.max(1, s.price - 1);
if (id === com('offer_price_plus')) s.price = Math.min(MAX_GP, s.price + 1);
@@ -412,6 +452,8 @@ export function exchangeButton(p: Player, id: number): boolean {
s.side = id === com(`slot${i}_buy`) ? 'buy' : 'sell';
s.slot = i;
s.screen = 'catalog';
s.query = '';
s.sort = 'relevance';
s.page = 0;
render(p);
return true;
@@ -419,7 +461,13 @@ export function exchangeButton(p: Player, id: number): boolean {
}
}
if (id === com('collect')) {
const result = marketStore().collectMany(account(p), marketStore().offers(p.username).map(offer => ({ id: offer.id, itemId: noteFor(offer.item) })), coins());
const result = marketStore().collectMany(
account(p),
marketStore()
.offers(p.username)
.map(offer => ({ id: offer.id, itemId: noteFor(offer.item) })),
coins()
);
s.receipt = { token: ++nextReceipt, kind: 'collect', ...result };
s.screen = 'home';
render(p, `Collected ${result.items} items and ${result.coins} coins. Any overflow stays here.`);
@@ -514,8 +562,10 @@ export function exchangeButton(p: Player, id: number): boolean {
function draft(p: Player, id: number) {
const s = sessions.get(p)!;
marketItem(id);
const owned = availableQuantity(p, id);
if (s.side === 'sell' && !owned) throw new Error('This item is no longer in your backpack. Choose another item.');
s.item = id;
s.quantity = 1;
s.quantity = s.side === 'sell' ? owned : 1;
s.price = marketStore().quote(id).lastPrice ?? Math.max(1, ObjType.get(id).cost);
s.screen = 'draft';
}
@@ -530,7 +580,12 @@ export function exchangeCount(p: Player, value: number): boolean {
try {
positive(value, input);
if (input === 'item') draft(p, value);
else s[input] = value;
else {
if (input === 'quantity' && s.side === 'sell' && value > availableQuantity(p, s.item)) {
throw new Error(`You only have ${availableQuantity(p, s.item).toLocaleString('en-US')} of this item in your backpack.`);
}
s[input] = value;
}
render(p);
} catch (error) {
s.error = error instanceof Error ? error.message : 'Invalid number.';
@@ -1,3 +1,4 @@
import { exchangeInventoryButton } from '#/engine/market/GrandExchange.js';
import Component from '#/cache/config/Component.js';
import Player from '#/engine/entity/Player.js';
import ScriptProvider from '#/engine/script/ScriptProvider.js';
@@ -45,6 +46,8 @@ export default class InvButtonHandler extends ClientGameMessageHandler<InvButton
return false;
}
if (exchangeInventoryButton(player, comId, slot, obj, message.op)) return true;
player.lastItem = obj;
player.lastSlot = slot;
+91 -11
View File
@@ -16,6 +16,8 @@ const { default: LocType } = await import('../src/cache/config/LocType.js');
const { default: Packet } = await import('../src/io/Packet.js');
const { PlayerLoading } = await import('../src/engine/entity/PlayerLoading.js');
const { default: IfButtonHandler } = await import('../src/network/game/client/handler/IfButtonHandler.js');
const { default: InvButtonHandler } = await import('../src/network/game/client/handler/InvButtonHandler.js');
const { default: InvButton } = await import('../src/network/game/client/model/InvButton.js');
const { default: IfButton } = await import('../src/network/game/client/model/IfButton.js');
const { default: SearchHandler } = await import('../src/network/game/client/handler/MarketSearchHandler.js');
const { default: SearchDecoder } = await import('../src/network/game/client/codec/MarketSearchDecoder.js');
@@ -50,9 +52,15 @@ try {
const sdkCollision = JSON.parse(readFileSync('../../sdk/collision-data.json', 'utf8'));
const boothTiles = sdkCollision.tiles.filter((t: number[]) => t[0] === 0 && t[1] === 3180 && [3443, 3444].includes(t[2]));
assert.equal(boothTiles.length, 2);
assert(boothTiles.every((t: number[]) => (t[3] & 256) !== 0), 'SDK routes respect the original table footprint');
assert(
boothTiles.every((t: number[]) => (t[3] & 256) !== 0),
'SDK routes respect the original table footprint'
);
const oldTiles = sdkCollision.tiles.filter((t: number[]) => t[0] === 0 && [3180, 3181].includes(t[1]) && [3439, 3440].includes(t[2]));
assert(oldTiles.every((t: number[]) => (t[3] & 256) === 0), 'SDK routes no longer avoid the removed stall');
assert(
oldTiles.every((t: number[]) => (t[3] & 256) === 0),
'SDK routes no longer avoid the removed stall'
);
assert(findPathToLoc(0, 3183, 3437, 3180, 3443, 1, 2, 1, booth.angle, 10, 0).length > 0, 'Table is reachable from the entrance aisle');
assert(findPathToEntity(0, 3183, 3437, teller.x, teller.z, 1, 1, 1).length > 0, 'Teller is reachable');
const seller = PlayerLoading.load('geseller', new Packet(new Uint8Array()), null);
@@ -70,7 +78,13 @@ try {
}
World.playerLoop.add(1n, seller);
World.playerLoop.add(2n, buyer);
seller.invAdd(inv, note, 10);
for (const [i, p] of [seller, buyer].entries()) {
p.slot = i + 1;
p.uid = ((Number(p.username37 & 0x1fffffn) << 11) | p.slot) >>> 0;
World.players[p.slot] = p;
}
seller.invAdd(inv, note, 8);
seller.invAdd(inv, logs, 2);
buyer.invAdd(inv, coins, 200);
const originalSeller = seller.save();
// Reproduce a running server whose cache predates optional SDK metadata.
@@ -105,11 +119,52 @@ try {
return new SearchHandler().handle(new SearchDecoder().decode(packet), p);
};
const count = (p: typeof seller, n: number) => assert(new CountHandler().handle(new Count(n), p));
const sellerUpdates: any[] = [];
seller.write = message => sellerUpdates.push(message);
const sellerState = () => JSON.parse(sellerUpdates.findLast(m => m.component === metadataId && m.text)?.text);
const side = Component.getId('grand_exchange_side:inv');
const inventoryClick = (p: typeof seller, item: number) =>
new InvButtonHandler().handle(
new InvButton(
1,
item,
p.getInventory(inv)!.items.findIndex(i => i?.id === item),
side
),
p
);
const script = ScriptProvider.getByTrigger(ServerTriggerType.OPLOC1, booth.type, -1);
assert(script, 'Compiled booth trigger exists');
seller.executeScript(ScriptRunner.init(script, seller, booth), true);
assert.equal(seller.modalMain, Component.getId('grand_exchange'), 'Booth opens native interface');
assert.equal(seller.modalSide, Component.getId('grand_exchange_side'));
assert(
seller.invListeners.some(l => l.com === side),
'Backpack is transmitted to the sell sidebar'
);
assert(inventoryClick(seller, note), 'An inventory click opens a sell draft directly from home');
assert.deepEqual([sellerState().draft.item, sellerState().draft.side, sellerState().draft.quantity, sellerState().draft.slot], [logs, 'sell', 10, 0]);
assert.equal(marketStore().offers(seller.username).length, 0, 'Clicking does not submit an offer');
assert.equal(seller.getInventory(inv)!.getItemCount(note), 8, 'Draft creation does not escrow items');
click(seller, 'offer_price');
assert(inventoryClick(seller, logs), 'Loose items also open drafts and replace pending count input');
assert.equal(sellerState().input, null);
assert(
sellerUpdates.some(m => m.constructor.name === 'IfOpenMainSide'),
'Dismissing count input preserves the sidebar'
);
assert(!new CountHandler().handle(new Count(999), seller), 'A stale numeric reply cannot edit the replacement draft');
assert(!new InvButtonHandler().handle(new InvButton(1, logs, 0, side), seller), 'Stale inventory slots are rejected');
click(seller, 'home');
click(seller, 'slot4_buy');
search(seller, 'rune');
assert(inventoryClick(seller, note), 'Inventory clicks also work while browsing buy offers');
assert.equal(sellerState().draft.slot, 4, 'Inventory selling preserves the selected empty slot');
assert.equal(sellerState().draft.side, 'sell');
click(seller, 'home');
click(seller, 'slot4_sell');
assert.equal(sellerState().search.query, '', 'New sell selection starts with the full backpack');
assert(sellerState().search.results.some((i: any) => i.item === logs));
assert(search(seller, 'log'));
assert(search(seller, 'log', coins)); // A non-result cannot be selected.
assert(search(seller, 'old query', logs)); // A stale query cannot select an item.
@@ -119,14 +174,31 @@ try {
assert(!search(seller, 'logs'), 'Search is closed once the draft opens');
click(seller, 'offer_qty_all');
click(seller, 'offer_quantity_plus');
assert.equal(sellerState().draft.quantity, 10, 'Plus cannot exceed the combined owned quantity');
click(seller, 'offer_quantity');
count(seller, 11);
assert.match(sellerState().error, /only have 10/);
assert.equal(sellerState().draft.quantity, 10);
click(seller, 'offer_quantity_minus');
assert.equal(sellerState().draft.quantity, 9);
click(seller, 'offer_qty_all');
click(seller, 'offer_price');
count(seller, 10);
click(seller, 'offer_confirm');
assert.equal(seller.getInventory(inv)!.getItemCount(note), 0, 'notes escrowed');
assert.equal(seller.getInventory(inv)!.getItemCount(logs), 0, 'loose items escrowed with notes');
assert.equal(marketStore().offers(seller.username)[0]?.remaining, 10);
assert.equal(marketStore().offers(seller.username)[0]?.slot, 4, 'Offer uses the selected grid slot');
assert(marketStore().saved(buyer.username), 'Other online players share the atomic save boundary');
click(seller, 'home');
click(seller, 'slot0_sell');
assert.equal(sellerState().search.total, 0);
assert.match(sellerUpdates.findLast(m => m.component === Component.getId('grand_exchange:subtitle'))?.text, /No sellable items/);
seller.closeModal();
assert(!seller.invListeners.some(l => l.com === side), 'Closing GE stops the sidebar inventory listener');
assert(!inventoryClick(seller, note), 'Closed GE rejects inventory clicks');
openExchange(seller, 3180, 3443);
click(seller, 'slot4_view');
buyer.x = 3182;
buyer.z = 3445;
for (const trigger of [ServerTriggerType.OPNPC1, ServerTriggerType.OPNPC2]) {
@@ -148,11 +220,7 @@ try {
click(buyer, 'offer_quantity');
count(buyer, 2);
click(buyer, 'offer_qty_all');
assert.equal(
buyerUpdates.findLast(m => m.component === Component.getId('grand_exchange:offer_quantity'))?.text,
'2',
'Buy All is ignored even if dispatched directly'
);
assert.equal(buyerUpdates.findLast(m => m.component === Component.getId('grand_exchange:offer_quantity'))?.text, '2', 'Buy All is ignored even if dispatched directly');
assert.equal(buyerUpdates.findLast(m => m.component === Component.getId('grand_exchange:offer_sell_quantity'))?.hidden, true);
click(buyer, 'offer_quantity');
count(buyer, 10);
@@ -250,6 +318,9 @@ try {
p.level = 0;
p.invAdd(inv, coins, 1000);
p.invAdd(inv, note, 20);
p.slot = Number(agentIndex);
p.uid = ((Number(p.username37 & 0x1fffffn) << 11) | p.slot) >>> 0;
World.players[p.slot] = p;
World.playerLoop.add(agentIndex++, p);
let ge: any = null;
const sdk = new BotSDK({ botUsername: name, autoLaunchBrowser: false });
@@ -325,6 +396,17 @@ try {
assert(result.success, result.message);
}
assert.equal((await a.bot.placeGEOffer({ item: logs, side: 'buy', quantity: 1, price: 1 })).reason, 'no_slot');
const beforeFull = JSON.stringify(marketStore().offers(a.p.username));
assert(inventoryClick(a.p, note));
assert.match(a.sdk.getState().ge.error, /slots are full/);
assert.equal(JSON.stringify(marketStore().offers(a.p.username)), beforeFull, 'Inventory clicks cannot overwrite occupied offers');
const invalid = agent('invalidsell');
assert(inventoryClick(invalid.p, coins));
assert.match(invalid.sdk.getState().ge.error, /cannot be traded/);
assert.equal(invalid.sdk.getState().ge.screen, 'home');
invalid.p.x = 3200;
assert(inventoryClick(invalid.p, note));
assert.equal(invalid.p.modalMain, -1, 'Inventory clicks recheck exchange distance');
assert(largestStatePacket < 4997, 'Six-offer snapshots fit the native socket packet buffer including framing');
// Raw forged/stale UI packets still cannot mutate remotely, regardless of SDK preflight.
for (const operation of ['confirm', 'cancel', 'collect', 'count', 'search']) {
@@ -358,9 +440,7 @@ try {
assert.equal(upstairs.p.modalMain, -1);
assert.equal(upstairs.p.getInventory(inv)!.getItemCount(coins), upstairsCoins);
console.log('PASS: SDK search/place/cancel/collect receipts, rejection, six slots, native state encoding and remote/floor guards.');
console.log(
'PASS: compiled booth → native fuzzy search/selection/click/count handlers → noted escrow → matching → 5% burn → local collection → save recovery → HTTP market data.'
);
console.log('PASS: compiled booth → native fuzzy search/selection/click/count handlers → noted escrow → matching → 5% burn → local collection → save recovery → HTTP market data.');
} finally {
marketStore().db.close();
rmSync(dir, { recursive: true, force: true });
+41 -13
View File
@@ -15,7 +15,9 @@ const { default: InvType } = await import('../src/cache/config/InvType.js');
const { default: ObjType } = await import('../src/cache/config/ObjType.js');
const { default: Packet } = await import('../src/io/Packet.js');
const { PlayerLoading } = await import('../src/engine/entity/PlayerLoading.js');
const { openExchange, exchangeButton, exchangeSearch } = await import('../src/engine/market/GrandExchange.js');
const { openExchange, exchangeButton, exchangeSearch, exchangeInventoryButton } = await import('../src/engine/market/GrandExchange.js');
const { noteFor } = await import('../src/engine/market/MarketCatalog.js');
const { default: UpdateInvFull } = await import('../src/network/game/server/model/UpdateInvFull.js');
const { marketStore } = await import('../src/engine/market/MarketStore.js');
try {
World.reload();
@@ -48,8 +50,8 @@ try {
const client = Object.create(Client.prototype);
Object.assign(client, { p11: fonts[0], overMainComId: -1, overSideComId: -1, overChatComId: -1, objDragArea: 0, selectedArea: 0 });
const player = PlayerLoading.load('gepreview', new Packet(new Uint8Array()), null);
player.x = 3182;
player.z = 3439;
player.x = 3181;
player.z = 3443;
player.write = (message: any) => {
const c = IfType.list[message.component];
if (message.component === undefined) return;
@@ -91,11 +93,16 @@ try {
break;
}
};
const save = async (name: string) => {
const pixels = new Int32Array(512 * 334);
Pix2D.setPixels(pixels, 512, 334);
const save = async (name: string, sidebar = false) => {
const width = sidebar ? 730 : 512;
const pixels = new Int32Array(width * 334);
Pix2D.setPixels(pixels, width, 334);
Pix3D.setRenderClipping();
client.drawInterface(IfType.list[Component.getId('grand_exchange')], 0, 0, 0);
if (sidebar) {
player.write(new UpdateInvFull(Component.getId('grand_exchange_side:inv'), player.getInventory(InvType.getId('inv'))!));
client.drawInterface(IfType.list[Component.getId('grand_exchange_side')], 530, 0, 0);
}
const rgba = Buffer.alloc(pixels.length * 4);
pixels.forEach((rgb: number, i: number) => {
rgba[i * 4] = (rgb >> 16) & 255;
@@ -103,9 +110,9 @@ try {
rgba[i * 4 + 2] = rgb & 255;
rgba[i * 4 + 3] = 255;
});
await new Jimp({ width: 512, height: 334, data: rgba }).write(join(out, name + '.png') as `${string}.png`);
await new Jimp({ width, height: 334, data: rgba }).write(join(out, name + '.png') as `${string}.png`);
};
openExchange(player, 3180, 3439);
openExchange(player, 3180, 3443);
await save('empty');
// Isolated visual fixtures, never connected to a game world or normal ledger.
const fixtures = [
@@ -141,7 +148,11 @@ try {
await save('offers');
exchangeButton(player, Component.getId('grand_exchange:slot3_view'));
await save('detail');
for (const [slot, name] of [[0, 'detail-cancelled'], [1, 'detail-completed'], [2, 'detail-buy']] as const) {
for (const [slot, name] of [
[0, 'detail-cancelled'],
[1, 'detail-completed'],
[2, 'detail-buy']
] as const) {
exchangeButton(player, Component.getId('grand_exchange:home'));
exchangeButton(player, Component.getId(`grand_exchange:slot${slot}_view`));
await save(name);
@@ -187,16 +198,33 @@ try {
exchangeButton(player, Component.getId('grand_exchange:home'));
player.invAdd(InvType.getId('inv'), ObjType.getId('rune_scimitar'), 1);
exchangeButton(player, Component.getId('grand_exchange:slot4_sell'));
exchangeSearch(player, 'rscim');
exchangeSearch(player, 'rscim', ObjType.getId('rune_scimitar'));
const logs = ObjType.getId('logs');
const note = noteFor(logs);
player.invAdd(InvType.getId('inv'), note, 1250);
player.invAdd(InvType.getId('inv'), logs, 2);
exchangeSearch(player, '');
await save('sell-inventory', true);
client.menuNumEntries = 0;
const side = Component.getId('grand_exchange_side:inv');
Object.assign(client, { mouseX: 620, mouseY: 225, sideModalId: Component.getId('grand_exchange_side'), splitPrivateChat: 0 });
client.buildMinimenu();
const sellAction = client.menuNumEntries - 1;
assert.equal(client.menuOption[sellAction], 'Sell @lre@Logs', 'Sell is the default inventory click, including noted items');
assert.equal(client.menuParamC[sellAction], side);
assert(exchangeInventoryButton(player, side, client.menuParamB[sellAction], note, 1));
assert.equal(IfType.list[Component.getId('grand_exchange:offer_quantity')].text, '1,252');
assert(!IfType.list[Component.getId('grand_exchange:offer_sell_quantity')].hide, 'Sell quantity shows All');
client.menuNumEntries = 0;
client.addComponentOptions(IfType.list[client.mainModalId], 216, 219, 0, 0, 0);
assert.equal(client.menuParamC[client.menuNumEntries - 1], Component.getId('grand_exchange:offer_qty_all'), 'Sell All is clickable');
await save('sell-offer');
await save('sell-offer', true);
exchangeButton(player, Component.getId('grand_exchange:home'));
exchangeButton(player, Component.getId('grand_exchange:slot3_view'));
for (const [x, name] of [[80, 'view_collect'], [240, 'view_notes'], [410, 'view_cancel']] as const) {
for (const [x, name] of [
[80, 'view_collect'],
[240, 'view_notes'],
[410, 'view_cancel']
] as const) {
client.menuNumEntries = 0;
client.addComponentOptions(IfType.list[client.mainModalId], x, 272, 0, 0, 0);
assert.equal(client.menuParamC[client.menuNumEntries - 1], Component.getId(`grand_exchange:${name}`), `${name} is the default mouse action`);
+2 -1
View File
@@ -12594,7 +12594,8 @@ export class Client extends GameShell {
icon.plotSprite(slotX, slotY);
}
if ((child.clientCode < RESULT_ICON || child.clientCode >= RESULT_ICON + 12) && (icon.owi === 33 || child.linkObjNumber[slot] !== 1)) {
const searchResult = child.clientCode >= RESULT_ICON && child.clientCode < RESULT_ICON + 12;
if (searchResult ? child.linkObjNumber[slot] > 1 : icon.owi === 33 || child.linkObjNumber[slot] !== 1) {
const count: number = child.linkObjNumber[slot];
this.p11?.drawString(this.invNumber(count), slotX + dx + 1, slotY + 10 + dy, Colour.BLACK);
this.p11?.drawString(this.invNumber(count), slotX + dx, slotY + 9 + dy, Colour.YELLOW);