mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
feat: create Grand Exchange sell offers from inventory clicks
This commit is contained in:
1 parent
e5282efdd9
commit
4906fb6e83
9 files changed
+239
-54
No files matched your search
@@ -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.
|
||||
|
||||
@@ -11476,3 +11476,5 @@
|
||||
11475
|
||||
11476
|
||||
11477
|
||||
11478
|
||||
11479
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user